Files
qbz/crates/qbz-qt/qml/views/MixView.qml
T
2026-08-26 11:11:50 -06:00

331 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.
// - Back/forward scroll restore is handled by ScrollMemory below.
// - 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
}
}