From c6d142ccb4e2397a9722a142111bff84e9c0fcb8 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:35:36 -0600 Subject: [PATCH] perf(qt): virtualize discovery and warm playback cx --- crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/cards/ArtistCard.qml | 24 +- crates/qbz-qt/qml/cards/PlaylistCard.qml | 50 +- crates/qbz-qt/qml/cards/TrackCard.qml | 24 +- crates/qbz-qt/qml/theme/QbzKineticScroll.qml | 134 ++++++ crates/qbz-qt/qml/theme/QbzScrollBar.qml | 57 ++- crates/qbz-qt/qml/theme/RoundedImage.qml | 13 + crates/qbz-qt/qml/views/AlbumView.qml | 10 +- crates/qbz-qt/qml/views/HomeView.qml | 466 +++++++++---------- crates/qbz-qt/src/artwork_qt.rs | 54 ++- crates/qbz-qt/src/cast_qt.rs | 15 + crates/qbz-qt/src/local_ephemeral.rs | 2 + crates/qbz-qt/src/main.rs | 33 +- crates/qbz-qt/src/playback_qt.rs | 302 ++++++++++-- crates/qbz-qt/src/session_bridge.rs | 13 + 15 files changed, 841 insertions(+), 357 deletions(-) create mode 100644 crates/qbz-qt/qml/theme/QbzKineticScroll.qml diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 66b1c3b2b..2d86fec40 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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", diff --git a/crates/qbz-qt/qml/cards/ArtistCard.qml b/crates/qbz-qt/qml/cards/ArtistCard.qml index f7ae4e665..a798f493f 100644 --- a/crates/qbz-qt/qml/cards/ArtistCard.qml +++ b/crates/qbz-qt/qml/cards/ArtistCard.qml @@ -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) } diff --git a/crates/qbz-qt/qml/cards/PlaylistCard.qml b/crates/qbz-qt/qml/cards/PlaylistCard.qml index b0e343c98..06a8c18df 100644 --- a/crates/qbz-qt/qml/cards/PlaylistCard.qml +++ b/crates/qbz-qt/qml/cards/PlaylistCard.qml @@ -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. diff --git a/crates/qbz-qt/qml/cards/TrackCard.qml b/crates/qbz-qt/qml/cards/TrackCard.qml index d6d06368a..4669fb271 100644 --- a/crates/qbz-qt/qml/cards/TrackCard.qml +++ b/crates/qbz-qt/qml/cards/TrackCard.qml @@ -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) } diff --git a/crates/qbz-qt/qml/theme/QbzKineticScroll.qml b/crates/qbz-qt/qml/theme/QbzKineticScroll.qml new file mode 100644 index 000000000..e7a48e903 --- /dev/null +++ b/crates/qbz-qt/qml/theme/QbzKineticScroll.qml @@ -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); + } + } +} diff --git a/crates/qbz-qt/qml/theme/QbzScrollBar.qml b/crates/qbz-qt/qml/theme/QbzScrollBar.qml index 196735f7f..e7ef63556 100644 --- a/crates/qbz-qt/qml/theme/QbzScrollBar.qml +++ b/crates/qbz-qt/qml/theme/QbzScrollBar.qml @@ -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; } } } diff --git a/crates/qbz-qt/qml/theme/RoundedImage.qml b/crates/qbz-qt/qml/theme/RoundedImage.qml index 8da99d4fc..f7bfc7e78 100644 --- a/crates/qbz-qt/qml/theme/RoundedImage.qml +++ b/crates/qbz-qt/qml/theme/RoundedImage.qml @@ -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 { diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index abf277191..fd3580593 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index 36379f336..d3cd3c716 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -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 - } } } diff --git a/crates/qbz-qt/src/artwork_qt.rs b/crates/qbz-qt/src/artwork_qt.rs index 24e1a70f4..51d41109a 100644 --- a/crates/qbz-qt/src/artwork_qt.rs +++ b/crates/qbz-qt/src/artwork_qt.rs @@ -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 { + 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 { - 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 { // 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()? diff --git a/crates/qbz-qt/src/cast_qt.rs b/crates/qbz-qt/src/cast_qt.rs index 45bd5157f..c2ec82f98 100644 --- a/crates/qbz-qt/src/cast_qt.rs +++ b/crates/qbz-qt/src/cast_qt.rs @@ -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 { + 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 diff --git a/crates/qbz-qt/src/local_ephemeral.rs b/crates/qbz-qt/src/local_ephemeral.rs index 92a757a12..6229eb3df 100644 --- a/crates/qbz-qt/src/local_ephemeral.rs +++ b/crates/qbz-qt/src/local_ephemeral.rs @@ -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(), diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index a50d9b983..32f64645a 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -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(); diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index aac688797..4972ecefd 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -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, 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 { + 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>) { + 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 = 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>) { // 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>) { 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>) { // 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>) { 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>) { #[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. ------------------------------------------- diff --git a/crates/qbz-qt/src/session_bridge.rs b/crates/qbz-qt/src/session_bridge.rs index ca9e208b0..eb04a865d 100644 --- a/crates/qbz-qt/src/session_bridge.rs +++ b/crates/qbz-qt/src/session_bridge.rs @@ -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() + } }