`mixtape_collection_items.artwork_url` is a SNAPSHOT written once at add time. The grid tile and the hero mosaic read that column and stop — only the detail row has a backfill — so an item stored without a cover is blank on those surfaces for the life of the row. WHAT THE DATA SAID, against two hypotheses including mine. The owner reported albums added "from Qobuz" arriving coverless, and the obvious suspects were a narrow ImageSet read and the disco builder. Querying the live library.db settled it: 8 of 35 rows have an empty artwork_url and every one of them is `item_type='album' source='local'`, while every Qobuz album row carries a url. So the confirmed gap is the LOCAL album page's cassette button, which passed `artwork_url: None` outright — the only call site in the app that could produce those rows. It now stamps the album's own cover path, which is correct to store here: the mosaic passes local paths through unrewritten, and that is what lets a collection of local albums render with no network at all. Also stamped, where the remote url was already in hand and only an empty string was being sent: Search, Playlist, Library, Artist and Label track rows. AlbumView takes the HEADER's url rather than the row's — album-view track rows are published with an empty artUrl by construction (album_qt.rs says so in the field's own doc), so the obvious edit there would have been a no-op, and every track of an album shares its cover. Four sites still send nothing and now SAY why in place: the three now-playing bars and MixView have only a file:// cache path or no url at all, and a cache path must never be stored — other machines read this column. Those need a remote-url field on their documents first. The `image.best()` widening in album_qt/bulk_tracks_qt rides along as hygiene, not as the fix: `thumbnail.or(small)` was the outlier in a file that reads every other cover through `best()`. An adversarial pass over 494 real ImageSets found ZERO albums lacking both fields, so it was not what the owner hit — it is simply the read the rest of the file already agreed on. STILL OPEN: if the owner's two albums were Qobuz after all, their urls ARE stored, and the symptom is on the render side — the mosaics have no fallback where the detail row does. cc
332 lines
15 KiB
QML
332 lines
15 KiB
QML
// 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 — and the ORIGINAL reason is now STALE on both
|
|
// halves: rows/TrackRow.qml:116 DOES have the `selectMode` arm, and
|
|
// add-to-playlist DOES have a bridge seam (QbzPlaylistPicker.openForTracks,
|
|
// src/playlist_picker_bridge.rs). What is still missing here is only the
|
|
// selection STATE and the bar itself — LibraryView.qml's `tracksSelected`
|
|
// map + qml/controls/MultiSelectBar-style host is the pattern to copy.
|
|
// Until then the .slint header's select disc renders DIMMED and inert here
|
|
// (the ArtistView / LabelView precedent). Per-ROW add-to-playlist and
|
|
// add-to-mixtape are both LIVE on this page already (the shared TrackRow
|
|
// menu).
|
|
// - 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: theme.ambientOn
|
|
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)
|
|
}
|
|
// MyQBZ "Add to mixtape" — the HOST builds the AddItem
|
|
// array (TrackRow does not know itemType/source).
|
|
//
|
|
// SOURCE: these rows carry no source field, and they do
|
|
// not need one — every one of the four mixes is built
|
|
// from `qbz_models::Track` values that came back from the
|
|
// Qobuz API (foryou_qt.rs:855-873: dynamic/suggest for
|
|
// daily+weekly, `get_favorites("tracks")` for fav,
|
|
// `get_playlist` for top), and the DailyQ/WeeklyQ seed
|
|
// explicitly drops local / Plex / ephemeral recents
|
|
// (foryou_qt.rs:774-776). `modelData.id` is therefore a
|
|
// Qobuz catalog id by construction of the document, not
|
|
// by assumption at this call site.
|
|
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "track", "source": "qobuz",
|
|
"sourceItemId": modelData.id,
|
|
"title": modelData.title || "",
|
|
"subtitle": modelData.artist || "",
|
|
// artworkUrl STAYS EMPTY here, deliberately: the mix row carries no remote art url.
|
|
// A file:// cache path must NOT be stored — the collection's
|
|
// artwork_url is a snapshot other machines read — so this needs a
|
|
// remote-url field on the document first. The five sister sites
|
|
// that HAD one were stamped 2026-08-22.
|
|
"artworkUrl": "", "year": null, "trackCount": null
|
|
}]))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Thin auto-hiding scrollbar in the right gutter (ListScrollbar).
|
|
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
|
// this container's offset while it is the live page, and restores it
|
|
// when a back/forward step arms this route.
|
|
ScrollMemory { target: flick; scope: "mix" }
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: flick
|
|
}
|
|
}
|