Files
qbz/crates/qbz-qt/qml/views/PlaylistView.qml
T
vicrodh f3a33245b2 feat(qbz-qt): staged loading, skeletons everywhere, lyrics, gestures, richer menus
**Deferred external rows.** The album page awaited the Last.fm row before
publishing anything, so the tracklist waited on an external service plus
MusicBrainz. It publishes in stages now — header and tracks first, then each
Qobuz rail as it lands, then Last.fm — with every patch merged under a
generation counter so a late reply for album A cannot paint onto album B. The
artist page was already staged this way; its views were the part still showing
bare "Loading…" text.

**Skeletons as the standard.** One control, new variants rather than new
components, mounted across the views. Its second job is the one the owner
named: cleanup. A cover that never resolves settles the placeholder once and
reveals the card's own empty state instead of shimmering forever — local albums
with no embedded art produce no artwork entry at all, so a placeholder gated
only on "not arrived yet" would never stop.

**Local artwork streams.** Fifteen seconds to first cover on a 1274-album grid
was one batch emitted after the whole window resolved. Each cover emits as it
resolves now, visible band before prefetch margin.

**Lyrics migrated.** Smooth sync instead of a 1 Hz tick, word-level karaoke
where the document carries it, the surrounding-line dimming, the controls
flyout and the translation layer the v10 backend already supported. Exactly one
row subscribes to the 30 Hz output; the timer drops to 250ms when paused or
hidden.

**Navigation by mouse and touchpad.** Side buttons and a two-finger horizontal
swipe, matching #653 — including the part that matters, which is that a swipe
over a carousel scrolls the carousel. The layer is declared first in the shell
so everything else gets first refusal.

**Context menus.** Brought to the Slint inventory, Play later included, with
right-click opening the same menu as the button everywhere. Entries whose seam
does not exist are hidden rather than shipped inert.

Two controls that rendered and no-opped are now honestly disabled: the My QBZ
nav row, which routes to a view the shell has no case for, and three sidebar
menu entries with no backing call.

The sidebar caret was never a condition bug. The row is a positioner and the
name cell's width ignored the count label, so a folder WITH content pushed the
chevron past the clip — which is why it vanished exactly when it mattered.

cc
2026-07-28 18:12:31 -06:00

682 lines
31 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 (server image, else first-track cover, else 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: rename + delete modal). 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 / Go to artist / Go to album / Add|Remove from Library / Remove
// from playlist (owner).
//
// 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 "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
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
readonly property string sortField: doc.sortField || "default"
readonly property bool sortAsc: doc.sortAsc === true
readonly property string searchQuery: doc.search || ""
// 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
// 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: (doc.coverUrl || "") !== ""
&& (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.
Rectangle {
width: 150
height: 150
radius: theme.radiusMd
color: theme.surfaceElevated
clip: true
RoundedImage {
visible: (doc.coverPath || "") !== ""
anchors.fill: parent
source: doc.coverPath || ""
radius: theme.radiusMd
}
QbzIcon {
// Hidden only while the placeholder is actually up; the
// moment it settles (or the cover lands) the designed
// empty-state glyph is what the user sees.
visible: (doc.coverPath || "") === ""
&& (!root.coverPending || plCoverSkel.settled)
name: "list-music"
width: 56
height: 56
anchors.centerIn: parent
tintName: "muted"
}
// 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 glyph above takes over.
QbzSkeleton {
id: plCoverSkel
visible: root.coverPending
variant: "art"
anchors.fill: parent
blockRadius: theme.radiusMd
phase: root.skelPhase
settleMs: 4000
}
}
// 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: (doc.isFavorite === true) ? "heart-filled" : "heart"
active: doc.isFavorite === true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistToggleFavorite()
}
QbzCircleAction {
name: (doc.pinned === true) ? "pin-filled" : "pin"
active: doc.pinned === true
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistTogglePin()
}
QbzCircleAction {
visible: !root.isOwner
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
name: "copy"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playlistCopy()
}
QbzCircleAction {
visible: root.isOwner
name: "pen-line"
anchors.verticalCenter: parent.verticalCenter
onClicked: editModal.open()
}
Item { width: parent.width - 40 - 32 * 6 - 7 * 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
color: sortArea.containsMouse ? theme.surfaceHover : 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) },
{ "field": "custom", "label": QbzSession.tr("Custom", QbzSession.trRev), "ownerOnly": true },
]
delegate: Rectangle {
required property var modelData
visible: modelData.ownerOnly !== true || root.isOwner
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 ---------------------------------------------------
Row {
visible: root.tracks.length > 0
width: parent.width
anchors.leftMargin: 12
spacing: 14
Text { text: "#"; width: 32; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
Rectangle { width: 36; height: 1; color: "transparent" }
Text { text: QbzSession.tr("Title", QbzSession.trRev); width: parent.width - 32 - 36 - 220 - 70 - 92 - 28 - 28 - 32 - 8 * 14; color: theme.textMuted; font.pixelSize: theme.fontLegal }
Text { text: QbzSession.tr("Album", QbzSession.trRev); width: 220; color: theme.textMuted; font.pixelSize: theme.fontLegal }
Text { text: QbzSession.tr("Duration", QbzSession.trRev); width: 70; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
Text { text: QbzSession.tr("Quality", QbzSession.trRev); width: 92; color: theme.textMuted; font.pixelSize: theme.fontLegal; horizontalAlignment: Text.AlignHCenter }
Rectangle { width: 28; height: 1; color: "transparent" }
Rectangle { width: 28; height: 1; color: "transparent" }
Rectangle { width: 32; height: 1; color: "transparent" }
}
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
// 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
menuShowRemove: root.isOwner
onPlayRequested: QbzBridge.playlistPlayTrack(item.id)
onEnqueueRequested: function (m) { QbzBridge.playlistEnqueueTrack(item.id, m) }
onRemoveRequested: QbzBridge.playlistRemoveTrack(item.playlistTrackId)
onBodyDragStarted: function (n) {
// #589: report the source index BEFORE the shared drag.
if (root.isOwner) {
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
}
}
}
// --- Edit modal (rename + delete; owner) ---------------------------------
Popup {
id: editModal
parent: Overlay.overlay
anchors.centerIn: parent
width: 380
padding: 20
closePolicy: Popup.CloseOnEscape
background: Rectangle {
color: theme.surfaceCard
radius: theme.radiusMd
border.width: 1
border.color: theme.borderSubtle
}
contentItem: Column {
spacing: 14
Text {
text: QbzSession.tr("Edit playlist", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Rectangle {
width: parent.width
height: 36
radius: 6
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
text: root.doc.name || ""
color: theme.textPrimary
font.pixelSize: 14
verticalAlignment: Text.AlignVCenter
clip: true
}
}
Row {
spacing: 10
anchors.right: parent.right
Rectangle {
width: delText.implicitWidth + 28
height: 34
radius: 6
color: delArea.containsMouse ? "#33ef4444" : "transparent"
border.width: 1
border.color: "#66ef4444"
Text {
id: delText
anchors.centerIn: parent
text: QbzSession.tr("Delete", QbzSession.trRev)
color: "#ef4444"
font.pixelSize: 13
font.weight: theme.weightMedium
}
MouseArea {
id: delArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
editModal.close()
QbzBridge.playlistDelete()
}
}
}
Rectangle {
width: saveText.implicitWidth + 28
height: 34
radius: 6
color: saveArea.containsMouse ? theme.accentHover : theme.accent
Text {
id: saveText
anchors.centerIn: parent
text: QbzSession.tr("Save", QbzSession.trRev)
color: "#ffffff"
font.pixelSize: 13
font.weight: theme.weightMedium
}
MouseArea {
id: saveArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
editModal.close()
QbzBridge.playlistRename(nameInput.text)
}
}
}
}
}
}
}