Files
qbz/crates/qbz-qt/qml/views/PlaylistManagerView.qml
T
vicrodh a16cf56c86 feat(qt): playlist manager, folders, importer and the Library parity round
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
2026-07-31 22:10:19 -06:00

359 lines
14 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: 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
}
}