Files
qbz/crates/qbz-qt/qml/views/PlaylistView.qml
T
vicrodh 7780efa5db feat(qt): mark tracks Qobuz pulled, and keep them out of the queue
Qobuz withdraws tracks: sometimes a recording, sometimes only a quality tier,
sometimes it republishes the same recording under a new album id. The row keeps
appearing and simply cannot play. Until now the user found out by clicking it.

The app already knew and threw the answer away: eight builders hardcoded
`streamable: true`, so the API's own flag never reached a row model. The
reactive half was already here and stays -- a terminal failure still toasts and
skips, bounded at five consecutive, with the 403 back-off kept separate so a
403 storm cannot burn five good tracks.

Detection has two clauses. The API's `streamable: false`, and a session-only
memory of tracks that died terminally under the player this run: /album/get is
honest, but plenty of rows only fail at the stream-url step, and that memory is
what stops the queue re-offering them. Session-only on purpose -- the reference
kept the equivalent set in localStorage, never expired it and never revalidated
it, so a track Qobuz restored stayed dead forever.

Dead rows are dimmed, get an alert glyph and a tooltip, lose click, drag,
favourite and download, and STAY visible and right-clickable: hiding them would
defeat the point and remove the only route to a replacement. Qobuz's own player
does exactly this, and the pulled track carries displayable:false while still
being rendered -- that flag is not a hide instruction.

A downloaded copy is exempt everywhere. The offline tier resolves before any
availability question, so a pulled track already on disk plays fine; dropping it
from the queue would take away something the user downloaded, and it would bite
hardest offline. Those rows say they are playing the local copy and keep their
play affordance.

Two seams filter, not one, and both had to learn the surviving anchor: every
caller read `first_id` from the UNFILTERED list, so the seam dropped that row
and the app then asked the player for the id it had just discarded -- silence,
no toast. That was already reachable today through the artist blacklist. The
seams now return the surviving start and its id.

Two id bugs surfaced on the way and are fixed here. A playlist row's
`playlist_track_id` was filled with the CATALOG id; they are different numbers
for the same row, one naming the recording and the other its slot in that
playlist, and Qobuz's delete and reposition verbs both take the membership id --
so "remove from playlist" was addressing a row that does not exist. And the For
You rows were built with `..default()`, claiming availability while the queue
built from the same track read the truth.

Catalogs: the two strings this needed already existed in all seven translated
locales and are reused; the skipped-tracks toast is a plural pair, and the
replacement feature's strings ride along here since they share the files.

cc
2026-08-17 14:41:26 -06:00

1009 lines
52 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 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).
readonly property bool canReorder: root.isOwner
&& root.searchQuery.trim() === ""
&& (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
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
}
}
}
}