Files
qbz/crates/qbz-qt/qml/views/playlistmanager/PmPageHead.qml
T
vicrodh c7a3012955 feat(local-library): complete catalog and metadata workflows
Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens.

cx
2026-08-28 18:50:05 -06:00

264 lines
11 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 string currentFolderId: ""
property string currentFolderName: ""
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.currentFolderId === ""
&& 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: root.currentFolderId !== ""
? (root.playlistCount === 1
? QbzSession.tr("1 playlist", QbzSession.trRev)
: QbzSession.tr("{} playlists", QbzSession.trRev)
.replace("{}", root.playlistCount))
: 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
}
}
// Grid/list folder drill-down. The manager keeps the selected view
// mode; this compact breadcrumb is the explicit route back to the
// folders + root-playlists surface.
Item {
visible: !root.loading && root.currentFolderId !== ""
width: parent.width
height: visible ? 40 : 0
QbzToolButton {
id: folderBack
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
name: "chevron-left"
label: QbzSession.tr("Back", QbzSession.trRev)
large: true
onClicked: QbzPlaylistManager.closeFolder()
}
Text {
anchors.left: folderBack.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.currentFolderName
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
}
Item {
visible: !root.loading && root.currentFolderId !== ""
width: 1
height: visible ? 14 : 0
}
// ---- 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: QbzPlaylistManager.openFolder(String(modelData.id || ""))
onEditRequested: 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: QbzPlaylistManager.openFolder(String(modelData.id || ""))
onEditRequested: 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
}
}
}