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:
vicrodh committed 2026-07-31 22:10:19 -06:00
1 parent ac9740b83a
commit a16cf56c86
115 files changed
+20778 -1637

No files matched your search

@@ -0,0 +1,391 @@
// FolderEditPanel — the BODY of the folder editor
// (primitives/FolderEditModal.slint:66-266): name field, the seven icon-preset
// tiles plus the custom-image tile, the eleven colour swatches, the "Hide from
// sidebar" row and the Delete / Save footer.
//
// The chrome around it — scrim, card, title, close X, Escape scope and the
// delete confirm — belongs to FolderModals.qml, which mounts this. The split
// is declared up front (rule 2): 04 §5.2's single ~380-line FolderModals is
// rejected because the two panels plus the confirm plus the drafts land nearer
// 600, and the port's precedents for "one file, several panels" are
// MyQbzModals.qml at 523 lines for three SIMPLER panels and
// PlaylistPickerModal.qml at 685.
//
// ── THE DRAFTS LIVE HERE ───────────────────────────────────────────────────
// Name, preset, colour and the hidden switch are QML-LOCAL and reach Rust only
// as arguments to `editSave(name, preset, color, hidden)`. That is the port's
// standing rule (MyQbzModals.qml's header: "TEXT STATE IS QML-LOCAL ... every
// submit invokable takes the value as an argument") and it is why the bridge
// has no `selectPreset` / `selectColor` / `toggleHidden` invokables: a colour
// click has no business making a round trip.
//
// Preset selection is the ONE draft write with a Rust side effect — it also
// calls `QbzFolderEdit.clearImage()`, because picking a preset means dropping
// the custom image, and the image is Rust-owned (the file dialog is native).
// That call is also what makes the reference's declared-but-never-called
// `clear-image` callback (main.rs:5611-5619) live.
//
// ── SEEDING ────────────────────────────────────────────────────────────────
// `doc.name` / `iconPreset` / `iconColor` / `isHidden` are SEEDS, consumed
// once on the open transition. They are NOT bound: Rust republishes editJson
// on every `busy` flip and on every image pick, and a bound field would throw
// away whatever the user had typed each time. `hasCustomImage` /
// `customImagePath` / `busy` ARE read live, because Rust owns them.
//
// ── REUSED, NOT REDRAWN ────────────────────────────────────────────────────
// QbzLineEdit the name field (its width MUST be set — the plain arm is
// a fixed 240, where the Slint LineEdit stretches).
// QbzToggle the hidden row. The reference uses QbzToggle too
// (FolderEditModal.slint:214). A second switch is exactly
// the fork rule 5 forbids.
// QbzPrimaryButton the Save button. The reference's `#ffffffd6` hover on an
// accent fill and its hardcoded `#ffffff` label are NOT
// copied — that white-wash is the white-button-on-artwork
// treatment and it washes out on an accent fill (illegible
// on the 11 light themes). The sibling
// LibFolderEditModal.slint:376 already uses accent-hover.
// Contract §10 Q5 records the exact literals if parity is
// ever wanted.
// PmFolderIcon NOT reused for the preset tiles: a tile is a
// surface-elevated square with a theme-tinted 17px glyph,
// where PmFolderIcon is a saturated colour tile with a
// fixed-white glyph at half its own size. Only the id ->
// glyph chain is common, and it is seven ternary arms; a
// shared .js would need its own build.rs entry for that.
// Keep the two in step — PmFolderIcon.qml carries the
// same chain.
// Delete button hand-drawn, because it is a GHOST danger button and the
// port's filled danger control (QbzPrimaryButton
// `destructive`) is a different affordance. SettingsButton
// has `danger`, but it is a settings-row control with its
// own leading-glyph geometry.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
/// The parsed QbzFolderEdit.editJson (§4.5). The HOST parses it once; a
/// second `JSON.parse` in here would double the work on every republish.
property var doc: ({})
/// The panel never raises the confirm itself — FolderModals owns it, so
/// the confirm can stack over this panel with its own z (D21).
signal deleteRequested()
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// --- Drafts -----------------------------------------------------------
property string draftName: ""
property string draftPreset: "folder"
property string draftColor: ""
property bool draftHidden: false
readonly property bool busy: root.doc.busy === true
readonly property bool isCreate: (root.doc.id || "") === ""
readonly property bool canSave: root.draftName.trim() !== "" && !root.busy
/// Consume the seeds. Called by the host on the open transition — never on
/// a plain republish.
function seed() {
root.draftName = root.doc.name || ""
root.draftPreset = root.doc.iconPreset || "folder"
root.draftColor = root.doc.iconColor || ""
root.draftHidden = root.doc.isHidden === true
}
/// Focus the name field. Split out so the host can call it AFTER the panel
/// is actually visible — an invisible item cannot take active focus.
function focusName() { nameField.focusField() }
/// The id -> glyph chain, shared with PmFolderIcon.qml. "folder" is not a
/// case in the reference's chain at all and falls through to the default
/// arm, which is also the unknown-id arm; "headphones" draws audio-lines.
/// Both are upstream intent (§4.5) — do not "fix" either.
function presetGlyph(id) {
return id === "heart" ? "heart"
: id === "star" ? "star"
: id === "music" ? "music"
: id === "disc" ? "disc"
: id === "library" ? "library"
: id === "headphones" ? "audio-lines"
: "folder"
}
implicitHeight: col.implicitHeight
Column {
id: col
width: parent.width
spacing: 16
// ================= Name =========================================
Column {
width: parent.width
spacing: 6
Text {
text: root.t("Folder name")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
QbzLineEdit {
id: nameField
// MUST be explicit: the plain arm is a fixed `width: 240`
// (QbzLineEdit.qml:62), where the Slint LineEdit stretches to
// the panel.
width: parent.width
text: root.draftName
placeholder: root.t("Enter folder name")
onEdited: function (value) { root.draftName = value }
// `accepted`, NEVER `committed`: committed also fires on blur,
// and closing the modal blurs the field, which would submit a
// second time (MyQbzModals.qml:34-36 documents the incident).
onAccepted: function (value) {
root.draftName = value
if (root.draftName.trim() !== "" && !root.busy)
QbzFolderEdit.editSave(root.draftName, root.draftPreset,
root.draftColor, root.draftHidden)
}
}
}
// ================= Icon =========================================
Column {
width: parent.width
spacing: 6
Text {
text: root.t("Icon")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Row {
spacing: 8
Repeater {
model: root.doc.presets || []
delegate: Rectangle {
id: presetTile
required property var modelData
// A preset is only "the" selection while there is no
// custom image — the image wins, exactly as in the
// reference (`custom-image-path == "" && ...`).
readonly property bool active:
root.doc.hasCustomImage !== true
&& root.draftPreset === presetTile.modelData
width: 40
height: 40
radius: 8
color: presetTile.active
? theme.accent
: (presetArea.containsMouse ? theme.surfaceHover
: theme.surfaceElevated)
QbzIcon {
anchors.centerIn: parent
width: 17
height: 17
name: root.presetGlyph(presetTile.modelData)
// On an accent fill the port goes through the
// measured on-accent selector, not a hardcoded
// #ffffff (QbzTheme.qml "ON AN ACCENT FILL").
tintName: presetTile.active ? theme.accentGlyphTint : "secondary"
}
MouseArea {
id: presetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.draftPreset = presetTile.modelData
// The image is RUST-owned (native dialog), so
// dropping it is the one draft write that has
// to make a round trip.
QbzFolderEdit.clearImage()
}
}
}
}
// --- The custom-image tile.
Rectangle {
id: imageTile
readonly property bool active: root.doc.hasCustomImage === true
width: 40
height: 40
radius: 8
color: imageTile.active
? theme.accent
: (imageArea.containsMouse ? theme.surfaceHover
: theme.surfaceElevated)
// No `clip: true` — it is a rectangular scissor and does
// not follow `radius`. RoundedImage masks its own corners.
RoundedImage {
anchors.fill: parent
visible: imageTile.active
source: imageTile.active ? (root.doc.customImagePath || "") : ""
radius: 8
fit: "crop"
}
QbzIcon {
anchors.centerIn: parent
visible: !imageTile.active
width: 17
height: 17
name: "image-plus"
tintName: "secondary"
}
MouseArea {
id: imageArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzFolderEdit.pickImage()
}
}
}
}
// ================= Colour =======================================
Column {
width: parent.width
spacing: 6
Text {
text: root.t("Color")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
// ELEVEN swatches, ONE row — 11 * 28 + 10 * 8 = 388 px. The
// .slint comment at :167 saying "Two rows" is wrong about its own
// grid. The wrap arithmetic is kept anyway, so a longer constant
// list wraps instead of overflowing the card.
Item {
id: swatchGrid
readonly property int columns: 11
readonly property int sw: 28
readonly property int gap: 8
readonly property int count: (root.doc.swatches || []).length
readonly property int rows: Math.ceil(swatchGrid.count / swatchGrid.columns)
width: parent.width
height: swatchGrid.rows * swatchGrid.sw
+ Math.max(0, swatchGrid.rows - 1) * swatchGrid.gap
Repeater {
model: root.doc.swatches || []
delegate: Rectangle {
id: swatch
required property int index
required property var modelData
readonly property string value: swatch.modelData.value || ""
readonly property bool active: root.draftColor === swatch.value
x: (swatch.index % swatchGrid.columns) * (swatchGrid.sw + swatchGrid.gap)
y: Math.floor(swatch.index / swatchGrid.columns)
* (swatchGrid.sw + swatchGrid.gap)
width: swatchGrid.sw
height: swatchGrid.sw
radius: swatchGrid.sw / 2
color: swatch.modelData.isAccent === true ? theme.accent : swatch.value
// A fixed white ring: the host is a saturated swatch
// under every theme, so a theme-following border would
// vanish on half of them.
border.width: swatch.active ? 2 : 0
border.color: "#ffffff"
QbzIcon {
anchors.centerIn: parent
visible: swatch.active
width: 14
height: 14
name: "check"
tintName: "white"
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// "" IS a value ("use the theme accent") and it
// reaches the DB as "" — see contract D24.
onClicked: root.draftColor = swatch.value
}
}
}
}
}
// ================= Hidden =======================================
Item {
width: parent.width
height: 22
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 50)
text: root.t("Hide from sidebar")
color: theme.textSecondary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
QbzToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.draftHidden
enabled: !root.busy
// QbzToggle never self-flips; the owner of the state does.
onToggled: function (value) { root.draftHidden = value }
}
}
// ================= Footer =======================================
Item {
width: parent.width
height: 36
// Delete — edit mode only. A ghost danger button: fill on hover
// only, 1px danger border, danger label. theme.danger /
// theme.dangerHover ARE the reference's #ef4444 / #ef444433 under
// the default theme (the engine defaults danger to rgb(ef,44,44)
// and derives danger_hover as a 0.2 tint, i.e. alpha 0x33) and the
// correct colours under the other 35.
Rectangle {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
visible: !root.isCreate
width: delLabel.implicitWidth + 28
height: 36
radius: 8
color: (delArea.containsMouse && !root.busy) ? theme.dangerHover : "transparent"
border.width: 1
border.color: theme.danger
opacity: root.busy ? 0.5 : 1.0
Text {
id: delLabel
anchors.centerIn: parent
text: root.t("Delete")
color: theme.danger
font.pixelSize: 15
font.weight: theme.weightMedium
}
MouseArea {
id: delArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: if (!root.busy) root.deleteRequested()
}
}
// Save. The label stays "Save" in create mode too — that is the
// reference (FolderEditModal.slint:246); the small sidebar create
// panel is the one that says "Create".
QbzPrimaryButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: root.t("Save")
btnHeight: 36
labelSize: 15
btnEnabled: root.canSave
onClicked: QbzFolderEdit.editSave(root.draftName, root.draftPreset,
root.draftColor, root.draftHidden)
}
}
}
}