perf(qt): virtualize discovery and warm playback
cx
This commit is contained in:
15 files changed
+841
-357
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. -------------------------------------------
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user