Files
qbz/crates/qbz-qt/qml/views/PlaylistView.qml
T
vicrodh 81e172457f fix(qt): migrate playlist_meta, break a router binding loop, zebra the playlist
RECENTLY PLAYED PLAYLISTS STAYED EMPTY THROUGH EIGHT TRACKS, a restart and a
manual refresh — while the plays were sitting in the database the whole time.
Read off the live file rather than reasoned about: `playlist_play_events` held
3, 9 and 10 rows for three playlists, so the context edge was working perfectly;
`playlist_meta` was empty, and its `.schema` said why — no `covers`, no
`own_image`.

`covers` and `own_image` were added by inlining them into the CREATE. `CREATE
TABLE IF NOT EXISTS` does NOTHING when the table already exists, so anyone who
had run the previous build kept the six-column table, every upsert failed on
"no such column: covers", and the rail JOINs meta against events — so it
rendered its empty state with the evidence one table away. The failure was
invisible by construction: swallowed into a warn, and the UI's empty state is
also its correct state for a user who has played nothing.

A brand-new table is only new ONCE. The moment a build runs anywhere its schema
is legacy and every later column is a migration, so the pair moves to the same
idempotent ADD COLUMN the album history already uses, and the upsert's failure
becomes an error rather than a warn. Verified against a copy of the real
database: the columns land and all 22 events survive. A regression test builds
the exact pre-covers schema, records a play into it, migrates, and asserts the
upsert works and the old event still counts — it reproduces the bug without the
fix, and it also asserts a second `init_schema` is a no-op, since every launch
calls it.

The meta is written by `playlist_qt::load`, so the three playlists already in
the events table surface as soon as each is opened once.

BINDING LOOP IN THE ROUTER, and this one was self-inflicted: "QML Loader:
Binding loop detected for property source". `_stampRoute` runs inside the
`source` binding, and it read the last path from a `property string` and then
wrote it — a binding depending on a property it mutates. QML detects the cycle,
warns, and stops re-evaluating, which is a broken router and not merely a noisy
log. The holder becomes an object whose MEMBER is mutated: no change signal, so
the dependency is satisfied once and never fires again.

(The second warning on the same file, the `when` loop on the nav-flyout
`activeTab` Binding, is untouched by any of this work and predates it. Left
alone deliberately — it is a warning on a working feature, and rewriting that
Binding is its own change.)

ZEBRA on the playlist detail's track rows, like the local album page. TrackRow
already owns the stripe, so it is one property.

Release build clean, offscreen smoke green, 421/421 and 216/216.

cc
2026-08-19 09:01:32 -06:00

1028 lines
53 KiB
QML

// Playlist detail view — the QML port of playlist/PlaylistView.slint
// (phase 17). ONE JSON document (QbzBridge.playlistJson, playlist_qt.rs
// PlaylistDoc: header + track rows + ownership/follow/pin/sort/search).
//
// Header: 150px cover (the playlist's OWN Qobuz graphic contain-fitted,
// else the member-cover mosaic via the shared cards/PlaylistCollage.qml,
// which also supplies the placeholder glyph),
// "PLAYLIST" eyebrow, name, description-short + Read
// more (the shared AppShell text modal), owner • N tracks • duration,
// the action row: Play / Shuffle / heart (owned, wired) / pin (wired) /
// follow (foreign, subscribe API) / copy (foreign, create+add) / edit
// (owner: opens the SHARED controls/PlaylistEditModal.qml through
// QbzPlaylistEdit — this view mounts no editor of its own; the inline
// rename+delete Popup that used to live at the bottom of this file is gone,
// contract D20). Right edge: in-playlist search + the
// sort dropdown (Default/Title/Artist/Album/Duration/Date added/Custom).
//
// Track list: the exact PlaylistView row (# / 36px art / title+artist /
// Album 220px link / Duration 70 / Quality 92 / heart / cloud reserve /
// ⋯ menu with Remove-from-playlist for owners), column header, empty and
// loading states. Per-row ⋯: Play / Play next / Play later / Add to
// queue / Add to mixtape / Add to playlist / Go to artist / Go to album /
// Add|Remove from Library / Remove from playlist (owner). "Add to playlist"
// routes by the ROW's source: a Qobuz row takes the catalog arm, a local /
// Plex row of an adopted LOCAL playlist takes the local-mode one
// (QbzPlaylistPicker.openForLocalRow -> local_picker_ref_for_row).
//
// Drag & drop (issue #589): the row BODY is the drag source (press-drag
// >6px — the same gesture that drops onto sidebar playlists). A release
// INSIDE this list reorders (owner, switches the sort to Custom and
// persists the order); a release on a sidebar playlist row is the shared
// add-to-playlist drop (main.rs drag_end). The 2px accent line marks the
// insertion slot while dragging.
//
// LOADING: header + track-row QbzSkeleton placeholders in the shape of what
// is coming (the Slint mounts a bare LoadingSpinner), plus a per-item cover
// placeholder that clears when the playlist's own cover lands.
//
// POC-NOTEs (playlist_qt.rs has the full list): local playlists,
// custom-cover set/clear, multi-select + bulk bar, Suggested Songs,
// offline download, share — not ported.
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzBridge.playlistJson)
} catch (e) {
return ({})
}
}
readonly property var allTracks: doc.tracks || []
readonly property bool isOwner: doc.isOwner === true
readonly property bool loading: doc.loading === true
// --- Multi-select (the Qobuz track rows) ------------------------------
// Same shape as AlbumView: the selection lives in QML, select-all/clear
// never reach Rust, everything else goes down as a JSON id array through
// QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
property bool multiSelect: false
property var selected: ({})
readonly property int selectedCount: Object.keys(root.selected).length
readonly property bool multiSelectOn: root.multiSelect
property string loadedPlaylistId: ""
function setMultiSelect(on) {
root.multiSelect = on
if (!on) root.selected = ({})
}
function toggleSelected(id) {
var m = root.selected
if (m[id] === true) delete m[id]
else m[id] = true
// A rebind needs a NEW object reference — mutating in place notifies
// nothing.
root.selected = Object.assign({}, m)
}
function selectedIdsInOrder() {
var rows = root.tracks
var out = []
for (var i = 0; i < rows.length; i++)
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
return out
}
function bulkAction(action) {
if (action === "select-all") {
var m = {}
var rows = root.tracks
for (var i = 0; i < rows.length; i++) m[rows[i].id] = true
root.selected = m
return
}
if (action === "clear") { root.selected = ({}); return }
var ids = root.selectedIdsInOrder()
if (ids.length === 0) return
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || ""))
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
root.selected = ({})
}
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
function selectAll() {
if (!root.multiSelect) root.setMultiSelect(true)
root.bulkAction("select-all")
}
function exitMultiSelectMode() {
if (root.multiSelect) root.setMultiSelect(false)
}
function syncPlaylistState() {
var id = doc.id || ""
if (id === loadedPlaylistId)
return
loadedPlaylistId = id
setMultiSelect(false)
}
onDocChanged: syncPlaylistState()
readonly property string sortField: doc.sortField || "default"
readonly property bool sortAsc: doc.sortAsc === true
readonly property string searchQuery: doc.search || ""
/// A LOCAL detail (`local:<uuid>`) adopted into this view
/// (local_playlist_qt.rs -> playlist_qt::adopt_doc). It drops the Qobuz-
/// only affordances and, for the reorder below, it changes WHICH sort is
/// the reorderable one.
readonly property bool isLocal: doc.isLocalPlaylist === true
// A MIXED playlist: a QOBUZ playlist carrying local-file and/or Plex rows
// from the `library.db` sidecar tables. It is NOT `isLocal` and must not be
// conflated with it — it keeps every Qobuz affordance (owner, follow, copy,
// share, the heart). The one thing it loses is reorder; see `canReorder`.
readonly property bool isMixed: doc.isMixed === true
/// A Qobuz WRITE can be attempted. `QbzSession.offlineMode` is the shared
/// tri-state (0 = online, 1 = no connection, 2 = induced offline — the same
/// property `shell/HeaderBar.qml:101` and `controls/QbzOfflinePlaceholder`
/// read), and BOTH non-zero arms refuse the request in Rust, so the buttons
/// that depend on one must not advertise otherwise.
readonly property bool online: QbzSession.offlineMode === 0
// --- Reorderable? (owner findings 5 + 8) -----------------------------
// PlaylistView.slint:1094-1096 states the rule: the reorder affordance is
// offered under the CUSTOM sort — "or, on a LOCAL playlist, the Default
// sort: its natural order IS the editable repo order (B2), written via
// repo::reorder". A reorder against a COMPUTED order (title, duration, …)
// is meaningless, which is why it is a sort test and not an ownership one
// alone.
//
// The empty-search term is this port's own, and it is load-bearing: the
// in-playlist search filters `root.tracks` HERE, in QML, while the Rust
// arms index into the unfiltered document. Offering a reorder while a
// filter is active would hand a filtered index to a document index — the
// Playlist Manager takes the same precaution (`playlist_manager_qt.rs:236`
// requires `query.is_empty()` before it lights its arrows).
//
// MIXED playlists are excluded outright, and this is a data-integrity gate
// rather than a UX one: a Qobuz playlist's custom order is stored keyed by
// `u64` catalog id, while a local row's display id is a `library.db` rowid
// in the same numeric space, so a reorder here would write an order that
// collides with a real track and moves the WRONG row on the next load.
// Rust refuses it too (main.rs `playlist_reorder` / `playlist_move_row`) —
// this only keeps the app from offering something it will not do.
readonly property bool canReorder: root.isOwner
&& root.searchQuery.trim() === ""
&& !root.isMixed
&& (root.isLocal ? root.sortField === "default" : root.sortField === "custom")
// --- Settled state from Rust (`libraryFavoriteChanged` / `pinChanged`) -
// Two jobs, and the first one is why this exists at all.
//
// TRACK ROWS. The list is a ListView with cacheBuffer 500 — it RECYCLES.
// A row's heart lives on the delegate (rows/TrackRow.qml), so a delegate
// that is destroyed and rebuilt reads its state back out of `modelData`,
// i.e. out of `allTracks`. Without patching that array, favouriting a
// track and scrolling past it and back showed the OLD glyph again — and
// `playlist_qt` is one of the page-lifetime documents that main.rs's
// `emit_library_favorite` deliberately does NOT fan out to, so nothing
// else was going to fix it either.
//
// The row objects are patched IN PLACE. Re-deriving `tracks` (or
// re-parsing `doc`) would push a NEW array into `model:`, and
// QQuickItemView::setModel() resets the scroll offset to 0 — the user
// would be thrown to the top of the playlist on every heart click. The
// live delegates need no array change anyway: each one settles itself
// from this same signal.
//
// BOUNDARY, stated so nobody hunts it twice: this patch lasts until the
// NEXT republish of `QbzBridge.playlistJson`, because that hands over a
// freshly parsed object graph. `playlist_qt` keeps its document in a Rust
// `PAGE` cache and re-serializes it as-is (covers landing, a sort change,
// an in-playlist search), so a republish restores the heart the rows were
// STAMPED with. Closing that needs `playlist_qt::apply_favorite_change`
// on the Rust side — main.rs's `emit_library_favorite` doc lists it as a
// known gap — not more QML.
//
// HEADER. Two one-slot overrides so the heart and the pin also answer a
// flip made somewhere else (a sidebar card, the Home Pinned rail) instead
// of waiting for a document republish that those paths do not trigger.
// They carry their OWN playlist id (one slot each, never a shared one —
// a shared id would make a pin on playlist B resurrect playlist A's stale
// heart), so an override expires the moment the page shows anything else.
property string favOverrideId: ""
property bool favOverrideValue: false
property string pinOverrideId: ""
property bool pinOverrideValue: false
readonly property bool headerFavorite: (root.favOverrideId !== ""
&& root.favOverrideId === (doc.id || ""))
? root.favOverrideValue : (doc.isFavorite === true)
readonly property bool headerPinned: (root.pinOverrideId !== ""
&& root.pinOverrideId === (doc.id || ""))
? root.pinOverrideValue : (doc.pinned === true)
Connections {
target: QbzLibrary
function onLibraryFavoriteChanged(key, value) {
if (key.indexOf("track:") === 0) {
var tid = key.substring(6)
var t = root.allTracks
for (var i = 0; i < t.length; i++) {
if (String(t[i].id) === tid) { t[i].isFavorite = value; break }
}
return
}
var pid = root.doc.id || ""
if (pid !== "" && key === "playlist:" + pid) {
root.favOverrideId = pid
root.favOverrideValue = value
}
}
function onPinChanged(key, value) {
var pid = root.doc.id || ""
if (pid !== "" && key === "playlist:" + pid) {
root.pinOverrideId = pid
root.pinOverrideValue = value
}
}
}
// Visible rows after the in-playlist search filter.
readonly property var tracks: {
if (searchQuery.trim() === "") return allTracks
const needle = searchQuery.trim().toLowerCase()
return allTracks.filter(function (t) {
return (t.title || "").toLowerCase().indexOf(needle) >= 0
|| (t.artist || "").toLowerCase().indexOf(needle) >= 0
|| (t.album || "").toLowerCase().indexOf(needle) >= 0
})
}
// --- skeleton pulse (QbzSkeleton's preferred drive mode) -------------
// ONE 900ms Timer drives EVERY placeholder in this view. GATING RULE:
// freeze on NOT VISIBLE — the view hidden, or the window minimized /
// hidden. NEVER on lost focus (a tiling desktop keeps windows visible
// and unfocused). Same rule as HomeView / LibraryView / AmbientField.
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// True when this playlist carries artwork of its OWN (`image_rectangle`
// — editorial playlists). playlist_qt.rs publishes coverUrl/coverPath for
// that graphic ONLY; everything else falls back to the member-cover
// mosaic in `doc.covers`.
readonly property bool hasOwnArt: (doc.coverUrl || "") !== ""
// The cover is its OWN progressive item: it clears the moment the
// playlist's own cover lands, independently of the track rows.
readonly property bool coverPending: root.hasOwnArt
&& (doc.coverPath || "") === ""
// The header has no data at all until the first document lands
// (playlist_qt.rs publishes `{ loading: true }` with empty fields).
readonly property bool headerPending: root.loading && (doc.name || "") === ""
readonly property bool listPending: root.loading && root.allTracks.length === 0
Timer {
interval: 900
repeat: true
running: (root.headerPending || root.listPending || root.coverPending)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
function sortLabel() {
return sortField === "title" ? QbzSession.tr("Title", QbzSession.trRev)
: sortField === "artist" ? QbzSession.tr("Artist", QbzSession.trRev)
: sortField === "album" ? QbzSession.tr("Album", QbzSession.trRev)
: sortField === "duration" ? QbzSession.tr("Duration", QbzSession.trRev)
: sortField === "added" ? QbzSession.tr("Date added", QbzSession.trRev)
: sortField === "custom" ? QbzSession.tr("Custom", QbzSession.trRev)
: QbzSession.tr("Default", QbzSession.trRev)
}
// --- Reorder state (the view-level half of the shared drag) ----------
property int reorderFrom: -1
property int reorderOver: -1
property string reorderDropPlaylist: ""
// The guarded mirror (PlaylistView.slint: Rust clears over-playlist-id
// in the same turn it clears active, so the value must be captured
// WHILE the drag is live).
Connections {
target: QbzShell
function onDragOverPlaylistIdChanged() {
if (QbzShell.dragActive) root.reorderDropPlaylist = QbzShell.dragOverPlaylistId
}
function onDragXChanged() { root.updateSlot() }
function onDragYChanged() { root.updateSlot() }
function onDragActiveChanged() {
if (QbzShell.dragActive) return
// Drag ended (main.rs drag_end already ran — it handled a
// sidebar-playlist drop, if any). A release INSIDE the list and
// NOT on a sidebar playlist reorders.
if (root.reorderFrom >= 0
&& root.reorderDropPlaylist === ""
&& root.pointerInList()
&& root.slotFromPointer() !== root.reorderFrom
&& root.slotFromPointer() !== root.reorderFrom + 1) {
QbzBridge.playlistReorder(root.reorderFrom, root.slotFromPointer())
}
root.reorderFrom = -1
root.reorderOver = -1
root.reorderDropPlaylist = ""
}
}
function pointerInList() {
if (!trackList) return false
const tl = trackList.mapToItem(null, 0, 0)
const br = trackList.mapToItem(null, trackList.width, trackList.height)
return QbzShell.dragX >= tl.x && QbzShell.dragX <= br.x
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= br.y
}
function slotFromPointer() {
const tl = trackList.mapToItem(null, 0, 0)
return Math.max(0, Math.min(tracks.length,
Math.round((QbzShell.dragY - tl.y + trackList.contentY) / 50)))
}
function updateSlot() {
if (root.reorderFrom >= 0 && QbzShell.dragActive) {
root.reorderOver = root.pointerInList() ? root.slotFromPointer() : -1
}
}
// Read more → the shared AppShell text modal (phase 16 pattern).
function openDescription() {
var shell = root.parent
while (shell && shell.openTextModal === undefined) shell = shell.parent
if (shell) shell.openTextModal(doc.name || "", doc.description || "")
}
// --- Circular header action (CircleAction, on-surface variant) -------
// ============================ the view ================================
Column {
anchors.fill: parent
anchors.leftMargin: 32
anchors.rightMargin: 16
anchors.topMargin: 11
anchors.bottomMargin: 16
spacing: 0
Item { width: 1; height: 22 }
// Header placeholder — the SHAPE of the header that is coming
// (150px cover + eyebrow/title/description/meta bars + the 7 round
// action buttons), not a spinner. Replaced wholesale by the real
// header the moment the document lands.
QbzSkeleton {
visible: root.headerPending
variant: "header"
width: parent.width
height: 150
coverSize: 150
coverGap: 24
actionCount: 7
phase: root.skelPhase
}
// --- Header ---------------------------------------------------------
Row {
visible: !root.headerPending
width: parent.width
spacing: 24
// Cover — the SAME two arms as PlaylistCard.qml (the card fix of
// this session, applied to the detail header):
// 1. the playlist's OWN Qobuz graphic (`image_rectangle`,
// published as coverUrl/coverPath) — CONTAIN, because those
// are landscape and cropping cuts the wordmark;
// 2. otherwise the member-cover MOSAIC through the shared
// cards/PlaylistCollage.qml (no second collage), which also
// owns the list-music empty state.
// `doc.covers` are MEMBER-ALBUM covers, never the playlist's own
// artwork — binding one of them as the header image is the bug
// this replaces.
Rectangle {
width: 150
height: 150
radius: theme.radiusMd
color: theme.surfaceElevated
clip: true
// Arm 2 first: the collage is the BACKDROP. Declaration order
// is z-order, so the resolved single cover and the skeleton
// below must come after it.
PlaylistCollage {
anchors.fill: parent
visible: (doc.coverPath || "") === ""
// A playlist that HAS its own graphic passes no urls, so
// it shows the placeholder while that graphic downloads
// instead of flashing a member mosaic (PlaylistCard rule).
urls: root.hasOwnArt ? [] : (doc.covers || [])
radius: theme.radiusMd
}
// `pad`, not `contain`: the playlist's own graphic is the
// 800x380 `image_rectangle`, so a 150px square leaves two
// ~35px bands. Flat theme grey there made the header read as
// a small picture floating in a box; the bands now carry a
// gradient sampled from the image's own top and bottom edges
// (theme/RoundedImage.qml). Slint crops this same header
// (`playlist/PlaylistView.slint:481` is `image-fit: cover`)
// and loses half the banner — this is one of the few places
// the reference is not the target.
RoundedImage {
visible: (doc.coverPath || "") !== ""
anchors.fill: parent
source: doc.coverPath || ""
radius: theme.radiusMd
fit: root.hasOwnArt ? "pad" : "auto"
}
// Per-item: the cover shimmers until THIS playlist's cover
// lands, then clears on its own. settleMs bounds it — a
// failed download republishes the document with an empty
// coverPath and would otherwise shimmer forever; on settle
// the placeholder fades and the collage below takes over
// (empty urls -> its list-music glyph, the old empty state).
QbzSkeleton {
id: plCoverSkel
visible: root.coverPending
variant: "art"
anchors.fill: parent
blockRadius: theme.radiusMd
phase: root.skelPhase
settleMs: 4000
}
// Left-click: the lightbox (the shared CoverLightbox, same as
// the album page). Right-click: the custom-cover menu (Qt
// port addition — the Slint playlist header has no menu).
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
plCoverMenu.openAtCursor(plCoverAnchor, mouse.x, mouse.y)
} else {
var src = (doc.coverUrl || "") !== "" ? doc.coverUrl
: ((doc.covers || []).length > 0 ? doc.covers[0] : "")
if (src !== "") plCoverLightbox.openWith(src)
}
}
}
Item { id: plCoverAnchor; anchors.fill: parent }
}
// The playlist cover menu's rows, rebuilt per open (Add vs
// Change+Remove flips on hasCustomCover). The store is Qt-first
// (custom_playlist_covers.json — a `playlists` key inside the
// shared custom_artwork.json would be dropped on the Slint
// app's next write).
function buildPlCoverMenuModel() {
var rows = []
if (doc.hasCustomCover === true) {
rows.push({ "label": QbzSession.tr("Change cover", QbzSession.trRev), "icon": "image-plus", "action": "add" })
rows.push({ "label": QbzSession.tr("Remove cover", QbzSession.trRev), "icon": "trash-2", "action": "remove" })
} else {
rows.push({ "label": QbzSession.tr("Add cover", QbzSession.trRev), "icon": "image-plus", "action": "add" })
}
return rows
}
QbzContextMenu {
id: plCoverMenu
menuWidth: 196
onAboutToShow: plCoverRepeater.model = root.buildPlCoverMenuModel()
Repeater {
id: plCoverRepeater
model: []
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: pcmiArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
width: parent.width - 23
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: pcmiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
plCoverMenu.close()
if (modelData.action === "add") QbzBridge.playlistCoverAdd(doc.id || "")
else if (modelData.action === "remove") QbzBridge.playlistCoverRemove(doc.id || "")
}
}
}
}
}
CoverLightbox { id: plCoverLightbox }
// Metadata.
Column {
width: parent.width - 150 - 24
spacing: 0
Text {
text: QbzSession.tr("Playlist", QbzSession.trRev).toUpperCase()
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 1.5
}
Item { width: 1; height: 4 }
Text {
width: parent.width
text: doc.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
wrapMode: Text.WordWrap
}
Item { visible: (doc.descriptionShort || "") !== ""; width: 1; height: 6 }
Text {
visible: (doc.descriptionShort || "") !== ""
width: Math.min(parent.width, 700)
text: doc.descriptionShort || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
Text {
visible: (doc.description || "") !== (doc.descriptionShort || "")
height: 18
text: QbzSession.tr("Read more", QbzSession.trRev)
color: rmArea.containsMouse ? theme.accentHover : theme.accent
font.pixelSize: theme.fontLegal
verticalAlignment: Text.AlignVCenter
MouseArea {
id: rmArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openDescription()
}
}
Item { width: 1; height: 8 }
Text {
text: (doc.owner || "") + " • " + (doc.trackCount || 0) + " " + QbzSession.tr("tracks", QbzSession.trRev)
+ " • " + (doc.totalDuration || "")
color: theme.textSecondary
font.pixelSize: theme.fontLegal
}
Item { width: 1; height: 14 }
// Actions — buttons left, search + sort floating right.
Row {
width: parent.width
spacing: 12
QbzCircleAction {
name: "play-fill"
primary: true
btnEnabled: root.allTracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistPlayAll()
}
QbzCircleAction {
name: "shuffle"
btnEnabled: root.allTracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistShuffle()
}
QbzCircleAction {
name: root.headerFavorite ? "heart-filled" : "heart"
active: root.headerFavorite
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistToggleFavorite()
}
QbzCircleAction {
name: root.headerPinned ? "pin-filled" : "pin"
active: root.headerPinned
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistTogglePin()
}
// Follow / unfollow and Copy are Qobuz WRITES
// (`playlist/subscribe`, `playlist/create`): offline they
// cannot succeed, and the port let them be clicked anyway —
// the request was gate-refused and the only trace was a log
// line. They stay visible and go inert (0.4 opacity, no
// hover, arrow cursor), which is the reference's read-only
// treatment of the offline hearts rather than a control
// that vanishes and leaves the row jumping.
QbzCircleAction {
visible: !root.isOwner
btnEnabled: root.online
name: (doc.isFollowing === true) ? "check" : "user-plus"
active: doc.isFollowing === true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistToggleFollow()
}
QbzCircleAction {
visible: !root.isOwner && doc.isCopied !== true
btnEnabled: root.online
name: "copy"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistCopy()
}
QbzCircleAction {
visible: root.isOwner
name: "pen-line"
anchors.verticalCenter: parent.verticalCenter
// The ONE shared editor (controls/PlaylistEditModal.qml,
// mounted in AppShell), which replaced the inline Popup
// that used to live at the bottom of this file: that one
// could express neither a description nor the
// offline-only flag, and it wiped nothing only because
// it never sent one (contract D20).
//
// The id comes from `root.doc.id`. This view has no
// dedicated id property of its own, and a missing one
// would read `undefined`, convert to an empty QString
// and open the editor on no playlist, silently.
onClicked: QbzPlaylistEdit.open(String(root.doc.id || ""))
}
// Multi-select toggle (AlbumView's button, here for the
// playlist's track list).
QbzCircleAction {
name: "square-check-big"
active: root.multiSelect
btnEnabled: root.tracks.length > 0
anchors.verticalCenter: parent.verticalCenter
onClicked: root.setMultiSelect(!root.multiSelect)
}
Item { width: parent.width - 40 - 32 * 7 - 8 * 12 - 220 - 140; height: 1 }
// In-playlist search.
Rectangle {
width: 220
height: 30
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
spacing: 6
QbzIcon { name: "search"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
TextInput {
width: parent.width - 19
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: QbzBridge.playlistSetSearch(text)
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzSession.tr("Search tracks", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
}
// Sort dropdown.
Rectangle {
width: 132
height: 34
radius: 6
anchors.verticalCenter: parent.verticalCenter
// PlaylistView.slint:855-859 — resting fill
// translucent under the dynamic background.
color: sortArea.containsMouse
? theme.surfaceHover
: (root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 11
anchors.rightMargin: 8
spacing: 5
Text {
width: parent.width - 13 - 5
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Sort", QbzSession.trRev) + ": " + root.sortLabel()
color: theme.textSecondary
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
QbzIcon { name: "chevron-down"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" }
}
MouseArea {
id: sortArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: sortMenu.openBelowRight(sortArea)
}
QbzContextMenu {
id: sortMenu
menuWidth: 172
Repeater {
model: [
{ "field": "default", "label": QbzSession.tr("Default", QbzSession.trRev) },
{ "field": "title", "label": QbzSession.tr("Title", QbzSession.trRev) },
{ "field": "artist", "label": QbzSession.tr("Artist", QbzSession.trRev) },
{ "field": "album", "label": QbzSession.tr("Album", QbzSession.trRev) },
{ "field": "duration", "label": QbzSession.tr("Duration", QbzSession.trRev) },
{ "field": "added", "label": QbzSession.tr("Date added", QbzSession.trRev) },
// Manual order — enables the per-row
// reorder chevrons. HIDDEN on a LOCAL
// playlist (PlaylistView.slint:945-952,
// B2): its Default order IS the editable
// repo order, so a separate custom
// sidecar is moot — and the sidecar is
// u64-keyed, which a local row's id is
// not.
{ "field": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev), "ownerOnly": true, "qobuzOnly": true },
]
delegate: Rectangle {
required property var modelData
visible: (modelData.ownerOnly !== true || root.isOwner)
&& (modelData.qobuzOnly !== true || !root.isLocal)
width: parent ? parent.width : 0
height: visible ? 33 : 0
radius: 5
color: soArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 6
Text {
width: parent.width - 26
height: parent.height
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
font.weight: root.sortField === modelData.field ? theme.weightSemibold : theme.weightRegular
verticalAlignment: Text.AlignVCenter
}
QbzIcon {
visible: root.sortField === modelData.field
name: root.sortAsc ? "chevron-up" : "chevron-down"
width: 12
height: 12
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
}
MouseArea {
id: soArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
sortMenu.close()
QbzBridge.playlistSetSort(modelData.field)
}
}
}
}
}
}
}
}
}
Item { width: 1; height: 18 }
// Empty. (The loading state is the row placeholders inside the
// track-list area below — the Slint's bare LoadingSpinner said
// "busy" but not "this is the shape of what is coming".)
Text {
visible: !root.loading && root.allTracks.length === 0
text: QbzSession.tr("This playlist is empty.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
// --- Column header ---------------------------------------------------
// rows/TrackListHeader.qml — the same rows/TrackCols.qml geometry the
// TrackRows below use. Two things were wrong when this was hand-rolled
// here, and both are now structural:
// * `anchors.leftMargin: 12` on a Column child is a NO-OP (a
// positioner owns its children's x), so the labels started 12px
// left of the row cells; the header component pads itself;
// * it was as wide as the page while the rows are the ListView's
// width, which is inset 14px for the scrollbar gutter — 14px of
// extra stretch pushed Album/Duration/Quality right. The width
// below is the ROW width, which is the component's contract.
// Multi-select bulk bar — INLINE above the column header (the Local
// Library album view's layout: bar in flow, list below).
QbzMultiSelectBar {
visible: root.multiSelect && root.tracks.length > 0
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": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
]
onAction: function (id) { root.bulkAction(id) }
}
TrackListHeader {
visible: root.tracks.length > 0
width: parent.width - 14
showArtwork: true
showAlbum: true
showDownload: true
// MIRROR the delegate's arm, or the labels slide by 22 + 14 the
// moment the list becomes reorderable (TrackListHeader's rule 2).
showReorder: root.canReorder
}
Rectangle { visible: root.tracks.length > 0; width: 1; height: 3; color: "transparent" }
Rectangle { visible: root.tracks.length > 0; width: parent.width; height: 1; color: theme.borderSubtle }
Item { width: 1; height: 6 }
// --- Track list -------------------------------------------------------
Item {
width: parent.width
height: parent.height - 28 - 150 - 18 - 50 - (root.multiSelect ? 46 : 0)
// Track-list placeholder: the exact TrackRow footprint (50px
// rows, no gap, 36px art cell), one instance for the whole
// viewport — ONE animator, not one per row (see QbzSkeleton's
// COST note). It unmounts the moment the first rows land.
QbzSkeleton {
visible: root.listPending
variant: "rowList"
anchors.fill: parent
anchors.rightMargin: 14
rowH: 50
rowGap: 0
rowArtSize: 36
phase: root.skelPhase
}
ListView {
id: trackList
anchors.fill: parent
anchors.rightMargin: 14
clip: true
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 500
model: root.tracks
delegate: TrackRow {
required property var modelData
required property int index
width: parent ? parent.width : 0
item: modelData
number: index + 1
showArtwork: true
showAlbum: true
showDownload: true
// Alternating row tint, like the local album page
// (LocalAlbumView.qml). TrackRow already owns the stripe —
// it paints `#07ffffff` on even `number`s — so this is the
// whole change.
zebra: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id)
menuShowRemove: root.isOwner
// Reorder chevrons (owner findings 5 + 8). `index` is the
// index into `root.tracks`, which under `canReorder` is
// the unfiltered document — the empty-search term of that
// predicate is what guarantees it.
showReorder: root.canReorder
canMoveUp: index > 0
canMoveDown: index < root.tracks.length - 1
onMoveUpRequested: QbzBridge.playlistMoveRow(String(item.id), -1)
onMoveDownRequested: QbzBridge.playlistMoveRow(String(item.id), 1)
onPlayRequested: QbzBridge.playlistPlayTrack(item.id)
onEnqueueRequested: function (m) { QbzBridge.playlistEnqueueTrack(item.id, m) }
// The DISPLAY row id, as a string. For a Qobuz row it IS
// the membership id (`playlist_qt.rs:363-364` sets both
// from `track.id`); for a LOCAL row `playlistTrackId` is a
// queue id — or 0 on an unresolved one — and the removal
// has to be keyed on the id the position map knows.
onRemoveRequested: QbzBridge.playlistRemoveTrack(String(item.id))
// "Find available version" (contract §6.1) — the ONE
// surface that offers it, and the gate is the reference's:
// a QOBUZ playlist the signed-in user OWNS. Qobuz refuses
// a write to anyone else's playlist, so offering it there
// would be a dead action; a LOCAL playlist's rows are not
// catalog memberships at all, and a mixed local/plex row in
// this same view carries its own `source` word, which is
// why the third term reads the row and not just the doc.
routeReplaceExternally: root.isOwner && !root.isLocal
&& (modelData.source || "") === ""
// Everything Rust needs, in ONE JSON object (the
// `QbzMyQbzAdd.open(JSON.stringify(...))` idiom). No row
// INDEX is passed on purpose: `index` here is the index
// into the DISPLAYED list, which under a search filter or a
// non-default sort is not the playlist's own order — the
// reposition step derives the real slot from the
// authoritative playlist it re-fetches anyway.
onReplaceRequested: QbzTrackReplace.open(JSON.stringify({
"playlistId": String(root.doc.id || ""),
"playlistTrackId": String(item.playlistTrackId || item.id),
"trackId": String(item.id),
"title": item.title || "",
"artist": item.artist || "",
"album": item.album || "",
"isrc": item.isrc || "",
"durationSecs": item.durationSecs || 0
}))
// "Add to playlist" — this view serves BOTH a Qobuz
// playlist and a LOCAL one (`local_playlist_qt::load`
// publishes into this same document through
// `playlist_qt::adopt_doc`), so the row's id space is not
// uniform here and the routing reads the row's own
// `source` word ("" = Qobuz, "local" | "plex" = a
// library.db / Plex ref). The local arm hands the DISPLAY
// id to Rust, which resolves it through
// `local_playlist_qt::local_picker_ref_for_row` — a Plex
// row's rating key only exists in the open detail's queue
// snapshot and QML cannot build the ref itself.
routePlaylistAddExternally: modelData.source === "local"
|| modelData.source === "plex"
onPlaylistAddRequested: QbzPlaylistPicker.openForLocalRow(item.id)
// MyQBZ "Add to mixtape" — the HOST builds the AddItem
// array (TrackRow does not know itemType/source).
//
// SOURCE comes off the ROW, never a literal: a Qobuz
// playlist's rows carry no `source` and are catalog rows,
// while a LOCAL playlist's rows carry "local" | "plex"
// (playlist_qt.rs PlaylistTrackRow.source). "plex" folds
// into "local" because `AddItem.source` is
// "qobuz" | "local" and `source_from_str`
// (myqbz_add_qt.rs:85-90) maps anything that is not
// "local" back to Qobuz — passing "plex" verbatim would
// store a Plex row under a catalog id again.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track",
"source": (item.source === "local" || item.source === "plex")
? "local" : "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
onBodyDragStarted: function (n) {
// #589: report the source index BEFORE the shared drag.
// Gated on `canReorder`, not on ownership alone: under
// a computed sort (or an active search) this drag is
// ONLY the add-to-a-sidebar-playlist gesture, and
// claiming a source index would let a release inside
// the list commit a move against an order that is not
// the stored one.
if (root.canReorder) {
root.reorderFrom = index
root.reorderOver = -1
root.reorderDropPlaylist = ""
}
}
}
}
// Drop indicator: a 2px accent line at the insertion slot,
// hidden on the no-op slots and while outside the list.
Rectangle {
visible: root.reorderFrom >= 0
&& root.reorderOver >= 0
&& root.reorderOver !== root.reorderFrom
&& root.reorderOver !== root.reorderFrom + 1
x: 0
y: Math.max(0, Math.min(parent.height - 2,
root.reorderOver * 50 - trackList.contentY - 1))
width: parent.width - 14
height: 2
radius: 1
color: theme.accent
}
QbzScrollBar {
target: trackList
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}