Files
qbz/crates/qbz-qt/qml/views/playlistmanager/PmPageHead.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

219 lines
8.9 KiB
QML

// PmPageHead — everything above the playlist body, mounted as the ONE
// `ListView.header` (contract D1): the counter/hint row, the loading spinner,
// the whole FOLDERS section, the PLAYLISTS header and BOTH empty states.
//
// It rides in the header rather than in a second scroller because the page is a
// single scroll surface in the reference (one Flickable over the lot,
// `:1281`) — and a ListView header is instantiated once, which is also why the
// FOLDERS section is deliberately NOT windowed (stated decision: the reference
// does not window it either and this file's own "many folders" threshold is 8).
//
// It INSETS ITSELF: the reference page is
// `VerticalLayout { padding-left: 32; padding-right: 32; padding-top: 6;
// padding-bottom: 100 }` (`:1300-1303`), so the content sits at x 32 with a
// 6 px lead — without that the counter row is flush against the toolbar. The
// bottom 100 lives in the ListView's footer, minus the last delegate's own
// trailing gap.
//
// `implicitHeight` is EXPLICIT and includes the 6 px: a zero-height
// `ListView.header` is silent, and this one's height is content-derived.
//
// Empty state: a plain left-aligned 15 px muted `Text`, NOT
// `controls/QbzEmptyState.qml` — that control is a centred Column with an 18 px
// `fontSection` title, so reusing it would silently change the size, the weight
// and the alignment. One of the few places where reuse is the parity break.
//
// There is no error state anywhere: the loader swallows every failure, so a
// network outage renders as "No playlists found." That is the reference
// (contract §5.13 / §10 Q4).
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Item {
id: root
property bool loading: false
/// `QbzPlaylistManager.foldersJson`, parsed (contract §4.1).
property var folders: []
property string viewMode: "grid"
property bool folderMode: true
property bool foldersCollapsed: false
property bool canReorder: false
/// `managerJson.folderCount` — equal to `folders.length` by construction,
/// and what the COUNTER row reads (the section header reads the array).
property int folderCount: 0
property int playlistCount: 0
/// `playlists.length`, or `tree.length` in tree mode — what decides the
/// empty state.
property int rowsInBody: 0
QbzTheme { id: theme }
readonly property bool foldersVisible: !root.loading && root.folderMode
&& root.viewMode !== "tree" && (root.folders || []).length > 0
implicitHeight: 6 + col.height
height: implicitHeight
Column {
id: col
x: 32
y: 6
width: Math.max(0, root.width - 64)
spacing: 0
// ---- counter + reorder hint (h 24) --------------------------------
Item {
width: parent.width
height: 24
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
// ONE string with TWO placeholders (`:1314`), not two strings.
// `String.replace` with a string pattern replaces only the
// FIRST occurrence, so the two calls chain.
text: QbzSession.tr("{} folders, {} playlists", QbzSession.trRev)
.replace("{}", root.folderCount)
.replace("{}", root.playlistCount)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Text {
visible: root.canReorder
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Use the arrows to reorder playlists",
QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
// Unconditional 14 px (`:1332`).
Item { width: 1; height: 14 }
// ---- loading spinner ----------------------------------------------
Item {
visible: root.loading
width: parent.width
height: visible ? 36 : 0
QbzSpinner {
anchors.centerIn: parent
size: 36
}
}
// ---- FOLDERS section ----------------------------------------------
// The WHOLE section disappears with zero folders — no header, no
// placeholder (`:1343`). It is also the only collapsible section, and
// collapsing does not rebuild anything.
Column {
visible: root.foldersVisible
width: parent.width
spacing: 12
QbzSectionHeader {
title: QbzSession.tr("Folders ({})", QbzSession.trRev)
.replace("{}", (root.folders || []).length)
// The section header's count is the ARRAY length (`:1346`),
// while the counter row above reads `folderCount`. Same number,
// two bindings, exactly as upstream.
titleSecondary: true
collapsible: true
collapsed: root.foldersCollapsed
// DEFAULTS TO TRUE — without this the carousel page chevrons
// would render on a page that has no carousel.
showChevrons: false
onToggled: QbzPlaylistManager.toggleFoldersCollapsed()
}
// GRID mode — the big folder cards, hand-wrapped (Slint has no
// flex-wrap and neither does a plain QML positioner at fixed pitch).
Item {
id: folderGrid
visible: !root.foldersCollapsed && root.viewMode === "grid"
width: parent.width
readonly property int cols: Math.max(1, Math.floor((width + 16) / 176))
readonly property int rows:
Math.ceil((root.folders || []).length / Math.max(1, cols))
height: visible && rows > 0
? rows * 150 + Math.max(0, rows - 1) * 16 : 0
Repeater {
model: root.folders || []
delegate: PmFolderCard {
required property var modelData
required property int index
x: (index % folderGrid.cols) * 176
y: Math.floor(index / folderGrid.cols) * 166
folder: modelData
onOpenRequested: QbzFolderEdit.openEditor(String(modelData.id || ""))
}
}
}
// LIST mode — the compact chips, uniform 184 px cells.
Item {
id: folderChips
visible: !root.foldersCollapsed && root.viewMode === "list"
width: parent.width
readonly property int cols: Math.max(1, Math.floor((width + 8) / 192))
readonly property int rows:
Math.ceil((root.folders || []).length / Math.max(1, cols))
height: visible && rows > 0
? rows * 52 + Math.max(0, rows - 1) * 8 : 0
Repeater {
model: root.folders || []
delegate: PmFolderChip {
required property var modelData
required property int index
x: (index % folderChips.cols) * 192
y: Math.floor(index / folderChips.cols) * 60
width: 184
folder: modelData
onOpenRequested: QbzFolderEdit.openEditor(String(modelData.id || ""))
}
}
}
// The trailing spacer is a CHILD of a spacing-12 column, so the real
// gap before the PLAYLISTS header is 36, not 24 (`:1388`).
Item { width: 1; height: 24 }
}
// ---- PLAYLISTS header (never in tree mode) -------------------------
QbzSectionHeader {
visible: !root.loading && root.viewMode !== "tree"
title: QbzSession.tr("Playlists ({})", QbzSession.trRev)
.replace("{}", root.playlistCount)
titleSecondary: true
showChevrons: false
}
Item {
visible: !root.loading && root.viewMode !== "tree"
width: 1
height: visible ? 14 : 0
}
// ---- the two empty states ------------------------------------------
Text {
visible: !root.loading && root.viewMode !== "tree" && root.rowsInBody === 0
text: QbzSession.tr("No playlists found.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
Text {
visible: !root.loading && root.viewMode === "tree" && root.rowsInBody === 0
text: QbzSession.tr("No playlists found.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
}