Files
qbz/crates/qbz-qt/qml/views/MixView.qml
T
vicrodh 0e5f160a7c fix(myqbz): stamp the cover when an item is added to a collection
`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
2026-08-22 05:10:17 -06:00

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
}
}