Files
qbz/crates/qbz-qt/qml/views/PlaylistListRow.qml
T
vicrodh a16cf56c86 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
2026-07-31 22:10:19 -06:00

184 lines
6.6 KiB
QML

// PlaylistListRow — 1:1 port of primitives/PlaylistListRow.slint: the LIST
// arm of the Qobuz Playlists browse page. 60px, radius 8, odd-row zebra,
// 44px thumb, name over "owner • N tracks", a play affordance and the ⋯
// menu. The whole row body opens the playlist.
//
// item contract: home_qt::HomeCard as the playlist pages publish it —
// { id, title, subtitle, artPath }.
//
// Menu inventory vs the .slint's five rows: Play / Play next / Play later /
// Add to queue are wired through QbzPlayer; "Follow on Qobuz" is NOT — the
// follow invokable takes the playlist that is currently OPEN
// (`playlistToggleFollow`), so a row cannot call it, exactly as
// PlaylistCard.qml documents for "Copy to your library". The entry stays out
// of the menu rather than rendering and doing nothing.
import QtQuick
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../theme"
Rectangle {
id: root
property var item: ({})
/// Cover override for hosts whose rows carry no `artPath` of their own.
/// The Library feed is id-keyed (`artKey` -> a decoded file:// path in
/// LibraryView's artMap); "" falls straight back to `item.artPath`, so the
/// browse pages are untouched.
property string artSource: ""
/// Up to four member-track covers — the 2x2 mosaic a USER playlist shows
/// when it has no artwork of its own (`library_qt::FeedItem.covers`). The
/// browse pages publish none, so they keep the designed glyph placeholder.
property var covers: []
readonly property string artResolved:
root.artSource !== "" ? root.artSource : (root.item.artPath || "")
/// Row index — drives the even/odd zebra (coherent with TrackRow).
/// NOT named `index`: a Repeater injects one into the delegate context
/// and a same-named property here would shadow it.
property int rowIndex: 0
QbzTheme { id: theme }
readonly property bool rowHovered: rowArea.containsMouse || playArea.containsMouse
|| moreArea.containsMouse
width: parent ? parent.width : 0
height: 60
radius: theme.radiusSm
color: rowHovered ? theme.surfaceHover
: (rowIndex % 2 === 1 ? theme.alphaTier(4) : "transparent")
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton)
rowMenu.openAtCursor(rowArea, mouse.x, mouse.y)
else
QbzBridge.openPlaylist(root.item.id || "")
}
}
CardMenu {
id: rowMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
var id = root.item.id || ""
if (id === "") return
if (a === "play") QbzPlayer.playPlaylistById(id)
else QbzPlayer.enqueuePlaylistById(id, a)
}
}
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 12
Rectangle {
width: 44
height: 44
anchors.verticalCenter: parent.verticalCenter
radius: 6
color: theme.surfaceElevated
clip: true
// These rows come from `home_qt::map_playlist` (browse_qt.rs:612),
// whose art is the playlist's own `image.rectangle` — 800x380.
// `auto` pads that instead of cropping to the middle 47% of it,
// and still crops the square `image.covers[0]` fallback.
RoundedImage {
anchors.fill: parent
visible: !rowCollage.visible
source: root.artResolved
radius: 6
fit: "auto"
}
PlaylistCollage {
id: rowCollage
anchors.fill: parent
visible: root.artResolved === "" && (root.covers || []).length > 0
urls: root.covers || []
radius: 6
}
QbzIcon {
visible: root.artResolved === "" && !rowCollage.visible
name: "list-music"
width: 20
height: 20
anchors.centerIn: parent
tintName: "muted"
}
}
Column {
width: parent.width - 44 - 2 * 30 - 4 * 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.item.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.item.subtitle || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Item {
width: 30
height: parent.height
QbzIcon {
name: "play-fill"
width: 16
height: 16
anchors.centerIn: parent
tintName: root.rowHovered ? "textPrimary" : "muted"
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playPlaylistById(root.item.id || "")
}
}
Item {
width: 30
height: parent.height
QbzIcon {
name: "ellipsis"
width: 16
height: 16
anchors.centerIn: parent
tintName: moreArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
}
}
}
}