feat(qt): For You gains Qobuz Mixes pages, Artist Spotlight and Radios

Ports the three sections the tab was missing, against the Slint
implementation rather than invented: the section rails, the cards, and the
mix landing page with its navigation seam so back/forward behave.

The mixes reuse the shared crates for their requests -- the Slint app
already makes them, so nothing new talks to Qobuz here.

Also carries the qt-run.sh portability work needed for the Mac mini:
`free`, `nproc`, `pgrep` and `timeout` all differ or are simply absent on
BSD/macOS. Two of those bit during the first Mac build. `pgrep` exits 1
when it matches nothing -- the normal case -- and under `pipefail` that
killed the script before a single line of output, which reads exactly like
a build that did nothing. And `timeout` does not exist on macOS at all, so
the smoke produced a one-line log over which the grep cheerfully reported
"0 QML complaints": a test that never ran, rendering as a pass. The smoke
now fails outright on a log that short.

cc
This commit is contained in:
vicrodh committed 2026-07-29 16:26:11 -06:00
1 parent a3b89107fc
commit 0c159c141c
24 files changed
+2659 -205

No files matched your search

+8 -2
View File
@@ -122,7 +122,12 @@ Column {
ribbonKind: gcell.modelData.ribbonKind || ""
artSource: gcell.modelData.artPath || ""
isPinned: gcell.modelData.isPinned === true
isFavorite: false
// Snapshot url the pin payload persists (artPath is the
// local cache path — see AlbumCard.artworkUrl).
artworkUrl: gcell.modelData.artUrl || ""
// Stamped on the row (AlbumCardData / HomeCard); false
// made the glyph lie and inverted the first click.
isFavorite: gcell.modelData.isFavorite === true
}
}
}
@@ -235,8 +240,9 @@ Column {
ribbonKind: cell.modelData.ribbonKind || ""
artSource: cell.modelData.artPath || ""
isPinned: cell.modelData.isPinned === true
artworkUrl: cell.modelData.artUrl || ""
plays: root.showPlays ? (cell.modelData.plays || 0) : 0
isFavorite: false
isFavorite: cell.modelData.isFavorite === true
}
}
Loader {
+415 -86
View File
@@ -5,9 +5,16 @@
// 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" | "recentPlaceholder". Rail
// ORDER + VISIBILITY follow the persisted Discover prefs (phase 11,
// discover_prefs.db).
// "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
@@ -68,6 +75,40 @@ Rectangle {
// 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 ({})
}
}
property string activeTab: "home"
// Slint gates the 4th tab on the persisted `show_recommendations` pref
@@ -118,15 +159,29 @@ Rectangle {
: 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.
// 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.
|| root.anyItemArtPending(root.radioStations)
|| root.anyItemArtPending(root.spotlight.albums || [])
function anyArtPending(model) {
for (var s = 0; s < model.length; s++) {
var items = model[s].items || []
for (var i = 0; i < items.length; i++) {
if ((items[i].artUrl || "") !== "" && (items[i].artPath || "") === "")
return true
}
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
}
@@ -255,9 +310,18 @@ Rectangle {
ribbonKind: modelData.ribbonKind
artSource: modelData.artPath
isPinned: modelData.isPinned
// POC-NOTE: Home hearts are not seeded from fav_cache
// (store not open in the POC); toggles still hit the API.
isFavorite: false
// 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
@@ -454,10 +518,16 @@ Rectangle {
// 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 (home_qt "pinned"
// section; most-recent first).
// 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
@@ -482,7 +552,7 @@ Rectangle {
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: sectionData.items
model: pinRail.items
delegate: Item {
required property var modelData
required property int index
@@ -497,23 +567,40 @@ Rectangle {
artist: modelData.artist
artSource: modelData.artPath || ""
isPinned: true
isFavorite: false
// 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 {
// Pinned model: { id, title, artist, artPath,
// isPinned } — artist maps to the subtitle line.
item: Object.assign({}, modelData, { subtitle: modelData.subtitle || modelData.artist || "" })
// 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
}
@@ -543,64 +630,30 @@ Rectangle {
}
}
// Qobuz Mixes rail (QobuzMixesRow.slint) — four static 220px
// navigation tiles (gradient art + badge + name, description below).
// POC-NOTE: the mix DETAIL views are out of scope — tiles are inert.
// (Slint's 135° linear gradients are approximated with corner
// RadialGradients — QML has no angled linear gradient.)
// 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 {
property string badge: ""
property string mixName: ""
property string desc: ""
property color c0: "#000000"
property color c1: "#000000"
property color c2: "#000000"
id: mixTile
property string kind: "daily"
spacing: 8
width: 220
Rectangle {
width: 220
height: 220
radius: 8
gradient: Gradient {
GradientStop { position: 0.0; color: c0 }
GradientStop { position: 0.5; color: c1 }
GradientStop { position: 1.0; color: c2 }
}
// Fake the 135° sweep with a corner-centered radial overlay.
Rectangle {
anchors.fill: parent
radius: 8
gradient: Gradient {
GradientStop { position: 0.0; color: "#00000000" }
GradientStop { position: 1.0; color: "#33000000" }
}
}
Text {
x: 12
y: 12
text: badge
color: "#ccffffff"
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1
}
Text {
anchors.centerIn: parent
text: mixName
color: "#ffffff"
font.pixelSize: 22
font.weight: theme.weightBold
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// Inert (mix detail views out of scope).
}
MixArtwork {
id: art
kind: mixTile.kind
size: 220
titleSize: 22
cornerRadius: 8
onClicked: QbzHome.openMix(mixTile.kind)
}
Text {
width: 220
text: desc
text: art.tileDescription(mixTile.kind)
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
@@ -618,25 +671,265 @@ Rectangle {
}
Row {
spacing: 32
MixTile {
badge: "qobuz"; mixName: "DailyQ"
desc: QbzSession.tr("Elevate your day with a customized selection of music.", QbzSession.trRev)
c0: "#1e3a8a"; c1: "#6366f1"; c2: "#c084fc"
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)
artSource: modelData.artPath || ""
// 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: modelData.artPath || ""
phase: root.skelPhase
cellIndex: index
settleMs: 6000
}
}
}
MixTile {
badge: "qobuz"; mixName: "WeeklyQ"
desc: QbzSession.tr("Take a weekly journey with a fresh mix every Friday.", QbzSession.trRev)
c0: "#065f46"; c1: "#10b981"; c2: "#fbbf24"
}
}
// 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
}
MixTile {
badge: "qbz"; mixName: "FavQ"
desc: QbzSession.tr("A fresh shuffle from your personal library.", QbzSession.trRev)
c0: "#7f1d1d"; c1: "#ef4444"; c2: "#fb923c"
Text {
text: QbzSession.tr("Shine a light on one of your favourite artists.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
MixTile {
badge: "qbz"; mixName: "TopQ"
desc: QbzSession.tr("Discover new music from your most-played playlists.", QbzSession.trRev)
c0: "#1f2937"; c1: "#4b5563"; c2: "#fbbf24"
}
// Hero.
Row {
spacing: 16
Rectangle {
width: 140
height: 140
radius: 70
color: theme.surfaceElevated
clip: true
QbzIcon {
visible: (spot.doc.artPath || "") === ""
name: "user"
width: 40
height: 40
anchors.centerIn: parent
tintName: "muted"
}
RoundedImage {
anchors.fill: parent
source: spot.doc.artPath || ""
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: spot.doc.artPath || ""
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: spot.doc.artPath || ""
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: modelData.album.artPath || ""
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
}
}
}
}
}
@@ -658,6 +951,22 @@ Rectangle {
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
@@ -665,6 +974,8 @@ Rectangle {
: 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
@@ -719,6 +1030,11 @@ Rectangle {
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
}
}
}
@@ -766,6 +1082,11 @@ Rectangle {
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 || ""
}
}
}
@@ -774,6 +1095,14 @@ Rectangle {
id: mixesRailComp
MixesRail { sectionData: parent.sectionData }
}
Component {
id: radioRailComp
RadioRail { sectionData: parent.sectionData }
}
Component {
id: spotlightRailComp
SpotlightRail { sectionData: parent.sectionData }
}
Component {
id: recentComp
QbzEmptyState {
+18 -46
View File
@@ -16,6 +16,7 @@ import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
import "local"
@@ -278,53 +279,24 @@ Rectangle {
}
// ---- Column header ----
Row {
// rows/TrackListHeader.qml, on rows/TrackCols.qml geometry — the
// same object the LocalTrackRows below lay their cells out from.
// (LocalAlbumView.slint:501-540 hardcodes a second set here —
// 16px gaps, Duration 80, Quality 80 — that its own rows do not
// use; the port does not copy that hazard.)
//
// `trailingReserve: 72` is the slice LocalTrackRow spends OUTSIDE
// the shared row: it narrows the shared TrackRow by 26 (source
// glyph gutter) + 46 (its own re-drawn ⋯: 32 + one 14px gap), so
// the labelled columns end 72px short of the row's right edge.
// Same constant, one place: LocalTrackRow.qml:82.
TrackListHeader {
width: parent.width
height: 40
leftPadding: 12
rightPadding: 12
spacing: 16
Text {
width: 32
height: parent.height
text: "#"
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
Text {
width: parent.width - 24 - 32 - 80 - 80 - 28 - 32 - 5 * 16
height: parent.height
text: QbzSession.tr("Title", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
Text {
width: 80
height: parent.height
text: QbzSession.tr("Duration", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: 0.5
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
Text {
width: 80
height: parent.height
text: QbzSession.tr("Quality", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: 0.5
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// Source column + the per-row more-button slot.
Item { width: 28; height: parent.height }
Item { width: 32; height: parent.height }
bandHeight: 40
labelSpacing: 0.5
showFavorite: false
showMenu: false
trailingReserve: 72
}
// ---- Track list ----
+297
View File
@@ -0,0 +1,297 @@
// Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ) — QML port of
// mix/MixView.slint. Route "mix", opened from the Qobuz Mixes tiles on
// Discover > For You.
//
// ONE JSON document (QbzHome.mixJson, src/foryou_qt.rs MixDoc): kind, title,
// subtitle, trackCount, totalDuration, loading, tracks[]. The gradient is
// keyed off `kind` through the shared cards/MixArtwork.qml, so the page's
// artwork is literally the tile the user clicked, enlarged.
//
// SCROLLING-PAGE CONVENTION (AlbumView / LabelView / PlaylistView): one
// Flickable whose Column pads 32 / 32 / top 11 / bottom 100. The .slint's
// NavButtons row is dropped (back/forward live in the shell header,
// HeaderBar.qml:230) but its 22px spacer is KEPT, so the header sits where
// every other detail page's does.
//
// Numbers from MixView.slint: art 224 radius Radius.md · header gap 32 ·
// eyebrow 11px semibold letter-spacing 1.5 · title Typography.section bold ·
// subtitle Typography.body wrapped at `root.width - 320` · info line
// Typography.legal · spacers 6/8/10/20/28 · then the column header, a 3px
// gap, a 1px border-subtle rule, 6px, then the rows.
//
// The column header is rows/TrackListHeader.qml — NOT the .slint's numbers.
// MixView.slint:246-284 lays its header out with 16px gaps, a 40px "#", a
// 64px Duration and NO artwork reserve, while its rows are the shared
// TrackRow (14px gaps, 32px number, 36px artwork, 70px Duration): the labels
// there sit right of the columns they name, which is exactly the defect
// being fixed here. The port takes the ROW's geometry from
// rows/TrackCols.qml for both.
//
// POC-NOTEs, each deliberate and each with a precedent in this port:
// - NO multi-select bar. qml/rows/TrackRow.qml has no multi-select arm and
// two of the .slint bar's seven actions (add-to-playlist, add-to-mixtape)
// have no bridge seam at all — LabelView.qml made the same call. The
// .slint header's select disc renders DIMMED and inert here (the
// ArtistView / LabelView precedent).
// - The "Add to playlist" disc (list-plus) is inert in the .slint too — it
// carries no `clicked` there (MixView.slint:182-188) — so it is inert
// here, dimmed the same way, rather than silently absent.
// - No scroll restore: this port has no NavState.restore-scope counterpart
// (the same note LabelView.qml carries).
// - CIRCLE ACTION SIZE: the .slint MixView declares a LOCAL CircleAction
// fixed at 38px, predating the shared primitives/CircleAction.slint
// (44 primary / 32 secondary). This port mounts the shared
// controls/QbzCircleAction.qml — the port of the SHARED primitive — so the
// mix page's discs match every other detail page in this build.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
radius: 12
QbzTheme { id: theme }
readonly property var doc: {
try {
return JSON.parse(QbzHome.mixJson)
} catch (e) {
return ({})
}
}
readonly property var tracks: root.doc.tracks || []
readonly property bool loading: root.doc.loading === true
readonly property string kind: root.doc.kind || "daily"
// --- skeleton pulse (the HomeView gating rule: freeze on NOT VISIBLE,
// never on lost focus) --------------------------------------------------
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
Timer {
interval: 900
repeat: true
running: root.visible && root.windowShowing && root.loading
onTriggered: root.skelPhase = !root.skelPhase
}
// ============================ offline gate ============================
QbzOfflinePlaceholder {
visible: QbzSession.offline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
// ============================ the page ================================
Flickable {
id: flick
anchors.fill: parent
visible: !QbzSession.offline
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 11
bottomPadding: 100
spacing: 0
// The .slint's NavButtons row is not drawn (shell header owns
// back/forward) but its 22px spacer is kept.
Item { width: 1; height: 22 }
// --- Header -----------------------------------------------------
Row {
width: parent.width - 64
spacing: 32
// The mix gradient — the SAME square the For You tile drew,
// at 224px with no badge and a 34px name.
MixArtwork {
kind: root.kind
size: 224
titleSize: 34
cornerRadius: theme.radiusMd
showBadge: false
interactive: false
}
Column {
width: parent.width - 224 - 32
anchors.top: parent.top
anchors.topMargin: 4
spacing: 0
Text {
text: QbzSession.tr("Qobuz Mixes", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Item { width: 1; height: 6 }
Text {
width: parent.width
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item { width: 1; height: 8 }
Text {
// .slint: `width: root.width - 320px`.
width: Math.max(0, root.width - 320)
text: root.doc.subtitle || ""
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
Item { width: 1; height: 10 }
Text {
visible: (root.doc.trackCount || 0) > 0
text: (root.doc.trackCount || 0) + " "
+ QbzSession.tr("tracks", QbzSession.trRev)
+ " • " + (root.doc.totalDuration || "")
color: theme.textSecondary
font.pixelSize: theme.fontLegal
}
Item { width: 1; height: 20 }
// Action discs (MixView.slint:161-210).
Row {
spacing: 12
QbzCircleAction {
name: "play-fill"
primary: true
btnEnabled: root.tracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.mixPlayAll()
}
QbzCircleAction {
name: "shuffle"
btnEnabled: root.tracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.mixShuffle()
}
// Add-to-playlist: inert in the .slint too (no
// `clicked` there) — see the header note.
QbzCircleAction {
name: "list-plus"
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter
}
// Multi-select: DIMMED and inert (see the header note).
QbzCircleAction {
name: "square-check-big"
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter
}
QbzCircleAction {
name: "home-gear"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.mixRefresh()
}
}
}
}
Item { width: 1; height: 28 }
// --- Loading / empty --------------------------------------------
// The .slint mounts a bare centred spinner. This port shows the
// SHAPE of the list that is coming (the PlaylistView / LabelView
// convention): a column of track-row placeholders.
QbzSkeleton {
visible: root.loading && root.tracks.length === 0
width: parent.width - 64
height: visible ? 8 * 56 : 0
variant: "rowList"
rowH: 50
rowGap: 6
rowArt: true
rowArtSize: 36
phase: root.skelPhase
}
Text {
visible: !root.loading && root.tracks.length === 0
text: QbzSession.tr("No tracks for this mix.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
// --- Track list column header -----------------------------------
// `parent.width - 64` is the width the rows get (see the delegate
// below) — the header component's contract is that it is exactly
// as wide as the rows it labels.
TrackListHeader {
visible: root.tracks.length > 0
width: parent.width - 64
showArtwork: true
showAlbum: true
}
Item { visible: root.tracks.length > 0; width: 1; height: visible ? 3 : 0 }
Rectangle {
visible: root.tracks.length > 0
width: parent.width - 64
height: visible ? 1 : 0
color: theme.borderSubtle
}
Item { width: 1; height: 6 }
// --- Rows --------------------------------------------------------
// A Repeater, not a ListView: the mix is capped at 50 (dynamic
// suggest) / 100 (TopQ) / 200 (FavQ) rows and the page is ONE
// Flickable, exactly like LabelView's Popular Tracks. The 2000-row
// case that forces windowing does not arise here.
Repeater {
model: root.tracks
delegate: TrackRow {
required property var modelData
required property int index
// `parent` is the padded page Column (the LabelView
// guard: it is momentarily null while the delegate is
// being reparented).
width: parent ? parent.width - 64 : 0
item: modelData
number: index + 1
showArtwork: true
showAlbum: true
showFavorite: true
// The mix list is not reorderable and is not a drag
// source for playlists (flat catalog rows, no container).
onPlayRequested: QbzHome.mixPlayTrack(modelData.id)
onEnqueueRequested: function (mode) {
QbzHome.mixEnqueueTrack(modelData.id, mode)
}
}
}
}
}
// 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: flick
}
}
@@ -299,6 +299,15 @@ Rectangle {
// the overlay button plays it.
item: plCell.modelData
artSource: plCell.modelData.artPath || ""
// The row carries the pin state
// (home_qt `map_playlist`, which this page
// reuses); the card defaults to false, so
// without the hand-over the glyph read
// "unpinned" on an already-pinned playlist
// and the first click un-pinned it.
// `artworkUrl` needs no hand-over: the
// card defaults it to `item.artUrl`.
isPinned: plCell.modelData.isPinned === true
}
}
}
@@ -82,10 +82,15 @@ Rectangle {
radius: 6
color: theme.surfaceElevated
clip: true
// These rows come from `home_qt::map_playlist` (browse_qt.rs:612),
// whose art is the playlist's own `image.rectangle` — 800x380.
// `auto` pads that instead of cropping to the middle 47% of it,
// and still crops the square `image.covers[0]` fallback.
RoundedImage {
anchors.fill: parent
source: root.item.artPath || ""
radius: 6
fit: "auto"
}
QbzIcon {
visible: (root.item.artPath || "") === ""
+38 -6
View File
@@ -469,6 +469,13 @@ Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
item: root.mp.artist || ({})
artSource: (root.mp.artist || ({})).artPath || ""
// Same two fields the album hero below
// carries: the pin state (or the glyph
// lies and the first click UN-pins) and
// the REMOTE url the pin payload stores
// (artPath is a local cache path).
isPinned: (root.mp.artist || ({})).isPinned === true
artworkUrl: (root.mp.artist || ({})).artUrl || ""
}
AlbumCard {
visible: root.mp.kind === "album"
@@ -481,8 +488,17 @@ Rectangle {
year: (root.mp.album || ({})).year || ""
qualityTier: (root.mp.album || ({})).qualityTier || ""
artSource: (root.mp.album || ({})).artPath || ""
isFavorite: false
isPinned: false
// search_qt::map_album stamps the heart
// from fav_cache; false inverted the
// first click on an album already in the
// library.
isFavorite: (root.mp.album || ({})).isFavorite === true
// The row carries the pin state
// (search_qt `map_album`); hardcoding
// false made the glyph lie and turned
// the first click into an UN-pin.
isPinned: (root.mp.album || ({})).isPinned === true
artworkUrl: (root.mp.album || ({})).artUrl || ""
}
SearchTrackHero {
visible: root.mp.kind === "track"
@@ -525,6 +541,8 @@ Rectangle {
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
}
@@ -552,6 +570,8 @@ Rectangle {
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
}
@@ -590,8 +610,12 @@ Rectangle {
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: false
isPinned: false
// search_qt::map_album stamps the heart
// from fav_cache; false made the glyph lie
// and inverted the first click.
isFavorite: modelData.isFavorite === true
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
card: modelData
@@ -626,8 +650,9 @@ Rectangle {
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: false
isPinned: false
isFavorite: modelData.isFavorite === true
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
card: modelData
@@ -712,6 +737,8 @@ Rectangle {
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
// Artists: ROUND cells (ArtistGridCard's portrait), and
@@ -761,8 +788,12 @@ Rectangle {
width: 200
height: 246
PlaylistCard {
// artworkUrl is not passed: the card
// defaults it to `item.artUrl`, which is
// this row's remote cover.
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
card: modelData
@@ -790,6 +821,7 @@ Rectangle {
PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
card: modelData
+10 -1
View File
@@ -53,7 +53,16 @@ Column {
year: modelData.year
qualityTier: modelData.qualityTier
artSource: root.coverMap[modelData.artUrl] || ""
isFavorite: false
// The row carries the heart (home_qt / recommendations_qt
// stamp it from fav_cache at build time); hardcoding false
// made a library album draw hollow and read "Add to
// Library", so the first click REMOVED it.
isFavorite: modelData.isFavorite === true
// Pin glyph: the row carries the state, and the pin payload
// needs the REMOTE url (coverMap holds local file:// paths,
// which are worthless as a stored display snapshot).
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
Rectangle {
@@ -139,7 +139,33 @@ Item {
onContentYChanged: {
root.report()
// Infinite scroll: 600px of runway (Slint :1442).
if (contentY + height >= contentHeight - 600
//
// `contentHeight > height` states the precondition the
// predicate always had: a list shorter than its viewport
// has no bottom to run out of, so it cannot be "600px from
// the end". Nothing is lost by it — a list that short
// cannot scroll (StopAtBounds), so contentY never moves
// and this handler never runs for it.
//
// It is ALSO the guard for this handler's one re-entrant
// entry: it runs from INSIDE QQuickItemView::setModel()
// (the same window that makes a `.model` readback segfault
// — see views/LibraryView.qml), where contentY has been
// reset but the layout has not been redone.
//
// MEASURED, so nobody re-hunts it: that entry does NOT in
// fact misfire under qml6 6.11.1. contentHeight inside the
// swap is the PREVIOUS model's value, not 0 — checked over
// empty->long, short->long, long->short, long->empty,
// same-length republishes, both with forceLayout and on
// the real async polish path. Every one of them entered
// the handler with (contentY 0, contentHeight stale-LARGE)
// and evaluated FALSE. So this line changes no observed
// behaviour today; it is kept because it is true and free,
// and because the only shape that would misfire is the one
// it covers.
if (contentHeight > height
&& contentY + height >= contentHeight - 600
&& QbzLocal.localTracksHasMore
&& !QbzLocal.localTracksLoadingMore
&& !QbzLocal.localTracksLoading) {