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
This commit is contained in:
115 files changed
+20778
-1637
No files matched your search
@@ -0,0 +1,368 @@
|
||||
// FolderModals — the TWO folder panels and their delete confirm, in one file,
|
||||
// as sibling self-gating overlays (the MyQbzModals.qml shape):
|
||||
//
|
||||
// CREATE primitives/CreateFolderModal.slint (109 lines) — a name field and
|
||||
// a Create button, opened from the sidebar's "..." -> New folder.
|
||||
// Panel width min(host - 80, 400).
|
||||
// EDITOR primitives/FolderEditModal.slint (277) — the full editor, opened
|
||||
// from every folder card / chip / tree row (D7) and from the
|
||||
// sidebar row menu, and by the manager toolbar's own "New folder"
|
||||
// in CREATE mode (`id == ""`). Panel width min(host - 80, 480). Its
|
||||
// body is FolderEditPanel.qml; this file owns the chrome.
|
||||
// CONFIRM QbzConfirmModal, declared LAST so it stacks over the editor, with
|
||||
// its own z: 3100.
|
||||
//
|
||||
// Mounted ONCE in qml/shell/AppShell.qml as a direct child of the root, after
|
||||
// MyQbzModals and before QbzToast, with no `z` — that file's convention is
|
||||
// declaration order, and ADR-009's ">= 3000" is satisfied structurally
|
||||
// (AppShell.qml:533-563). 05 §5.4's "mount the create modal inside Sidebar.qml"
|
||||
// is rejected: the sidebar animates to width 0 with clip: true and already
|
||||
// force-closes its own flyout on a state change, so a modal parented into it
|
||||
// disappears with the sidebar.
|
||||
//
|
||||
// ── TWO DRAFTS, NOT ONE ────────────────────────────────────────────────────
|
||||
// `draftCreateName` belongs to the CREATE panel and is reset on its OWN open
|
||||
// transition; the editor's four drafts live inside FolderEditPanel. Sharing
|
||||
// one draft set between them (04 §5.6) makes "New folder" reopen holding
|
||||
// whatever was last typed in the editor.
|
||||
//
|
||||
// ── `busy` HOLDS THE PANEL OPEN ────────────────────────────────────────────
|
||||
// Neither panel closes itself. Rust closes them, and only after the write
|
||||
// landed (contract D22) — a failed create keeps the name on screen and toasts,
|
||||
// where the reference closes first and loses both the name and the error.
|
||||
//
|
||||
// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ─────────────────────
|
||||
// QbzConfirmModal gets away with a sibling FocusScope because it has no text
|
||||
// field. Here the name input takes active focus, and QML propagates an
|
||||
// unhandled key up the focused item's PARENT chain — a sibling scope is never
|
||||
// on that chain, so Escape would be dead the moment the field is focused.
|
||||
// Each panel is therefore wrapped in its scope. QbzLineEdit's TextInput leaves
|
||||
// Escape unaccepted on the plain arm (QbzLineEdit.qml:177-184), so it reaches
|
||||
// us. Escape closes the innermost open thing: while the confirm is up it holds
|
||||
// focus and handles its own.
|
||||
//
|
||||
// ── ENTER ──────────────────────────────────────────────────────────────────
|
||||
// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on
|
||||
// focus-out, and closing the modal blurs the field, which would submit the
|
||||
// same value twice (MyQbzModals.qml:34-36).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
|
||||
|
||||
// --- Documents. Guarded parses: a raw JSON.parse in a binding throws on
|
||||
// the pre-publish frame (PlaylistView.qml:55-61).
|
||||
readonly property var createDoc: {
|
||||
try { return JSON.parse(QbzFolderEdit.createJson) } catch (e) { return ({}) }
|
||||
}
|
||||
readonly property var editDoc: {
|
||||
try { return JSON.parse(QbzFolderEdit.editJson) } catch (e) { return ({}) }
|
||||
}
|
||||
|
||||
readonly property bool createOpen: root.createDoc.open === true
|
||||
readonly property bool creating: root.createDoc.creating === true
|
||||
readonly property bool editOpen: root.editDoc.open === true
|
||||
/// Rust REFUSES `createClose()` / `editClose()` while a write is in flight
|
||||
/// (D22 — `busy` holds the panel open). The close X must therefore SHOW
|
||||
/// that it is inert for that window, or it is a control that renders and
|
||||
/// no-ops. Same treatment the panel's own Delete button already carries.
|
||||
readonly property bool editBusy: root.editDoc.busy === true
|
||||
|
||||
visible: root.createOpen || root.editOpen
|
||||
enabled: root.visible
|
||||
|
||||
// ==================================================================
|
||||
// PANEL 1 — Create folder (CreateFolderModal.slint)
|
||||
// ==================================================================
|
||||
property string draftCreateName: ""
|
||||
readonly property bool canCreate: root.draftCreateName.trim() !== "" && !root.creating
|
||||
|
||||
onCreateOpenChanged: {
|
||||
if (root.createOpen) {
|
||||
// Its OWN draft, reset on its OWN open transition.
|
||||
root.draftCreateName = ""
|
||||
createScope.forceActiveFocus()
|
||||
createField.focusField()
|
||||
}
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: createScope
|
||||
anchors.fill: parent
|
||||
visible: root.createOpen
|
||||
enabled: root.createOpen
|
||||
|
||||
// Taken HERE rather than in the open handler: an invisible item cannot
|
||||
// hold active focus, and a handler on the document property is not
|
||||
// ordered against the `visible` binding it shares a frame with.
|
||||
onVisibleChanged: if (createScope.visible) createScope.forceActiveFocus()
|
||||
|
||||
Keys.onEscapePressed: function (event) {
|
||||
QbzFolderEdit.createClose()
|
||||
event.accepted = true
|
||||
}
|
||||
|
||||
// `radius` is load-bearing: this Item fills AppShell's ROUNDED content
|
||||
// frame and Qt Quick's clip is a rectangular scissor, so an opaque
|
||||
// full-bleed child paints into the frame's four bezel corners
|
||||
// (AppShell.qml:246-310). #bf000000 is Slint's #000000bf CONVERTED —
|
||||
// Slint is #RRGGBBAA, Qt is #AARRGGBB.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: theme.radiusMd
|
||||
color: "#bf000000"
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: QbzFolderEdit.createClose()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 80, 400)
|
||||
height: createCol.implicitHeight + 40
|
||||
x: Math.round((root.width - width) / 2)
|
||||
y: Math.round((root.height - height) / 2)
|
||||
radius: theme.radiusMd
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
// FIRST child: swallows clicks so they never reach the scrim.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Column {
|
||||
id: createCol
|
||||
x: 20
|
||||
y: 20
|
||||
width: parent.width - 40
|
||||
spacing: 16
|
||||
|
||||
// --- Title + close X.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 28
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(0, parent.width - 28)
|
||||
text: root.t("New folder")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
opacity: root.creating ? 0.5 : 1.0
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
width: 17
|
||||
height: 17
|
||||
name: "x"
|
||||
tintName: createCloseArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: createCloseArea
|
||||
anchors.fill: parent
|
||||
// `createClose()` is refused mid-create, so the
|
||||
// affordance goes with it rather than lying.
|
||||
enabled: !root.creating
|
||||
hoverEnabled: !root.creating
|
||||
cursorShape: root.creating ? Qt.ArrowCursor
|
||||
: Qt.PointingHandCursor
|
||||
onClicked: QbzFolderEdit.createClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Name. No label above it — the reference has none here.
|
||||
QbzLineEdit {
|
||||
id: createField
|
||||
width: parent.width
|
||||
text: root.draftCreateName
|
||||
placeholder: root.t("Folder name")
|
||||
onEdited: function (value) { root.draftCreateName = value }
|
||||
onAccepted: function (value) {
|
||||
root.draftCreateName = value
|
||||
if (root.canCreate)
|
||||
QbzFolderEdit.createSubmit(root.draftCreateName)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Footer.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 36
|
||||
QbzPrimaryButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: root.creating ? root.t("Creating...") : root.t("Create")
|
||||
btnHeight: 36
|
||||
labelSize: 15
|
||||
btnEnabled: root.canCreate
|
||||
onClicked: QbzFolderEdit.createSubmit(root.draftCreateName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// PANEL 2 — The folder editor (FolderEditModal.slint)
|
||||
// ==================================================================
|
||||
// Seeds are consumed ONCE per open, and again when the editor is pointed
|
||||
// at a different folder without closing. A plain republish (a busy flip, a
|
||||
// picked image) must NOT reseed — it would throw away what the user typed.
|
||||
readonly property string editSeedKey:
|
||||
(root.editOpen ? "1" : "0") + "|" + (root.editDoc.id || "")
|
||||
|
||||
onEditSeedKeyChanged: {
|
||||
if (root.editOpen) {
|
||||
editPanel.seed()
|
||||
editScope.forceActiveFocus()
|
||||
editPanel.focusName()
|
||||
}
|
||||
}
|
||||
onEditOpenChanged: {
|
||||
// A sub-modal must never outlive its host panel.
|
||||
if (!root.editOpen)
|
||||
deleteConfirm.close()
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: editScope
|
||||
anchors.fill: parent
|
||||
visible: root.editOpen
|
||||
enabled: root.editOpen
|
||||
|
||||
onVisibleChanged: if (editScope.visible) editScope.forceActiveFocus()
|
||||
|
||||
Keys.onEscapePressed: function (event) {
|
||||
// The confirm holds focus while it is up and handles its own Esc,
|
||||
// so reaching here means the panel is the innermost open thing.
|
||||
QbzFolderEdit.editClose()
|
||||
event.accepted = true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: theme.radiusMd
|
||||
color: "#bf000000"
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: QbzFolderEdit.editClose()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(root.width - 80, 480)
|
||||
height: editCol.implicitHeight + 40
|
||||
x: Math.round((root.width - width) / 2)
|
||||
y: Math.round((root.height - height) / 2)
|
||||
radius: theme.radiusMd
|
||||
color: theme.surfaceCard
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Column {
|
||||
id: editCol
|
||||
x: 20
|
||||
y: 20
|
||||
width: parent.width - 40
|
||||
spacing: 16
|
||||
|
||||
// --- Title + close X.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 28
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(0, parent.width - 28)
|
||||
// "" is CREATE mode — the manager toolbar's New folder
|
||||
// opens this same editor with no id.
|
||||
text: (root.editDoc.id || "") === "" ? root.t("New folder")
|
||||
: root.t("Edit folder")
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: theme.fontHeading
|
||||
font.weight: theme.weightSemibold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Item {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
opacity: root.editBusy ? 0.5 : 1.0
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
width: 17
|
||||
height: 17
|
||||
name: "x"
|
||||
tintName: editCloseArea.containsMouse ? "textPrimary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: editCloseArea
|
||||
anchors.fill: parent
|
||||
// `editClose()` is refused while a save or delete
|
||||
// is in flight — see `editBusy`.
|
||||
enabled: !root.editBusy
|
||||
hoverEnabled: !root.editBusy
|
||||
cursorShape: root.editBusy ? Qt.ArrowCursor
|
||||
: Qt.PointingHandCursor
|
||||
onClicked: QbzFolderEdit.editClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FolderEditPanel {
|
||||
id: editPanel
|
||||
width: parent.width
|
||||
doc: root.editDoc
|
||||
onDeleteRequested: deleteConfirm.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// The delete confirm — LAST, so it stacks over the editor (D21).
|
||||
// ==================================================================
|
||||
// The reference raises a NATIVE rfd message box with untranslated &str
|
||||
// (main.rs:5724); this is the in-app surface the port already has, and its
|
||||
// two strings are new msgids. The body interpolates the LIVE draft name,
|
||||
// matching the reference's `get_name()` — rename the folder, then delete
|
||||
// it without saving, and the prompt still names what is on screen.
|
||||
QbzConfirmModal {
|
||||
id: deleteConfirm
|
||||
anchors.fill: parent
|
||||
danger: true
|
||||
title: root.t("Delete folder")
|
||||
body: root.t("Delete the folder “{}”? Its playlists move back to the root.")
|
||||
.replace("{}", editPanel.draftName)
|
||||
confirmLabel: root.t("Delete")
|
||||
onConfirmed: {
|
||||
QbzFolderEdit.editDeleteConfirmed()
|
||||
// Hand focus back so Esc keeps working on the panel that is still
|
||||
// up while the delete is in flight.
|
||||
if (root.editOpen)
|
||||
editScope.forceActiveFocus()
|
||||
}
|
||||
onCancelled: {
|
||||
if (root.editOpen)
|
||||
editScope.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user