Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus 22 shared opt-in hosts, shared navigation history with Back/Forward, Albums as the default library tab, native paged Local Library (artists/tracks), lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and artwork sized to the physical frame, static skeletons, 64px transport bar, Settings touch reflow and a persistent Immersive exit. Desktop keeps its prior behavior: every shared component defaults to the desktop path and only opts into Kiosk behavior through explicit properties (origin-relative ScrollMemory, viewport-bounded artwork requests). Regression coverage: kiosk navigation and artwork Node tests added to the runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n strings for all eight locales. Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and 1280x800 and a Desktop A/B with seven pixel-identical captures. cc
435 lines
19 KiB
QML
435 lines
19 KiB
QML
// 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
|
||
property bool kioskHost: false
|
||
|
||
// 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) / (root.kioskHost ? 216 : 176)))
|
||
readonly property int cardH: root.kioskHost ? (root.canReorder ? 320 : 270) : (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: root.kioskHost ? 64 : 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 {
|
||
visible: !root.kioskHost
|
||
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
|
||
}
|
||
|
||
Row {
|
||
visible: root.kioskHost
|
||
x: 16
|
||
height: 64
|
||
spacing: 12
|
||
Text { width: 190; height: 64; text: QbzSession.tr("Playlist manager",QbzSession.trRev); color: theme.textPrimary; font.pixelSize: 20; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter }
|
||
QbzLineEdit { kioskHost: true; width: Math.max(120,root.width-306); anchors.verticalCenter: parent.verticalCenter; searchMode: true; text: root.searchSeed; placeholder: QbzSession.tr("Search playlists…",QbzSession.trRev); onEdited: function(v) { QbzPlaylistManager.searchChanged(v) } }
|
||
SettingsButton { id: kioskActions; kioskHost: true; minWidth: 64; btnHeight: 64; text: "⋯"; onClicked: kioskMenu.openBelowRight(kioskActions) }
|
||
}
|
||
|
||
PmToolbar {
|
||
id: tools
|
||
visible: !root.kioskHost
|
||
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
|
||
}
|
||
}
|
||
|
||
CardMenu {
|
||
id: kioskMenu
|
||
kioskHost: root.kioskHost
|
||
menuWidth: Math.min(root.width-24,320)
|
||
entries: [
|
||
{label: QbzSession.tr("Import",QbzSession.trRev),action:"import"},
|
||
{label: QbzSession.tr("New folder",QbzSession.trRev),action:"new-folder"},
|
||
{label: QbzSession.tr("Folders",QbzSession.trRev),action:"folders"},
|
||
{label: QbzSession.tr("Grid",QbzSession.trRev),action:"view:grid"},
|
||
{label: QbzSession.tr("List",QbzSession.trRev),action:"view:list"},
|
||
{label: QbzSession.tr("Tree",QbzSession.trRev),action:"view:tree"},
|
||
{label: QbzSession.tr("All",QbzSession.trRev),action:"filter:all"},
|
||
{label: QbzSession.tr("Visible",QbzSession.trRev),action:"filter:visible"},
|
||
{label: QbzSession.tr("Hidden",QbzSession.trRev),action:"filter:hidden"},
|
||
{label: QbzSession.tr("Name",QbzSession.trRev),action:"sort:name"},
|
||
{label: QbzSession.tr("Tracks",QbzSession.trRev),action:"sort:tracks"},
|
||
{label: QbzSession.tr("Recent",QbzSession.trRev),action:"sort:recent"},
|
||
{label: QbzSession.tr("Play Count",QbzSession.trRev),action:"sort:playcount"},
|
||
{label: QbzSession.tr("Custom",QbzSession.trRev),action:"sort:custom"}
|
||
]
|
||
onPicked: function(a) {
|
||
if(a==="import") QbzPlaylistImport.open()
|
||
else if(a==="new-folder") QbzFolderEdit.openNewFolder()
|
||
else if(a==="folders") QbzPlaylistManager.toggleFolderMode()
|
||
else if(a.indexOf("view:")===0) QbzPlaylistManager.setViewMode(a.slice(5))
|
||
else if(a.indexOf("filter:")===0) QbzPlaylistManager.setFilter(a.slice(7))
|
||
else if(a.indexOf("sort:")===0) QbzPlaylistManager.setSort(a.slice(5))
|
||
}
|
||
}
|
||
|
||
// ================================ 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: root.kioskHost ? 128 : 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 {
|
||
kioskHost: root.kioskHost
|
||
viewport: pmList
|
||
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 {
|
||
kioskHost: root.kioskHost
|
||
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: root.kioskHost && root.canReorder ? 92 : 68
|
||
|
||
PmListRow {
|
||
kioskHost: root.kioskHost
|
||
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: (root.kioskHost ? 64 : (trow.isFolder ? 44 : 40)) + 2
|
||
|
||
Loader {
|
||
x: 32
|
||
width: root.contentW
|
||
height: root.kioskHost ? 64 : (trow.isFolder ? 44 : 40)
|
||
sourceComponent: trow.isFolder ? treeFolderArm : treePlaylistArm
|
||
}
|
||
|
||
Component {
|
||
id: treeFolderArm
|
||
|
||
PmTreeFolderRow {
|
||
kioskHost: root.kioskHost
|
||
folder: trow.entry.folder || ({})
|
||
expanded: trow.entry.expanded === true
|
||
}
|
||
}
|
||
Component {
|
||
id: treePlaylistArm
|
||
|
||
PmTreePlaylistRow {
|
||
kioskHost: root.kioskHost
|
||
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
|
||
kioskHost: root.kioskHost
|
||
folders: root.folders
|
||
}
|
||
}
|