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,120 @@
// PmFolderCard — the GRID-mode folder cell (PlaylistManagerView.slint:291-373).
// 160 × 150, r10, elevated → surface-hover on body hover, 0.6 opacity while the
// folder is hidden (the ONLY hidden affordance on a folder — no eye glyph, no
// badge).
//
// BOTH click targets open the folder EDITOR (contract D7): the reference wires
// `open =>` and `edit =>` to the SAME callback, `edit-folder(folder.id)`
// (`:1364-1365`). You cannot enter a folder from the grid — folder navigation
// is the tree. Do not "fix" this into a drill-down.
//
// Hover semantics are the reference's OR (`ta.has-hover || edit-ta.has-hover`,
// `:349`): the pencil's own MouseArea sits above the card's and would otherwise
// steal `containsMouse`, dropping the pencil to opacity 0 under the pointer
// while it still accepts the click. Declaration order is z-order in QML and
// `propagateComposedEvents` does not rescue `pressed`, so the pencil is
// declared AFTER the card's MouseArea, deliberately.
import QtQuick
import com.blitzfc.qbz
import "../../controls"
import "../../theme"
Rectangle {
id: root
/// One `QbzPlaylistManager.foldersJson` entry (contract §4.1).
property var folder: ({})
/// Emitted by the body AND by the pencil — one action, two targets (D7).
signal openRequested()
QbzTheme { id: theme }
readonly property bool hot: cardArea.containsMouse || pencilArea.containsMouse
width: 160
height: 150
radius: 10
color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
opacity: (root.folder.isHidden === true) ? 0.6 : 1.0
// padding 16 / spacing 8 / centred column.
Column {
anchors.centerIn: parent
width: parent.width - 32
spacing: 8
PmFolderIcon {
anchors.horizontalCenter: parent.horizontalCenter
size: 64
iconPreset: root.folder.iconPreset || "folder"
iconColor: root.folder.iconColor || ""
hasColor: root.folder.hasColor === true
hasCustomImage: root.folder.hasCustomImage === true
customImagePath: root.folder.customImagePath || ""
}
Text {
width: parent.width
height: 18
text: root.folder.name || ""
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
width: parent.width
// Rust never pluralises this one — the reference picks the string in
// the markup (`:332-334`), so the two msgids are resolved here. An
// empty folder reads "0 playlists"; there is no empty state.
text: (root.folder.count === 1)
? QbzSession.tr("1 playlist", QbzSession.trRev)
: QbzSession.tr("{} playlists", QbzSession.trRev)
.replace("{}", root.folder.count || 0)
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
MouseArea {
id: cardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openRequested()
}
// Hover pencil, top-right. Declared after the card area so it wins the
// press (see the header).
Rectangle {
x: parent.width - width - 8
y: 8
width: 24
height: 24
radius: 4
opacity: root.hot ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
// Goes DARKER than the card on its own hover — deliberate upstream
// (`:351`).
color: pencilArea.containsMouse ? theme.surfaceCard : theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
name: "pen-line"
width: 12
height: 12
tintName: pencilArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: pencilArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openRequested()
}
}
}