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
@@ -1,51 +0,0 @@
|
||||
// Vertical A-Z jump strip (LocalLibraryView.slint:577 AlphaStrip, itself a
|
||||
// 1:1 lift of FavoritesView's). 18px gutter, 15px rows, 9px semibold muted
|
||||
// letters that go accent on hover. Emits jump(ordinal, index); the host
|
||||
// scrolls its own view proportionally — exactly like the Slint (:1298).
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
import "../../theme"
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
/// [{ letter, index }] — index = the row ordinal in the flat model.
|
||||
property var jumps: []
|
||||
signal jump(int ordinal, int index)
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: 18
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: root.jumps
|
||||
delegate: Item {
|
||||
id: cell
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: 18
|
||||
height: 15
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
text: cell.modelData.letter
|
||||
color: cellArea.containsMouse ? theme.accent : theme.textMuted
|
||||
font.pixelSize: 9
|
||||
font.weight: theme.weightSemibold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
MouseArea {
|
||||
id: cellArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.jump(cell.index, cell.modelData.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ Item {
|
||||
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
|
||||
onToggleSelect: function (id) { root.view.toggleAlbumSelected(id) }
|
||||
}
|
||||
AlphaStrip {
|
||||
QbzAlphaStrip {
|
||||
visible: root.view.albumsGroup === "alpha"
|
||||
&& collection.alphaJumps.length > 0
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -197,7 +197,7 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
AlphaStrip {
|
||||
QbzAlphaStrip {
|
||||
visible: root.alphaJumps.length > 0
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
|
||||
@@ -234,7 +234,12 @@ Row {
|
||||
options: root.tracksGroupLabels
|
||||
marked: root.view.tracksGroup !== "off"
|
||||
currentIndex: Math.max(0, root.tracksGroupIds.indexOf(root.view.tracksGroup))
|
||||
onSelected: function (i) { root.view.tracksGroup = root.tracksGroupIds[i] }
|
||||
// Through the bridge, like the sort above and the album identity
|
||||
// below it: the choice is PERSISTED (locallibrary_ui.json, shared
|
||||
// with the Slint build). Writing the QML property directly is what
|
||||
// made it reset on every restart (PARITY-DEBT #13). No re-query —
|
||||
// grouping is a client-side reorder, not a new ORDER BY.
|
||||
onSelected: function (i) { QbzLocal.tracksSetGroup(root.tracksGroupIds[i]) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,22 @@
|
||||
// is: Play now, Play next, Play later, Add to queue, Add to mixtape, Add to
|
||||
// playlist, Go to album, Go to artist.
|
||||
//
|
||||
// Add to mixtape and Add to playlist are OMITTED here, not disabled: this
|
||||
// port has no Mixtape/Collection store and no playlist picker (the Rust side
|
||||
// says so itself — `local_album_actions.rs` keeps them as LOGGED SEAMS), and
|
||||
// a menu row that silently does nothing is worse than an absent one. Every
|
||||
// row that IS shown is wired.
|
||||
// Add to playlist is LIVE (QbzPlaylistPicker). It goes through
|
||||
// `QbzLocal.bulkAction("track", [id], "add-to-playlist")` — the LOCAL-mode
|
||||
// arm — and not through the picker singleton directly, because only
|
||||
// `local_bulk::resolve_blocking` can turn a Tracks-table row id into the
|
||||
// `LocalTrack` that `local_picker_ref_for_track` needs; a Plex row's ref is
|
||||
// "plex:<rating key>" and its numeric display id means a different track the
|
||||
// moment anything reads it as a catalog id. The bulk entry point takes a JSON
|
||||
// id array, so a single row is a one-element array — same code path, no
|
||||
// second resolver.
|
||||
//
|
||||
// Add to mixtape rides the SAME entry point for the same reason
|
||||
// (`local_bulk.rs::apply` "add-to-mixtape" -> `myqbz_add_qt::open_items(
|
||||
// track_items_from_local(rows))`), which restores the .slint's order:
|
||||
// Play now · Play next · Play later · Add to queue · Add to mixtape ·
|
||||
// Add to playlist · Go to album · Go to artist. Every row that IS shown is
|
||||
// wired; nothing here renders and no-ops.
|
||||
//
|
||||
// The shared row's own ⋯ is turned OFF (`showMenu: false`) because its
|
||||
// entries are the Qobuz set: its Go-to-album/artist call QbzAlbum.openAlbum /
|
||||
@@ -91,26 +102,26 @@ Item {
|
||||
// the sidebar drop handler forwards it as a Qobuz catalog id.
|
||||
draggable: false
|
||||
qualityStyle: "icon"
|
||||
// Multi-select is the SHARED row's arm now (rows/TrackRow.qml:116,
|
||||
// the port of TrackRow.slint:58 `multi-select-mode`). This file used
|
||||
// to overlay its own SelectCheck on the number cell because the
|
||||
// shared row had no such arm — it does now, and the overlay was
|
||||
// strictly worse: it drew a checkbox but left `sharedRow.selectMode`
|
||||
// false, so the row BODY kept its normal gestures and a single click
|
||||
// anywhere off the 14px disc PLAYED the track instead of selecting
|
||||
// it. TrackRow.slint:174 is explicit that in select mode "the WHOLE
|
||||
// row body is a selection target", and LocalAlbumRow.qml:55 already
|
||||
// does that for the albums tab. Forwarding the three members gets the
|
||||
// reference behaviour for free: body-click toggles, double-click no
|
||||
// longer plays, the number/play cell stands down, and the checkbox is
|
||||
// the 14px accent disc the .slint actually specifies.
|
||||
selectMode: root.selectMode
|
||||
checked: root.checked
|
||||
onToggleSelect: root.toggleSelect()
|
||||
onPlayRequested: root.playRequested()
|
||||
onEnqueueRequested: function (m) { root.enqueueRequested(m) }
|
||||
}
|
||||
|
||||
// Multi-select checkbox over the number cell (x 12..44 in TrackRow).
|
||||
Rectangle {
|
||||
visible: root.selectMode
|
||||
x: 12
|
||||
width: 32
|
||||
height: 40
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceMain
|
||||
radius: 4
|
||||
SelectCheck {
|
||||
anchors.centerIn: parent
|
||||
on: root.checked
|
||||
onToggled: root.toggleSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// ⋯ menu button — the shared row's own gutter, re-drawn with the local
|
||||
// action set behind it.
|
||||
Rectangle {
|
||||
@@ -153,9 +164,16 @@ Item {
|
||||
// (TrackRow.slint's `open-track-menu(..., at-pointer: true)`). Declared
|
||||
// last so it sits on top, and RIGHT-only so every left click still falls
|
||||
// through to the row body / checkbox / ⋯ underneath.
|
||||
//
|
||||
// Suppressed in select mode — TrackRow.slint:201 gates the same gesture on
|
||||
// `!root.multi-select-mode` ("a right-click toggles nothing there"), and
|
||||
// the shared row's own trArea already does it (rows/TrackRow.qml:926).
|
||||
// Disabled rather than hidden so the press falls through to the row body
|
||||
// instead of landing on nothing.
|
||||
MouseArea {
|
||||
id: rcArea
|
||||
anchors.fill: parent
|
||||
enabled: !root.selectMode
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: function (mouse) { rowMenu.openAtCursor(rcArea, mouse.x, mouse.y) }
|
||||
}
|
||||
@@ -169,6 +187,8 @@ Item {
|
||||
{ "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" },
|
||||
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "add-to-mixtape" },
|
||||
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "add-to-playlist" },
|
||||
]
|
||||
if (root.canGoAlbum) m.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
|
||||
if (root.canGoArtist) m.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
|
||||
@@ -176,6 +196,12 @@ Item {
|
||||
}
|
||||
onPicked: function (a) {
|
||||
if (a === "play") root.playRequested()
|
||||
// LOCAL-mode adds: one-element selection through the bulk entry
|
||||
// point, which owns the only source-aware row -> ref resolver
|
||||
// (see the file header).
|
||||
else if (a === "add-to-playlist" || a === "add-to-mixtape") {
|
||||
QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a)
|
||||
}
|
||||
else if (a === "go-album") {
|
||||
QbzLocal.openAlbum(root.item.albumId)
|
||||
QbzShell.navigateTo("localalbum")
|
||||
|
||||
@@ -71,6 +71,28 @@ Item {
|
||||
function onArtworkRefresh() { root.reportSoon() }
|
||||
}
|
||||
|
||||
// --------------------------- row play ---------------------------------
|
||||
// PARITY-DEBT #14. Rust used to queue `state.tracks_raw` — the raw SQL
|
||||
// page order — and find the clicked row in it, so with a group mode on
|
||||
// (a CLIENT-side reorder, see LocalLibraryView.tracksVisible) the user
|
||||
// heard a different order than the one on screen. The visible order only
|
||||
// exists QML-side, so it goes down as a JSON id array and the raw rows
|
||||
// play the part of the authoritative cache
|
||||
// (local_playback::order_by_visible, the local twin of
|
||||
// library_qt::order_by_visible).
|
||||
//
|
||||
// `view.tracksVisible` is THE array `entries` is built from — never read
|
||||
// `list.model` back off the view (see views/LibraryView.qml).
|
||||
function visibleTrackIds() {
|
||||
var rows = root.view ? root.view.tracksVisible : []
|
||||
var out = []
|
||||
for (var i = 0; i < rows.length; i++) out.push(rows[i].id)
|
||||
return out
|
||||
}
|
||||
function playRow(id) {
|
||||
QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id)
|
||||
}
|
||||
|
||||
// First page = the shape of the 50px track rows (the Slint mounts a bare
|
||||
// 36px LoadingSpinner). ONE instance for the whole viewport.
|
||||
QbzSkeleton {
|
||||
@@ -115,7 +137,11 @@ Item {
|
||||
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
||||
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
||||
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
||||
{ "id": "remove-favorites", "label": QbzSession.tr("Remove from favorites", QbzSession.trRev), "icon": "heart", "danger": true, "needsSelection": true },
|
||||
// NO "Remove from favorites": this tab is the whole local
|
||||
// library, not a favourites surface — removing from a
|
||||
// collection the user is not looking at is not an action this
|
||||
// context can offer. (It was doubly wrong: local hearts are
|
||||
// unwired, so `local_bulk.rs`'s arm is a log-only no-op.)
|
||||
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
||||
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
||||
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true },
|
||||
@@ -220,7 +246,7 @@ Item {
|
||||
showArtwork: root.view.trackArtwork
|
||||
selectMode: root.view.tracksMultiSelect
|
||||
checked: root.view.tracksSelected[modelData.row.id] === true
|
||||
onPlayRequested: QbzLocal.playTrack(modelData.row.id)
|
||||
onPlayRequested: root.playRow(modelData.row.id)
|
||||
onEnqueueRequested: function (m) {
|
||||
QbzLocal.enqueue("track", modelData.row.id, m)
|
||||
}
|
||||
@@ -247,7 +273,7 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
AlphaStrip {
|
||||
QbzAlphaStrip {
|
||||
visible: root.view.tracksGroup === "name" && root.alphaJumps.length > 0
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
|
||||
Reference in new issue
Block a user