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,685 @@
// "Add to playlist" picker — QML port of
// crates/qbz-ui/ui/primitives/PlaylistPickerModal.slint (+ its
// PlaylistDuplicateConfirmModal sibling, folded in here because the two
// share one document and one carried track set).
//
// A focus-opened dropdown lists the user's playlists; clicking a row only
// SELECTS it (the selection becomes the input's placeholder); the footer
// button fires the actual add. "+ Create new playlist" is always the first
// dropdown row and reveals an inline name field confirmed by the same
// footer button ("Create & Add").
//
// Mounted ONCE in AppShell next to AddToMixtapeModal: every host that opens
// it (the queue footer and row menu today) can be unmounted by the time the
// user picks a target, so the modal must outlive them.
//
// Data: QbzPlaylistPicker.pickerJson (playlist_picker_qt.rs PickerDoc). The
// name filter runs in RUST (the QbzMyQbzAdd.search convention), so the row
// list arrives already filtered; the 8-row window and the overflow line are
// applied here against `total`/`playlists.length`.
//
// LOCAL playlists are listed alongside the Qobuz ones (a hard-drive glyph
// leads the row, `isLocal` in the document) and are the ONLY rows while
// offline — a Qobuz playlist cannot be written to without a session, so
// offering one would be a dead target (D3/D11). The carried payload is
// likewise either Qobuz catalog ids or LocalLibrary refs; `localMode` only
// changes the counter's wording ("Adding {} local track(s)"), never the row
// list — a local selection can legitimately target a Qobuz playlist, which
// Rust routes through the library.db sidecar.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// An unopened overlay is an invisible, non-interactive Item and costs
// nothing (the AppShell mount comment states the contract).
visible: doc.open === true || dup.open === true
readonly property var doc: JSON.parse(QbzPlaylistPicker.pickerJson)
readonly property var rows: doc.playlists || []
readonly property var dup: doc.dup || ({})
readonly property int trackCount: doc.trackCount || 0
// The carried payload is LocalLibrary refs (counter wording only).
readonly property bool localMode: doc.localMode === true
readonly property bool creatingOpen: doc.creatingOpen === true
readonly property bool creating: doc.creating === true
// Rows past the 8-row window collapse into the "{} more playlist(s)..."
// line, exactly as the .slint's filter-rank window does.
readonly property int windowSize: 8
readonly property int shown: Math.min(rows.length, windowSize)
readonly property int overflow: Math.max(0, rows.length - windowSize)
// --- Local selection state (the .slint keeps these on the `if open:`
// subtree so a remount resets them; here they are reset when `open`
// flips, which is the same lifetime without a conditional Loader). ---
property bool dropdownOpen: false
property string selectedId: ""
property string selectedLabel: ""
property string filterText: ""
property string createName: ""
onVisibleChanged: if (!visible) resetLocals()
function resetLocals() {
dropdownOpen = false
selectedId = ""
selectedLabel = ""
filterText = ""
createName = ""
}
function selectRow(row) {
selectedId = row.id
selectedLabel = (row.tracksLine || "") === ""
? row.name : row.name + " (" + row.tracksLine + ")"
// The reference clears the query on selection (Tauri §2.4).
filterText = ""
QbzPlaylistPicker.search("")
QbzPlaylistPicker.showCreate(false)
dropdownOpen = false
}
function confirm() {
if (root.creating)
return
if (root.creatingOpen) {
if (createName.trim() !== "")
QbzPlaylistPicker.createAndAdd(createName)
} else if (selectedId !== "") {
QbzPlaylistPicker.pick(selectedId)
}
}
QbzTheme { id: theme }
// ======================= The picker ==================================
Item {
anchors.fill: parent
visible: root.doc.open === true && root.dup.open !== true
// Scrim — click outside dismisses.
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzPlaylistPicker.close()
}
}
Rectangle {
id: card
anchors.centerIn: parent
width: Math.min(parent.width - 80, 490)
// Tauri: min-height 400 / max-height 90vh. The body stretches so
// the footer pins to the bottom.
height: Math.min(Math.max(400, panel.implicitHeight), parent.height * 0.9)
radius: theme.radiusLg
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
// NO clip: the dropdown legitimately overflows the card bottom.
// Swallow clicks so they never reach the scrim; doubles as
// "a click inside the card closes the list" (Tauri parity).
MouseArea {
anchors.fill: parent
onClicked: root.dropdownOpen = false
}
Column {
id: panel
width: parent.width
// --- Header -------------------------------------------
Item {
width: parent.width
height: 68
Text {
anchors.left: parent.left
anchors.leftMargin: 24
anchors.right: closeX.left
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Add to Playlist", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Item {
id: closeX
anchors.right: parent.right
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: closeArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlaylistPicker.close()
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// --- Body ---------------------------------------------
Column {
id: body
width: parent.width
padding: 24
spacing: 16
// Loading — the playlists are still on the wire.
Item {
width: parent.width - 48
height: visible ? 30 : 0
visible: root.doc.loading === true
QbzSpinner {
anchors.horizontalCenter: parent.horizontalCenter
size: 30
}
}
Column {
width: parent.width - 48
spacing: 8
Item {
width: parent.width
height: 20
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Choose playlist", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
// Two msgids, not one with an inserted word: the
// .slint counter switches the whole string on
// `local-mode` so every locale can order it its
// own way.
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: (root.localMode
? QbzSession.tr("Adding {} local track(s)", QbzSession.trRev)
: QbzSession.tr("Adding {} track(s)", QbzSession.trRev))
.replace("{}", root.trackCount)
color: theme.textMuted
font.pixelSize: theme.fontLink
}
}
// The search / selection input (43px, the Tauri metric).
Rectangle {
id: inputBox
width: parent.width
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: searchInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
id: searchInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 36 // room for the clear chip
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
enabled: root.doc.loading !== true
text: root.filterText
onTextEdited: {
root.filterText = text
root.dropdownOpen = true // typing re-opens
QbzPlaylistPicker.search(text)
}
onAccepted: root.confirm()
// Tauri: the list opens on focus.
onActiveFocusChanged: if (activeFocus) root.dropdownOpen = true
// Re-seed while not being edited (the
// AddToMixtapeModal Binding shape).
Binding {
target: searchInput
property: "text"
value: root.filterText
when: !searchInput.activeFocus
}
}
// Placeholder, doubling as the SELECTION label —
// the filter is cleared on select, so the field is
// empty exactly when the label should show.
Text {
visible: searchInput.text === ""
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 36
verticalAlignment: Text.AlignVCenter
text: root.creatingOpen
? QbzSession.tr("+ Create new playlist", QbzSession.trRev)
: (root.selectedLabel !== ""
? root.selectedLabel
: QbzSession.tr("Search playlists...", QbzSession.trRev))
color: theme.textMuted
font.pixelSize: theme.fontLink
elide: Text.ElideRight
}
// Clear-selection chip — only with a selection (or
// the inline-create state) and the list closed.
Rectangle {
visible: (root.selectedId !== "" || root.creatingOpen)
&& !root.dropdownOpen
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 20
height: 20
radius: 10
color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 12
height: 12
tintName: chipArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: chipArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.selectedId = ""
root.selectedLabel = ""
root.createName = ""
QbzPlaylistPicker.showCreate(false)
}
}
}
}
}
// Inline create — a second form group BELOW the picker
// (Tauri §1.4.6); the footer button confirms it.
Column {
width: parent.width - 48
spacing: 8
visible: root.creatingOpen
Text {
text: QbzSession.tr("Playlist Name", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
Rectangle {
width: parent.width
height: 43
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: nameInput.activeFocus ? theme.accent : theme.borderSubtle
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontLink
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
enabled: !root.creating
text: root.createName
onTextEdited: root.createName = text
onAccepted: root.confirm()
Binding {
target: nameInput
property: "text"
value: root.createName
when: !nameInput.activeFocus
}
}
Text {
visible: nameInput.text === ""
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("My Playlist", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLink
elide: Text.ElideRight
}
}
}
}
}
// --- Footer, pinned to the card bottom --------------------
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: footerRow.top
height: 1
color: theme.borderSubtle
}
Row {
id: footerRow
anchors.right: parent.right
anchors.rightMargin: 24
anchors.bottom: parent.bottom
anchors.bottomMargin: 16
spacing: 12
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Cancel", QbzSession.trRev)
btnHeight: 34
minWidth: 0
onClicked: QbzPlaylistPicker.close()
}
// The single accent confirm (ADR-008). Disabled until valid —
// this is what replaces Tauri's in-body error box.
QbzPrimaryButton {
anchors.verticalCenter: parent.verticalCenter
btnHeight: 34
label: root.creating
? QbzSession.tr("Saving...", QbzSession.trRev)
: (root.creatingOpen
? QbzSession.tr("Create & Add", QbzSession.trRev)
: QbzSession.tr("Add", QbzSession.trRev))
btnEnabled: !root.creating
&& (root.creatingOpen
? root.createName.trim() !== ""
: root.selectedId !== "")
onClicked: root.confirm()
}
}
// --- Dropdown overlay -------------------------------------
// Declared LAST inside the card so it paints above the body and
// the footer (declaration order IS z-order in QML), and it
// legitimately overflows the unclipped card bottom.
Rectangle {
id: dropdown
visible: root.dropdownOpen && root.doc.loading !== true
x: 24
// Anchored below the input by static arithmetic over fixed
// metrics — never an absolute-position binding: header 68 +
// 1 divider + 24 body pad + 20 label row + 8 spacing +
// 43 input + 4 gap.
y: 68 + 1 + 24 + 20 + 8 + 43 + 4
width: parent.width - 48
// create row 41 + 1 divider, 41 per shown row, 41 no-results,
// 1 + 34 overflow. Tauri max-height 320 + border.
height: Math.min(2 + 42 + root.shown * 41
+ ((root.rows.length === 0 && root.filterText !== "") ? 41 : 0)
+ (root.overflow > 0 ? 35 : 0), 322)
radius: theme.radiusSm
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
// Keep clicks on the list chrome from reaching the card
// swallower, which would close the list under the pointer.
MouseArea { anchors.fill: parent }
Flickable {
anchors.fill: parent
anchors.margins: 1
contentWidth: width
contentHeight: dropInner.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: dropInner
width: parent.width
// "+ Create new playlist" — ALWAYS first, never
// filtered out (Tauri parity).
Rectangle {
width: parent.width
height: 41
color: createArea.containsMouse ? theme.surfaceHover : "transparent"
Text {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("+ Create new playlist", QbzSession.trRev)
color: theme.accent
font.pixelSize: theme.fontLink
font.weight: theme.weightMedium
elide: Text.ElideRight
}
MouseArea {
id: createArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.selectedId = ""
root.selectedLabel = ""
root.filterText = ""
QbzPlaylistPicker.search("")
QbzPlaylistPicker.showCreate(true)
root.dropdownOpen = false
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Repeater {
model: root.rows.slice(0, root.windowSize)
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 41
color: rowArea.containsMouse ? theme.surfaceHover : "transparent"
// LOCAL playlists (library.db) keep a small
// hard-drive glyph LEADING the name, muted —
// PlaylistPickerModal.slint:62-69. It is what
// tells a local target apart from a Qobuz one
// in a list that mixes both.
QbzIcon {
id: localGlyph
visible: modelData.isLocal === true
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
name: "hard-drive"
width: 14
height: 14
tintName: "muted"
}
Text {
anchors.left: localGlyph.visible ? localGlyph.right : parent.left
anchors.leftMargin: localGlyph.visible ? 8 : 12
anchors.right: countText.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: theme.textPrimary
font.pixelSize: theme.fontLink
elide: Text.ElideRight
}
Text {
id: countText
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.tracksLine || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.selectRow(modelData)
}
}
}
Text {
visible: root.rows.length === 0 && root.filterText !== ""
width: parent.width
height: visible ? 41 : 0
text: QbzSession.tr("No playlists found", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// Overflow line — the rows past the 8-row window.
Rectangle {
visible: root.overflow > 0
width: parent.width
height: visible ? 1 : 0
color: theme.borderSubtle
}
Text {
visible: root.overflow > 0
width: parent.width
height: visible ? 34 : 0
text: QbzSession.tr("{} more playlist(s)...", QbzSession.trRev)
.replace("{}", root.overflow)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.italic: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
}
}
// ============= Duplicate-confirm sub-modal ===========================
// Opens INSTEAD of adding when the chosen playlist already holds some of
// the carried tracks. It replaces the picker rather than stacking on it
// (the picker's own state is already parked in Rust).
Item {
anchors.fill: parent
visible: root.dup.open === true
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzPlaylistPicker.cancelDuplicates()
}
}
Rectangle {
anchors.centerIn: parent
width: Math.min(parent.width - 80, 460)
height: dupPanel.implicitHeight
radius: theme.radiusLg
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Column {
id: dupPanel
width: parent.width
padding: 24
spacing: 16
Item {
width: parent.width - 48
height: 28
Text {
anchors.left: parent.left
anchors.right: dupClose.left
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Some of these tracks were already added", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Item {
id: dupClose
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: dupCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: dupCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlaylistPicker.cancelDuplicates()
}
}
}
Text {
width: parent.width - 48
text: QbzSession.tr("This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates.", QbzSession.trRev)
.replace("{}", root.dup.duplicateCount || 0)
.replace("{}", root.trackCount)
color: theme.textSecondary
font.pixelSize: theme.fontLink
wrapMode: Text.WordWrap
}
Row {
anchors.right: parent.right
anchors.rightMargin: 24
spacing: 12
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: root.dup.busy === true
? QbzSession.tr("Adding...", QbzSession.trRev)
: QbzSession.tr("Add all tracks", QbzSession.trRev)
btnHeight: 34
minWidth: 0
enabled: root.dup.busy !== true
onClicked: QbzPlaylistPicker.confirmDuplicates(true)
}
QbzPrimaryButton {
anchors.verticalCenter: parent.verticalCenter
btnHeight: 34
label: root.dup.busy === true
? QbzSession.tr("Adding...", QbzSession.trRev)
: QbzSession.tr("Add only new tracks", QbzSession.trRev)
btnEnabled: root.dup.busy !== true
onClicked: QbzPlaylistPicker.confirmDuplicates(false)
}
}
}
}
}
}