Split the manual insertion block into two tiers so "Play later" no longer behaves identically to "Play next": - add a `manual_later_count` to InternalState tracking the trailing "Play later" sub-block: [now][play-next block][play-later block][source]. - play-next inserts at the FRONT of the manual block; play-later appends at its END (after any earlier play-later tracks), in order. - fix the idle + shuffle case: with no current track, both actions anchor at base 0 instead of the end of shuffle_order, so an idle-built queue plays play-next first and play-later right after, and does not collapse the rest of the queue on the first transport play. - next()/remove_at()/jump decrement drain the play-next tier before the play-later tier; set_queue/clear/shuffle-enable dissolve both. - the public QueueState.manual_next_count exposes mnc+mlc so both tiers render inside the single "Next in queue" visual container. NorinB's shuffle-randomization report keeps a debug log in next() to capture a repro if it recurs; the temporary INFO instrumentation is lowered to debug. MixView Q-mixes reach transport parity with playlists: functional multiselect (SelectionModel + multi-select bar), header queue menu (play next / later / add to queue via bulkTracksAction), and create-playlist-from-selection. Queue engine covered by 86 passing tests (play-later ordering non-shuffle, under shuffle, and idle-shuffle cases added). cc
515 lines
25 KiB
QML
515 lines
25 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
|
|
property bool kioskHost: false
|
|
|
|
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"
|
|
|
|
// --- Multi-select (parity with PlaylistView) --------------------------
|
|
// The mix rows are Qobuz catalog tracks (see the delegate's SOURCE note),
|
|
// so the selection rides the shared bulk seam exactly like a playlist:
|
|
// selection lives in QML, select-all/clear never reach Rust, and every
|
|
// other action goes down as a JSON id array through
|
|
// QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs). This is what the header
|
|
// note called out as the only missing piece.
|
|
property bool multiSelect: false
|
|
property var selected: ({})
|
|
readonly property int selectedCount: Object.keys(root.selected).length
|
|
SelectionModel { id: sel }
|
|
function setMultiSelect(on) {
|
|
root.multiSelect = on
|
|
if (!on) { root.selected = ({}); sel.anchorId = "" }
|
|
}
|
|
function toggleSelected(id, mods) {
|
|
root.selected = sel.next(root.selected, id, root.tracks,
|
|
mods === undefined ? Qt.NoModifier : mods)
|
|
}
|
|
function selectedIdsInOrder() {
|
|
var rows = root.tracks
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++)
|
|
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
|
|
return out
|
|
}
|
|
function allTrackIds() {
|
|
var rows = root.tracks
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++) out.push(rows[i].id)
|
|
return out
|
|
}
|
|
function bulkAction(action) {
|
|
if (action === "select-all") {
|
|
var m = {}
|
|
var rows = root.tracks
|
|
for (var i = 0; i < rows.length; i++) m[rows[i].id] = true
|
|
root.selected = m
|
|
return
|
|
}
|
|
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
|
|
var ids = root.selectedIdsInOrder()
|
|
if (ids.length === 0) return
|
|
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "mix", root.kind)
|
|
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
|
|
root.selected = ({})
|
|
}
|
|
// Ctrl+A / Escape hotkey seam (AppShell duck-types these).
|
|
function selectAll() {
|
|
if (!root.multiSelect) root.setMultiSelect(true)
|
|
root.bulkAction("select-all")
|
|
}
|
|
function exitMultiSelectMode() {
|
|
if (root.multiSelect) root.setMultiSelect(false)
|
|
}
|
|
// Drop the selection when the mix identity changes (switching tiles).
|
|
onKindChanged: root.setMultiSelect(false)
|
|
|
|
// --- 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")
|
|
}
|
|
|
|
// Whole-mix queue menu (header "list-end" disc). Routes every track
|
|
// through the shared bulk seam, so Play next / Play later land in the same
|
|
// sections a per-row action would.
|
|
CardMenu {
|
|
id: mixQueueMenu
|
|
menuWidth: 190
|
|
entries: [
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "play-next" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "play-later" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }
|
|
]
|
|
onPicked: function (a) {
|
|
if (root.tracks.length === 0) return
|
|
QbzPlayer.bulkTracksAction(
|
|
JSON.stringify(root.allTrackIds()), a, "mix", root.kind)
|
|
}
|
|
}
|
|
|
|
// ============================ 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: root.kioskHost ? 112 : 224
|
|
titleSize: 34
|
|
cornerRadius: theme.radiusMd
|
|
showBadge: false
|
|
interactive: false
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - (root.kioskHost ? 112 : 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 {
|
|
diameterOverride: root.kioskHost ? 64 : 0
|
|
name: "play-fill"
|
|
primary: true
|
|
btnEnabled: root.tracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzHome.mixPlayAll()
|
|
}
|
|
QbzCircleAction {
|
|
diameterOverride: root.kioskHost ? 64 : 0
|
|
name: "shuffle"
|
|
btnEnabled: root.tracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzHome.mixShuffle()
|
|
}
|
|
// Queue options for the WHOLE mix — Play next / Play
|
|
// later / Add to queue (the header lacked these).
|
|
QbzCircleAction {
|
|
id: mixQueueDisc
|
|
diameterOverride: root.kioskHost ? 64 : 0
|
|
name: "list-end"
|
|
btnEnabled: root.tracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: mixQueueMenu.openBelowLeft(mixQueueDisc)
|
|
}
|
|
// Add-to-playlist — "create playlist from this mix":
|
|
// the picker (QbzPlaylistPicker) offers a New Playlist
|
|
// arm, so this seeds a fresh playlist from every track.
|
|
QbzCircleAction {
|
|
diameterOverride: root.kioskHost ? 64 : 0
|
|
name: "list-plus"
|
|
btnEnabled: root.tracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: QbzPlaylistPicker.openForTracks(
|
|
JSON.stringify(root.allTrackIds()))
|
|
}
|
|
// Multi-select toggle — lights the bulk bar + the row
|
|
// checkboxes (parity with PlaylistView).
|
|
QbzCircleAction {
|
|
diameterOverride: root.kioskHost ? 64 : 0
|
|
name: "square-check-big"
|
|
active: root.multiSelect
|
|
btnEnabled: root.tracks.length > 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
onClicked: root.setMultiSelect(!root.multiSelect)
|
|
}
|
|
QbzCircleAction {
|
|
diameterOverride: root.kioskHost ? 64 : 0
|
|
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
|
|
}
|
|
|
|
// --- Multi-select bulk bar (parity with PlaylistView) -----------
|
|
// In flow above the column header; shown only while selecting.
|
|
QbzMultiSelectBar {
|
|
kioskHost: root.kioskHost
|
|
visible: root.multiSelect && root.tracks.length > 0
|
|
width: parent.width - 64
|
|
selectedCount: root.selectedCount
|
|
actions: [
|
|
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
|
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
|
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
|
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
|
|
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
|
|
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
|
|
]
|
|
onAction: function (id) { root.bulkAction(id) }
|
|
}
|
|
|
|
// --- 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 {
|
|
kioskHost: root.kioskHost
|
|
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.kioskHost ? [] : 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
|
|
// Multi-select: the leading cell becomes a checkbox and
|
|
// the row toggles its membership (parity with PlaylistView).
|
|
selectMode: root.multiSelect
|
|
checked: root.selected[modelData.id] === true
|
|
onToggleSelect: function (mods) { root.toggleSelected(modelData.id, mods) }
|
|
// 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
|
|
}]))
|
|
}
|
|
}
|
|
Item {
|
|
id: mixWindow
|
|
visible: root.kioskHost
|
|
width: parent.width - 64
|
|
height: visible ? root.tracks.length * 64 : 0
|
|
readonly property real viewTop: { var h=flick.contentHeight; return flick.contentY-mapToItem(flick.contentItem,0,0).y }
|
|
readonly property int first: Math.max(0, Math.floor(viewTop/64)-1)
|
|
readonly property int last: Math.max(first,Math.min(root.tracks.length,Math.ceil((viewTop+flick.height)/64)+1))
|
|
Repeater {
|
|
model: root.kioskHost ? root.tracks.slice(mixWindow.first,mixWindow.last) : []
|
|
delegate: TrackRow {
|
|
kioskHost: true
|
|
y: (mixWindow.first + index) * 64
|
|
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: mixWindow.width
|
|
item: modelData
|
|
number: mixWindow.first + 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
|
|
}
|
|
}
|