Files
qbz/crates/qbz-qt/qml/views/HomeView.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

cc
2026-09-14 00:49:54 -06:00

1889 lines
88 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 live on ALL FOUR tabs: on
// Recommendations the modal shows that tab's own arm instead of a section
// list (cache window + "Refresh now"), matching the reference.
// - 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 }
// ---- mount stopwatch (same category as ContentRouter's) --------------
// 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"
defaultLogLevel: LoggingCategory.Warning
}
// 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 _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
// 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)
// Track edges replace only these three descriptors. Keeping them outside
// the tab documents preserves every unrelated rail's delegate model and
// horizontal scroll position.
readonly property var recentRails: {
try {
return JSON.parse(QbzHome.recentRailsJson)
} catch (e) {
return ({})
}
}
// 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 ({})
}
}
// Recommendations' two Weekly rails, same split and same reason as the
// three above: they come from ListenBrainz about a second after the rest
// of that tab has painted, and folding them into the document made their
// arrival republish it — which hands every rail a new `model:` array and
// tears down all nine to deliver two. That was the "it loads, then it
// flashes and loads again" report. Keyed by section id
// ("weeklyExploration" / "weeklyJams"); see src/recommendations_qt.rs.
readonly property var recoWeekly: {
try {
return JSON.parse(QbzHome.recoWeeklyJson)
} catch (e) {
return ({})
}
}
/// The rows for an out-of-document Weekly slot, or [] before they land.
function weeklyRows(id) {
var r = root.recoWeekly[id]
return r ? r : []
}
/// Is this descriptor one of the two slots whose rows live outside the
/// document? Ids are unique per tab, so this cannot collide with the
/// other three tabs' sections.
function isWeeklySlot(id) {
return id === "weeklyExploration" || id === "weeklyJams"
}
// 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
}
}
// Late covers for the three targeted recent rails. Like the For You and
// Recommendations maps, this patches tiles by URL instead of republishing
// a section document after an image download.
property var recentArt: ({})
Connections {
target: QbzHome
function onRecentArtReady(patchJson) {
var patch
try {
patch = JSON.parse(patchJson)
} catch (e) {
return
}
var m = root.recentArt
var changed = false
for (var url in patch) {
if (m[url] !== patch[url]) {
m[url] = patch[url]
changed = true
}
}
if (changed)
root.recentArt = Object.assign({}, m)
}
}
// 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()
QbzHome.refreshRecoArt()
QbzHome.refreshRecentArt()
}
// Recommendations' rails used to get their covers by REPUBLISHING
// `recoSectionsJson` once per landing batch, which hands `model:` a new JS
// array — `QQuickItemView::setModel()` then resets the rail's scroll and
// tears down its QQmlDelegateModel, so every batch rebuilt all nine rails'
// delegates (the same defect the For You rails were moved off, :161). Rust
// now emits a url-keyed patch instead and leaves the document alone.
property var recoArt: ({})
Connections {
target: QbzHome
function onRecoArtReady(patchJson) {
var patch
try {
patch = JSON.parse(patchJson)
} catch (e) {
return
}
var m = root.recoArt
var changed = false
for (var url in patch) {
if (m[url] !== patch[url]) {
m[url] = patch[url]
changed = true
}
}
if (changed)
root.recoArt = Object.assign({}, m) // rebind needs a NEW ref
}
}
/// Cover for a card that came out of a SECTIONS document: the patch map
/// first, then whatever the document itself carried.
///
/// The fallback is what makes this safe to put on rails that render the
/// other tabs too. `recoArt` only ever holds Recommendations urls, so for a
/// Home / Editor's Picks / For You card the lookup misses and the result is
/// `item.artPath` — byte for byte what the binding read before. No consumer
/// of a baked `artPath` can lose its cover to this change.
function sectionArtOf(item) {
return (item && item.artUrl && root.recentArt[item.artUrl])
|| (item && item.artUrl && root.recoArt[item.artUrl])
|| (item ? (item.artPath || "") : "")
}
// --- Qobuz Playlists: category filter ---------------------------------
// Client-side over the 40 cached cards; the tags ride in the SAME
// /discover/index response, so nothing here costs a request. The selection
// itself lives in Rust (`home_qt`) because this view is destroyed on every
// navigation — a property here would forget it the moment the user opened
// an album and came back, where the reference survives (its selection lives
// in TAB_SECTIONS and its comment says "survives a tab switch").
readonly property var playlistTagDoc: {
try {
return JSON.parse(QbzHome.playlistTagsJson)
} catch (e) {
return {}
}
}
readonly property var playlistTags: root.playlistTagDoc.tags || []
readonly property var playlistTagSel: root.playlistTagDoc.selected || []
/// The selected tags' display NAMES, for the applied-filters tooltip.
readonly property var playlistTagNames: {
var out = []
var sel = root.playlistTagSel
var all = root.playlistTags
for (var i = 0; i < all.length; i++)
if (sel.indexOf(all[i].slug) >= 0)
out.push(all[i].name)
return out
}
/// UNION, and an empty selection passes everything (home.rs:79-92).
function playlistPasses(card) {
if (root.playlistTagSel.length === 0)
return true
var t = (card && card.tags) || []
for (var i = 0; i < t.length; i++)
if (root.playlistTagSel.indexOf(t[i]) >= 0)
return true
return false
}
function filterPlaylists(items) {
var out = []
var src = items || []
for (var i = 0; i < src.length; i++)
if (root.playlistPasses(src[i]))
out.push(src[i])
return out
}
// The popup is declared last (z-order); a QML id is document-scoped, so a
// rail delegate cannot name it. Same bridge-function pattern the genre
// popup already uses from the toolbar.
function togglePlaylistTagPopup(btn) {
plTagPopup.toggle(btn)
}
/// 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"
// 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
// (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
/// Replace only a recent-history ordering slot with its live local-store
/// snapshot. Every other descriptor keeps the original object identity.
function effectiveSection(section) {
if (!section)
return section
return root.recentRails[section.id] || section
}
/// 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, tab) {
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
: section.kind === "recentPlaceholder" && tab === "forYou" ? false
: 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(root.effectiveSection(model[i]), root.activeTab))
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
// 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++) {
var section = root.effectiveSection(model[s])
if (root.anyItemArtPending(section.items || []))
return true
}
return false
}
// Consults the patch map, NOT a bare `artPath === ""`. A Recommendations
// cover that arrives as a patch never gets its `artPath` back through a
// republish any more, so the bare predicate would stay true forever and
// pin the 900ms skeleton pulse Timer on — the exact trap the For You
// rails hit (see anyForYouArtPending). Unchanged for every other tab,
// where `sectionArtOf` just returns `artPath`.
function anyItemArtPending(items) {
for (var i = 0; i < items.length; i++) {
if ((items[i].artUrl || "") !== "" && root.sectionArtOf(items[i]) === "")
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 {
id: railAlbumCard
albumId: modelData.id
source: modelData.source || ""
sources: modelData.sources || []
title: modelData.title
artist: modelData.artist
artistId: modelData.historyArtistLink ? "" : modelData.artistId
hostArtistLink: modelData.historyArtistLink === true && (modelData.artist || "").trim() !== ""
onArtistRequested: QbzHome.openHistoryAlbumArtist(
modelData.id, modelData.source || "", modelData.artist || "", modelData.artistId || "")
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
qualityDetail: modelData.qualityLabel || modelData.qualityDetail || ""
ribbon: modelData.ribbon
ribbonKind: modelData.ribbonKind
artSource: root.sectionArtOf(modelData)
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 || "") !== ""
artworkItem: railAlbumCard.artworkImmediate ? railAlbumCard : null
coverSource: railAlbumCard.artworkImmediate ? "" : root.sectionArtOf(modelData)
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: root.sectionArtOf(card)
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
// The row's ORIGIN goes with the id. A Recently-Played row can be a
// Qobuz track, a local file or a Plex row, and `playTrack` alone
// sends every id to the Qobuz catalog — which 404s on the other two
// (the owner hit it on a Plex id from this exact rail). Empty
// source = qobuz, which is the legacy history shape.
onClicked: QbzPlayer.playTrackFrom(card.id, card.source || "")
}
}
// 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 {
// 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 {
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)
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, sharing this capped slot row.
Component {
id: albumRowComp
SlimCard { card: slimSlot.cardData }
}
Component {
id: trackRowComp
TrackSlimRow { card: slimSlot.cardData }
}
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 {
readonly property bool localPinned:
modelData.isLocalAlbum === true
localMode: localPinned
source: modelData.source || ""
sources: modelData.sources || []
quickViewAffordance: true
pinAffordance: true
albumId: modelData.id
title: modelData.title
artist: modelData.artist
hostArtistLink: (modelData.artist || "") !== ""
onArtistRequested: QbzHome.openPinnedAlbumArtist(
modelData.id, 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 || ""
pinArtworkUrl: artworkUrl !== "" ? artworkUrl : artSource
onOpenRequested: QbzLocal.openAlbum(modelData.id)
onPlayRequested: QbzLocal.playAlbum(modelData.id, false)
onEnqueueRequested: function (mode) {
QbzLocal.enqueue("album", modelData.id, mode)
}
}
}
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 {
id: pinnedCardLoader
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 || "") !== ""
artworkItem: pinnedCardLoader.item && pinnedCardLoader.item.artworkImmediate ? pinnedCardLoader.item : null
coverSource: artworkItem && artworkItem.artworkImmediate ? "" : (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 {
id: radioArtworkCard
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)
// Per-station key, so the tile that was clicked is the
// only one in the rail that spins.
loading: QbzHome.radioPending === "album:" + modelData.albumId
// 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 || "") !== ""
artworkItem: radioArtworkCard.artworkImmediate ? radioArtworkCard : null
coverSource: radioArtworkCard.artworkImmediate ? "" : 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)
// The smart pool builder is the slowest radio in
// the app; without this the tile went back to its
// resting state the instant the pointer left.
loading: QbzHome.radioPending
=== "artist:" + (spot.doc.artistId || "")
onActivated: QbzHome.startArtistRadio(spot.doc.artistId || "")
onPlayRequested: QbzHome.startArtistRadio(spot.doc.artistId || "")
}
}
Component {
id: albumCardComp
AlbumCard {
albumId: modelData.album.id
source: modelData.album.source || ""
sources: modelData.album.sources || []
title: modelData.album.title
artist: modelData.album.artist
artistId: modelData.album.artistId
genre: modelData.album.genre
year: modelData.album.year
qualityTier: modelData.album.qualityTier
qualityDetail: modelData.album.qualityLabel || modelData.album.qualityDetail || ""
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
}
}
}
}
}
// 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 the rail so its "View all" resolves per tab.
property string railTab: "home"
readonly property var effectiveData: root.effectiveSection(modelData)
readonly property bool shown: root.sectionIsVisible(effectiveData, railTab)
width: parent ? parent.width : 0
// 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
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. `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
// `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.
active: railSlot.shown
visible: active
sourceComponent: sectionData.kind === "album" ? albumRailComp
: sectionData.kind === "playlist" ? playlistRailComp
: sectionData.kind === "slim" ? slimGridComp
: sectionData.kind === "slimTracks" ? trackGridComp
: sectionData.kind === "artists" ? artistRailComp
: sectionData.kind === "pinned" ? pinnedRailComp
: sectionData.kind === "mixes" ? mixesRailComp
: sectionData.kind === "radio" ? radioRailComp
: sectionData.kind === "spotlight" ? spotlightRailComp
: recentComp
// A Weekly slot arrives with EMPTY items and is filled from
// `recoWeekly` here. Object.assign builds a new descriptor
// only for those two ids; every other rail returns the very
// same `modelData` reference, so a Weekly landing re-evaluates
// all nine bindings but re-creates only the two delegates
// whose value actually changed.
property var sectionData: root.isWeeklySlot(railSlot.effectiveData.id)
? Object.assign({}, railSlot.effectiveData,
{ "items": root.weeklyRows(railSlot.effectiveData.id) })
: railSlot.effectiveData
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.
//
// The CATEGORY FILTER on this title line
// (HomeView.slint:360-374) is now ported too, and with
// it the empty-result line. The POC-NOTE that used to
// sit here said neither was, and pointed at the
// PlaylistBrowse page as the substitute — that page's
// tag filter is a DIFFERENT one (single-select and
// server-side), so the note was directing the reader to
// a control that does not do this job.
//
// Only the Qobuz-Playlists rail offers it: it is the
// only playlist section the index ships tags for, and
// the button hides itself when the set is empty.
readonly property bool tagsOffered:
plRail.sectionData.id === "qobuzPlaylists"
&& root.playlistTags.length > 0
readonly property var visibleItems:
root.playlistTagSel.length > 0 && plRail.tagsOffered
? root.filterPlaylists(plRail.sectionData.items)
: plRail.sectionData.items
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)
leading: plRail.tagsOffered ? plTagFilterComp : null
}
Component {
id: plTagFilterComp
PlaylistTagFilterButton {
count: root.playlistTagSel.length
selectedNames: root.playlistTagNames
onClicked: root.togglePlaylistTagPopup(this)
}
}
// Zero results with a filter on: say so, instead of
// showing an empty band the user cannot explain
// (HomeView.slint:393-397). The rail stays mounted so
// the filter is still reachable to clear.
Text {
visible: plRail.visibleItems.length === 0
&& root.playlistTagSel.length > 0
width: parent.width
text: QbzSession.tr("No playlists match the selected categories.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
wrapMode: Text.WordWrap
}
ListView {
id: plList
visible: plRail.visibleItems.length > 0
width: parent.width
height: visible ? 246 : 0
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: plRail.visibleItems
delegate: PlaylistCard {
item: modelData
artSource: root.sectionArtOf(modelData)
// 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: root.sectionArtOf(modelData)
// 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
// Rounded at the TOP because this bar is full-bleed at y=0 of
// the content pane, and under the dynamic background the pane's
// own rounding cannot reach it: Qt's `clip` is a rectangular
// scissor that ignores `radius`, and AppShell hides its bezel
// nubs while the field is meant to show through the corners.
// So a full-bleed pane child rounds ITSELF — AppShell.qml says
// exactly this ("there is no mask that can do it for it here")
// and this bar was the counterexample the owner spotted in
// Discover. Invisible with the background off: the view root
// paints the same colour underneath.
topLeftRadius: theme.radiusMd
topRightRadius: theme.radiusMd
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, and on Recommendations the cache window +
// "Refresh now" instead (that tab has no orderable sections).
//
// It used to be DISABLED on Recommendations, on the grounds
// that the external reco engine was not ported and the modal
// would open empty. The engine is ported, and the modal now
// carries that tab's own arm — so the gear is live on all four
// and `gearEnabled` is gone with the condition it existed for.
Rectangle {
id: gearBtn
width: 32
height: 32
radius: 4
color: 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
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: configModal.open(root.activeTab)
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Scrollable sections -----------------------------------------
Item {
width: parent.width
height: parent.height - 57
// 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))
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 {
phase: root.skelPhase
}
}
Component {
id: homeErrorHeader
Item {
implicitHeight: errorBox.implicitHeight
+ (homeFlick.count > 0 ? 40 : 0)
Rectangle {
id: errorBox
width: parent.width
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.surfaceElevatedHover : 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
}
}
}
// ============================ 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
}
// Qobuz-Playlists category filter. Its trigger lives inside the flickable
// at a y that depends on the scroll and on the rail order, so the popup
// captures the anchor by mapToItem AT OPEN rather than pinning to a fixed
// offset the way the genre popup can.
PlaylistTagFilterPopup {
id: plTagPopup
anchors.fill: parent
tags: root.playlistTags
selected: root.playlistTagSel
onToggled: function (slug) { QbzHome.togglePlaylistTag(slug) }
onCleared: {
QbzHome.clearPlaylistTags()
plTagPopup.close()
}
}
}