**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
682 lines
31 KiB
QML
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|