Files
qbz/crates/qbz-qt/qml/views/HomeView.qml
T
vicrodh b136c9cf5d feat(qt): ambient background port, clip sweep and measurement tooling
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc),
uncommitted until now: the app-wide dynamic background in both modes
(AmbientField with GPU/Canvas arms, AppShell layering, ambient palette
pipeline), the wave-1 clip/batch sweep across the tree (alpha batches
449 -> 327, reproducible), the rounded-cover and icon-tint arms, and
scripts/qt-batches.sh (batches + presents/s sampler).

The GPU behaviour of these pieces was finalized in the follow-up pulse
commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/
GPU-COST-INVESTIGATION.md for the full campaign.

km
2026-08-13 13:01:32 -06:00

1545 lines
70 KiB
QML

// Discover > Home — QML port of crates/qbz-ui/ui/discover/HomeView.slint
// (+ Carousel / AlbumCard / SlimCarousel / SlimCard / ArtistCarousel /
// PlaylistCarousel / HomeSkeleton / OfflinePlaceholder).
//
// Data: QbzHome.homeSectionsJson (one JSON document — see bridge.rs),
// published by src/home_qt.rs; artwork file:// paths resolve through the
// qbz-cache image cache. Section kinds: "album" | "playlist" | "slim" |
// "slimTracks" | "artists" | "pinned" | "mixes" | "radio" | "spotlight" |
// "recentPlaceholder". Rail ORDER + VISIBILITY follow the persisted Discover
// prefs (phase 11, discover_prefs.db).
//
// THREE of those kinds are ORDERING SLOTS with no rows in the document —
// "pinned", "radio", "spotlight". Their rows travel on their own bridge
// properties (pinnedJson / radioStationsJson / spotlightJson) so a per-click
// pin, or the Spotlight's late /artist/page landing, does not republish all
// three tab documents and tear down every rail's delegates. See
// src/foryou_qt.rs.
//
// POC-NOTEs:
// - The genre filter (shared GenreFilterPopup, context "discover") and the
// section-configurator gear (DiscoverConfigModal) are WIRED: the genre
// selection feeds get_discover_index and persists to genre_filter.json; the
// configurator persists to discover_prefs.db and re-renders the tabs from
// the cached section data. The gear is DISABLED on the Recommendations tab
// — its Slint arm also carries the reco cache-window + "Refresh now"
// controls, which DiscoverConfigModal.qml does not have, so it would open
// empty.
// - Editor's Picks / For You mount the same rails, ordered by each tab's
// discover prefs (phase 13). Recommendations is the external-reco engine
// (src/recommendations_qt.rs), LAZY-loaded the first time the tab becomes
// visible, and the tab itself is gated on the persisted showRecommendations
// pref exactly as in Slint.
// - "View all" is LIVE. WHICH rails carry the link is decided HERE, per
// TAB, by `viewAllKind()` — not in Rust: `home_qt::assemble()` clones ONE
// candidate list into all three tab documents, and Slint's For You arm for
// `recentlyPlayedAlbums` has NO link (ForYouView.slint:117-126) while
// Home's does (HomeView.slint:411). Stamping the decision on the shared
// candidate would leak the link onto For You. The four destinations:
// endpoint != "" -> DiscoverBrowse for that /discover/<module>
// qobuzPlaylists -> PlaylistBrowse (Home + Editor's Picks only)
// recentlyPlayedAlbums -> Recently Played (Home only, non-placeholder)
// mostPlayedAlbums -> Most Played (Home + For You)
// - Card clicks / hover actions (play / favorite / more / pin) are live;
// context menus follow each card's own seam constants.
// - The offline mount mirrors AppShell's ADR-010 seam: OfflineState.offline
// -> the OfflinePlaceholder replica (exact msgids).
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../theme"
Rectangle {
id: root
// Transparent while the ambient background is active (phase 14 —
// HomeView.slint:163: the frosted content panel shows through).
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
// Round to the AppShell content-frame bezel (Radius.md): QML clips
// are rectangular, so the frame's own rounding never reaches the
// view — the view's own fill must round instead.
radius: 12
QbzTheme { id: theme }
// Reparsed whenever Rust republishes the JSON documents (one per
// Discover tab — phase 13).
readonly property var sections: JSON.parse(QbzHome.homeSectionsJson)
readonly property var editorSections: JSON.parse(QbzHome.editorSectionsJson)
readonly property var forYouSections: JSON.parse(QbzHome.forYouSectionsJson)
// Recommendations (the 4th tab). Lazy: the document stays "[]" until the
// tab is first shown and src/recommendations_qt.rs publishes into it.
readonly property var recoSections: JSON.parse(QbzHome.recoSectionsJson)
// The Pinned rail's ROWS, on their own property. The three documents
// above carry the "pinned" section as an EMPTY ordering slot only (see
// home_qt::publish_pinned): a pin is a per-click mutation, and folding
// its rows into them meant every click re-parsed all three documents and
// rebuilt every rail's delegates — with each rail's horizontal scroll
// snapped back to 0. Rebinding THIS re-creates the pinned delegates and
// nothing else. 1:1 with the Slint split (descriptor for the position,
// the PinnedState global for the rows).
readonly property var pinnedItems: {
try {
return JSON.parse(QbzHome.pinnedJson)
} catch (e) {
return []
}
}
// For You's two other out-of-document rails — same split, same reason as
// `pinnedItems` (src/foryou_qt.rs): the tab documents carry an EMPTY
// ordering slot for each and the rows arrive on their own property, so
// the Spotlight's late /artist/page landing does not re-create every
// rail's delegates on all three tabs.
readonly property var radioStations: {
try {
return JSON.parse(QbzHome.radioStationsJson)
} catch (e) {
return []
}
}
readonly property var spotlight: {
try {
return JSON.parse(QbzHome.spotlightJson)
} catch (e) {
return ({})
}
}
// Per-URL cover patches for the two out-of-document For You rails. The
// documents are NOT republished for artwork (src/foryou_qt.rs), because a
// republish hands `model:` a new JS array and `QQuickItemView::setModel()`
// resets the rail's scroll offset AND tears down the QQmlDelegateModel —
// the "a cover batch landing mid-drag snaps the rail back" report, and this
// build's only crash signature. So this map is what fills the tiles in: the
// `coverMap` pattern AlbumView / ArtistView / QueuePanel already use, on a
// signal only this view listens to.
//
// The map is VIEW-LOCAL and this view is NOT durable: AppShell binds
// `viewLoader.source` to QbzShell.currentView, so opening an album destroys
// HomeView and the map with it, while the rails' documents are published
// once per session. That is what `refreshForyouArt()` below is for — on
// every mount Rust re-hands whatever it has already resolved. Without it the
// Radio rail and the Spotlight came back blank for the rest of the session
// and `anyForYouArtPending` never went false again, pinning the pulse Timer.
property var forYouArt: ({})
Connections {
target: QbzHome
// ONE emit carries the WHOLE landed batch as a `{artUrl: path}` object
// (src/foryou_qt.rs): merge it, then rebind ONCE. `forYouArt` is a
// `var`, so only a new object REFERENCE notifies its dependents — an
// emit per url paid a copy of the growing map plus a sweep over every
// forYouArtOf(...) binding in both rails, per cover.
function onForyouArtReady(patchJson) {
var patch
try {
patch = JSON.parse(patchJson)
} catch (e) {
return
}
var m = root.forYouArt
var changed = false
for (var url in patch) {
if (m[url] !== patch[url]) {
m[url] = patch[url]
changed = true
}
}
if (changed)
root.forYouArt = Object.assign({}, m) // rebind needs a NEW ref
}
}
// Re-hand the resolved covers to this (possibly brand-new) instance. Cheap
// and idempotent: it re-reads the memoized cache paths off the Rust stores,
// never downloads, never republishes a document, and emits nothing at all
// when nothing is resolved yet — so the first, cold mount is a no-op and the
// in-flight download's own emit still fills the map.
Component.onCompleted: QbzHome.refreshForyouArt()
/// The patch map first, then whatever the document happened to carry (a
/// full Home reload publishes the paths it already has).
function forYouArtOf(item) {
return (item && item.artUrl && root.forYouArt[item.artUrl])
|| (item ? (item.artPath || "") : "")
}
/// Same predicate as anyItemArtPending, plus the patch map — the rails no
/// longer get their `artPath` back through a republish, so "artUrl set and
/// artPath empty" is now true forever and would pin the pulse Timer on.
function anyForYouArtPending(items) {
for (var i = 0; i < items.length; i++) {
var it = items[i]
if ((it.artUrl || "") !== "" && root.forYouArtOf(it) === "")
return true
}
return false
}
property string activeTab: "home"
// Slint gates the 4th tab on the persisted `show_recommendations` pref
// (discover_prefs.db). The port publishes it inside the settings document
// (settings_qt SettingsDoc.showRecommendations) — read it straight off the
// bridge singleton, the NavFlyout precedent.
readonly property bool showRecommendations: {
try {
return JSON.parse(QbzBridge.settingsJson).showRecommendations !== false
} catch (e) {
return true
}
}
// Never leave the user parked on a tab that just disappeared.
onShowRecommendationsChanged: {
if (!showRecommendations && root.activeTab === "recommendations")
root.activeTab = "home"
}
// --- shared genre filter, "discover" context -------------------------
// The badge lives in controls/BrowseGenreButton.qml now (the browse
// pages draw the same control). It reads QbzBridge.genreFilterJson
// STRAIGHT off the bridge singleton, never off genrePopup: the popup is
// declared LAST (z-order) and a creation-time binding that dereferences
// a not-yet-created id registers NO dependency, so it would never
// re-evaluate. The popup instance is only touched from click handlers,
// which run long after creation.
// --- skeleton pulse ---------------------------------------------------
// ONE 900ms Timer drives EVERY placeholder in this view (QbzSkeleton's
// preferred drive mode). GATING RULE: freeze on NOT VISIBLE — the view
// hidden, or the window minimized/hidden. NEVER on lost focus (a tiling
// desktop keeps windows visible and unfocused).
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// Runs only while something can actually be shimmering: the first fetch
// (section skeletons), a card still waiting for its cover, or the grace
// window in which landed covers are still decoding (artHold).
readonly property bool skelNeeded: QbzHome.homeLoading || QbzHome.recoLoading
|| root.artPending || root.artHold
// The sections document the active tab is rendering.
readonly property var activeSections:
root.activeTab === "editorPicks" ? root.editorSections
: root.activeTab === "forYou" ? root.forYouSections
: root.activeTab === "recommendations" ? root.recoSections
: root.sections
// 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
// documents, and without them a freshly pinned card's placeholder would
// sit frozen (the pulse Timer runs only while something is pending).
readonly property bool artPending: root.anyArtPending(root.activeSections)
|| root.anyItemArtPending(root.pinnedItems)
// The two out-of-document For You rails are probed for the same
// reason the pinned rows are: without them a Spotlight or Radio tile
// waiting for its cover would sit on a FROZEN placeholder, because
// the pulse Timer runs only while something is pending.
//
// They use `anyForYouArtPending`, which consults the per-URL patch map
// as well: their `artPath` never comes back through a republish any
// more, so the plain `artPath === ""` probe would be true forever and
// would pin the pulse Timer on. The third term is NEW coverage — the
// Spotlight DOCUMENT itself (the 140px hero portrait) was never probed.
|| root.anyForYouArtPending(root.radioStations)
|| root.anyForYouArtPending(root.spotlight.albums || [])
|| root.anyForYouArtPending(root.spotlight.visible === true ? [root.spotlight] : [])
function anyArtPending(model) {
for (var s = 0; s < model.length; s++) {
if (root.anyItemArtPending(model[s].items || []))
return true
}
return false
}
function anyItemArtPending(items) {
for (var i = 0; i < items.length; i++) {
if ((items[i].artUrl || "") !== "" && (items[i].artPath || "") === "")
return true
}
return false
}
// The pulse must OUTLIVE `artPending`. That flag drops when the last
// PATH lands, but every one of those cards still has a decode and a
// canvas raster ahead of it and its placeholder is still up (see
// QbzSkeleton's handover) — without the grace the tiles would freeze
// mid-shimmer for the rest of the wait.
property bool artHold: false
Timer { id: artHoldOff; interval: 1500; onTriggered: root.artHold = false }
onArtPendingChanged: { root.artHold = true; artHoldOff.restart() }
Timer {
interval: 900
repeat: true
running: root.visible && root.windowShowing && root.skelNeeded
onTriggered: root.skelPhase = !root.skelPhase
}
// ============================ "View all" ==============================
/// Which full-list page a rail's "View all" opens on THIS tab, or "" for
/// no link. Read the header note for why the decision lives here.
/// "discover" -> DiscoverBrowse for `section.endpoint`
/// "playlists" -> PlaylistBrowse
/// "recent" -> Recently Played Albums
/// "mostplayed"-> Most Played Albums
function viewAllKind(s, tab) {
if (!s)
return ""
// Generic album carousels + both mostStreamed variants: the section
// carries the /discover endpoint the page pages through.
if ((s.endpoint || "") !== "")
return "discover"
// HomeView.slint:355 mounts the playlist arm on the Home AND Editor's
// Picks repeater; discover_prefs has no qobuzPlaylists entry for For
// You, so this only ever fires on the two tabs that render it.
if (s.id === "qobuzPlaylists" && (tab === "home" || tab === "editorPicks"))
return "playlists"
// Home only (ForYouView.slint's arm has no show-view-all), and never
// on the empty-history placeholder (kind "recentPlaceholder").
if (s.id === "recentlyPlayedAlbums" && tab === "home" && s.kind === "album")
return "recent"
// HomeView.slint:506 AND ForYouView.slint:152 — both tabs.
if (s.id === "mostPlayedAlbums")
return "mostplayed"
return ""
}
/// The page title, when it differs from the rail title. Only the slim
/// "Most Streamed" rail does: it is titled "Popular albums" on Home but
/// HomeView.slint:466 hard-codes @tr("Most Streamed") for the page.
function viewAllTitle(s) {
if (s.id === "mostStreamed")
return QbzSession.tr("Most Streamed", QbzSession.trRev)
return s.title || ""
}
function openViewAll(s, tab) {
var kind = root.viewAllKind(s, tab)
if (kind === "discover")
QbzHome.openDiscoverBrowse(s.endpoint || "", root.viewAllTitle(s))
else if (kind === "playlists")
QbzHome.openPlaylistBrowse()
else if (kind === "recent")
QbzHome.openRecentAlbums()
else if (kind === "mostplayed")
QbzHome.openMostPlayedAlbums()
}
// ============================ shared components =======================
// Circular page-control button (Carousel's NavButton).
// Horizontal album rail (Carousel.slint): header + clipped ListView,
// page chevrons (per-page step like the Slint paging).
component AlbumRail: Column {
id: albumRail
property var sectionData: ({})
/// Which Discover tab is rendering this rail — the "View all"
/// decision is per tab (see viewAllKind).
property string tabId: "home"
width: parent ? parent.width : 0
spacing: 12
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
QbzSectionHeader {
title: sectionData.title
leftEnabled: rail.contentX > 1
rightEnabled: rail.contentX < albumRail.maxScroll - 1
showViewAll: root.viewAllKind(albumRail.sectionData, albumRail.tabId) !== ""
onViewAllClicked: root.openViewAll(albumRail.sectionData, albumRail.tabId)
// Math.MIN here paged LEFT to a permanent 0 (the chevron looked
// enabled and did nothing once contentX passed the first step);
// PinnedRail:408 and SlimGrid:341 both use max.
onPageLeft: rail.contentX = Math.max(0, rail.contentX - albumRail.step)
onPageRight: rail.contentX = Math.min(albumRail.maxScroll, rail.contentX + albumRail.step)
}
Item {
width: parent.width
height: 246
ListView {
id: rail
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: sectionData.items
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
ribbon: modelData.ribbon
ribbonKind: modelData.ribbonKind
artSource: modelData.artPath
isPinned: modelData.isPinned
// Snapshot url the pin payload persists (artPath is
// a local cache path — see AlbumCard.artworkUrl).
artworkUrl: modelData.artUrl || ""
// The POC-NOTE that used to sit here ("Home hearts
// are not seeded from fav_cache") is obsolete: the
// store IS open and home_qt stamps `isFavorite` on
// every card row. While it was hardcoded false, a
// Home rail album that IS in the library drew the
// hollow heart, read "Add to Library", and the click
// REMOVED it — the toggle takes its direction from
// the same cache the row is stamped from.
isFavorite: modelData.isFavorite === true
}
// Per-item artwork placeholder: the grey tile shimmers
// until THIS card's cover is ON SCREEN, then dissolves
// into it — the rail fills in progressively instead of
// all at once. A bare Rectangle, so it does not eat the
// card's hover/click areas underneath.
// AlbumCard seals its RoundedImage away, so the handover
// uses the probe arm (`coverSource`): it rides the same
// pixmap-cache entry the card is loading and retires on
// the DECODE, never on the path merely appearing — the
// path lands while the card's canvas is still blank.
QbzSkeleton {
variant: "art"
width: 200
height: 200
pending: (modelData.artUrl || "") !== ""
coverSource: modelData.artPath || ""
phase: root.skelPhase
cellIndex: index
// A cover whose download fails republishes the
// document with an empty artPath and no further
// signal, so the tile must be bounded (same rule and
// constant as SearchView's CardArtSkeleton).
settleMs: 6000
}
}
}
// Soft edge fades (Carousel.slint): content dissolves
// into the page background at the scrolled edges. Hidden under the
// app-wide dynamic background — they fade to the OPAQUE
// surface-main, so over a moving field they read as two dark slabs
// rather than a dissolve (Carousel.slint:304, :312).
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
visible: !theme.ambientOn
opacity: rail.contentX > 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: theme.surfaceMain }
GradientStop { position: 1.0; color: "transparent" }
}
}
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
visible: !theme.ambientOn
opacity: rail.contentX < maxScroll - 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: theme.surfaceMain }
}
}
}
}
// TrackSlimRow — the TRACK flavour of the slim row. Visually the shared
// qml/cards/SlimCard.qml (same 60px row, 44px thumb, title/subtitle),
// but the whole-row click PLAYS the track instead of opening an album.
// SlimCard hardcodes `QbzAlbum.openAlbum(card.id)`, so mounting it for a
// track row would navigate to an album id that does not exist — a control
// that renders and does the WRONG thing. The consolidation (a `kind` /
// `activated()` seam on SlimCard, then delete this) is in the handoff.
component TrackSlimRow: Rectangle {
property var card: ({})
height: 60
radius: theme.radiusSm
color: trArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 12
Rectangle {
width: 44
height: 44
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: card.artPath || ""
radius: 4
}
}
Column {
width: parent.width - 44 - 2 * 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: card.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontLink
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: card.artist || ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
}
MouseArea {
id: trArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playTrack(card.id)
}
}
// Slim rows mount the shared qml/cards/SlimCard.qml (SlimCard.slint) for
// ALBUM data and TrackSlimRow for TRACK data — one paging shell, two row
// flavours (Slint's SlimCarousel is likewise shared by Popular albums and
// Recently Played Tracks).
// Popular slim grid (SlimCarousel.slint): 4x3 pages of 12, capped 24.
component SlimGrid: Column {
id: sgrid
property var sectionData: ({})
property string tabId: "home"
// true = the rows are TRACKS (click plays), false = ALBUMS (click opens).
property bool tracks: false
width: parent ? parent.width : 0
spacing: 12
readonly property int perPage: 12
readonly property int total: Math.min(sectionData.items.length, 2 * perPage)
readonly property int pageCount: Math.max(1, Math.ceil(total / perPage))
readonly property real maxScroll: (pageCount - 1) * grid.width
QbzSectionHeader {
title: sectionData.title
leftEnabled: grid.contentX > 1
rightEnabled: grid.contentX < sgrid.maxScroll - 1
showViewAll: root.viewAllKind(sgrid.sectionData, sgrid.tabId) !== ""
onViewAllClicked: root.openViewAll(sgrid.sectionData, sgrid.tabId)
onPageLeft: grid.contentX = Math.max(0, grid.contentX - grid.width)
onPageRight: grid.contentX = Math.min(sgrid.maxScroll, grid.contentX + grid.width)
}
Item {
width: parent.width
height: 3 * 60 + 2 * 8
clip: true
Flickable {
id: grid
width: parent.width
height: parent.height
contentWidth: sgrid.pageCount * width
contentHeight: height
boundsBehavior: Flickable.StopAtBounds
readonly property real cellWidth: (width - 3 * 8) / 4
Repeater {
model: sectionData.items
delegate: Item {
required property var modelData
required property int 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.
Component {
id: albumRowComp
SlimCard { card: modelData }
}
Component {
id: trackRowComp
TrackSlimRow { card: modelData }
}
Loader {
anchors.fill: parent
sourceComponent: sgrid.tracks ? trackRowComp : albumRowComp
}
}
}
}
}
}
// Pinned rail (PinnedCarousel.slint) — one 200x246 slot per item, the
// card picked by the item's own kind: albums reuse AlbumCard, artists
// render the ArtistGridCard circle, playlists the PlaylistCard square.
// Fed from the shared per-user pinned_items.db (most-recent first).
//
// `sectionData` supplies ONLY the header (title + the rail's position in
// the tab); the rows come from root.pinnedItems — the dedicated
// `pinnedJson` property — so a pin/unpin touches this rail and nothing
// else in the view.
component PinnedRail: Column {
id: pinRail
property var sectionData: ({})
readonly property var items: root.pinnedItems
width: parent ? parent.width : 0
spacing: 12
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
readonly property int step: perPage * 232
QbzSectionHeader {
title: sectionData.title
leftEnabled: rail.contentX > 1
rightEnabled: rail.contentX < maxScroll - 1
onPageLeft: rail.contentX = Math.max(0, rail.contentX - step)
onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step)
}
Item {
width: parent.width
height: 246
ListView {
id: rail
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: pinRail.items
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
Component {
id: pAlbum
AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artSource: modelData.artPath || ""
isPinned: true
// home_qt::pinned_cards stamps the heart per row,
// routed by the row's own item_kind.
isFavorite: modelData.isFavorite === true
// Un-pinning from this rail is fine without it,
// but the glyph is a TOGGLE: re-pinning the row
// the user just dropped must restore the same
// snapshot url, not blank it.
artworkUrl: modelData.artUrl || ""
}
}
Component {
id: pArtist
ArtistCard {
// The pinned row carries the stored snapshot in
// BOTH `artist` and `subtitle` (home_qt
// `pinned_cards`) precisely so this card and the
// album one can each read their own slot. While
// only `artist` was published, this card drew a
// blank second line — and, because it hands
// `item.subtitle` back to togglePin, re-pinning
// from the rail persisted that blank.
item: modelData
artSource: modelData.artPath || ""
isPinned: true
artworkUrl: modelData.artUrl || ""
}
}
Component {
id: pPlaylist
PlaylistCard {
// Same story as the artist slot above; the card's
// `artworkUrl` default already resolves the row's
// `artUrl` snapshot.
item: modelData
artSource: modelData.artPath || ""
isPinned: true
}
}
Loader {
anchors.fill: parent
sourceComponent: modelData.itemKind === "artist" ? pArtist
: modelData.itemKind === "playlist" ? pPlaylist : pAlbum
}
// Square-art slots only — a pinned ARTIST keeps the
// designed round gradient+glyph placeholder ArtistCard
// already draws (Slint's ArtistGridCard), which reads as
// a portrait, not as a missing tile.
QbzSkeleton {
variant: "art"
width: 200
height: 200
pending: modelData.itemKind !== "artist"
&& (modelData.artUrl || "") !== ""
coverSource: modelData.artPath || ""
phase: root.skelPhase
cellIndex: index
settleMs: 6000
}
}
}
}
}
// Qobuz Mixes rail (QobuzMixesRow.slint) — four static 220px navigation
// tiles (gradient art + badge + name, description below). The gradient
// square itself is the shared cards/MixArtwork.qml, because the mix
// LANDING page draws the same four identities at 224px and the colours
// must not be able to drift apart (the .slint re-declares them in both
// files). Tiles are LIVE: `openMix` records the "mix" nav entry and
// fetches — 1:1 with `media-action("mix", which, "open")`.
component MixTile: Column {
id: mixTile
property string kind: "daily"
spacing: 8
width: 220
MixArtwork {
id: art
kind: mixTile.kind
size: 220
titleSize: 22
cornerRadius: 8
onClicked: QbzHome.openMix(mixTile.kind)
}
Text {
width: 220
text: art.tileDescription(mixTile.kind)
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
component MixesRail: Column {
property var sectionData: ({})
width: parent ? parent.width : 0
spacing: 12
Text {
text: sectionData.title
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Row {
spacing: 32
MixTile { kind: "daily" }
MixTile { kind: "weekly" }
MixTile { kind: "fav" }
MixTile { kind: "top" }
}
}
// Radio Stations rail (RadioCarousel.slint) — album-seeded radio tiles in
// a paged horizontal list with the same chevrons every other rail has.
// 200px card + 32px spacing = the 232px pitch the album rails use.
//
// The rows come from `QbzHome.radioStationsJson`, NOT from `sectionData`:
// the section in the tab document is an empty ordering slot (the `pinned`
// split — see src/foryou_qt.rs). `sectionData` supplies the title only.
component RadioRail: Column {
id: radioRail
property var sectionData: ({})
readonly property var items: root.radioStations
width: parent ? parent.width : 0
spacing: 12
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
QbzSectionHeader {
title: radioRail.sectionData.title
leftEnabled: rail.contentX > 1
rightEnabled: rail.contentX < radioRail.maxScroll - 1
onPageLeft: rail.contentX = Math.max(0, rail.contentX - radioRail.step)
onPageRight: rail.contentX = Math.min(radioRail.maxScroll, rail.contentX + radioRail.step)
}
Item {
width: parent.width
// RadioCarousel.slint:94 — 234, not the album rails' 246 (the
// radio card's meta block is one line shorter in the worst case).
height: 234
ListView {
id: rail
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: radioRail.items
delegate: Item {
required property var modelData
required property int index
width: 200
height: 234
RadioCard {
seedTitle: modelData.title
seedSubtitle: modelData.artist
label: QbzSession.tr("RADIO", QbzSession.trRev)
// The patch map, not `artPath`: the rail's document is
// published ONCE and covers arrive per-URL.
artSource: root.forYouArtOf(modelData)
// Both the card body and the hover disc start the
// album radio (the .slint wires `clicked` and `play`
// to the same callback).
onActivated: QbzHome.startAlbumRadio(modelData.albumId)
onPlayRequested: QbzHome.startAlbumRadio(modelData.albumId)
}
// The card's artwork is an INSET 126px display, so the
// placeholder is that square, not the whole tile.
QbzSkeleton {
variant: "art"
width: 126
height: 126
x: 37
y: 29
blockRadius: 4
pending: (modelData.artUrl || "") !== ""
coverSource: root.forYouArtOf(modelData)
phase: root.skelPhase
cellIndex: index
settleMs: 6000
}
}
}
}
}
// Spotlight (Spotlight.slint) — one favourite artist: header, a hero
// (140px round portrait + ARTIST + name + play / open-artist discs), and
// a draggable row mixing a TOP TRACKS card, a RADIO card and the
// artist's albums. Rows come from `QbzHome.spotlightJson`; `sectionData`
// is the ordering slot only.
component SpotlightRail: Column {
id: spot
property var sectionData: ({})
readonly property var doc: root.spotlight
readonly property var albums: root.spotlight.albums || []
readonly property bool hasTopTracks: root.spotlight.hasTopTracks === true
width: parent ? parent.width : 0
spacing: 12
// Header — the .slint hardcodes the pair, not sectionData.title.
Column {
spacing: 2
Text {
text: QbzSession.tr("Spotlight", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Text {
text: QbzSession.tr("Shine a light on one of your favourite artists.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// Hero.
Row {
spacing: 16
Rectangle {
width: 140
height: 140
radius: 70
color: theme.surfaceElevated
clip: true
QbzIcon {
// The REMOTE url, not the local path — 1:1 with
// Spotlight.slint:91-98 (`spotlight-image-url == ""`).
// Gating on artPath flashed this glyph on every cold
// cache, because the path is empty until the download
// lands; the artist HAS a portrait, it just is not here
// yet, and the placeholder for that is the surface fill
// (Slint's hero is an unconditional Image over the
// surface-elevated box, so it simply draws nothing).
visible: (spot.doc.artUrl || "") === ""
name: "user"
width: 40
height: 40
anchors.centerIn: parent
tintName: "muted"
}
RoundedImage {
anchors.fill: parent
source: root.forYouArtOf(spot.doc)
radius: 70
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzArtist.openArtist(spot.doc.artistId || "")
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Text {
visible: (spot.doc.category || "") !== ""
text: QbzSession.tr("ARTIST", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Text {
text: spot.doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontTitle
font.weight: theme.weightBold
}
// Spotlight.slint's local CircleButton is 44px for BOTH
// discs (not the shared primitive's 44/32) — a 32px disc next
// to the 140px portrait reads wrong, which is presumably why.
// QbzCircleAction's size is a plain binding, so a caller can
// override it without a new knob on the shared control.
Row {
spacing: 10
QbzCircleAction {
visible: spot.hasTopTracks
name: "play-fill"
primary: true
onClicked: QbzHome.playArtistTopTracks(spot.doc.artistId || "")
}
QbzCircleAction {
name: "user"
width: 44
height: 44
onClicked: QbzArtist.openArtist(spot.doc.artistId || "")
}
}
}
}
Item { width: 1; height: 4 }
// Content row — one draggable strip, like the other carousels.
Item {
width: parent.width
height: 270
ListView {
id: spotRow
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 24
clip: true
boundsBehavior: Flickable.StopAtBounds
// The two radio cards are prepended to the album rows, so the
// strip is ONE model and the drag/scroll covers all of it (the
// .slint puts them in the same HorizontalLayout).
model: {
var out = []
if (spot.hasTopTracks)
out.push({ "slot": "top" })
out.push({ "slot": "radio" })
for (var i = 0; i < spot.albums.length; i++)
out.push({ "slot": "album", "album": spot.albums[i] })
return out
}
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
// The PinnedRail dispatch pattern: Components declared in
// the delegate scope, so `modelData` resolves.
Component {
id: topCard
RadioCard {
seedTitle: QbzSession.tr("Top tracks", QbzSession.trRev)
seedSubtitle: QbzSession.tr("By {}", QbzSession.trRev)
.replace("{}", spot.doc.name || "")
label: QbzSession.tr("TOP TRACKS", QbzSession.trRev)
artSource: root.forYouArtOf(spot.doc)
onActivated: QbzHome.playArtistTopTracks(spot.doc.artistId || "")
onPlayRequested: QbzHome.playArtistTopTracks(spot.doc.artistId || "")
}
}
Component {
id: radioSeedCard
RadioCard {
seedTitle: spot.doc.name || ""
seedSubtitle: QbzSession.tr("Qobuz Radio Station", QbzSession.trRev)
label: QbzSession.tr("RADIO", QbzSession.trRev)
artSource: root.forYouArtOf(spot.doc)
onActivated: QbzHome.startArtistRadio(spot.doc.artistId || "")
onPlayRequested: QbzHome.startArtistRadio(spot.doc.artistId || "")
}
}
Component {
id: albumCardComp
AlbumCard {
albumId: modelData.album.id
title: modelData.album.title
artist: modelData.album.artist
artistId: modelData.album.artistId
genre: modelData.album.genre
year: modelData.album.year
qualityTier: modelData.album.qualityTier
artSource: root.forYouArtOf(modelData.album)
artworkUrl: modelData.album.artUrl || ""
isPinned: modelData.album.isPinned === true
isFavorite: modelData.album.isFavorite === true
}
}
Loader {
anchors.fill: parent
sourceComponent: modelData.slot === "top" ? topCard
: modelData.slot === "radio" ? radioSeedCard
: albumCardComp
}
}
}
}
}
// 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: []
/// "home" | "editorPicks" | "forYou" | "recommendations" — forwarded
/// to every rail so its "View all" resolves per tab.
property string tabId: "home"
width: parent ? parent.width : 0
spacing: 40
Repeater {
model: sectionsModel
delegate: Loader {
required property var modelData
property string railTab: rails.tabId
width: parent ? parent.width : 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.
// 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
// `PinnedState.items.length > 0` /
// `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
: true
sourceComponent: modelData.kind === "album" ? albumRailComp
: modelData.kind === "playlist" ? playlistRailComp
: modelData.kind === "slim" ? slimGridComp
: modelData.kind === "slimTracks" ? trackGridComp
: modelData.kind === "artists" ? artistRailComp
: modelData.kind === "pinned" ? pinnedRailComp
: modelData.kind === "mixes" ? mixesRailComp
: modelData.kind === "radio" ? radioRailComp
: modelData.kind === "spotlight" ? spotlightRailComp
: recentComp
property var sectionData: modelData
Component {
id: pinnedRailComp
PinnedRail { sectionData: parent.sectionData }
}
Component {
id: albumRailComp
AlbumRail { sectionData: parent.sectionData; tabId: parent.railTab }
}
Component {
id: playlistRailComp
Column {
id: plRail
property var sectionData: parent.sectionData
property string tabId: parent.railTab
width: parent ? parent.width : 0
spacing: 12
readonly property int perPage: Math.max(1, Math.floor((plList.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, plList.contentWidth - plList.width)
// The rail used to draw a bare Text: no chevrons (so
// a 40-playlist rail's tail was unreachable) and no
// "View all". PlaylistCarousel.slint:109-115 has both.
// POC-NOTE: the Slint arm ALSO puts a PlaylistTagFilter
// on this title line (HomeView.slint:360-374) and shows
// @tr("No playlists match the selected categories.")
// when the tags filter everything out. Neither is
// ported here — the tag filter DOES exist on the
// PlaylistBrowse page this link opens.
QbzSectionHeader {
title: plRail.sectionData.title
leftEnabled: plList.contentX > 1
rightEnabled: plList.contentX < plRail.maxScroll - 1
showViewAll: root.viewAllKind(plRail.sectionData, plRail.tabId) !== ""
onViewAllClicked: root.openViewAll(plRail.sectionData, plRail.tabId)
onPageLeft: plList.contentX = Math.max(0, plList.contentX - plRail.step)
onPageRight: plList.contentX = Math.min(plRail.maxScroll, plList.contentX + plRail.step)
}
ListView {
id: plList
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: plRail.sectionData.items
delegate: PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
// The card's own default is `false`; the
// pin state travels on the row (home_qt
// `map_playlist`), so it has to be handed
// over or the glyph lies on every rail.
isPinned: modelData.isPinned === true
}
}
}
}
Component {
id: slimGridComp
SlimGrid { sectionData: parent.sectionData; tabId: parent.railTab }
}
Component {
id: trackGridComp
SlimGrid { sectionData: parent.sectionData; tabId: parent.railTab; tracks: true }
}
Component {
id: artistRailComp
Column {
id: arRail
property var sectionData: parent.sectionData
width: parent ? parent.width : 0
spacing: 12
readonly property int perPage: Math.max(1, Math.floor((arList.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, arList.contentWidth - arList.width)
// ArtistCarousel.slint:141-147 pages with chevrons; a
// bare Text left the tail of an 18-artist rail
// unreachable. No "View all" — the Slint artist arms
// (topArtists / artistsToFollow) carry none.
QbzSectionHeader {
title: arRail.sectionData.title
leftEnabled: arList.contentX > 1
rightEnabled: arList.contentX < arRail.maxScroll - 1
onPageLeft: arList.contentX = Math.max(0, arList.contentX - arRail.step)
onPageRight: arList.contentX = Math.min(arRail.maxScroll, arList.contentX + arRail.step)
}
ListView {
id: arList
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: arRail.sectionData.items
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
// Same hand-over as the playlist rail —
// home_qt `map_fav_artist` publishes the
// pin state and the card defaults to false.
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
}
}
Component {
id: mixesRailComp
MixesRail { sectionData: parent.sectionData }
}
Component {
id: radioRailComp
RadioRail { sectionData: parent.sectionData }
}
Component {
id: spotlightRailComp
SpotlightRail { sectionData: parent.sectionData }
}
Component {
id: recentComp
QbzEmptyState {
property var sectionData: parent.sectionData
title: sectionData.title
body: sectionData.hint
}
}
}
}
}
// HomeSkeleton.slint's SkeletonRow, 1:1 on the shared QbzSkeleton
// control: a 180x22 title bar over five 200px card placeholders,
// spacing 32/12/8. `phase` comes from the ONE Timer on the view root.
component SkeletonRow: Column {
id: skelRow
property bool phase: false
width: parent ? parent.width : 0
spacing: 12
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: skelRow.phase }
Row {
spacing: 32
Repeater {
model: 5
delegate: QbzSkeleton {
required property int index
variant: "card"
width: 200
phase: skelRow.phase
cellIndex: index
}
}
}
}
// Two SkeletonRows = the whole HomeSkeleton component. Mounted by each
// Discover tab while its sections document is still empty.
component TabSkeleton: Column {
id: tabSkel
property bool phase: false
width: parent ? parent.width : 0
spacing: 40
SkeletonRow { phase: tabSkel.phase }
SkeletonRow { phase: tabSkel.phase }
}
// ============================ offline gate ============================
// (OfflinePlaceholder.slint replica; mounted INSTEAD of the view.)
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
// The induced-only "Open Settings" arm (Slint) is wired:
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
// ============================ the view ================================
Column {
anchors.fill: parent
spacing: 0
visible: !QbzSession.offline
// --- Toolbar (fixed 56px) ---------------------------------------
Item {
width: parent.width
height: 56
// Thin-bar tier: surface-main @ bar-alpha (0.3) under the app-wide
// dynamic background, opaque surface-main otherwise (HomeView.slint:182).
// The toolbar had NO fill of its own — with the background off the
// view root's surface-main showed through and it looked right, but
// the view root goes transparent under the background, so the bar
// lost its backing exactly when it needed one.
Rectangle {
anchors.fill: parent
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
Row {
// Slint left-controls: x 32 + NavButtons (now a 0px
// placeholder) + 16px spacing -> the pill starts at 48.
x: 48
y: 25 - height / 2
spacing: 16
QbzTabBar {
// The 4th tab is present only while the pref is on (1:1
// with the Slint `if SettingsState.show-recommendations`).
tabs: {
var t = [
{ "id": "home", "label": QbzSession.tr("Home", QbzSession.trRev) },
{ "id": "editorPicks", "label": QbzSession.tr("Editor's Picks", QbzSession.trRev) },
{ "id": "forYou", "label": QbzSession.tr("For You", QbzSession.trRev) },
]
if (root.showRecommendations)
t.push({ "id": "recommendations", "label": QbzSession.tr("Recommendations", QbzSession.trRev) })
return t
}
activeId: root.activeTab
// Data is per-tab JSON (no refetch on switch); scroll
// resets to top.
onSelected: function (id) {
root.activeTab = id
homeFlick.contentY = 0
}
}
}
// Genre filter + configurator gear (HomeView.slint right-controls).
Row {
x: parent.width - width - 32
y: 25 - height / 2
height: 32
spacing: 6
// GenreButton — now the SHARED controls/BrowseGenreButton
// (the browse pages draw the same control; this was a
// verbatim copy of it). HomeView.slint:85 is the 32px
// variant, BrowseHeaderTools.slint:108 the 34px one.
BrowseGenreButton {
context: "discover"
btnHeight: 32
onClicked: genrePopup.toggle()
}
// GearButton — per-tab show/hide + reorder of the Discover
// sections. Disabled on Recommendations: its Slint arm
// configures the external reco engine (not ported), so the
// modal would open empty.
Rectangle {
id: gearBtn
readonly property bool gearEnabled: root.activeTab !== "recommendations"
width: 32
height: 32
radius: 4
opacity: gearBtn.gearEnabled ? 1.0 : 0.35
color: (gearBtn.gearEnabled && gearArea.containsMouse)
? theme.surfaceHover : "transparent"
QbzIcon {
name: "home-gear"
width: 20
height: 20
anchors.centerIn: parent
tintName: gearArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: gearArea
anchors.fill: parent
enabled: gearBtn.gearEnabled
hoverEnabled: gearBtn.gearEnabled
cursorShape: gearBtn.gearEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: configModal.open(root.activeTab)
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Scrollable sections -----------------------------------------
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 ==========================================
Column {
id: homeTab
visible: root.activeTab === "home"
width: parent.width - 64
spacing: 40
// Loading skeleton (HomeSkeleton: two shimmer rows). The
// pulse comes from the view-root Timer, which is itself
// gated on visibility + window state.
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" }
}
// ===== Editor's Picks (phase 13) ========================
Column {
visible: root.activeTab === "editorPicks"
width: parent.width - 64
spacing: 40
TabSkeleton {
visible: QbzHome.homeLoading && root.editorSections.length === 0
phase: root.skelPhase
}
SectionRails { sectionsModel: root.editorSections; tabId: "editorPicks" }
}
// ===== For You (phase 13) =================================
Column {
visible: root.activeTab === "forYou"
width: parent.width - 64
spacing: 40
TabSkeleton {
visible: QbzHome.homeLoading && root.forYouSections.length === 0
phase: root.skelPhase
}
SectionRails { sectionsModel: root.forYouSections; tabId: "forYou" }
}
// ===== 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.
//
// The load is kicked BOTH on mount and on becoming visible:
// the view is instantiated while Home is the active tab, so a
// visible-only hook would be thrown away on the mount pass and
// a mount-only hook would never fire for a tab switch.
Column {
id: recoTab
visible: root.activeTab === "recommendations"
width: parent.width - 64
spacing: 40
function kick() {
if (recoTab.visible)
QbzHome.loadRecommendations()
}
Component.onCompleted: recoTab.kick()
onVisibleChanged: recoTab.kick()
// 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
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")
}
}
}
}
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: homeFlick
}
}
}
// ============================ overlays ================================
// Declared LAST: in QML declaration order IS z-order, so these sit above
// the toolbar and the rails and keep their own presses (ADR-009 is also
// satisfied explicitly by their z). Both are hidden + disabled while
// closed, so they never eat a click meant for the view.
// Shared Filter-by-genre popup, "discover" context: the selection feeds
// get_discover_index, so toggling one re-fetches the index.
GenreFilterPopup {
id: genrePopup
anchors.fill: parent
context: "discover"
// Under the toolbar (56px + the 1px divider + a 5px gap).
anchorTop: 62
anchorRight: 32
}
// Per-tab section configurator (the gear).
DiscoverConfigModal {
id: configModal
anchors.fill: parent
}
}