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

+30 -9
View File
@@ -37,33 +37,54 @@ Rectangle {
property bool active: false
/// Active = accent FILL (labelled buttons) vs accent BORDER (icon-only).
property bool fillActive: false
/// THE SECOND SCALE — the Playlist Manager toolbar's `PmToolButton` /
/// `PmIconButton` (playlist/PlaylistManagerView.slint:162-230), which is the
/// same control 4 px taller with more air. The port's established idiom for
/// a second size on one control (`QbzLineEdit.sm`, `QbzSelect.sm`,
/// `QbzToggleButton.sm`), rather than a 90 %-identical fork.
///
/// Under `large`: height 34, padH 11 (the reference's `row { x: 11 }` and
/// `width: row.preferred-width + 22`), row spacing 7, label 13, NO border
/// (except the accent-border arm, which the PM never uses), and an
/// icon-only button is a 34 × 34 square with a 16 px glyph — the 15 → 16
/// delta between the labelled and icon-only forms IS the reference
/// (`:180-184` vs `:216-222`); do not unify it.
///
/// The idle GLYPH tint also changes, and it is the largest of the deltas:
/// this control tints idle glyphs `muted` (#888888) where the reference
/// paints `Theme.text-secondary` (#cccccc) on five of its six toolbar
/// controls. Defaults are untouched, so the MyQBZ toolbars cannot regress.
property bool large: false
signal clicked()
QbzTheme { id: theme }
readonly property bool filled: root.active && root.fillActive
readonly property int padH: root.label === "" ? 8 : 10
readonly property bool iconOnly: root.label === ""
readonly property int padH: root.large ? 11 : (root.iconOnly ? 8 : 10)
height: 30
implicitWidth: 2 * root.padH + row.implicitWidth
height: root.large ? 34 : 30
implicitWidth: (root.large && root.iconOnly)
? 34
: 2 * root.padH + row.implicitWidth
radius: 6
color: root.filled ? theme.accent
: (toolArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated)
border.width: 1
border.width: (root.large && !(root.active && !root.fillActive)) ? 0 : 1
border.color: (root.active && !root.fillActive) ? theme.accent : theme.borderSubtle
Row {
id: row
anchors.centerIn: parent
spacing: 6
spacing: root.large ? 7 : 6
QbzIcon {
anchors.verticalCenter: parent.verticalCenter
name: root.name
width: 15
height: 15
width: (root.large && root.iconOnly) ? 16 : 15
height: (root.large && root.iconOnly) ? 16 : 15
tintName: root.filled ? theme.accentGlyphTint
: (root.active ? "textPrimary" : "muted")
: (root.active ? "textPrimary" : (root.large ? "secondary" : "muted"))
}
Text {
visible: root.label !== ""
@@ -71,7 +92,7 @@ Rectangle {
text: root.label
color: root.filled ? theme.accentGlyphColor
: (root.active ? theme.textPrimary : theme.textSecondary)
font.pixelSize: 12
font.pixelSize: root.large ? 13 : 12
verticalAlignment: Text.AlignVCenter
}
Rectangle {