perf(qt): virtualize discovery and warm playback

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

No files matched your search

+16 -8
View File
@@ -114,6 +114,11 @@ Rectangle {
QbzLibrary.libraryToggleFavorite(root.followKind, root.item.id)
}
function openMenu(anchor, x, y) {
agMenuLoader.active = true
agMenuLoader.item.openAtCursor(anchor, x, y)
}
Connections {
target: QbzLibrary
// Pin fan-out — the AlbumCard contract, artist key (see AlbumCard.qml:
@@ -192,7 +197,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
agMenu.openAtCursor(agArea, mouse.x, mouse.y)
root.openMenu(agArea, mouse.x, mouse.y)
else
QbzArtist.openArtist(root.item.id)
}
@@ -221,14 +226,17 @@ Rectangle {
id: agMore
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { agMenu.openAtCursor(agMore, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openMenu(agMore, mouse.x, mouse.y) }
}
}
CardMenu {
id: agMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
Loader {
id: agMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
}
}
}
// Pin badge — top-right of the FRAME (outside the circle clip),
@@ -289,7 +297,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
agMenu.openAtCursor(agNameArea, mouse.x, mouse.y)
root.openMenu(agNameArea, mouse.x, mouse.y)
else
QbzArtist.openArtist(root.item.id)
}
+32 -18
View File
@@ -132,6 +132,11 @@ Rectangle {
}
}
function openMenu(anchor, x, y) {
plMenuLoader.active = true
plMenuLoader.item.openAtCursor(anchor, x, y)
}
Connections {
target: QbzLibrary
// Pin fan-out — the AlbumCard contract, playlist key (see AlbumCard.qml).
@@ -182,21 +187,27 @@ Rectangle {
// with no flag at all. `auto` measures the ratio instead, so
// those surfaces pad too, and a genuinely square cover
// (`image.covers[0]`, the fallback that map picks) still crops.
RoundedImage {
Loader {
anchors.fill: parent
visible: root.artSource !== ""
source: root.artSource
radius: theme.radiusSm
fit: root.ownImage ? "pad" : "auto"
sourceComponent: root.artSource !== "" ? singleArtwork : collageArtwork
}
Component {
id: singleArtwork
RoundedImage {
source: root.artSource
radius: theme.radiusSm
fit: root.ownImage ? "pad" : "auto"
}
}
// Arm 2 — the member-cover mosaic. A row that HAS its own
// graphic passes no urls, so it shows the placeholder while
// that graphic downloads instead of flashing a mosaic.
PlaylistCollage {
anchors.fill: parent
visible: root.artSource === ""
urls: root.ownImage ? [] : root.collageUrls
radius: theme.radiusSm
Component {
id: collageArtwork
PlaylistCollage {
urls: root.ownImage ? [] : root.collageUrls
radius: theme.radiusSm
}
}
Rectangle {
anchors.fill: parent
@@ -214,7 +225,7 @@ Rectangle {
// body-opens (every Slint mount); right press = the ⋯ menu.
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
plMenu.openAtCursor(plArtArea, mouse.x, mouse.y)
root.openMenu(plArtArea, mouse.x, mouse.y)
else
QbzBridge.openPlaylist(root.item.id)
}
@@ -243,7 +254,7 @@ Rectangle {
id: plMore
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { plMenu.openAtCursor(plMore, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openMenu(plMore, mouse.x, mouse.y) }
}
}
// Pin badge — top-right (opacity follows overlay-on).
@@ -276,11 +287,14 @@ Rectangle {
}
}
}
CardMenu {
id: plMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
Loader {
id: plMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
}
}
}
Item { width: 1; height: 6 }
@@ -335,7 +349,7 @@ Rectangle {
width: 200
height: 40
acceptedButtons: Qt.RightButton
onClicked: function (mouse) { plMenu.openAtCursor(plMetaArea, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openMenu(plMetaArea, mouse.x, mouse.y) }
}
// PlaylistCard.slint's menu, in its order.
+16 -8
View File
@@ -55,6 +55,11 @@ Rectangle {
QbzLibrary.libraryToggleFavorite("track", root.item.id)
}
function openMenu(anchor, x, y) {
trackMenuLoader.active = true
trackMenuLoader.item.openAtCursor(anchor, x, y)
}
// Fan-out + rollback (the shape cards/AlbumCard.qml uses for pin).
Connections {
target: QbzLibrary
@@ -97,7 +102,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
trackMenu.openAtCursor(tcArtArea, mouse.x, mouse.y)
root.openMenu(tcArtArea, mouse.x, mouse.y)
else
QbzPlayer.playTrack(root.item.id)
}
@@ -126,7 +131,7 @@ Rectangle {
id: moreBtn
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: function (mouse) { trackMenu.openAtCursor(moreBtn, mouse.x, mouse.y) }
onClicked: function (mouse) { root.openMenu(moreBtn, mouse.x, mouse.y) }
}
}
// Source badge (All feed, show-local): bottom-right of the art.
@@ -166,11 +171,14 @@ Rectangle {
y: Math.round((parent.height - height) / 2)
}
}
CardMenu {
id: trackMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.trackAction(a) }
Loader {
id: trackMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.trackAction(a) }
}
}
}
Item { width: 1; height: 6 }
@@ -200,7 +208,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
trackMenu.openAtCursor(tcTitleArea, mouse.x, mouse.y)
root.openMenu(tcTitleArea, mouse.x, mouse.y)
else
QbzPlayer.playTrack(root.item.id)
}
@@ -0,0 +1,134 @@
// Shared touchpad kinetic tail for a Flickable/ListView/GridView.
//
// Qt 6.11 already owns the direct pixel-delta drag while the fingers are on
// the pad, including nested-axis arbitration and bounds. On Linux/Wayland the
// gesture commonly ends with ScrollEnd and NO compositor momentum stream, so
// Qt stops the content on the same frame the fingers lift. This component only
// observes that native gesture and hands its final vertical velocity back to
// Flickable.flick(). It never writes contentY and never owns an animation.
//
// RESOURCE CONTRACT (GPU-COST-INVESTIGATION.md): WheelHandler is event-driven;
// there is no Timer, Behavior or permanent clock. The one callLater crosses
// Qt's ScrollEnd cleanup, then Flickable's bounded native timeline owns motion
// and stops itself. At rest this item writes nothing and schedules no frame.
import QtQuick
Item {
id: root
required property Flickable target
// QbzScrollBar declares us beside the view. Reparent to the Flickable
// explicitly so this observer covers its whole VIEWPORT, including child
// rails and cards, rather than the scrollbar's 14px gutter.
parent: target
anchors.fill: parent
z: 2147483646
property real _sumX: 0
property real _sumY: 0
property real _velocityY: 0
property double _lastMs: 0
property bool _vertical: false
property bool _horizontal: false
property bool _nativeMomentum: false
property int _generation: 0
function _resetGesture() {
root._sumX = 0;
root._sumY = 0;
root._velocityY = 0;
root._lastMs = Date.now();
root._vertical = false;
root._horizontal = false;
root._nativeMomentum = false;
++root._generation;
}
function _sample(dx, dy) {
const now = Date.now();
// A suspended event loop must not turn one stale delta into an absurd
// velocity; 1..80ms covers the useful gesture sample window.
const dt = Math.max(1, Math.min(80, now - root._lastMs));
root._lastMs = now;
root._sumX += dx;
root._sumY += dy;
// Match Qt's own nested-Flickable decision: one axis must beat the
// other 2:1 before it owns the gesture. A horizontal carousel therefore
// keeps horizontal/diagonal swipes and can never launch the page tail.
if (!root._vertical && !root._horizontal) {
if (Math.abs(root._sumY) > Math.abs(root._sumX) * 2)
root._vertical = true;
else if (Math.abs(root._sumX) > Math.abs(root._sumY) * 2)
root._horizontal = true;
}
if (root._vertical) {
const instant = dy * 1000 / dt;
// Recent samples matter most at finger lift, without letting one
// noisy event replace the entire gesture estimate.
root._velocityY = root._velocityY === 0 ? instant : root._velocityY * 0.35 + instant * 0.65;
}
}
function _launchTail() {
let velocity = root._velocityY;
const serial = root._generation;
if (!root._vertical || root._nativeMomentum || Math.abs(root._sumY) < 12 || Math.abs(velocity) < 220)
return;
// Respect each view's platform-tuned ceiling. `-1` means unlimited;
// use the platform's usual 2500 px/s rather than allowing a noisy
// high-resolution sample to fling tens of screens.
const cap = root.target.maximumFlickVelocity > 0 ? root.target.maximumFlickVelocity : 2500;
velocity = Math.max(-cap, Math.min(cap, velocity));
// WheelHandler observes before Flickable finishes ScrollEnd. Launch on
// the next event-loop turn or that cleanup would cancel the new tail.
Qt.callLater(function () {
if (serial !== root._generation || !root.target || !root.target.visible || !root.target.interactive)
return;
root.target.flick(0, velocity);
});
}
WheelHandler {
target: null
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
blocking: false
enabled: root.target.interactive
onWheel: function (event) {
// Observer only. Native Flickable still applies the direct delta,
// arbitrates nested axes and owns bounds/overshoot.
event.accepted = false;
if (event.phase === Qt.ScrollBegin) {
root._resetGesture();
return;
}
if (event.phase === Qt.ScrollMomentum) {
// macOS and some compositors already provide the kinetic tail.
// Never stack ours on top of theirs.
root._nativeMomentum = true;
return;
}
if (event.phase === Qt.ScrollEnd) {
root._launchTail();
return;
}
if (event.phase !== Qt.ScrollUpdate)
return;
// A physical wheel normally has angleDelta only and is handled by
// Qt's process-wide wheel-deceleration path (main.rs). This half is
// deliberately limited to high-resolution pixel gestures.
const dx = event.pixelDelta.x;
const dy = event.pixelDelta.y;
if (dx !== 0 || dy !== 0)
root._sample(dx, dy);
}
}
}
+43 -14
View File
@@ -14,9 +14,11 @@ Item {
visible: maxScroll > 0
readonly property real maxScroll: Math.max(0, target.contentHeight - target.height)
readonly property real thumbH: target.contentHeight > 0
? Math.max(48, target.height / target.contentHeight * height)
: height
// ListView/GridView origins are not guaranteed to stay at zero when their
// model or variable-size delegates change. Use the documented Flickable
// coordinate (`contentY - originY`) for both drawing and seeking.
readonly property real scrollY: Math.max(0, Math.min(maxScroll, target.contentY - target.originY))
readonly property real thumbH: target.contentHeight > 0 ? Math.max(48, target.height / target.contentHeight * height) : height
readonly property real travel: Math.max(0, height - thumbH)
// Shown while scrolling, hovering, or dragging (auto-hide).
property bool scrollActive: false
@@ -25,8 +27,8 @@ Item {
Connections {
target: root.target
function onContentYChanged() {
root.scrollActive = true
hideTimer.restart()
root.scrollActive = true;
hideTimer.restart();
}
}
Timer {
@@ -35,6 +37,13 @@ Item {
onTriggered: root.scrollActive = false
}
// One shared input seam for every page that uses the house scrollbar:
// physical-wheel acceleration is Qt's native process policy (main.rs),
// while this observer supplies only a missing touchpad tail.
QbzKineticScroll {
target: root.target
}
// Track.
Rectangle {
width: 6
@@ -43,19 +52,31 @@ Item {
radius: 3
color: "#12ffffff"
opacity: root.shown ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 160 } }
Behavior on opacity {
NumberAnimation {
duration: 160
}
}
}
// Thumb.
Rectangle {
width: barArea.containsMouse || barArea.pressed ? 10 : 8
x: Math.round((parent.width - width) / 2)
height: root.thumbH
y: root.maxScroll > 0 ? (root.target.contentY / root.maxScroll) * root.travel : 0
y: root.maxScroll > 0 ? (root.scrollY / root.maxScroll) * root.travel : 0
radius: 5
color: barArea.containsMouse || barArea.pressed ? "#77ffffff" : "#44ffffff"
opacity: root.shown ? 1.0 : 0.0
Behavior on width { NumberAnimation { duration: 100 } }
Behavior on opacity { NumberAnimation { duration: 160 } }
Behavior on width {
NumberAnimation {
duration: 100
}
}
Behavior on opacity {
NumberAnimation {
duration: 160
}
}
}
// Drag / click-to-position over the whole gutter.
MouseArea {
@@ -63,12 +84,20 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: position(mouseY)
onPositionChanged: if (pressed) position(mouseY)
onPressed: {
// Public Flickable semantics already cancel on a contentY write;
// make the takeover explicit so scrollbar drag remains exact even
// if the first pressed position equals the current one.
root.target.cancelFlick();
position(mouseY);
}
onPositionChanged: if (pressed)
position(mouseY)
function position(my) {
if (root.travel <= 0) return
var frac = Math.min(1, Math.max(0, (my - root.thumbH / 2) / root.travel))
root.target.contentY = frac * root.maxScroll
if (root.travel <= 0)
return;
var frac = Math.min(1, Math.max(0, (my - root.thumbH / 2) / root.travel));
root.target.contentY = root.target.originY + frac * root.maxScroll;
}
}
}
+13
View File
@@ -337,6 +337,19 @@ Item {
var key = root.source + "|" + rw + "x" + rh
if (key === root._reqKey) return
root._reqKey = key
// Warm-cache fast path. Previously even a derivative already on disk
// took the async Rust -> Tokio -> Qt-thread round trip. The original
// pixmap therefore reached the scene graph first, then every card
// replaced a large texture with its derivative a few frames later — a
// visible burst of work when a cold ListView band mounted. The exact
// path lookup is one local stat and performs no decode/write; on a
// miss, generation remains off-thread through artScaled().
var cached = QbzSession.artScaledCached(root.source, rw, rh)
if (cached !== "") {
root._scaled = cached
return
}
QbzSession.artScaled(root.source, rw, rh)
}
Connections {
+6 -4
View File
@@ -581,10 +581,12 @@ Rectangle {
model: root.listCells
reuseItems: true
currentIndex: -1
// One viewport of asynchronously incubated delegates gives a heavy
// rail time to finish before it becomes visible, without the old
// Loader's synchronous build in the wheel frame that crossed a band.
cacheBuffer: Math.max(600, height)
// The track tape is cheap enough that a fast wheel can consume one
// viewport before the first heavy card rail has finished incubating.
// Two viewports give those bottom rails roughly one kinetic-scroll
// window of lead time. Cache delegates are asynchronous and pooled;
// this does not restore the old eager footer mount.
cacheBuffer: Math.max(900, 2 * height)
header: Item {
width: pageFlick.width
+213 -253
View File
@@ -67,11 +67,9 @@ Rectangle {
QbzTheme { id: theme }
// ---- mount stopwatch (same category as ContentRouter's) --------------
// OFF unless QT_LOGGING_RULES="qbz.nav.timing.info=true". It exists to
// attribute a Home mount: this view declares FOUR Discover tabs and QML
// instantiates all four (`visible: false` hides a Column, it does not stop
// it from being built), so the question "how much of the wait is the three
// tabs nobody asked for" needs per-tab numbers, not a single total.
// OFF unless QT_LOGGING_RULES="qbz.nav.timing.info=true". The sections
// count below is the DOCUMENT size, not the number of rails instantiated:
// the vertical ListView only builds its viewport and cache buffer.
LoggingCategory {
id: homeTiming
name: "qbz.nav.timing"
@@ -80,9 +78,10 @@ Rectangle {
// A property initialiser runs during creation, so this is stamped near the
// start of the mount rather than at the end of it like Component.onCompleted.
property double _mountT0: Date.now()
function _stampTab(id, count) {
console.info(homeTiming, "[hometiming] tab " + id + " (" + count
+ " sections) done at +" + (Date.now() - root._mountT0) + "ms")
function _stampViewport(id, count) {
console.info(homeTiming, "[hometiming] viewport " + id + " ("
+ count + " document sections) done at +"
+ (Date.now() - root._mountT0) + "ms")
}
// Reparsed whenever Rust republishes the JSON documents (one per
@@ -313,6 +312,13 @@ Rectangle {
}
property string activeTab: "home"
// Recommendations remains lazy. The old per-tab Loader called this when
// its body was constructed; with one virtualized ListView, selecting the
// tab is the equivalent existence boundary.
onActiveTabChanged: {
if (root.activeTab === "recommendations")
QbzHome.loadRecommendations()
}
// Slint gates the 4th tab on the persisted `show_recommendations` pref
// (discover_prefs.db). The port publishes it inside the settings document
@@ -361,6 +367,36 @@ Rectangle {
: root.activeTab === "forYou" ? root.forYouSections
: root.activeTab === "recommendations" ? root.recoSections
: root.sections
/// Ordering slots stay in the document even before their out-of-document
/// rows arrive. Keep the descriptor stable, but do not instantiate its
/// heavy rail until it has something to show.
function sectionIsVisible(section) {
if (!section)
return false
return section.kind === "pinned" ? root.pinnedItems.length > 0
: section.kind === "radio" ? root.radioStations.length > 0
: section.kind === "spotlight" ? root.spotlight.visible === true
: root.isWeeklySlot(section.id) ? root.weeklyRows(section.id).length > 0
: true
}
/// The vertical ListView uses zero spacing so a hidden ordering slot leaves
/// no phantom gap. A visible delegate carries the old 40px Column spacing
/// only when a later visible section exists.
function hasVisibleSectionAfter(index) {
var model = root.activeSections
for (var i = index + 1; i < model.length; i++) {
if (root.sectionIsVisible(model[i]))
return true
}
return false
}
readonly property string activeHeaderKind:
root.activeTab === "home" && QbzHome.homeError !== "" ? "error"
: ((root.activeTab === "recommendations" ? QbzHome.recoLoading : QbzHome.homeLoading)
&& root.activeSections.length === 0) ? "skeleton"
: (root.activeTab === "recommendations" && !QbzHome.recoLoading
&& root.activeSections.length === 0) ? "empty"
: ""
// Cheap "some card in the mounted rails has artUrl but no artPath yet"
// probe — recomputed only when a sections document is republished. The
// pinned rows are probed too: they no longer travel inside the sections
@@ -709,39 +745,35 @@ Rectangle {
readonly property real cellWidth: (width - 3 * 8) / 4
Repeater {
model: sectionData.items
// The grid is contractually capped at two pages. Model the
// cap itself: a Loader existence gate prevented the heavy
// row from being built past 24, but the old full-array
// model still created an Item + two Components + Loader
// shell for every discarded result.
model: sgrid.total
delegate: Item {
required property var modelData
id: slimSlot
required property int index
readonly property var cardData: sgrid.sectionData.items[index]
readonly property int slot: index % sgrid.perPage
readonly property int pageIdx: Math.floor(index / sgrid.perPage)
visible: index < sgrid.total
width: grid.cellWidth
height: 60
x: pageIdx * grid.width + (slot % 4) * (grid.cellWidth + 8)
y: Math.floor(slot / 4) * (60 + 8)
// The PinnedRail dispatch pattern: Components declared
// in the delegate scope, so `modelData` resolves.
// in the delegate scope, sharing this capped slot row.
Component {
id: albumRowComp
SlimCard { card: modelData }
SlimCard { card: slimSlot.cardData }
}
Component {
id: trackRowComp
TrackSlimRow { card: modelData }
TrackSlimRow { card: slimSlot.cardData }
}
// `active` matters as much as `sourceComponent`: the
// Repeater above is fed the WHOLE item list, and the
// rows past `total` were being built in full and then
// hidden by the delegate's `visible`. A hidden row
// costs exactly as much to construct as a shown one —
// it just never repays it. `sectionData.items` can run
// several times `total` (24), so this is most of the
// grid.
Loader {
anchors.fill: parent
active: index < sgrid.total
sourceComponent: sgrid.tracks ? trackRowComp : albumRowComp
}
}
@@ -1187,28 +1219,43 @@ Rectangle {
}
}
// The section-rails renderer (one per Discover tab — the tab bodies
// differ only in WHICH sections doc they mount).
component SectionRails: Column {
id: rails
property var sectionsModel: []
// ONE vertical section delegate. The page-level ListView below owns the
// model and therefore only asks for the rails in (or near) its viewport.
// This used to be a Column + Repeater: every section in the active tab
// existed at once, so every horizontal rail eagerly built its first screen
// of cards even when the rail was several viewports below the fold.
component SectionRail: Item {
id: railSlot
required property var modelData
required property int index
/// "home" | "editorPicks" | "forYou" | "recommendations" — forwarded
/// to every rail so its "View all" resolves per tab.
property string tabId: "home"
/// to the rail so its "View all" resolves per tab.
property string railTab: "home"
readonly property bool shown: root.sectionIsVisible(modelData)
width: parent ? parent.width : 0
spacing: 40
// ListView spacing would reserve a gap for zero-height hidden ordering
// slots. Carry the 40px gap inside each visible slot instead, and only
// when another visible section follows it.
height: shown && railLoader.item
? railLoader.height + (root.hasVisibleSectionAfter(index) ? 40 : 0)
: 0
Repeater {
model: sectionsModel
delegate: Loader {
required property var modelData
property string railTab: rails.tabId
width: parent ? parent.width : 0
Loader {
id: railLoader
property var modelData: railSlot.modelData
property string railTab: railSlot.railTab
// ListView owns the delegate's x-position and forces it back
// to the content origin. Put the horizontal inset on the
// child it does not position: 32px on both sides, exactly the
// old padded Column geometry.
x: 32
width: parent.width - 64
height: item ? item.implicitHeight : 0
// The pinned slot is always in the document (it is where the
// discover prefs put the rail); it renders only once the
// store has rows — the Slint `PinnedState.items.length > 0`
// gate. A Column skips invisible children entirely, spacing
// included, so an empty pinned rail leaves no gap.
// gate. `active`, not only `visible`, is the existence gate:
// an empty out-of-document slot builds no rail subtree.
// The out-of-document rails are always IN the document (that
// is where the discover prefs put them) and render only once
// their own store has rows — the Slint
@@ -1216,11 +1263,8 @@ Rectangle {
// `ForYouState.radio-stations.length > 0` /
// `spotlight-visible` gates. A Column skips invisible children
// entirely, spacing included, so an empty one leaves no gap.
visible: modelData.kind === "pinned" ? root.pinnedItems.length > 0
: modelData.kind === "radio" ? root.radioStations.length > 0
: modelData.kind === "spotlight" ? root.spotlight.visible === true
: root.isWeeklySlot(modelData.id) ? root.weeklyRows(modelData.id).length > 0
: true
active: railSlot.shown
visible: active
sourceComponent: modelData.kind === "album" ? albumRailComp
: modelData.kind === "playlist" ? playlistRailComp
: modelData.kind === "slim" ? slimGridComp
@@ -1411,7 +1455,6 @@ Rectangle {
body: sectionData.hint
}
}
}
}
}
@@ -1582,226 +1625,143 @@ Rectangle {
Item {
width: parent.width
height: parent.height - 57
Flickable {
id: homeFlick
width: parent.width
height: parent.height
clip: true
contentWidth: width
contentHeight: homeContent.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: homeContent
width: parent.width
padding: 32
spacing: 40
// ===== Home tab ==========================================
//
// ONE TAB EXISTS AT A TIME — and the `Loader` is what makes
// that true. The four tab bodies used to be four sibling
// Columns gated on `visible:`, which hides a Column but does
// NOT stop QML from building it: every Repeater under every
// tab still instantiated every rail and every card. Measured
// on this account (9 + 7 + 12 sections, 423 cards), a Home
// mount spent ~360 of its ~410 ms inside the tab columns, and
// three quarters of those sections belonged to tabs nobody was
// looking at. The proof was the per-tab stamp below firing for
// `forYou` and `editorPicks` while they were invisible.
//
// This is also what the REFERENCE does, and the port had
// simply diverged: HomeView.slint:321 runs ONE repeater whose
// model is picked by a ternary on the active tab, and mounts
// For You / Recommendations behind `if` (:579, :608). Its own
// comment — "the For You tab's lists are pushed empty so this
// repeater renders nothing for it" — is the same rule stated
// from the data side.
//
// SYNCHRONOUS on purpose (`asynchronous` is left at its
// default). Async incubation is time-sliced: it does not
// remove the work, it spreads it over more frames, which is
// why the router-level experiment made the app dramatically
// worse and was reverted the same day. See ContentRouter.qml.
//
// The trade is that switching tabs now builds the tab you
// switch TO, instead of it being pre-built. That is the right
// way round: a mount happens on every navigation into
// Discover, a tab switch only when asked for, and one tab
// costs a quarter of four.
Loader {
width: parent.width - 64
active: root.activeTab === "home"
// Without this the Column reserves a spacing slot for an
// inactive Loader (it is a zero-size but VISIBLE child),
// leaving a 40px gap above the active tab.
visible: active
sourceComponent: Column {
width: parent.width
spacing: 40
// The vertical viewport is the model owner. A Column + Repeater
// built every section in the active tab and, through each
// horizontal ListView, at least one screenful of cards per section.
// ListView keeps that cost proportional to this viewport while
// preserving the same synchronous first paint.
ListView {
id: homeFlick
anchors.fill: parent
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.activeSections
reuseItems: true
spacing: 0
// A rail is a burst of 6-12 cards plus their artwork textures.
// Half a viewport left that burst inside the first fast wheel
// gesture: the user's custom Recently bands were mounted just
// as they crossed the screen, then felt progressively better
// after QML/pixmap/texture caches warmed. ListView creates its
// cache delegates asynchronously, so a 1.25-viewport runway
// moves that finite work ahead of the gesture without bringing
// back the old all-tabs/all-sections synchronous mount.
cacheBuffer: Math.max(720, Math.round(height * 1.25))
// Loading skeleton (HomeSkeleton: two shimmer rows). The
// pulse comes from the view-root Timer, which is itself
// gated on visibility + window state.
delegate: SectionRail {
width: homeFlick.width
railTab: root.activeTab
}
// Loading, error and empty states remain inline at the top of
// the same scroll surface. Only the selected arm exists.
// Explicit header/footer spacers reproduce the old 32px
// Column padding without ListView margins changing contentY's
// zero-origin contract (ScrollMemory and QbzScrollBar both
// consume that coordinate).
header: Item {
width: homeFlick.width
height: 32 + discoverHeader.height
Loader {
id: discoverHeader
x: 32
y: 32
width: homeFlick.width - 64
property string kind: root.activeHeaderKind
active: kind !== ""
visible: active
height: active && item ? item.implicitHeight : 0
sourceComponent: kind === "error" ? homeErrorHeader
: kind === "empty" ? recoEmptyHeader
: tabSkeletonHeader
}
}
footer: Item { width: homeFlick.width; height: 32 }
Component {
id: tabSkeletonHeader
TabSkeleton {
visible: QbzHome.homeLoading && root.sections.length === 0
phase: root.skelPhase
}
// Error state with retry (the Slint Home has no error
// arm; the box mirrors the FavoritesView Retry button).
Rectangle {
visible: QbzHome.homeError !== ""
width: parent.width
height: errorColumn.height + 28
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: errorColumn
anchors.centerIn: parent
spacing: 10
Text {
text: QbzHome.homeError
color: theme.textSecondary
font.pixelSize: 13
}
Rectangle {
width: retryText.implicitWidth + 28
height: 32
radius: 6
color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: retryText
anchors.centerIn: parent
text: QbzSession.tr("Retry", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.reloadHome()
}
}
}
}
// Section rails.
SectionRails { sectionsModel: root.sections; tabId: "home" }
Component.onCompleted: root._stampTab("home", root.sections.length)
}
}
// ===== Editor's Picks (phase 13) ========================
// Loader-gated for the reason spelled out on the Home tab.
Loader {
width: parent.width - 64
active: root.activeTab === "editorPicks"
visible: active
sourceComponent: Column {
width: parent.width
spacing: 40
TabSkeleton {
visible: QbzHome.homeLoading && root.editorSections.length === 0
phase: root.skelPhase
}
SectionRails { sectionsModel: root.editorSections; tabId: "editorPicks" }
Component.onCompleted: root._stampTab("editorPicks", root.editorSections.length)
}
}
// ===== For You (phase 13) =================================
// Loader-gated for the reason spelled out on the Home tab.
Loader {
width: parent.width - 64
active: root.activeTab === "forYou"
visible: active
sourceComponent: Column {
width: parent.width
spacing: 40
TabSkeleton {
visible: QbzHome.homeLoading && root.forYouSections.length === 0
phase: root.skelPhase
}
SectionRails { sectionsModel: root.forYouSections; tabId: "forYou" }
Component.onCompleted: root._stampTab("forYou", root.forYouSections.length)
}
}
// ===== Recommendations (external reco engine) =============
// qbz-external-reco (Last.fm + ListenBrainz -> Qobuz), driven
// by src/recommendations_qt.rs. LAZY: the first time this
// column becomes visible it asks Rust to build the tab; every
// later entry repaints from memory / the engine's own result
// cache, so reopening Discover costs no external traffic.
//
// Loader-gated like the other three (see the Home tab), and
// the gate SIMPLIFIED the lazy-load hook it used to need. The
// old pair — `Component.onCompleted` plus `onVisibleChanged`,
// both funnelled through a `kick()` that re-checked
// `visible` — existed only because this Column was built
// eagerly while Home was the active tab: the mount fired too
// early to mean anything, so a second hook had to catch the
// tab actually being shown. Now the component does not exist
// until the tab is selected, so mounting IS being selected and
// one `Component.onCompleted` says it exactly.
Loader {
width: parent.width - 64
active: root.activeTab === "recommendations"
visible: active
sourceComponent: Column {
width: parent.width
spacing: 40
// Every later entry repaints from memory / the
// engine's own result cache, so re-selecting the tab
// costs no external traffic even though the component
// is rebuilt.
Component.onCompleted: QbzHome.loadRecommendations()
// Same two-row shimmer as the other tabs, while the
// first build is in flight and nothing has painted yet.
TabSkeleton {
visible: QbzHome.recoLoading && root.recoSections.length === 0
phase: root.skelPhase
}
// Rails resolve progressively — a row that is still
// building, or whose service is not connected, is
// simply ABSENT from the document (never an empty frame).
SectionRails { sectionsModel: root.recoSections; tabId: "recommendations" }
// Nothing built and nothing in flight: the Slint empty
// state, verbatim msgids.
QbzEmptyState {
visible: !QbzHome.recoLoading && root.recoSections.length === 0
Component {
id: homeErrorHeader
Item {
implicitHeight: errorBox.implicitHeight
+ (homeFlick.count > 0 ? 40 : 0)
Rectangle {
id: errorBox
width: parent.width
title: QbzSession.tr("No recommendations yet", QbzSession.trRev)
body: QbzSession.tr("Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations.", QbzSession.trRev)
actionLabel: QbzSession.tr("Open Settings", QbzSession.trRev)
onActionClicked: QbzShell.navigateTo("settings")
implicitHeight: errorColumn.implicitHeight + 28
height: implicitHeight
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Column {
id: errorColumn
anchors.centerIn: parent
spacing: 10
Text {
text: QbzHome.homeError
color: theme.textSecondary
font.pixelSize: 13
}
Rectangle {
width: retryText.implicitWidth + 28
height: 32
radius: 6
color: retryArea.containsMouse
? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Text {
id: retryText
anchors.centerIn: parent
text: QbzSession.tr("Retry", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontLegal
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzHome.reloadHome()
}
}
}
}
}
}
Component {
id: recoEmptyHeader
QbzEmptyState {
title: QbzSession.tr("No recommendations yet", QbzSession.trRev)
body: QbzSession.tr("Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations.", QbzSession.trRev)
actionLabel: QbzSession.tr("Open Settings", QbzSession.trRev)
onActionClicked: QbzShell.navigateTo("settings")
}
}
Component.onCompleted:
root._stampViewport(root.activeTab, root.activeSections.length)
}
// Back/forward scroll memory and the custom gutter both accept a
// ListView because it is a Flickable. The per-tab scope is
// unchanged.
ScrollMemory { target: homeFlick; scope: "home:" + root.activeTab }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: homeFlick
}
}
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
// this container's offset while it is the live page, and restores it
// when a back/forward step arms this route.
ScrollMemory { target: homeFlick; scope: "home:" + root.activeTab }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: homeFlick
}
}
}