Ports the whole playlist-organisation domain, which had no counterpart in this frontend, plus the Library work the owner asked not to defer. Playlist Manager. New controller (playlist_manager_qt, _rows, _ops) over the already-ported folders_qt repository, which until now had zero consumers. Grid, list and folder-tree modes; sort, filter and search; folder cards and chips; drag reorder and up/down arrows. Reached from the sidebar's "..." menu, whose New folder / Manage playlists / Import rows had been rendering dimmed and inert behind a comment saying the seam did not exist. Folders. QbzFolderEdit plus the create/edit modals and PmFolderIcon: preset or custom-image icons, colour, hidden flag, delete. Deleting a folder nulls its LOCAL members' folder_id explicitly, because the app keeps the foreign_keys pragma off, so the schema's ON DELETE SET NULL never fires for them. QbzPlaylistEdit. One shared rename/description/delete editor replacing the inline popup in PlaylistView. Three corrections the old path had wrong: the description is carried through a rename instead of being written as NULL, a FOLLOWED playlist routes to unsubscribe (playlist/delete returns 200 and silently no-ops on a playlist you do not own), and the post-delete back navigation is conditional on actually being on that playlist's page, so deleting from the manager no longer navigates you off it. Importer. Spotify / Apple Music / Tidal / Deezer, over the shared qbz-playlist-import crate. Picker local mode. The reference's four-case matrix — target local or Qobuz, payload local refs or Qobuz ids. Local playlists list unconditionally and Qobuz ones only when online. Local refs onto a Qobuz playlist write the library.db sidecar at next_playlist_sidecar_position(pid, qobuz_count); the 0-based enumerate the reference used before collided slots and lost rows. The carried payload can no longer be parsed as a catalog id when it is a library row id. Unblocks the local bulk add-to-playlist. Library. A-Z jump strip, Albums list mode, Artists side panel, per-tab persistence and multi-select. Preferences land in the same <data-dir>/qbz/favorites_ui.json the shipping build writes, so switching between the two frontends keeps them. LibraryView is split into views/library/ on the way past 1800 lines. Local library. Group-by now persists; playback queues the order on screen rather than the SQL page order; switching album-identity mode invalidates the Artists tab; artist matching folds diacritics and compares normalized credit parts, so "Air" stops listing "Airbourne" and "Beyonce" stops being two artists. Offline. activate_offline never persisted a last-user marker, so every per-user path resolved to None and all local reads came back empty; a session that started offline saw no local playlists at all. The sidebar's load-once latch was function-local and therefore unreachable from the logout reset, so a second login never rebuilt the tree. Both are session-scoped now, and the sidebar Refresh row runs the local-safe verb instead of being dimmed with no way to recover. Also fixes, found while verifying rather than reported: artwork derivatives were written straight to their final path, so a QML Image resolving one mid-encode opened a truncated png and Qt cached that failure against the URL for the rest of the session; Library's track group separators referenced a Component declared in another delegate's scope and could never render; the genre filter was drawn on Tracks and Albums but applied only on All; an All-feed row called a player method that does not exist; and the sidebar's drop-hot highlight used Slint's #RRGGBBAA literals in Qt's #AARRGGBB slot. 17 new msgids across all 8 locales. cc
359 lines
14 KiB
QML
359 lines
14 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
|
||
|
||
// 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: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
||
|
||
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
|
||
/// 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
|
||
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
|
||
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.
|
||
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
|
||
|
||
x: 32
|
||
width: root.contentW
|
||
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 {
|
||
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
|
||
}
|
||
}
|