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
219 lines
8.9 KiB
QML
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
|
|
}
|
|
}
|
|
}
|