Files
qbz/crates/qbz-qt/qml/rows/TrackListHeader.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

192 lines
7.6 KiB
QML

// TrackListHeader — the column header of the track table, the labelled twin
// of rows/TrackRow.qml. Both take their geometry from rows/TrackCols.qml, so
// a label sits over the column it names by construction rather than by two
// people typing the same numbers.
//
// --- THE CONTRACT (two lines, both checkable) ---------------------------
// 1. `width` MUST be the width the TrackRows below get. Not the page
// width — the ROW width. A list inset for a scrollbar gutter
// (PlaylistView's `anchors.rightMargin: 14`) or padded by its page
// (MixView's `parent.width - 64`) hands its rows a narrower width, and
// the header has to be narrowed the same way or every label after the
// stretch column slides right by the difference.
// 2. The five arms MUST be the arms the delegate sets. They gate BOTH the
// cell and its gap, so a header that reserves a column the row does not
// draw (or vice versa) shifts everything after it by width + 14.
//
// The owner's report was the second half of rule 2: the trailing unlabelled
// columns (heart, offline cloud, ⋯) were missing from some headers, so they
// took no space and every label was pushed right of its column. They are
// reserved here as empty slots — `favoriteGlyph` / `downloadGlyph` draw the
// 14px heart / cloud-download that AlbumPageView.slint:853-877 puts in the
// heads of those two columns, the only labelling Slint gives them.
//
// `trailingReserve` covers the one surface whose row draws controls OUTSIDE
// the shared TrackRow: views/local/LocalTrackRow.qml narrows the shared row
// and re-draws the ⋯ plus a source glyph in the gutter it freed. The header
// reserves the same slice so it stays the same width as the row it labels.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
// --- Arms: MIRROR the TrackRow arms of the list underneath ----------
// Defaults are TrackRow's own defaults, so a host that sets nothing gets
// a header for a row that sets nothing.
property bool showArtwork: false
property bool showAlbum: false
property bool showFavorite: true
property bool showDownload: false
property bool showMenu: true
/// Leading reorder gutter (the row's up/down chevrons). MIRROR the
/// delegate's `showReorder`, or every label slides by 22 + 14 the moment
/// the user switches the list to a custom / reorderable order.
property bool showReorder: false
/// Draw the 14px heart / cloud-download in the head of those two
/// reserved columns (AlbumPageView.slint). Off = an empty reserved slot,
/// which is what PlaylistView.slint and MixView.slint do.
property bool favoriteGlyph: false
property bool downloadGlyph: false
/// Label band height. 0 = the label's own line height, which is what the
/// playlist / mix headers use (Slint gives those HorizontalLayouts no
/// height, so they take the text's — 15px at Typography.legal, and
/// hardcoding an 18 here would have added 3px to those pages' rhythm).
/// The album headers set an explicit 40px band (AlbumPageView.slint:812).
property int bandHeight: 0
/// `letter-spacing: 0.5px` on the album headers; the playlist / mix
/// headers have none.
property real labelSpacing: 0
/// Width the ROW draws outside the shared TrackRow (LocalTrackRow only).
property int trailingReserve: 0
TrackCols { id: cols }
QbzTheme { id: theme }
width: parent ? parent.width : 0
height: bandH
/// Resolved band height (see `bandHeight`). The metric below is the same
/// font the labels use, so "natural" tracks a font-size change.
readonly property int bandH: root.bandHeight > 0
? root.bandHeight : Math.ceil(metric.implicitHeight)
Text {
id: metric
visible: false
text: "Xg"
font.pixelSize: theme.fontLegal
}
/// The row's own outer width minus whatever it spends outside the shared
/// TrackRow — i.e. exactly the width the shared row lays its cells in.
readonly property int innerWidth: Math.max(0, root.width - root.trailingReserve)
component ColLabel: Text {
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.letterSpacing: root.labelSpacing
height: root.bandH
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Row {
anchors.fill: parent
anchors.leftMargin: cols.padH
anchors.rightMargin: cols.padH
spacing: cols.gap
// Reorder gutter — unlabelled, reserved (the row draws two chevrons
// there; there is nothing to name).
Item {
visible: root.showReorder
width: cols.colReorder
height: root.bandH
}
// "#" — centred, because the cell it labels is TrackRow's play cell
// and its number is centred in the same 32px. (Slint's album headers
// left-align this one over a centred number; the playlist header
// centres it. Centred is the one that lines up.)
ColLabel {
width: cols.colNumber
text: "#"
horizontalAlignment: Text.AlignHCenter
}
// Artwork cell — unlabelled, reserved (PlaylistView.slint:990).
Item {
visible: root.showArtwork
width: cols.colArt
height: root.bandH
}
ColLabel {
width: cols.titleWidth(root.innerWidth, root.showArtwork, root.showAlbum,
root.showFavorite, root.showDownload, root.showMenu,
root.showReorder)
text: QbzSession.tr("Title", QbzSession.trRev)
}
ColLabel {
visible: root.showAlbum
width: cols.colAlbum
text: QbzSession.tr("Album", QbzSession.trRev)
}
ColLabel {
width: cols.colDuration
text: QbzSession.tr("Duration", QbzSession.trRev)
horizontalAlignment: Text.AlignHCenter
}
ColLabel {
width: cols.colQuality
text: QbzSession.tr("Quality", QbzSession.trRev)
horizontalAlignment: Text.AlignHCenter
}
// Heart column. `height: root.bandH` (not 1) so `centerIn`
// centres the glyph on the band instead of pinning it to the top —
// the bug AlbumView.qml carried and documented.
Item {
visible: root.showFavorite
width: cols.colFavorite
height: root.bandH
QbzIcon {
visible: root.favoriteGlyph
anchors.centerIn: parent
name: "heart"
width: 14
height: 14
tintName: "muted"
}
}
// Offline / cloud column.
Item {
visible: root.showDownload
width: cols.colDownload
height: root.bandH
QbzIcon {
visible: root.downloadGlyph
anchors.centerIn: parent
name: "cloud-download"
width: 14
height: 14
tintName: "muted"
}
}
// ⋯ column.
Item {
visible: root.showMenu
width: cols.colMenu
height: root.bandH
}
// Whatever the row draws outside the shared TrackRow.
Item {
visible: root.trailingReserve > 0
// The gap before it is already spent by the Row, so the slot
// itself is the reserve minus that gap.
width: Math.max(0, root.trailingReserve - cols.gap)
height: root.bandH
}
}
}