Files
qbz/crates/qbz-qt/qml/views/LocalAlbumView.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

541 lines
25 KiB
QML

// Local album detail — QML port of album/LocalAlbumView.slint, a ROUTED
// PAGE (not a pane inside the Local Library view, which is how this port
// used to render it).
//
// Homologated 1:1 with the Qobuz AlbumView: whole-page Flickable, the same
// header proportions and action row, the toolbar (quality badge + track
// search), the column header, and source-aware track rows. The intentional
// differences vs the Qobuz page are the Slint's own: no label/awards
// sidebar, no Qobuz context menus, and the local-only version picker.
//
// Local actions ONLY: play all / shuffle / edit tags / add to playlist /
// add to Mixtape. A multi-artist album gets the "+N more artists" expander;
// a multi-disc album gets the disc dividers with their per-disc ⋯ menu.
//
// ONE deliberate ADDITION over the reference (owner, 2026-07-31): the
// toolbar's multi-select toggle and its bulk bar. The Slint puts those on the
// QOBUZ album page (AlbumPageView.slint:752-808) and not on the local one,
// which is an asymmetry, not a design — the local page is where the owner
// asked for them. See the `multiSelect` block below for the wiring.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
import "local"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
radius: 12
QbzTheme { id: theme }
// ---------------------------- document -------------------------------
function parseDoc(json, fallback) {
if (json === "") return fallback
try { return JSON.parse(json) } catch (e) { return fallback }
}
readonly property var doc: parseDoc(QbzLocal.localAlbumJson, null)
readonly property var album: doc ? doc.album : null
readonly property var tracks: doc ? doc.tracks : []
readonly property var versions: album && album.versions ? album.versions : []
readonly property var allArtists: {
if (!album) return []
var raw = (album.allArtists || "").split(",")
var out = []
for (var i = 0; i < raw.length; i++) {
var n = raw[i].trim()
if (n !== "" && out.indexOf(n) < 0) out.push(n)
}
return out
}
// The Slint's `info-line` — built in Rust there, derived here from the
// fields the album row already carries.
readonly property string infoLine: {
if (!album) return ""
var parts = []
if ((album.year || "") !== "") parts.push(album.year)
parts.push((album.trackCount || 0) + " "
+ QbzSession.tr("tracks", QbzSession.trRev))
if ((album.duration || "") !== "") parts.push(album.duration)
if ((album.format || "") !== "") parts.push(album.format.toUpperCase())
return parts.join(" • ")
}
property string trackQuery: ""
// Client-side track search: an album's track list is bounded, so the
// Slint's LocalAlbumActions.search is a pure view filter here.
readonly property var visibleTracks: {
if (trackQuery === "") return tracks
var q = trackQuery.toLowerCase()
var out = []
for (var i = 0; i < tracks.length; i++) {
if ((tracks[i].title || "").toLowerCase().indexOf(q) >= 0) out.push(tracks[i])
}
return out
}
// ------------------------- multi-select ------------------------------
// The Slint's LOCAL album page has no bulk bar; its QOBUZ one does
// (album/AlbumPageView.slint:752-808 — the 30x30 square toggle in the
// toolbar beside the search box, then MultiSelectBar over the rows).
// Owner call 2026-07-31: close that asymmetry here, in the local page.
//
// Selection lives in QML, exactly as the two Local Library tabs keep
// theirs (LocalLibraryView.qml:684-720) — the ids go down per action, so
// `select-all` / `clear` never reach Rust. Scope is "track": the album
// detail's rows ARE `LocalState.detail_raw` (local_album_actions.rs:154
// caches every version's rows there), which is one of the two caches
// `local_bulk::resolve_blocking` reads for that scope, so a Plex row with
// no DB id resolves too.
property bool multiSelect: false
property var selected: ({})
readonly property int selectedCount: Object.keys(selected).length
// A republish means the track list changed underneath the selection — a
// different album, or the version picker swapping every row for another
// physical copy's. Both invalidate the ids, so the selection goes and
// select mode with it (the same "leaving drops the selection" contract
// `local_bulk::set_select_mode` documents for the tree rail).
onDocChanged: { multiSelect = false; selected = ({}) }
function toggleSelected(id) {
var s = Object.assign({}, selected)
if (s[id]) delete s[id]; else s[id] = true
selected = s
}
/// Selected ids in the order they are ON SCREEN, so a bulk enqueue lands
/// in disc/track order. `Object.keys` on the selection map would sort the
/// numeric-looking keys ascending by row id, which is the INSERT order of
/// the scan, not the album's. Iterates `tracks`, not `visibleTracks`, so a
/// selection made before typing in the search box still enqueues whole.
function selectedIdsInOrder() {
var out = []
for (var i = 0; i < tracks.length; i++) {
if (selected[tracks[i].id] === true) out.push(tracks[i].id)
}
return out
}
function bulkAction(action) {
if (action === "clear") { selected = ({}); return }
if (action === "select-all") {
// What the user can SEE: the search box is a view filter, so
// "select all" means the filtered set (LocalLibraryView.qml:715).
var s = {}
for (var i = 0; i < visibleTracks.length; i++) s[visibleTracks[i].id] = true
selected = s
return
}
QbzLocal.bulkAction("track", JSON.stringify(selectedIdsInOrder()), action)
}
// Disc divider before the first row of each disc on a multi-disc album
// (0 = flat list, as the Slint's disc-header-number).
function discHeader(i) {
var t = visibleTracks[i]
if (!t) return 0
var multi = false
for (var j = 0; j < visibleTracks.length; j++) {
if ((visibleTracks[j].disc || 1) > 1) { multi = true; break }
}
if (!multi) return 0
if (i === 0) return t.disc || 1
return (visibleTracks[i - 1].disc || 1) !== (t.disc || 1) ? (t.disc || 1) : 0
}
// Cover — the same id-keyed artwork channel every local surface uses.
property var artMap: ({})
Connections {
target: QbzLocal
function onLocalArtworkReady(key, path) {
var m = root.artMap
m[key] = path
root.artMap = Object.assign({}, m)
}
}
onAlbumChanged: {
if (album && album.artKey) {
QbzLocal.artworkWindow(JSON.stringify([album.artKey]))
}
}
// ------------------------- skeleton pulse ----------------------------
// ONE 900ms Timer for the whole page. GATING RULE: freeze on NOT VISIBLE
// (view hidden / window minimized), NEVER on lost focus — a tiling
// desktop keeps windows visible and unfocused.
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
readonly property bool pageLoading: QbzLocal.localAlbumLoading
&& root.tracks.length === 0
// Local artwork resolution DROPS keys with no cover (local_artwork.rs),
// so the cover placeholder MUST be bounded or an album with no embedded
// art shimmers forever. Same constant as LocalLibraryView.artSettleMs.
readonly property int artSettleMs: 2500
readonly property bool coverPending: root.album
? ((root.album.artKey || "") !== ""
&& (root.artMap[root.album.artKey] || "") === "")
: false
Timer {
interval: 900
repeat: true
running: (root.pageLoading || root.coverPending)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
// ============================ page ===================================
// Neutral header band (local albums have no artwork-derived tint yet).
Rectangle {
x: 0
y: 0
width: parent.width
height: 340
gradient: Gradient {
GradientStop { position: 0.0; color: "#181820" }
GradientStop { position: 0.16; color: "#181820" }
GradientStop { position: 1.0; color: "#00181820" }
}
}
Flickable {
id: flick
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: page.height + 100
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
x: 32
y: 11
width: parent.width - 64
spacing: 0
// ---- History navigation ----
Row {
spacing: 6
QbzNavButton {
name: "chevron-left"
btnEnabled: QbzShell.canBack
onClicked: QbzShell.navigateBack()
}
QbzNavButton {
name: "chevron-right"
btnEnabled: QbzShell.canForward
onClicked: QbzShell.navigateForward()
}
}
Item { width: 1; height: 22 }
// ---- Album header ----
// Placeholder in the header's own proportions (224px cover, 32px
// gap — LocalAlbumHeader.qml:32/46) until the document lands.
QbzSkeleton {
visible: root.pageLoading && !root.album
variant: "header"
width: parent.width
height: 224
coverSize: 224
coverGap: 32
actionCount: 5
phase: root.skelPhase
}
LocalAlbumHeader {
visible: !(root.pageLoading && !root.album)
width: parent.width
album: root.album
allArtists: root.allArtists
infoLine: root.infoLine
versions: root.versions
coverSource: root.album ? (root.artMap[root.album.artKey] || "") : ""
coverPending: root.coverPending
skelPhase: root.skelPhase
artSettleMs: root.artSettleMs
onOpenArtist: function (name) { root.openArtist(name) }
}
Item { width: 1; height: 20 }
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Item { width: 1; height: 8 }
// ---- Loading ----
// The track list in the shape it will arrive in (50px rows, no
// artwork column on this page), replacing the 36px spinner the
// Slint mounts here. ONE instance, ONE animator.
QbzSkeleton {
visible: root.pageLoading
variant: "rowList"
width: parent.width
height: visible ? 280 : 0
rowH: 50
rowGap: 0
rowArt: false
phase: root.skelPhase
}
// ---- Toolbar: quality badge + track search + select toggle ----
// AlbumPageView.slint:686-786 numbers: 52px band, the search box
// cut from 280 to 168 "so the toolbar gives room to the square
// select button beside it", 16px gap, a 30x30 radius-6 square
// (NOT a round CircleAction) that goes accent while select mode
// is on.
Item {
width: parent.width
height: 52
QualityBadgeFull {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
tier: root.album ? (root.album.qualityTier || "") : ""
detail: root.album ? (root.album.qualityDetail || "") : ""
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 16
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 168
height: 34
radius: 6
border.width: 1
border.color: theme.borderSubtle
color: theme.surfaceElevated
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 7
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Item {
width: parent.width - 21
height: parent.height
clip: true
TextInput {
id: searchInput
anchors.fill: parent
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
selectByMouse: true
onTextEdited: root.trackQuery = text
}
Text {
visible: searchInput.text === ""
anchors.fill: parent
text: QbzSession.tr("Search tracks...", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
}
// Multi-select toggle. Unlike the Qobuz AlbumView.qml's
// (dimmed and inert — that bridge has no bulk seam), this
// one is LIVE: QbzLocal.bulkAction already carries the
// "track" scope this page needs.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 30
radius: 6
border.width: 1
border.color: root.multiSelect ? theme.accent : theme.borderSubtle
color: (root.multiSelect || selectArea.containsMouse)
? theme.surfaceHover
: theme.surfaceElevated
QbzIcon {
name: "square-check-big"
width: 15
height: 15
anchors.centerIn: parent
tintName: root.multiSelect
? "accent"
: (selectArea.containsMouse ? "textPrimary" : "secondary")
}
MouseArea {
id: selectArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.multiSelect = !root.multiSelect
if (!root.multiSelect) root.selected = ({})
}
}
}
}
}
// ---- Bulk action bar ----
// Only actions with a LIVE backend, in a context where they mean
// something. Dropped from the Slint album bar's seven:
// * add-to-favorites / remove-favorites — local hearts are not
// wired at all (local_bulk.rs's arm is a log-only no-op, and
// the store is keyed by file path behind a private handle);
// * make-offline — these tracks ARE the local files.
// Added: add-to-mixtape, which the two Local Library bars already
// offer and which `local_bulk::apply` serves live.
QbzMultiSelectBar {
visible: root.multiSelect
width: parent.width
selectedCount: root.selectedCount
actions: [
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
{ "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 },
]
onAction: function (id) { root.bulkAction(id) }
}
// The Slint's 8px gutter under the bar (AlbumPageView.slint:808).
Item { visible: root.multiSelect; width: 1; height: 8 }
// ---- Column header ----
// rows/TrackListHeader.qml, on rows/TrackCols.qml geometry — the
// same object the LocalTrackRows below lay their cells out from.
// (LocalAlbumView.slint:501-540 hardcodes a second set here —
// 16px gaps, Duration 80, Quality 80 — that its own rows do not
// use; the port does not copy that hazard.)
//
// `trailingReserve: 72` is the slice LocalTrackRow spends OUTSIDE
// the shared row: it narrows the shared TrackRow by 26 (source
// glyph gutter) + 46 (its own re-drawn ⋯: 32 + one 14px gap), so
// the labelled columns end 72px short of the row's right edge.
// Same constant, one place: LocalTrackRow.qml:82.
TrackListHeader {
width: parent.width
bandHeight: 40
labelSpacing: 0.5
showFavorite: false
showMenu: false
trailingReserve: 72
}
// ---- Track list ----
// An album's track count is bounded (the windowed views are for
// the library-scale surfaces), so this mounts like the Slint.
Column {
width: parent.width
spacing: 0
Repeater {
model: root.visibleTracks
delegate: Column {
id: trackBlock
required property var modelData
required property int index
width: page.width
spacing: 0
// Disc divider + its per-disc ⋯ menu.
Item {
visible: root.discHeader(trackBlock.index) > 0
width: parent.width
height: visible ? 40 : 0
Text {
x: 12
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Disc", QbzSession.trRev) + " "
+ root.discHeader(trackBlock.index)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Rectangle {
x: parent.width - 44
width: 32
height: 32
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: discArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
name: "ellipsis"
width: 16
height: 16
anchors.centerIn: parent
tintName: discArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: discArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
discMenu.openAtCursor(discArea, mouse.x, mouse.y)
}
}
CardMenu {
id: discMenu
menuWidth: 200
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) {
QbzLocal.albumDiscAction(root.discHeader(trackBlock.index), a)
}
}
}
}
LocalTrackRow {
width: page.width
item: trackBlock.modelData
number: trackBlock.modelData.number > 0
? trackBlock.modelData.number : trackBlock.index + 1
showAlbum: false
showArtwork: false
// The checkbox LocalTrackRow already draws over
// the number cell for the Tracks tab — no fork,
// no new component (rule 5).
selectMode: root.multiSelect
checked: root.selected[trackBlock.modelData.id] === true
onPlayRequested: QbzLocal.playAlbumTrack(
root.album.id, trackBlock.modelData.id)
onEnqueueRequested: function (m) {
QbzLocal.enqueue("track", trackBlock.modelData.id, m)
}
onToggleSelect: root.toggleSelected(trackBlock.modelData.id)
}
}
}
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
visible: flick.contentHeight > flick.height
}
// Local/Plex artists have no catalog id, so "go to artist" is a NAME
// route into the Local Library Artists tab (the Slint's source-aware
// open-artist).
function openArtist(name) {
QbzLocal.openArtistByName(name)
QbzShell.navigateTo("local")
}
}