perf(qt): virtualize discovery and warm playback

cx
This commit is contained in:
vicrodh committed 2026-08-22 14:35:36 -06:00
1 parent 34d19211d5
commit c6d142ccb4
15 files changed
+841 -357

No files matched your search

+1
View File
@@ -581,6 +581,7 @@ fn main() {
"qml/immersive/VolumeBar.qml",
"qml/immersive/WaveBedPanel.qml",
"qml/theme/QbzIcon.qml",
"qml/theme/QbzKineticScroll.qml",
"qml/theme/QbzScrollBar.qml",
"qml/theme/QbzSpinner.qml",
"qml/theme/QbzTheme.qml",
+16 -8
View File
@@ -114,6 +114,11 @@ Rectangle {
QbzLibrary.libraryToggleFavorite(root.followKind, root.item.id)
}
function openMenu(anchor, x, y) {
agMenuLoader.active = true
agMenuLoader.item.openAtCursor(anchor, x, y)
}
Connections {
target: QbzLibrary
// Pin fan-out — the AlbumCard contract, artist key (see AlbumCard.qml:
@@ -192,7 +197,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
agMenu.openAtCursor(agArea, mouse.x, mouse.y)
root.openMenu(agArea, mouse.x, mouse.y)
else
QbzArtist.openArtist(root.item.id)
}
@@ -221,14 +226,17 @@ Rectangle {
id: agMore
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { agMenu.openAtCursor(agMore, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openMenu(agMore, mouse.x, mouse.y) }
}
}
CardMenu {
id: agMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
Loader {
id: agMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
}
}
}
// Pin badge — top-right of the FRAME (outside the circle clip),
@@ -289,7 +297,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
agMenu.openAtCursor(agNameArea, mouse.x, mouse.y)
root.openMenu(agNameArea, mouse.x, mouse.y)
else
QbzArtist.openArtist(root.item.id)
}
+32 -18
View File
@@ -132,6 +132,11 @@ Rectangle {
}
}
function openMenu(anchor, x, y) {
plMenuLoader.active = true
plMenuLoader.item.openAtCursor(anchor, x, y)
}
Connections {
target: QbzLibrary
// Pin fan-out — the AlbumCard contract, playlist key (see AlbumCard.qml).
@@ -182,21 +187,27 @@ Rectangle {
// with no flag at all. `auto` measures the ratio instead, so
// those surfaces pad too, and a genuinely square cover
// (`image.covers[0]`, the fallback that map picks) still crops.
RoundedImage {
Loader {
anchors.fill: parent
visible: root.artSource !== ""
source: root.artSource
radius: theme.radiusSm
fit: root.ownImage ? "pad" : "auto"
sourceComponent: root.artSource !== "" ? singleArtwork : collageArtwork
}
Component {
id: singleArtwork
RoundedImage {
source: root.artSource
radius: theme.radiusSm
fit: root.ownImage ? "pad" : "auto"
}
}
// Arm 2 — the member-cover mosaic. A row that HAS its own
// graphic passes no urls, so it shows the placeholder while
// that graphic downloads instead of flashing a mosaic.
PlaylistCollage {
anchors.fill: parent
visible: root.artSource === ""
urls: root.ownImage ? [] : root.collageUrls
radius: theme.radiusSm
Component {
id: collageArtwork
PlaylistCollage {
urls: root.ownImage ? [] : root.collageUrls
radius: theme.radiusSm
}
}
Rectangle {
anchors.fill: parent
@@ -214,7 +225,7 @@ Rectangle {
// body-opens (every Slint mount); right press = the ⋯ menu.
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
plMenu.openAtCursor(plArtArea, mouse.x, mouse.y)
root.openMenu(plArtArea, mouse.x, mouse.y)
else
QbzBridge.openPlaylist(root.item.id)
}
@@ -243,7 +254,7 @@ Rectangle {
id: plMore
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { plMenu.openAtCursor(plMore, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openMenu(plMore, mouse.x, mouse.y) }
}
}
// Pin badge — top-right (opacity follows overlay-on).
@@ -276,11 +287,14 @@ Rectangle {
}
}
}
CardMenu {
id: plMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
Loader {
id: plMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
}
}
}
Item { width: 1; height: 6 }
@@ -335,7 +349,7 @@ Rectangle {
width: 200
height: 40
acceptedButtons: Qt.RightButton
onClicked: function (mouse) { plMenu.openAtCursor(plMetaArea, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openMenu(plMetaArea, mouse.x, mouse.y) }
}
// PlaylistCard.slint's menu, in its order.
+16 -8
View File
@@ -55,6 +55,11 @@ Rectangle {
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
function openMenu(anchor, x, y) {
trackMenuLoader.active = true
trackMenuLoader.item.openAtCursor(anchor, x, y)
}
// Fan-out + rollback (the shape cards/AlbumCard.qml uses for pin).
Connections {
target: QbzLibrary
@@ -97,7 +102,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
trackMenu.openAtCursor(tcArtArea, mouse.x, mouse.y)
root.openMenu(tcArtArea, mouse.x, mouse.y)
else
QbzPlayer.playTrack(root.item.id)
}
@@ -126,7 +131,7 @@ Rectangle {
id: moreBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { trackMenu.openAtCursor(moreBtn, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openMenu(moreBtn, mouse.x, mouse.y) }
}
}
// Source badge (All feed, show-local): bottom-right of the art.
@@ -166,11 +171,14 @@ Rectangle {
y: Math.round((parent.height - height) / 2)
}
}
CardMenu {
id: trackMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.trackAction(a) }
Loader {
id: trackMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.trackAction(a) }
}
}
}
Item { width: 1; height: 6 }
@@ -200,7 +208,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
trackMenu.openAtCursor(tcTitleArea, mouse.x, mouse.y)
root.openMenu(tcTitleArea, mouse.x, mouse.y)
else
QbzPlayer.playTrack(root.item.id)
}
@@ -0,0 +1,134 @@
// Shared touchpad kinetic tail for a Flickable/ListView/GridView.
//
// Qt 6.11 already owns the direct pixel-delta drag while the fingers are on
// the pad, including nested-axis arbitration and bounds. On Linux/Wayland the
// gesture commonly ends with ScrollEnd and NO compositor momentum stream, so
// Qt stops the content on the same frame the fingers lift. This component only
// observes that native gesture and hands its final vertical velocity back to
// Flickable.flick(). It never writes contentY and never owns an animation.
//
// RESOURCE CONTRACT (GPU-COST-INVESTIGATION.md): WheelHandler is event-driven;
// there is no Timer, Behavior or permanent clock. The one callLater crosses
// Qt's ScrollEnd cleanup, then Flickable's bounded native timeline owns motion
// and stops itself. At rest this item writes nothing and schedules no frame.
import QtQuick
Item {
id: root
required property Flickable target
// QbzScrollBar declares us beside the view. Reparent to the Flickable
// explicitly so this observer covers its whole VIEWPORT, including child
// rails and cards, rather than the scrollbar's 14px gutter.
parent: target
anchors.fill: parent
z: 2147483646
property real _sumX: 0
property real _sumY: 0
property real _velocityY: 0
property double _lastMs: 0
property bool _vertical: false
property bool _horizontal: false
property bool _nativeMomentum: false
property int _generation: 0
function _resetGesture() {
root._sumX = 0;
root._sumY = 0;
root._velocityY = 0;
root._lastMs = Date.now();
root._vertical = false;
root._horizontal = false;
root._nativeMomentum = false;
++root._generation;
}
function _sample(dx, dy) {
const now = Date.now();
// A suspended event loop must not turn one stale delta into an absurd
// velocity; 1..80ms covers the useful gesture sample window.
const dt = Math.max(1, Math.min(80, now - root._lastMs));
root._lastMs = now;
root._sumX += dx;
root._sumY += dy;
// Match Qt's own nested-Flickable decision: one axis must beat the
// other 2:1 before it owns the gesture. A horizontal carousel therefore
// keeps horizontal/diagonal swipes and can never launch the page tail.
if (!root._vertical && !root._horizontal) {
if (Math.abs(root._sumY) > Math.abs(root._sumX) * 2)
root._vertical = true;
else if (Math.abs(root._sumX) > Math.abs(root._sumY) * 2)
root._horizontal = true;
}
if (root._vertical) {
const instant = dy * 1000 / dt;
// Recent samples matter most at finger lift, without letting one
// noisy event replace the entire gesture estimate.
root._velocityY = root._velocityY === 0 ? instant : root._velocityY * 0.35 + instant * 0.65;
}
}
function _launchTail() {
let velocity = root._velocityY;
const serial = root._generation;
if (!root._vertical || root._nativeMomentum || Math.abs(root._sumY) < 12 || Math.abs(velocity) < 220)
return;
// Respect each view's platform-tuned ceiling. `-1` means unlimited;
// use the platform's usual 2500 px/s rather than allowing a noisy
// high-resolution sample to fling tens of screens.
const cap = root.target.maximumFlickVelocity > 0 ? root.target.maximumFlickVelocity : 2500;
velocity = Math.max(-cap, Math.min(cap, velocity));
// WheelHandler observes before Flickable finishes ScrollEnd. Launch on
// the next event-loop turn or that cleanup would cancel the new tail.
Qt.callLater(function () {
if (serial !== root._generation || !root.target || !root.target.visible || !root.target.interactive)
return;
root.target.flick(0, velocity);
});
}
WheelHandler {
target: null
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
blocking: false
enabled: root.target.interactive
onWheel: function (event) {
// Observer only. Native Flickable still applies the direct delta,
// arbitrates nested axes and owns bounds/overshoot.
event.accepted = false;
if (event.phase === Qt.ScrollBegin) {
root._resetGesture();
return;
}
if (event.phase === Qt.ScrollMomentum) {
// macOS and some compositors already provide the kinetic tail.
// Never stack ours on top of theirs.
root._nativeMomentum = true;
return;
}
if (event.phase === Qt.ScrollEnd) {
root._launchTail();
return;
}
if (event.phase !== Qt.ScrollUpdate)
return;
// A physical wheel normally has angleDelta only and is handled by
// Qt's process-wide wheel-deceleration path (main.rs). This half is
// deliberately limited to high-resolution pixel gestures.
const dx = event.pixelDelta.x;
const dy = event.pixelDelta.y;
if (dx !== 0 || dy !== 0)
root._sample(dx, dy);
}
}
}
+43 -14
View File
@@ -14,9 +14,11 @@ Item {
visible: maxScroll > 0
readonly property real maxScroll: Math.max(0, target.contentHeight - target.height)
readonly property real thumbH: target.contentHeight > 0
? Math.max(48, target.height / target.contentHeight * height)
: height
// ListView/GridView origins are not guaranteed to stay at zero when their
// model or variable-size delegates change. Use the documented Flickable
// coordinate (`contentY - originY`) for both drawing and seeking.
readonly property real scrollY: Math.max(0, Math.min(maxScroll, target.contentY - target.originY))
readonly property real thumbH: target.contentHeight > 0 ? Math.max(48, target.height / target.contentHeight * height) : height
readonly property real travel: Math.max(0, height - thumbH)
// Shown while scrolling, hovering, or dragging (auto-hide).
property bool scrollActive: false
@@ -25,8 +27,8 @@ Item {
Connections {
target: root.target
function onContentYChanged() {
root.scrollActive = true
hideTimer.restart()
root.scrollActive = true;
hideTimer.restart();
}
}
Timer {
@@ -35,6 +37,13 @@ Item {
onTriggered: root.scrollActive = false
}
// One shared input seam for every page that uses the house scrollbar:
// physical-wheel acceleration is Qt's native process policy (main.rs),
// while this observer supplies only a missing touchpad tail.
QbzKineticScroll {
target: root.target
}
// Track.
Rectangle {
width: 6
@@ -43,19 +52,31 @@ Item {
radius: 3
color: "#12ffffff"
opacity: root.shown ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 160 } }
Behavior on opacity {
NumberAnimation {
duration: 160
}
}
}
// Thumb.
Rectangle {
width: barArea.containsMouse || barArea.pressed ? 10 : 8
x: Math.round((parent.width - width) / 2)
height: root.thumbH
y: root.maxScroll > 0 ? (root.target.contentY / root.maxScroll) * root.travel : 0
y: root.maxScroll > 0 ? (root.scrollY / root.maxScroll) * root.travel : 0
radius: 5
color: barArea.containsMouse || barArea.pressed ? "#77ffffff" : "#44ffffff"
opacity: root.shown ? 1.0 : 0.0
Behavior on width { NumberAnimation { duration: 100 } }
Behavior on opacity { NumberAnimation { duration: 160 } }
Behavior on width {
NumberAnimation {
duration: 100
}
}
Behavior on opacity {
NumberAnimation {
duration: 160
}
}
}
// Drag / click-to-position over the whole gutter.
MouseArea {
@@ -63,12 +84,20 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: position(mouseY)
onPositionChanged: if (pressed) position(mouseY)
onPressed: {
// Public Flickable semantics already cancel on a contentY write;
// make the takeover explicit so scrollbar drag remains exact even
// if the first pressed position equals the current one.
root.target.cancelFlick();
position(mouseY);
}
onPositionChanged: if (pressed)
position(mouseY)
function position(my) {
if (root.travel <= 0) return
var frac = Math.min(1, Math.max(0, (my - root.thumbH / 2) / root.travel))
root.target.contentY = frac * root.maxScroll
if (root.travel <= 0)
return;
var frac = Math.min(1, Math.max(0, (my - root.thumbH / 2) / root.travel));
root.target.contentY = root.target.originY + frac * root.maxScroll;
}
}
}
+13
View File
@@ -337,6 +337,19 @@ Item {
var key = root.source + "|" + rw + "x" + rh
if (key === root._reqKey) return
root._reqKey = key
// Warm-cache fast path. Previously even a derivative already on disk
// took the async Rust -> Tokio -> Qt-thread round trip. The original
// pixmap therefore reached the scene graph first, then every card
// replaced a large texture with its derivative a few frames later — a
// visible burst of work when a cold ListView band mounted. The exact
// path lookup is one local stat and performs no decode/write; on a
// miss, generation remains off-thread through artScaled().
var cached = QbzSession.artScaledCached(root.source, rw, rh)
if (cached !== "") {
root._scaled = cached
return
}
QbzSession.artScaled(root.source, rw, rh)
}
Connections {
+6 -4
View File
@@ -581,10 +581,12 @@ Rectangle {
model: root.listCells
reuseItems: true
currentIndex: -1
// One viewport of asynchronously incubated delegates gives a heavy
// rail time to finish before it becomes visible, without the old
// Loader's synchronous build in the wheel frame that crossed a band.
cacheBuffer: Math.max(600, height)
// The track tape is cheap enough that a fast wheel can consume one
// viewport before the first heavy card rail has finished incubating.
// Two viewports give those bottom rails roughly one kinetic-scroll
// window of lead time. Cache delegates are asynchronous and pooled;
// this does not restore the old eager footer mount.
cacheBuffer: Math.max(900, 2 * height)
header: Item {
width: pageFlick.width
+213 -253
View File
@@ -67,11 +67,9 @@ Rectangle {
QbzTheme { id: theme }
// ---- mount stopwatch (same category as ContentRouter's) --------------
// OFF unless QT_LOGGING_RULES="qbz.nav.timing.info=true". It exists to
// attribute a Home mount: this view declares FOUR Discover tabs and QML
// instantiates all four (`visible: false` hides a Column, it does not stop
// it from being built), so the question "how much of the wait is the three
// tabs nobody asked for" needs per-tab numbers, not a single total.
// OFF unless QT_LOGGING_RULES="qbz.nav.timing.info=true". The sections
// count below is the DOCUMENT size, not the number of rails instantiated:
// the vertical ListView only builds its viewport and cache buffer.
LoggingCategory {
id: homeTiming
name: "qbz.nav.timing"
@@ -80,9 +78,10 @@ Rectangle {
// A property initialiser runs during creation, so this is stamped near the
// start of the mount rather than at the end of it like Component.onCompleted.
property double _mountT0: Date.now()
function _stampTab(id, count) {
console.info(homeTiming, "[hometiming] tab " + id + " (" + count
+ " sections) done at +" + (Date.now() - root._mountT0) + "ms")
function _stampViewport(id, count) {
console.info(homeTiming, "[hometiming] viewport " + id + " ("
+ count + " document sections) done at +"
+ (Date.now() - root._mountT0) + "ms")
}
// Reparsed whenever Rust republishes the JSON documents (one per
@@ -313,6 +312,13 @@ Rectangle {
}
property string activeTab: "home"
// Recommendations remains lazy. The old per-tab Loader called this when
// its body was constructed; with one virtualized ListView, selecting the
// tab is the equivalent existence boundary.
onActiveTabChanged: {
if (root.activeTab === "recommendations")
QbzHome.loadRecommendations()
}
// Slint gates the 4th tab on the persisted `show_recommendations` pref
// (discover_prefs.db). The port publishes it inside the settings document
@@ -361,6 +367,36 @@ Rectangle {
: root.activeTab === "forYou" ? root.forYouSections
: root.activeTab === "recommendations" ? root.recoSections
: root.sections
/// Ordering slots stay in the document even before their out-of-document
/// rows arrive. Keep the descriptor stable, but do not instantiate its
/// heavy rail until it has something to show.
function sectionIsVisible(section) {
if (!section)
return false
return section.kind === "pinned" ? root.pinnedItems.length > 0
: section.kind === "radio" ? root.radioStations.length > 0
: section.kind === "spotlight" ? root.spotlight.visible === true
: root.isWeeklySlot(section.id) ? root.weeklyRows(section.id).length > 0
: true
}
/// The vertical ListView uses zero spacing so a hidden ordering slot leaves
/// no phantom gap. A visible delegate carries the old 40px Column spacing
/// only when a later visible section exists.
function hasVisibleSectionAfter(index) {
var model = root.activeSections
for (var i = index + 1; i < model.length; i++) {
if (root.sectionIsVisible(model[i]))
return true
}
return false
}
readonly property string activeHeaderKind:
root.activeTab === "home" && QbzHome.homeError !== "" ? "error"
: ((root.activeTab === "recommendations" ? QbzHome.recoLoading : QbzHome.homeLoading)
&& root.activeSections.length === 0) ? "skeleton"
: (root.activeTab === "recommendations" && !QbzHome.recoLoading
&& root.activeSections.length === 0) ? "empty"
: ""
// Cheap "some card in the mounted rails has artUrl but no artPath yet"
// probe — recomputed only when a sections document is republished. The
// pinned rows are probed too: they no longer travel inside the sections
@@ -709,39 +745,35 @@ Rectangle {
readonly property real cellWidth: (width - 3 * 8) / 4
Repeater {
model: sectionData.items
// The grid is contractually capped at two pages. Model the
// cap itself: a Loader existence gate prevented the heavy
// row from being built past 24, but the old full-array
// model still created an Item + two Components + Loader
// shell for every discarded result.
model: sgrid.total
delegate: Item {
required property var modelData
id: slimSlot
required property int index
readonly property var cardData: sgrid.sectionData.items[index]
readonly property int slot: index % sgrid.perPage
readonly property int pageIdx: Math.floor(index / sgrid.perPage)
visible: index < sgrid.total
width: grid.cellWidth
height: 60
x: pageIdx * grid.width + (slot % 4) * (grid.cellWidth + 8)
y: Math.floor(slot / 4) * (60 + 8)
// The PinnedRail dispatch pattern: Components declared
// in the delegate scope, so `modelData` resolves.
// in the delegate scope, sharing this capped slot row.
Component {
id: albumRowComp
SlimCard { card: modelData }
SlimCard { card: slimSlot.cardData }
}
Component {
id: trackRowComp
TrackSlimRow { card: modelData }
TrackSlimRow { card: slimSlot.cardData }
}
// `active` matters as much as `sourceComponent`: the
// Repeater above is fed the WHOLE item list, and the
// rows past `total` were being built in full and then
// hidden by the delegate's `visible`. A hidden row
// costs exactly as much to construct as a shown one —
// it just never repays it. `sectionData.items` can run
// several times `total` (24), so this is most of the
// grid.
Loader {
anchors.fill: parent
active: index < sgrid.total
sourceComponent: sgrid.tracks ? trackRowComp : albumRowComp
}
}
@@ -1187,28 +1219,43 @@ Rectangle {
}
}
// The section-rails renderer (one per Discover tab — the tab bodies
// differ only in WHICH sections doc they mount).
component SectionRails: Column {
id: rails
property var sectionsModel: []
// ONE vertical section delegate. The page-level ListView below owns the
// model and therefore only asks for the rails in (or near) its viewport.
// This used to be a Column + Repeater: every section in the active tab
// existed at once, so every horizontal rail eagerly built its first screen
// of cards even when the rail was several viewports below the fold.
component SectionRail: Item {
id: railSlot
required property var modelData
required property int index
/// "home" | "editorPicks" | "forYou" | "recommendations" — forwarded
/// to every rail so its "View all" resolves per tab.
property string tabId: "home"
/// to the rail so its "View all" resolves per tab.
property string railTab: "home"
readonly property bool shown: root.sectionIsVisible(modelData)
width: parent ? parent.width : 0
spacing: 40
// ListView spacing would reserve a gap for zero-height hidden ordering
// slots. Carry the 40px gap inside each visible slot instead, and only
// when another visible section follows it.
height: shown && railLoader.item
? railLoader.height + (root.hasVisibleSectionAfter(index) ? 40 : 0)
: 0
Repeater {
model: sectionsModel
delegate: Loader {
required property var modelData
property string railTab: rails.tabId
width: parent ? parent.width : 0
Loader {
id: railLoader
property var modelData: railSlot.modelData
property string railTab: railSlot.railTab
// ListView owns the delegate's x-position and forces it back
// to the content origin. Put the horizontal inset on the
// child it does not position: 32px on both sides, exactly the
// old padded Column geometry.
x: 32
width: parent.width - 64
height: item ? item.implicitHeight : 0
// The pinned slot is always in the document (it is where the
// discover prefs put the rail); it renders only once the
// store has rows — the Slint `PinnedState.items.length > 0`
// gate. A Column skips invisible children entirely, spacing
// included, so an empty pinned rail leaves no gap.
// gate. `active`, not only `visible`, is the existence gate:
// an empty out-of-document slot builds no rail subtree.
// The out-of-document rails are always IN the document (that
// is where the discover prefs put them) and render only once
// their own store has rows — the Slint
@@ -1216,11 +1263,8 @@ Rectangle {
// `ForYouState.radio-stations.length > 0` /
// `spotlight-visible` gates. A Column skips invisible children
// entirely, spacing included, so an empty one leaves no gap.
visible: modelData.kind === "pinned" ? root.pinnedItems.length > 0
: modelData.kind === "radio" ? root.radioStations.length > 0
: modelData.kind === "spotlight" ? root.spotlight.visible === true
: root.isWeeklySlot(modelData.id) ? root.weeklyRows(modelData.id).length > 0
: true
active: railSlot.shown
visible: active
sourceComponent: modelData.kind === "album" ? albumRailComp
: modelData.kind === "playlist" ? playlistRailComp
: modelData.kind === "slim" ? slimGridComp
@@ -1411,7 +1455,6 @@ Rectangle {
body: sectionData.hint
}
}
}
}
}
@@ -1582,226 +1625,143 @@ Rectangle {
Item {
width: parent.width
height: parent.height - 57
Flickable {
id: homeFlick
width: parent.width
height: parent.height
clip: true
contentWidth: width
contentHeight: homeContent.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: homeContent
width: parent.width
padding: 32
spacing: 40
// ===== Home tab ==========================================
//
// ONE TAB EXISTS AT A TIME — and the `Loader` is what makes
// that true. The four tab bodies used to be four sibling
// Columns gated on `visible:`, which hides a Column but does
// NOT stop QML from building it: every Repeater under every
// tab still instantiated every rail and every card. Measured
// on this account (9 + 7 + 12 sections, 423 cards), a Home
// mount spent ~360 of its ~410 ms inside the tab columns, and
// three quarters of those sections belonged to tabs nobody was
// looking at. The proof was the per-tab stamp below firing for
// `forYou` and `editorPicks` while they were invisible.
//
// This is also what the REFERENCE does, and the port had
// simply diverged: HomeView.slint:321 runs ONE repeater whose
// model is picked by a ternary on the active tab, and mounts
// For You / Recommendations behind `if` (:579, :608). Its own
// comment — "the For You tab's lists are pushed empty so this
// repeater renders nothing for it" — is the same rule stated
// from the data side.
//
// SYNCHRONOUS on purpose (`asynchronous` is left at its
// default). Async incubation is time-sliced: it does not
// remove the work, it spreads it over more frames, which is
// why the router-level experiment made the app dramatically
// worse and was reverted the same day. See ContentRouter.qml.
//
// The trade is that switching tabs now builds the tab you
// switch TO, instead of it being pre-built. That is the right
// way round: a mount happens on every navigation into
// Discover, a tab switch only when asked for, and one tab
// costs a quarter of four.
Loader {
width: parent.width - 64
active: root.activeTab === "home"
// Without this the Column reserves a spacing slot for an
// inactive Loader (it is a zero-size but VISIBLE child),
// leaving a 40px gap above the active tab.
visible: active
sourceComponent: Column {
width: parent.width
spacing: 40
// The vertical viewport is the model owner. A Column + Repeater
// built every section in the active tab and, through each
// horizontal ListView, at least one screenful of cards per section.
// ListView keeps that cost proportional to this viewport while
// preserving the same synchronous first paint.
ListView {
id: homeFlick
anchors.fill: parent
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.activeSections
reuseItems: true
spacing: 0
// A rail is a burst of 6-12 cards plus their artwork textures.
// Half a viewport left that burst inside the first fast wheel
// gesture: the user's custom Recently bands were mounted just
// as they crossed the screen, then felt progressively better
// after QML/pixmap/texture caches warmed. ListView creates its
// cache delegates asynchronously, so a 1.25-viewport runway
// moves that finite work ahead of the gesture without bringing
// back the old all-tabs/all-sections synchronous mount.
cacheBuffer: Math.max(720, Math.round(height * 1.25))
// Loading skeleton (HomeSkeleton: two shimmer rows). The
// pulse comes from the view-root Timer, which is itself
// gated on visibility + window state.
delegate: SectionRail {
width: homeFlick.width
railTab: root.activeTab
}
// Loading, error and empty states remain inline at the top of
// the same scroll surface. Only the selected arm exists.
// Explicit header/footer spacers reproduce the old 32px
// Column padding without ListView margins changing contentY's
// zero-origin contract (ScrollMemory and QbzScrollBar both
// consume that coordinate).
header: Item {
width: homeFlick.width
height: 32 + discoverHeader.height
Loader {
id: discoverHeader
x: 32
y: 32
width: homeFlick.width - 64
property string kind: root.activeHeaderKind
active: kind !== ""
visible: active
height: active && item ? item.implicitHeight : 0
sourceComponent: kind === "error" ? homeErrorHeader
: kind === "empty" ? recoEmptyHeader
: tabSkeletonHeader
}
}
footer: Item { width: homeFlick.width; height: 32 }
Component {
id: tabSkeletonHeader
TabSkeleton {
visible: QbzHome.homeLoading && root.sections.length === 0
phase: root.skelPhase
}
// Error state with retry (the Slint Home has no error
// arm; the box mirrors the FavoritesView Retry button).
Rectangle {
visible: QbzHome.homeError !== ""
width: parent.width
height: errorColumn.height + 28
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: errorColumn
anchors.centerIn: parent
spacing: 10
Text {
text: QbzHome.homeError
color: theme.textSecondary
font.pixelSize: 13
}
Rectangle {
width: retryText.implicitWidth + 28
height: 32
radius: 6
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: retryText
anchors.centerIn: parent
text: QbzSession.tr("Retry", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.reloadHome()
}
}
}
}
// Section rails.
SectionRails { sectionsModel: root.sections; tabId: "home" }
Component.onCompleted: root._stampTab("home", root.sections.length)
}
}
// ===== Editor's Picks (phase 13) ========================
// Loader-gated for the reason spelled out on the Home tab.
Loader {
width: parent.width - 64
active: root.activeTab === "editorPicks"
visible: active
sourceComponent: Column {
width: parent.width
spacing: 40
TabSkeleton {
visible: QbzHome.homeLoading && root.editorSections.length === 0
phase: root.skelPhase
}
SectionRails { sectionsModel: root.editorSections; tabId: "editorPicks" }
Component.onCompleted: root._stampTab("editorPicks", root.editorSections.length)
}
}
// ===== For You (phase 13) =================================
// Loader-gated for the reason spelled out on the Home tab.
Loader {
width: parent.width - 64
active: root.activeTab === "forYou"
visible: active
sourceComponent: Column {
width: parent.width
spacing: 40
TabSkeleton {
visible: QbzHome.homeLoading && root.forYouSections.length === 0
phase: root.skelPhase
}
SectionRails { sectionsModel: root.forYouSections; tabId: "forYou" }
Component.onCompleted: root._stampTab("forYou", root.forYouSections.length)
}
}
// ===== Recommendations (external reco engine) =============
// qbz-external-reco (Last.fm + ListenBrainz -> Qobuz), driven
// by src/recommendations_qt.rs. LAZY: the first time this
// column becomes visible it asks Rust to build the tab; every
// later entry repaints from memory / the engine's own result
// cache, so reopening Discover costs no external traffic.
//
// Loader-gated like the other three (see the Home tab), and
// the gate SIMPLIFIED the lazy-load hook it used to need. The
// old pair — `Component.onCompleted` plus `onVisibleChanged`,
// both funnelled through a `kick()` that re-checked
// `visible` — existed only because this Column was built
// eagerly while Home was the active tab: the mount fired too
// early to mean anything, so a second hook had to catch the
// tab actually being shown. Now the component does not exist
// until the tab is selected, so mounting IS being selected and
// one `Component.onCompleted` says it exactly.
Loader {
width: parent.width - 64
active: root.activeTab === "recommendations"
visible: active
sourceComponent: Column {
width: parent.width
spacing: 40
// Every later entry repaints from memory / the
// engine's own result cache, so re-selecting the tab
// costs no external traffic even though the component
// is rebuilt.
Component.onCompleted: QbzHome.loadRecommendations()
// Same two-row shimmer as the other tabs, while the
// first build is in flight and nothing has painted yet.
TabSkeleton {
visible: QbzHome.recoLoading && root.recoSections.length === 0
phase: root.skelPhase
}
// Rails resolve progressively — a row that is still
// building, or whose service is not connected, is
// simply ABSENT from the document (never an empty frame).
SectionRails { sectionsModel: root.recoSections; tabId: "recommendations" }
// Nothing built and nothing in flight: the Slint empty
// state, verbatim msgids.
QbzEmptyState {
visible: !QbzHome.recoLoading && root.recoSections.length === 0
Component {
id: homeErrorHeader
Item {
implicitHeight: errorBox.implicitHeight
+ (homeFlick.count > 0 ? 40 : 0)
Rectangle {
id: errorBox
width: parent.width
title: QbzSession.tr("No recommendations yet", QbzSession.trRev)
body: QbzSession.tr("Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations.", QbzSession.trRev)
actionLabel: QbzSession.tr("Open Settings", QbzSession.trRev)
onActionClicked: QbzShell.navigateTo("settings")
implicitHeight: errorColumn.implicitHeight + 28
height: implicitHeight
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: errorColumn
anchors.centerIn: parent
spacing: 10
Text {
text: QbzHome.homeError
color: theme.textSecondary
font.pixelSize: 13
}
Rectangle {
width: retryText.implicitWidth + 28
height: 32
radius: 6
color: retryArea.containsMouse
? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: retryText
anchors.centerIn: parent
text: QbzSession.tr("Retry", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.reloadHome()
}
}
}
}
}
}
Component {
id: recoEmptyHeader
QbzEmptyState {
title: QbzSession.tr("No recommendations yet", QbzSession.trRev)
body: QbzSession.tr("Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations.", QbzSession.trRev)
actionLabel: QbzSession.tr("Open Settings", QbzSession.trRev)
onActionClicked: QbzShell.navigateTo("settings")
}
}
Component.onCompleted:
root._stampViewport(root.activeTab, root.activeSections.length)
}
// Back/forward scroll memory and the custom gutter both accept a
// ListView because it is a Flickable. The per-tab scope is
// unchanged.
ScrollMemory { target: homeFlick; scope: "home:" + root.activeTab }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: homeFlick
}
}
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: homeFlick; scope: "home:" + root.activeTab }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: homeFlick
}
}
}
+39 -15
View File
@@ -943,6 +943,42 @@ mod tests {
// Scaled derivatives
// ---------------------------------------------------------------------------
/// The deterministic derivative path for one already-computed draw size.
///
/// Kept separate from [`scaled_path`] so the Qt GUI thread can perform the
/// cheap warm-cache probe below without decoding the original, creating the
/// directory, or touching the derivative's mtime. The expensive/mutating arm
/// remains on `spawn_blocking` through `QbzSession.artScaled()`.
fn scaled_output_path(path: &str, w: u32, h: u32) -> Option<(String, PathBuf)> {
if w == 0 || h == 0 {
return None;
}
let src = path.strip_prefix("file://").unwrap_or(path).to_string();
let dir = dirs::cache_dir()?.join("qbz").join("images").join("scaled");
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&src, &mut hasher);
let out = dir.join(format!(
"{:016x}_{w}x{h}.png",
std::hash::Hasher::finish(&hasher)
));
Some((src, out))
}
/// Return an existing derivative without doing any image work.
///
/// `RoundedImage` calls this after its dimension probe computes the exact
/// aspect-preserving draw size. On a warm disk cache the answer is available
/// in the SAME GUI event that made the original probe Ready, so the visible
/// `Image` switches to the small derivative before the next scene-graph frame
/// instead of painting/uploading the original and replacing that texture one
/// or more event-loop turns later. A miss is deliberately just one local
/// `stat`; generation still goes through [`scaled_path`] off-thread.
pub fn cached_scaled_path(path: &str, w: u32, h: u32) -> Option<PathBuf> {
let (_, out) = scaled_output_path(path, w, h)?;
out.is_file().then_some(out)
}
/// A cover re-encoded to fit inside `w`x`h`, ASPECT PRESERVED. Canvas
/// `drawImage` does not filter when it scales — a 600px cover drawn into a
/// 200px cell throws away 8 of every 9 pixels, which is the aliasing the
@@ -976,21 +1012,9 @@ mod tests {
///
/// Blocking: decodes and resizes. Call from `spawn_blocking`.
pub fn scaled_path(path: &str, w: u32, h: u32) -> Option<std::path::PathBuf> {
if w == 0 || h == 0 {
return None;
}
let src = path.strip_prefix("file://").unwrap_or(path);
let dir = dirs::cache_dir()?.join("qbz").join("images").join("scaled");
let (src, out) = scaled_output_path(path, w, h)?;
let dir = out.parent()?;
std::fs::create_dir_all(&dir).ok()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&src, &mut hasher);
// `.png`, so the lossy `.jpg` generation already on disk is never served
// as if it were this one — the name IS the cache key.
let out = dir.join(format!(
"{:016x}_{w}x{h}.png",
std::hash::Hasher::finish(&hasher)
));
if out.is_file() {
// Mark it used so `evict_scaled`'s mtime order is access order, not
// creation order. Best-effort: a failure only degrades eviction to
@@ -1007,7 +1031,7 @@ pub fn scaled_path(path: &str, w: u32, h: u32) -> Option<std::path::PathBuf> {
// The artwork cache stores files as `.img` — `image::open` guesses the
// format from the EXTENSION and fails on it, which is why not one
// derivative was ever produced. Sniff the content instead.
let img = image::ImageReader::open(src)
let img = image::ImageReader::open(&src)
.ok()?
.with_guessed_format()
.ok()?
+15
View File
@@ -205,6 +205,21 @@ impl CastService {
self.inner.lock().await.protocol.is_some()
}
/// The tier the next connected-renderer request would use: the raw
/// streaming preference clamped by that renderer's manual cap, never by
/// the local DAC. Immediate queue warming consumes this because the cast
/// resolver is cache-first and the playback cache is quality-blind.
pub(crate) async fn casting_prefetch_quality(&self) -> Option<Quality> {
let cap_key = {
let inner = self.inner.lock().await;
if inner.protocol.is_none() {
return None;
}
inner.connected_cap_key.clone()
};
Some(effective_cast_quality(cap_key.as_deref()).0)
}
// ---- Discovery ---------------------------------------------------------
/// Start mDNS (Chromecast) + SSDP (DLNA) discovery, the 2 s device-refresh
+2
View File
@@ -972,6 +972,8 @@ mod display_label_tests {
.into_iter()
.map(|(title, artist)| EphemeralAlbumBlock {
group_key: String::new(),
disc: 0,
sort_title: title.to_string(),
title: title.to_string(),
artist: artist.to_string(),
meta: String::new(),
+29 -4
View File
@@ -2888,6 +2888,30 @@ fn apply_renderer_preference() {
}
}
/// Enable Qt 6's native accumulated mouse-wheel flicks.
///
/// Qt 6.11 ships `wheelDeceleration = 15000`, exactly one unit above its
/// `_q_MaximumWheelDeceleration = 14999` switch. That selects proportional
/// scrolling: every notch travels the same distance and a rapid wheel spin
/// cannot build velocity. A value below the switch enables the already-built
/// Flickable wheel timeline. 1500 matches this platform's ordinary touch-flick
/// deceleration, so one isolated notch keeps Qt's 72px distance while a rapid
/// burst earns a bounded kinetic tail.
///
/// This is read when each QQuickFlickable is constructed, hence before the
/// QGuiApplication/QML engine. Respect an explicit environment value for
/// diagnostics and owner tuning; no hidden QBZ setting existed in the Slint or
/// retired web frontend to migrate.
fn apply_scroll_physics() {
const QT_WHEEL_DECELERATION: &str = "QT_QUICK_FLICKABLE_WHEEL_DECELERATION";
if std::env::var_os(QT_WHEEL_DECELERATION).is_none() {
std::env::set_var(QT_WHEEL_DECELERATION, "1500");
log::info!("[qbz-qt] native kinetic wheel enabled (deceleration=1500)");
} else {
log::info!("[qbz-qt] explicit {QT_WHEEL_DECELERATION} preserved");
}
}
// ============================ Shutdown guarantee ==========================
/// One-shot latch for [`arm_hard_exit_watchdog`].
@@ -3040,12 +3064,13 @@ fn main() {
let _ = APP.set(runtime);
// BOTH must run before QGuiApplication: the renderer envs are read when
// the backend is chosen, and the GPU envs when the graphics context is
// created. Setting either afterwards is a silent no-op — the context is
// already up and nothing complains.
// All three must run before QGuiApplication: the renderer envs are read
// when the backend is chosen, the GPU envs when the graphics context is
// created, and the wheel policy when Flickables are constructed. Setting
// any of them afterwards is a silent no-op.
apply_renderer_preference();
renderer_qt::apply_gpu_preference();
apply_scroll_physics();
let mut app = QGuiApplication::new();
let mut engine = QQmlApplicationEngine::new();
+269 -33
View File
@@ -17,7 +17,7 @@
//! - Streaming quality: seeded from ui_prefs ("streaming_quality") and
//! live-updated by Settings > Audio (settings_qt). The #638 device-cap clamp
//! lives in the Slint glue and is NOT ported.
//! - Offline-cache tier (offline bytes), prefetch warming, QConnect branches.
//! - Offline-cache tier (offline bytes), QConnect branches.
//!
//! DONE, previously listed here — kept as one line each so the list above
//! only ever names REAL gaps:
@@ -125,6 +125,144 @@ fn reconcile_device_cap(pref: Quality, cap: Option<Quality>) -> (Quality, qbz_mo
}
}
/// How many immediate successors a new-track edge warms into L1/L2. This is
/// deliberately independent of the engine's `GAPLESS_LEAD_SECS`: the full
/// downloads begin as soon as the current track surfaces, while the existing
/// ~10 s gapless stage later consumes the first successor from cache (or its
/// normal fallback) and hands it to `play_next`.
const PREFETCH_LOOKAHEAD: usize = 2;
/// Shared upper bound across overlapping track edges. A new current track can
/// surface while the second successor of the previous edge is still warming;
/// the player's own fetching marker de-duplicates the overlap, while this
/// semaphore keeps unrelated downloads bounded.
const MAX_CONCURRENT_PREFETCH: usize = 2;
static PREFETCH_SEMAPHORE: tokio::sync::Semaphore =
tokio::sync::Semaphore::const_new(MAX_CONCURRENT_PREFETCH);
/// The prefetch edge is separate from `last_track_id`: the latter belongs to
/// the LOCAL UI/integration pump and is intentionally reset while casting,
/// whereas immediate warming must follow whichever transport owns playback.
/// Zero means no owner/current track and rearms a later replay of the same id.
#[derive(Debug, Default)]
struct PrefetchTrackEdge {
last_track_id: u64,
}
impl PrefetchTrackEdge {
fn observe(&mut self, track_id: u64) -> bool {
if track_id == 0 {
self.last_track_id = 0;
return false;
}
if track_id == self.last_track_id {
return false;
}
self.last_track_id = track_id;
true
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PrefetchCandidate {
id: u64,
is_local: bool,
}
impl From<&QueueTrack> for PrefetchCandidate {
fn from(track: &QueueTrack) -> Self {
Self {
id: track.id,
is_local: track.is_local,
}
}
}
/// Pure policy seam for the deterministic contract tests. `upcoming` is
/// already the queue's repeat/shuffle-aware next-two snapshot; filtering a
/// local row does not reach beyond that snapshot and accidentally warm a third
/// successor.
fn prefetch_track_ids(
streaming_only: bool,
offline: bool,
throttle_cap: usize,
upcoming: &[PrefetchCandidate],
) -> Vec<u64> {
if streaming_only || offline || throttle_cap == 0 {
return Vec::new();
}
upcoming
.iter()
.take(PREFETCH_LOOKAHEAD)
.filter(|track| track.id != 0 && !track.is_local)
.map(|track| track.id)
.collect()
}
fn prefetch_quality_tag(quality: Quality) -> qbz_audio::network_throttle::PlaybackQualityTag {
use qbz_audio::network_throttle::PlaybackQualityTag;
match quality {
Quality::UltraHiRes => PlaybackQualityTag::UltraHiRes,
Quality::HiRes => PlaybackQualityTag::HiRes,
Quality::Lossless => PlaybackQualityTag::Lossless,
Quality::Mp3 => PlaybackQualityTag::Lossy,
}
}
/// Warm the next two queue rows in the player's shared L1/L2 cache. This only
/// performs cheap policy/queue work inline; every full download is spawned in
/// the background and goes through `Player::prefetch_into_cache`, which owns
/// the in-flight marker, cache re-check, 20 s failure cooldown and cache write.
async fn kick_prefetch(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let streaming_only = crate::settings_qt::audio_settings().streaming_only;
let offline = crate::offline_fwd::engine().is_offline();
if streaming_only || offline {
return;
}
// Resolve once so the bandwidth estimate and every spawned request agree.
// A renderer owns its own cap; the local DAC cap must never leak into the
// quality-blind cache that the cast path reads first.
let quality = crate::cast_qt::service()
.casting_prefetch_quality()
.await
.unwrap_or_else(|| local_playback_quality().0);
let throttle_cap = qbz_audio::network_throttle::state().current_prefetch_cap(
qbz_audio::network_throttle::playback_mbps_for_quality(prefetch_quality_tag(quality)),
MAX_CONCURRENT_PREFETCH,
);
if throttle_cap == 0 {
log::debug!("[qbz-qt] prefetch: skipped (network throttle cap 0)");
return;
}
let upcoming = runtime.core().peek_upcoming(PREFETCH_LOOKAHEAD).await;
let candidates: Vec<PrefetchCandidate> = upcoming.iter().map(PrefetchCandidate::from).collect();
let track_ids = prefetch_track_ids(streaming_only, offline, throttle_cap, &candidates);
for track_id in track_ids {
let player = runtime.core().player();
if player.is_track_cached(track_id) {
continue;
}
let runtime = runtime.clone();
tokio::spawn(async move {
let _permit = match PREFETCH_SEMAPHORE.acquire().await {
Ok(permit) => permit,
Err(_) => return,
};
let client_lock = runtime.core().client();
let guard = client_lock.read().await;
let Some(client) = guard.as_ref() else {
return;
};
let player = runtime.core().player();
if let Err(error) = player.prefetch_into_cache(client, track_id, quality).await {
log::debug!("[qbz-qt] prefetch: track {track_id} failed: {error}");
}
});
}
}
// Mute bookkeeping (mirrors playback.rs MUTED / PREMUTE_VOLUME: there is no
// dedicated mute API on the core — mute is volume 0 with a stash).
/// The track id of a COLD START that has been dispatched but has not yet
@@ -2614,6 +2752,10 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
// does not re-request it on every tick while the download is in flight
// (playback.rs:5049-5051).
let mut gapless_requested_for: u64 = 0;
// Immediate full-cache warming has a distinct edge because it follows
// both the local engine and the cast cursor. Unlike the gapless guard,
// it names the NEW current track whose two successors are being warmed.
let mut prefetch_track_edge = PrefetchTrackEdge::default();
// QConnect renderer-report throttle (contract §6.2): the official
// client reports RndrSrvrStateUpdated ~every 2s while playing PLUS
@@ -2802,41 +2944,57 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
last_track_id = 0;
was_playing = false;
seen_position = 0;
// A peer renderer owns its own playback/cache. Rearm only so a
// later hand-back to local/cast can warm from that real edge.
prefetch_track_edge.observe(0);
continue;
}
// CAST: the cast service runs its own 1 s poll and the local player is
// stopped, so this path would push position 0 / not-playing every
// second and fight it.
if crate::cast_qt::is_casting().await {
last_track_id = 0;
was_playing = false;
seen_position = 0;
// Same invariant for the PEER edge trackers (playback.rs:5268-5269):
// the remote branch above did not run, so without this reset a
// re-taken peer whose snapshot happens to match the last controller
// push would be skipped, leaving the cast renderer's values on the
// bar (mirrors the fall-through reset below).
last_peer_track_id = 0;
last_remote_ui_push = None;
// A cast target owns playback and there is no local download to
// outrun: seek is unlocked while casting, the way the Slint cast
// publish forces it (`cast_service.rs:1118`). Without this the last
// local stream's fraction would stay latched on the bar.
crate::now_playing::set_seekable_max(1.0);
continue;
}
// Not in controller mode (no peer / returned to local): reset the
// peer-track edge var so re-entering the peer state refreshes meta
// (playback.rs:5274-5277).
last_peer_track_id = 0;
last_remote_ui_push = None;
// §11.1: deliberately NO lyrics anchor clear here (the Slint clears
// per-tick at playback.rs:5279) — the Qt anchor clears from the single
// `now_playing::set_remote(false)` choke point, which unlike this
// fallthrough also runs while the cast branch owns the loop. See the
// note in `now_playing::set_remote`.
let event = runtime.core().player().get_playback_event();
// stopped, so this path would push position 0 / not-playing every
// second and fight it.
if crate::cast_qt::is_casting().await {
// The local engine is stopped while casting, so its PlaybackEvent can
// never surface a track edge. The cast service advances the shared
// core cursor; observe that cursor here and warm once per new current
// track at the renderer-effective tier.
let cast_track_id = runtime
.core()
.current_track()
.await
.map(|track| track.id)
.unwrap_or(0);
if prefetch_track_edge.observe(cast_track_id) {
kick_prefetch(&runtime).await;
}
last_track_id = 0;
was_playing = false;
seen_position = 0;
// Same invariant for the PEER edge trackers (playback.rs:5268-5269):
// the remote branch above did not run, so without this reset a
// re-taken peer whose snapshot happens to match the last controller
// push would be skipped, leaving the cast renderer's values on the
// bar (mirrors the fall-through reset below).
last_peer_track_id = 0;
last_remote_ui_push = None;
// A cast target owns playback and there is no local download to
// outrun: seek is unlocked while casting, the way the Slint cast
// publish forces it (`cast_service.rs:1118`). Without this the last
// local stream's fraction would stay latched on the bar.
crate::now_playing::set_seekable_max(1.0);
continue;
}
// Not in controller mode (no peer / returned to local): reset the
// peer-track edge var so re-entering the peer state refreshes meta
// (playback.rs:5274-5277).
last_peer_track_id = 0;
last_remote_ui_push = None;
// §11.1: deliberately NO lyrics anchor clear here (the Slint clears
// per-tick at playback.rs:5279) — the Qt anchor clears from the single
// `now_playing::set_remote(false)` choke point, which unlike this
// fallthrough also runs while the cast branch owns the loop. See the
// note in `now_playing::set_remote`.
let event = runtime.core().player().get_playback_event();
let track_id = event.track_id;
let position = event.position;
let duration = event.duration;
@@ -2849,6 +3007,12 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
// is the value the seek bars must enforce.
let seekable_max = event.buffer_progress.map(|p| p.clamp(0.0, 1.0)).unwrap_or(1.0);
// This observation is cheap and happens each tick; the work does
// not. Only a NEW non-zero engine track — including a gapless
// handoff — enters `kick_prefetch`. A stop rearms replaying the
// same id later.
let immediate_prefetch_edge = prefetch_track_edge.observe(track_id);
// --- Track-change edge (new current track surfaced) ----------
if track_id != 0 && track_id != last_track_id {
// A cold start that reached audio is no longer pending.
@@ -2898,6 +3062,14 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
gapless_requested_for = 0;
}
// Separate from the UI edge above on purpose: a 0-id stop rearms
// this tracker, so replaying the same queue id warms again even if
// another local edge tracker has not yet been cleared by its
// end-of-track arm.
if immediate_prefetch_edge {
kick_prefetch(&runtime).await;
}
if track_id != 0 {
log::debug!("[qbz-qt] poll: id={track_id} pos={position}/{duration} playing={is_playing} cache={cache:.2}");
}
@@ -3219,10 +3391,74 @@ pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
#[cfg(test)]
mod tests {
use super::{
filter_queue_with, reconcile_device_cap, stream_error_text, xorshift_shuffle_seeded,
filter_queue_with, prefetch_track_ids, reconcile_device_cap, stream_error_text,
xorshift_shuffle_seeded, PrefetchCandidate, PrefetchTrackEdge,
};
use qbz_models::{Quality, QualityLimit};
fn remote(id: u64) -> PrefetchCandidate {
PrefetchCandidate {
id,
is_local: false,
}
}
fn local(id: u64) -> PrefetchCandidate {
PrefetchCandidate { id, is_local: true }
}
// --- #688: immediate two-successor L1/L2 warming ---------------------
#[test]
fn streaming_only_on_plans_zero_prefetches() {
assert!(prefetch_track_ids(true, false, 2, &[remote(2), remote(3)]).is_empty());
}
#[test]
fn streaming_only_off_plans_at_most_two_successors() {
assert_eq!(
prefetch_track_ids(false, false, 2, &[remote(2), remote(3), remote(4)]),
vec![2, 3]
);
assert_eq!(prefetch_track_ids(false, false, 2, &[remote(9)]), vec![9]);
}
#[test]
fn local_successors_are_never_prefetched() {
// The policy inspects the queue's immediate next-two snapshot. It does
// not skip across the local row and turn the third row into a hidden
// third-successor warmup.
assert_eq!(
prefetch_track_ids(false, false, 2, &[remote(2), local(3), remote(4)]),
vec![2]
);
}
#[test]
fn a_repeated_track_edge_does_not_trigger_twice() {
let mut edge = PrefetchTrackEdge::default();
assert!(edge.observe(41));
assert!(!edge.observe(41));
assert!(!edge.observe(41));
}
#[test]
fn a_gapless_new_current_edge_prefetches_that_tracks_successors() {
let mut edge = PrefetchTrackEdge::default();
assert!(edge.observe(41), "initial audible track must be an edge");
assert!(edge.observe(42), "gapless handoff must surface a new edge");
assert_eq!(
prefetch_track_ids(false, false, 2, &[remote(43), remote(44)]),
vec![43, 44]
);
assert!(!edge.observe(42), "republishing the handoff must dedupe");
}
#[test]
fn throttle_cap_zero_plans_zero_downloads() {
assert!(prefetch_track_ids(false, false, 0, &[remote(2), remote(3)]).is_empty());
}
// --- #638 fix 3: the request tier reconciles preference against the
// detected local device cap. -------------------------------------------
+13
View File
@@ -102,6 +102,11 @@ pub mod qbz_session {
/// derivative already on disk answers on the next event-loop pass.
#[qinvokable]
fn art_scaled(self: Pin<&mut QbzSession>, path: QString, w: i32, h: i32);
/// Synchronous WARM-CACHE probe for the exact derivative request.
/// This never decodes or writes: it returns the existing local file,
/// or "" so QML can fall through to the asynchronous arm above.
#[qinvokable]
fn art_scaled_cached(self: &QbzSession, path: QString, w: i32, h: i32) -> QString;
/// (requested path, derivative url, requested w, requested h) — keyed
/// by the WHOLE REQUEST. The path alone is not a key: it is normal for
/// the same cover to be on screen at two sizes at once (a 200px card
@@ -250,4 +255,12 @@ impl qbz_session::QbzSession {
});
}
pub fn art_scaled_cached(&self, path: QString, w: i32, h: i32) -> QString {
if w <= 0 || h <= 0 {
return QString::default();
}
crate::artwork_qt::cached_scaled_path(&path.to_string(), w as u32, h as u32)
.map(|p| QString::from(crate::artwork_qt::file_url(&p.to_string_lossy()).as_str()))
.unwrap_or_default()
}
}