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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user