Files
qbz/crates/qbz-qt/qml/views/PlaylistManagerView.qml
T
vicrodh c7a3012955 feat(local-library): complete catalog and metadata workflows
Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens.

cx
2026-08-28 18:50:05 -06:00

385 lines
16 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PlaylistManagerView — the Playlist Manager, the port of
// playlist/PlaylistManagerView.slint (1498 lines). Route id
// "playlistmanager", reached from Sidebar > Playlists ⋯ > Manage playlists.
//
// A fixed 56 px title/toolbar bar over ONE scrolling surface with three view
// modes (grid / list / tree) and two toggles (folder mode, folders collapsed).
// Every row arrives ready to render from Rust — this file does no per-row map
// lookup, no pluralisation and no filtering.
//
// ── THE SCROLL ARCHITECTURE (contract D1) ─────────────────────────────────
// ONE `ListView` in all three modes, with the whole page chrome as its
// `header`, a footer spacer, and one delegate per ROW: a row of grid cards, a
// list row, or a tree row. NOT a GridView inside a Flickable — nesting
// Flickables gives nested flicking and two scrollbars, and buys no recycling
// (an inner view sized to its full content has a viewport equal to its content,
// so every delegate instantiates anyway). One ListView is also the only shape
// that keeps the scroll position across a view-mode switch, which the
// reference's single Flickable does for free.
//
// `ListView.spacing` is 0 and every gap lives INSIDE a delegate's height,
// because ListView.spacing would also apply between the header and row 0, where
// the reference has 14 px, not 16. The footer is therefore `100 − gap` per mode
// (84 / 96 / 98): the last delegate contributes its own trailing gap.
//
// `model: 0` while `pmLoading` is what reproduces the reference's `!loading`
// gate on the whole body; the header carries its own `!loading` gates.
//
// ── NEVER WRAPPED IN QbzOfflinePlaceholder (contract §5.12) ───────────────
// Seven views wrap themselves and one (PlayHistoryView) explicitly refuses
// because it reads a local store. This is the second refusal, and the stronger
// one: the manager keeps working offline, and for a user with no Qobuz account
// the LOCAL playlists it lists are the only library they have. An implementer
// following the majority would hide exactly the view that user needs.
//
// The two counts in the counter row come from the document; the folder cards'
// `count` is TOTAL membership while a tree folder row's is the POST-FILTER
// member count (§5.7) — two different questions from one field name, on
// purpose.
//
// Scroll-position restore is an INHERITED GAP: the reference restores and
// reports `viewport-y` for scope "playlist-manager" (`:1285-1296`); no view in
// this port restores scroll and `nav_qt.rs` has no equivalent seam. Recorded
// (contract D30), not silently dropped.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
import "playlistmanager"
Rectangle {
id: root
// Round to the AppShell content-frame bezel: QML clips are rectangular, so
// the frame's rounding never reaches the view — the view's own fill must
// round (the literal in all 14 painting view roots).
radius: 12
color: root.ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
QbzTheme { id: theme }
// ============================== documents =============================
// Both parses are GUARDED — a raw JSON.parse in a binding throws on the
// pre-publish frame.
readonly property var doc: {
try {
return JSON.parse(QbzPlaylistManager.managerJson)
} catch (e) {
return ({})
}
}
/// The CANONICAL folder array, its own property (contract D5): the sidebar
/// needs it before — and without — the manager document.
readonly property var folders: {
try {
return JSON.parse(QbzPlaylistManager.foldersJson)
} catch (e) {
return []
}
}
// `!== false`, NOT `=== true`, on the two that default TRUE upstream: a `{}`
// first frame would otherwise flash flat mode and a descending sort on every
// mount.
readonly property string searchSeed: root.doc.search || ""
readonly property string filter: root.doc.filter || "all"
readonly property string sort: root.doc.sort || "name"
readonly property bool sortAsc: root.doc.sortAsc !== false
readonly property string viewMode: root.doc.viewMode || "grid"
readonly property bool folderMode: root.doc.folderMode !== false
readonly property bool foldersCollapsed: root.doc.foldersCollapsed === true
readonly property string currentFolderId: root.doc.currentFolderId || ""
readonly property string currentFolderName: root.doc.currentFolderName || ""
/// Computed in RUST (`sort == "custom" && sortAsc && query empty`, D9);
/// QML must never re-derive it.
readonly property bool canReorder: root.doc.canReorder === true
readonly property int folderCount: root.doc.folderCount || 0
readonly property int playlistCount: root.doc.playlistCount || 0
readonly property var playlists: root.doc.playlists || []
/// EMPTY unless folderMode && viewMode === "tree".
readonly property var tree: root.doc.tree || []
// ---- geometry --------------------------------------------------------
readonly property int contentW: Math.max(0, pmList.width - 64)
readonly property int gridCols: Math.max(1, Math.floor((root.contentW + 16) / 176))
readonly property int cardH: root.canReorder ? 250 : 220
readonly property int rowsInBody:
root.viewMode === "tree" ? root.tree.length : root.playlists.length
readonly property int rowCount:
QbzPlaylistManager.pmLoading ? 0
: root.viewMode === "grid"
? Math.ceil(root.playlists.length / Math.max(1, root.gridCols))
: root.rowsInBody
/// The page's 100 px bottom padding, minus the trailing gap the last
/// delegate already carries.
readonly property int footerH:
root.viewMode === "grid" ? 84 : root.viewMode === "list" ? 96 : 98
// `navigate()` records the route and raises the spinner but does NOT load;
// this is the single load, and it is also the back/forward re-entry path
// (nav_qt::back()/forward() republish `currentView` and run no per-view
// load, so `navigate()` never ran in that case).
Component.onCompleted: QbzPlaylistManager.reload()
// ============================ fixed header ============================
Item {
id: headerBar
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: 56
// The reference paints this bar an opaque `surface-main` while the view
// root goes transparent (`:1089`, `:1097`) — under the dynamic
// background that is a solid band across the ambient field. Every Qt
// view's fixed header takes the A30 treatment instead.
Rectangle {
anchors.fill: parent
// Top-rounded: full-bleed at y=0 of the content pane, whose own
// rounding cannot reach a child (Qt's clip is a rectangular
// scissor). See the longer note in HomeView.
topLeftRadius: theme.radiusMd
topRightRadius: theme.radiusMd
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
// x 48, not the reference's 32: this port has NO per-view NavButtons
// (nav history is the global HeaderBar, the treatment
// BlacklistManagerView.qml:23-26 and settings/SettingsView.qml:24-25
// already document; ADR-004 is satisfied there). 48 is the number
// views/PlaylistBrowseView.qml:143 chose for the byte-identical Slint
// header, so the port's two 56 px headers align.
Text {
x: 48
y: 25 - height / 2
width: Math.max(0, tools.x - 48 - 16)
text: QbzSession.tr("Playlist manager", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
PmToolbar {
id: tools
x: Math.max(0, parent.width - width - 32)
y: 25 - height / 2
searchSeed: root.searchSeed
filter: root.filter
sort: root.sort
sortAsc: root.sortAsc
viewMode: root.viewMode
folderMode: root.folderMode
}
}
// ================================ body ================================
Item {
id: bodyHost
anchors.left: parent.left
anchors.right: parent.right
anchors.top: headerBar.bottom
anchors.bottom: parent.bottom
ListView {
id: pmList
anchors.fill: parent
clip: true
boundsBehavior: Flickable.StopAtBounds
spacing: 0
// ~2 grid rows of pre-instantiation past the viewport.
cacheBuffer: 400
reuseItems: true
// An INT model in all three modes: the delegates read
// `root.playlists[index]` / `root.tree[index]`, so a republish that
// keeps the row count re-evaluates their bindings instead of
// rebuilding the list, and the grid's row delegate can slice.
model: root.rowCount
header: PmPageHead {
width: pmList.width
loading: QbzPlaylistManager.pmLoading
folders: root.folders
viewMode: root.viewMode
folderMode: root.folderMode
foldersCollapsed: root.foldersCollapsed
currentFolderId: root.currentFolderId
currentFolderName: root.currentFolderName
canReorder: root.canReorder
folderCount: root.folderCount
playlistCount: root.playlistCount
rowsInBody: root.rowsInBody
}
footer: Item {
width: 1
height: root.footerH
}
// Assigning `delegate` from a ternary re-creates every delegate on a
// mode switch — which is exactly right here, and cheaper than a
// per-delegate Loader.
delegate: root.viewMode === "grid" ? gridRowDelegate
: root.viewMode === "tree" ? treeRowDelegate
: listRowDelegate
}
// The control's own rule is `visible: maxScroll > 0` (its auto-hide, =
// the Slint ListScrollbar's `auto-hide: true`). NEVER assign `visible`
// at this call site — that replaces the binding and a short page gets a
// full-height thumb on gutter hover.
// 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: pmList; scope: "playlistmanager" }
QbzScrollBar {
target: pmList
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
// ============================= delegates ==============================
// GRID — one delegate is one ROW of cards. This is what makes a card grid
// windowed without a GridView (which could not carry a header this tall and
// would force one uniform cell height across the 220/250 split).
Component {
id: gridRowDelegate
Item {
id: cell
required property int index
// FULL-WIDTH DELEGATE, INSET ON THE CHILD — the shape the list and
// tree delegates below already use, and the reason this one is
// written the same way now:
//
// this delegate used to carry `x: 32` ITSELF. A ListView owns its
// delegates' position, so an `x` set on the delegate root is not
// reliably the one that survives layout — and it did not here: the
// card grid sat ~24 px left of the section headings above it, which
// `PmPageHead` draws at 32 through its own Column. Headings at one
// inset and cards at another, on the same page.
//
// The inset now lives where the view has no opinion about it.
width: pmList.width
height: root.cardH + 16
// A BINDING, not a function call: it must re-evaluate when the
// window resizes (gridCols) or the document republishes.
readonly property var slice: root.playlists.slice(
cell.index * root.gridCols, (cell.index + 1) * root.gridCols)
Row {
x: 32
width: root.contentW
spacing: 16
Repeater {
model: cell.slice
delegate: PmGridCard {
required property var modelData
required property int index
item: modelData
// ABSOLUTE index in the visible order — the reorder
// arrows need the position in the whole set, not the
// column within this row.
itemIndex: cell.index * root.gridCols + index
canReorder: root.canReorder
}
}
}
}
}
// LIST — 64 px row + the 4 px inter-row gap.
Component {
id: listRowDelegate
Item {
id: lrow
required property int index
readonly property var entry: root.playlists[lrow.index] || ({})
width: pmList.width
height: 68
PmListRow {
x: 32
width: root.contentW
item: lrow.entry
itemIndex: lrow.index
canReorder: root.canReorder
foldersCount: root.folders.length
// The ONE menu lives at the view level (D17) — the row asks,
// the view opens.
onFolderMenuRequested: function (anchorItem) {
folderMenu.openFor(String(lrow.entry.id || ""),
String(lrow.entry.folderId || ""),
anchorItem)
}
}
}
}
// TREE — 44 px folder rows and 40 px playlist rows, + the 2 px gap. A
// Loader picks the arm so a folder row never instantiates a collage and a
// playlist row never instantiates a folder tile.
Component {
id: treeRowDelegate
Item {
id: trow
required property int index
readonly property var entry: root.tree[trow.index] || ({})
readonly property bool isFolder: trow.entry.kind === "folder"
width: pmList.width
height: (trow.isFolder ? 44 : 40) + 2
Loader {
x: 32
width: root.contentW
height: trow.isFolder ? 44 : 40
sourceComponent: trow.isFolder ? treeFolderArm : treePlaylistArm
}
Component {
id: treeFolderArm
PmTreeFolderRow {
folder: trow.entry.folder || ({})
expanded: trow.entry.expanded === true
}
}
Component {
id: treePlaylistArm
PmTreePlaylistRow {
item: trow.entry.playlist || ({})
indent: trow.entry.indent === true
}
}
}
}
// ======================== the ONE folder menu =========================
// Declared last (declaration order is z-order) and mounted ONCE: with
// `reuseItems: true` a per-row menu would give every recycled delegate its
// own Popup.
PmFolderMenu {
id: folderMenu
folders: root.folders
}
}