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:
24 files changed
+2659
-205
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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 || "") === ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user