Files
qbz/crates/qbz-qt/qml/shell/QueuePanel.qml
T
vicrodh fed0816e9b fix(qt): ambient mode reaches every chrome control
Under the ambient field some controls still painted the opaque theme
token while their neighbours used the half-alpha one: the album track
filter and its select toggle, the Library tab strip's host, the Local
Library icon selects, the header's active nav items and open playlists
button, the queue sidebar's now-playing card and artwork placeholders,
the Listen list's floating actions, and the shared SettingsButton,
IconTextButton, multi-select bar, text area, load-more pill, tree row
selection, sidebar count chips, Purchases tab strip, play-history filter
box and Orbit banner. Each now takes the ambient-aware fill the header
search and QbzTabBar already use (theme.ambientOn ? …A50 : …).

Tooltips, popups and modals stay opaque on purpose: they are read over
content, not chrome.

cc
2026-09-14 00:39:47 -06:00

1573 lines
76 KiB
QML

// Right-side Queue panel — QML port of
// crates/qbz-ui/ui/shell/QueueSidebar.slint.
//
// Tabs (Queue / History), NOW PLAYING card (live heart), UP NEXT with the
// #442 "Next in queue" / "Next up" section markers and the "stop after this
// song" marker, 40-row pagination, live search filter, row actions (play /
// remove / remove-all-after / stop-after / add-to-playlist / track info /
// heart), drag reorder (6px press-drag, drop indicator + floating ghost),
// footer (count line + Clear / save-as-playlist / infinite play / sleep
// timer + search field), History tab (thumbnail rows, click replays as a
// fresh single-track queue), exact empty and no-results states.
// Data: QbzQueue.queueJson (queue_qt.rs QueueDoc) + the two scalar sleep
// properties on the same singleton.
//
// EPHEMERAL rows drop every PERSISTENCE affordance (heart, Add to playlist,
// Track info) exactly as QueueSidebar.slint gates on `item.is-ephemeral`:
// an ephemeral play must leave nothing behind inside QBZ.
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../theme"
Rectangle {
id: root
color: "transparent"
// Clipped ONLY when stacked with the other panel. The drag ghost really
// does leave this rect, but in the single-panel case the panel coincides
// exactly with queueColumn, which already clips (AppShell.qml) — so the
// scissor is pure duplication there, and duplication costs a batch root.
clip: QbzShell.queueOpen && QbzShell.lyricsOpen
// Queue (0) / History (1) — Slint QueueState.tab.
property int tab: 0
readonly property var doc: JSON.parse(QbzQueue.queueJson)
readonly property var currentRow: doc.current || null
readonly property var upcoming: doc.upcoming || []
readonly property var historyRows: doc.history || []
readonly property var settingsDoc: {
try { return JSON.parse(QbzBridge.settingsJson) }
catch (e) { return ({}) }
}
// Opt-in: Up Next swaps its numeral for the same thumbnail History uses.
readonly property bool queueTrackArtwork:
settingsDoc.queueTrackArtwork === true
readonly property bool queueEmpty: currentRow === null && (doc.upcomingTotal || 0) === 0
// "Stop after this song": the decimal id of the marked track, "" for none.
readonly property string stopAfterId: doc.stopAfterId || ""
// A search is active but nothing matched — a different state from an empty
// queue, and the reason the reference echoes the query back.
readonly property bool noSearchResults: !queueEmpty
&& (doc.searchQuery || "") !== "" && (doc.upcomingTotal || 0) === 0
// The now-playing track is ephemeral -> the queue cannot be persisted, so
// the footer's save-as-playlist disappears (QueueSidebar.slint:850).
readonly property bool currentEphemeral: currentRow !== null && currentRow.isEphemeral === true
// The panel is `visible`-gated rather than conditionally mounted, so this
// is the equivalent of the reference's `init => QueueState.panel-opened()`:
// pull a fresh snapshot each time it comes back on screen, because the
// queue can change while it is hidden (a session restore, a play from any
// view). Cheap — an unchanged document never reaches Qt.
onVisibleChanged: {
if (!visible)
return
QbzQueue.queuePanelOpened()
// AND re-dispatch the covers: the publish dedup means
// `queuePanelOpened` above often changes nothing, so it cannot be
// relied on to trigger the doc change either. (This handler runs
// OUTSIDE the docChanged storm, which is why it used to be the only
// dispatch that asked for the right urls — see `dispatchCovers`.)
dispatchCovers()
}
// --- Favourite state, id-keyed (the ArtistView.localToggles idiom) ---
// The rows here are plain JS objects parsed out of `doc`, so the old
// `row.isFavorite = !row.isFavorite` notified nothing and NO glyph in
// this panel ever moved on click — not the Up Next menu entry, not the
// Now Playing heart. A map at root level is what fits: the queue
// document is re-parsed constantly (every position tick, every queue
// edit), so a per-delegate property alone would be overwritten by the
// next republish carrying the pre-toggle value.
//
// Both writers agree on the same map: the optimistic flip, and
// `libraryFavoriteChanged` — which carries what the write ACTUALLY
// produced, so it is also the rollback for a failed one and the way a
// heart set on another surface reaches this panel.
property var favOverrides: ({})
function favState(row) {
if (!row) return false
return root.favOverrides[row.id] !== undefined
? root.favOverrides[row.id]
: row.isFavorite === true
}
function setFavState(id, value) {
var m = root.favOverrides
m[id] = value
// Rebind requires a NEW object reference (same-ref is not a change).
root.favOverrides = Object.assign({}, m)
}
function toggleFav(row) {
if (!row)
return
root.setFavState(row.id, !root.favState(row))
QbzQueue.queueToggleFavorite("track", row.id)
}
Connections {
target: QbzLibrary
function onLibraryFavoriteChanged(key, value) {
if (key.indexOf("track:") === 0)
root.setFavState(key.substring(6), value)
}
}
// url-keyed cover map (shared artwork pipeline).
property var coverMap: ({})
// --- UP NEXT drag-reorder state (QueueSidebar.slint:371-410) ---------
// Panel-local on purpose: this is NOT the QbzShell drag global (that one
// is the track -> sidebar-playlist ghost, and its drag_end() posts an
// "add tracks to playlist" — a reorder must never reach it).
// ALL indices here are PAGE-LOCAL, which is exactly what queue_qt.rs's
// move_track takes (it resolves page-local -> queue-wide itself, filter
// and pagination included) — same contract as play/remove/remove-after.
property bool reorderActive: false
property int reorderFrom: -1 // page-local index of the dragged row
property int reorderOver: -1 // insertion slot, 0..N
property real reorderPointerY: 0 // live pointer WINDOW y (drives the ghost)
QbzTheme { id: theme }
// Pointer window-Y -> insertion slot [0..N]. The Slint does this with a
// fixed 44px stride plus a 22px correction per #442 section header; here
// the rows' real geometry answers it directly (the delegate exposes its
// row center in window space), so section headers, the list padding and
// the 8px inter-row spacing are all accounted for without a second
// arithmetic model to keep in sync. Top half of row k -> slot k, bottom
// half -> slot k+1, past the last row -> N (append). <=40 rows per page.
function slotFromPointer(winY) {
var n = root.upcoming.length
for (var i = 0; i < n; i++) {
var d = upcomingRepeater.itemAt(i)
if (d && winY < d.rowCenterY())
return i
}
return n
}
// --- EXTERNAL drop: a track dragged in from a list (owner ask
// 2026-08-10, "draggear hacia el queue definiendo la posición") ---------
//
// Distinct from the panel-local reorder above, and deliberately so: that
// one moves a row that is ALREADY in the queue and commits through
// `queueMoveTrack`; this one inserts a row that is not, and commits
// through the SHARED drag (`QbzShell.dragEnd` -> `insert_dragged_track`).
// They share only `slotFromPointer`, which is the part that is genuinely
// the same question.
//
// Same self-detection shape as the sidebar's playlist rows: the target
// reads the shared window-coord pointer and claims or releases itself,
// because the mouse grab belongs to the row being dragged and no hover
// event ever reaches us.
property bool queueDropHot: false
function recomputeQueueDrop() {
// Never claim while the panel's OWN reorder is running, or a row being
// reordered inside the queue would also read as an insert.
if (!QbzShell.dragActive || root.reorderActive || !root.visible) {
if (root.queueDropHot) { QbzShell.dragSetOverQueue(-1); root.queueDropHot = false }
return
}
// The hot region is the UP NEXT LIST, not the whole panel. It used to
// be `root`, which meant the header, the now-playing card and the
// footer buttons all silently accepted a drop — landing it at slot 0
// or at the end depending on which side of the rows you happened to
// release over. A drop you did not aim is worse than a drop that does
// not take.
// EMPTY QUEUE is the one case where the whole panel is the target:
// the UP NEXT section is not mounted (`upcomingTotal > 0`), so there
// are no rows to aim at and the only possible slot is 0. Without this
// arm, narrowing the region to the list would have made a drop into
// an empty queue impossible — the very case the prompt exists for.
const host = upNextSection.visible ? upNextSection : root
const tl = host.mapToItem(null, 0, 0)
const inside = QbzShell.dragX >= tl.x && QbzShell.dragX <= tl.x + host.width
&& QbzShell.dragY >= tl.y && QbzShell.dragY <= tl.y + host.height
if (!inside) {
if (root.queueDropHot) { QbzShell.dragSetOverQueue(-1); root.queueDropHot = false }
return
}
// Re-publish on every move while inside: the slot changes as the
// pointer travels, and the insertion line reads it.
QbzShell.dragSetOverQueue(root.slotFromPointer(QbzShell.dragY))
root.queueDropHot = true
}
Connections {
target: QbzShell
function onDragXChanged() { root.recomputeQueueDrop() }
function onDragYChanged() { root.recomputeQueueDrop() }
function onDragActiveChanged() { root.recomputeQueueDrop() }
}
// Commit — QueueSidebar.slint commit-reorder(). `to == from` and
// `to == from + 1` both drop back onto the SAME gap, so neither reaches
// the bridge (move_track's from == to early return would swallow them
// anyway, at the cost of a queue round-trip and a republish).
function commitReorder() {
var from = root.reorderFrom
var to = root.reorderOver
root.reorderActive = false
root.reorderFrom = -1
root.reorderOver = -1
if (from < 0 || to < 0 || to === from || to === from + 1)
return
QbzQueue.queueMoveTrack(from, to)
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: dispatchCovers()
onDocChanged: dispatchCovers()
onQueueTrackArtworkChanged: dispatchCovers()
// Collect the covers of the document CURRENTLY on the property and ask the
// shared pipeline for them.
//
// It re-derives current/upcoming/history from `root.doc` instead of reading
// the `currentRow` / `upcoming` / `historyRows` bindings, and that is the
// whole fix: called from `onDocChanged`, those three derived properties are
// still holding the PREVIOUS document. Measured on this Qt build (three
// publishes, one per event-loop turn): reading the derived properties in
// the handler dispatched [], then A's urls on B's publish, then B's on C's
// — always exactly one publish behind, so the row on screen NEVER had its
// cover requested. Re-reading `root.doc` in the same handler dispatched
// A, B, C in step.
//
// It is also why the defect looked source-dependent: an ALBUM queue has one
// cover for every row, so asking one publish late asks for the same url and
// is indistinguishable from correct. A PLAYLIST queue (a different album per
// row) and a mixed LOCAL queue ask for the previous row's cover forever.
function dispatchCovers() {
var d = root.doc
var cur = d.current || null
var up = d.upcoming || []
var hist = d.history || []
var urls = []
if (cur && cur.artUrl) urls.push(cur.artUrl)
var i
if (root.queueTrackArtwork)
for (i = 0; i < up.length; i++) if (up[i].artUrl) urls.push(up[i].artUrl)
for (i = 0; i < hist.length; i++) if (hist[i].artUrl) urls.push(hist[i].artUrl)
if (urls.length > 0) {
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
root.artPulse = true
artPulseOff.restart()
}
}
// --- skeleton pulse (PER-ITEM COVERS ONLY) ---------------------------
// The queue is LOCAL state — it never "loads", so this panel has no
// list-level skeleton ON PURPOSE: there is no asynchronous fetch to
// stand in for. What IS asynchronous is each row's cover, which is why
// the placeholders here are per-item and nothing else.
// GATING RULE: freeze on NOT VISIBLE — the panel collapsed, or the
// window minimized/hidden. NEVER on lost focus (a tiling desktop keeps
// windows visible and unfocused).
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// A cover that never resolves (no art, or a failed fetch) leaves the map
// untouched, so every placeholder is bounded — see QbzSkeleton settleMs.
readonly property int artSettleMs: 2500
property bool artPulse: false
Timer {
id: artPulseOff
interval: root.artSettleMs
onTriggered: root.artPulse = false
}
Timer {
interval: 900
repeat: true
running: root.artPulse && root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
function coverPending(url) {
return (url || "") !== "" && (root.coverMap[url] || "") === ""
}
component QueueTab: Text {
property bool active: false
signal clicked()
font.pixelSize: theme.fontBody
font.weight: theme.weightSemibold
color: active ? theme.textPrimary
: tabArea.containsMouse ? theme.textSecondary : theme.textMuted
verticalAlignment: Text.AlignVCenter
height: parent ? parent.height : 0
MouseArea {
id: tabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// Row action icon button (the panel's small IconButton).
// One UP NEXT / History row (QueueRow.slint).
component QueueRow: Rectangle {
id: qrRoot
property var row: ({})
property int rowIndex: 0
property bool showNumber: true
property bool inQueue: true
readonly property bool artworkLeading:
!qrRoot.showNumber || root.queueTrackArtwork
/// Drag-reorder source? UP NEXT rows only — History has no order to
/// commit, and it keeps its drag-scroll because of this (see qrArea's
/// preventStealing).
property bool reorderable: false
readonly property bool hovered: qrArea.containsMouse || menuArea.containsMouse
/// Pulled from the catalogue, and not playable from disk. D5 keeps
/// these out of a queue BUILD, so the only way one reaches this list is
/// a session restored from disk or a listing whose endpoint never
/// reported availability — the two cases the reactive backstop exists
/// for. Rare is not never, and an unmarked dead row in the queue is the
/// same lie D4 forbids everywhere else.
///
/// `cacheStatus` is read the same way rows/TrackRow.qml reads it and
/// for the same reason (a pulled track the user DOWNLOADED still plays
/// — contract §5.3/F5). `queue_qt::QueueRow` does not carry the field
/// today, so it reads `undefined !== 3` = true and a downloaded-but-
/// pulled queue row would be dimmed; the cost of that is cosmetic
/// (nothing here gates playback on it) and it corrects itself the day
/// the producer carries it.
readonly property bool pulledDead: row.qobuzUnavailable === true
&& row.cacheStatus !== 3
readonly property bool isActive: inQueue && QbzPlayer.npTrackId !== "" && QbzPlayer.npTrackId === row.id
readonly property bool dragSource: reorderable && root.reorderActive
&& root.reorderFrom === rowIndex
// Row center in WINDOW space — what slotFromPointer() measures the
// pointer against. A function, not a binding: mapToItem() is not
// reactive, and it is only ever read during a live drag.
function rowCenterY() {
return qrRoot.mapToItem(null, 0, qrRoot.height / 2).y
}
height: 44
radius: theme.radiusSm
color: (hovered && !qrRoot.pulledDead) ? theme.surfaceHover
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
border.width: dragSource ? 1 : 0
border.color: theme.accent
// The dead-row dim rides the SAME channel as the drag-source dim so the
// two cannot fight; a dragged dead row is dimmer still, which is
// harmless and honest.
opacity: dragSource ? 0.4 : (qrRoot.pulledDead ? 0.5 : 1.0)
Row {
anchors.fill: parent
anchors.leftMargin: theme.spacingSm
anchors.rightMargin: theme.spacingXs
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 9
// Leading: track number (default UP NEXT) or thumbnail (History,
// plus opt-in UP NEXT). On the row carrying the "stop after this
// song" marker, the status still owns this fixed slot.
Item {
visible: showNumber && !root.queueTrackArtwork
width: 22
height: parent.height
readonly property bool marked: qrRoot.inQueue
&& root.stopAfterId !== "" && row.id === root.stopAfterId
Text {
visible: !parent.marked && !qrRoot.pulledDead
anchors.centerIn: parent
width: parent.width
text: rowIndex + 1 + (doc.page || 0) * 40
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
}
QbzIcon {
visible: parent.marked
anchors.centerIn: parent
name: "circle-stop"
width: 13
height: 13
tintName: "accent"
}
// Same glyph and the same reason as rows/TrackRow.qml: the
// alert takes the number's slot so the list does not reflow,
// and it is `circle-alert` rather than `triangle-alert` to stay
// clear of the cache-failed meaning that row's download column
// gives the triangle.
//
// The stop-after marker wins the slot when both apply — it is
// a marker the USER placed on this exact row and removing it
// silently would be worse than leaving one dead row unglyphed
// (the dim still marks it, and D5 means the pair is close to
// unreachable anyway).
QbzIcon {
visible: qrRoot.pulledDead && !parent.marked
anchors.centerIn: parent
name: "circle-alert"
width: 13
height: 13
tintName: "favorite"
}
}
Rectangle {
visible: qrRoot.artworkLeading
width: 34
height: 34
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
// No clip: RoundedImage self-confines. One batch root per
// visible queue row.
RoundedImage {
anchors.fill: parent
source: qrRoot.artworkLeading ? (root.coverMap[row.artUrl] || "") : ""
radius: 4
}
// Per-item: this thumbnail clears when ITS cover lands, so
// History fills in progressively rather than all at once.
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.coverPending(row.artUrl)
phase: root.skelPhase
settleMs: root.artSettleMs
}
// When artwork replaces the numeral, preserve the two states
// that used to occupy that slot. The scrim makes either glyph
// legible without growing or shifting the 44px row.
Rectangle {
readonly property bool marked: qrRoot.inQueue
&& root.stopAfterId !== "" && row.id === root.stopAfterId
visible: qrRoot.showNumber
&& (marked || qrRoot.pulledDead)
anchors.fill: parent
radius: 4
color: "#a6000000"
QbzIcon {
anchors.centerIn: parent
name: parent.marked ? "circle-stop" : "circle-alert"
width: 14
height: 14
tintName: parent.marked ? "accent" : "favorite"
}
}
}
// Title + artist.
Column {
width: parent.width - (qrRoot.artworkLeading ? 34 : 22)
- durText.width - 32 - 3 * 9
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
spacing: 5
Text {
text: row.title
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
width: Math.min(implicitWidth, parent.parent.width - (row.explicit ? 21 : 0))
}
Rectangle {
visible: row.explicit
width: 16
height: 16
radius: 3
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
Text {
anchors.centerIn: parent
text: "E"
color: theme.textMuted
font.pixelSize: 9
font.weight: theme.weightSemibold
}
}
}
Text {
width: parent.width
text: row.artist
color: theme.textMuted
font.pixelSize: 10
elide: Text.ElideRight
}
}
Text {
id: durText
anchors.verticalCenter: parent.verticalCenter
text: row.duration
color: theme.textMuted
font.pixelSize: 11
}
// ⋯ menu trigger (UP NEXT rows only; always visible, 32px hit).
Rectangle {
visible: inQueue
width: 32
height: 32
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: menuArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "ellipsis"
width: 16
height: 16
tintName: menuArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: menuArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) { rowMenu.openAtCursor(menuArea, mouse.x, mouse.y) }
}
QbzContextMenu {
id: rowMenu
menuWidth: 196
// QueueSidebar.slint's track menu, in its order. The
// last entry is this port's own addition (the reference
// has no heart in the queue row menu) and is kept
// BELOW the ported set so the 1:1 block reads intact.
// EPHEMERAL rows drop the two persistence entries, the
// same gate the .slint applies (and the reason its menu
// is 105px tall instead of 175px on those rows).
Repeater {
model: {
var items = [
{ "label": QbzSession.tr("Remove from queue", QbzSession.trRev), "icon": "trash-2", "action": "remove" },
{ "label": row.id === root.stopAfterId
? QbzSession.tr("Cancel stop after this", QbzSession.trRev)
: QbzSession.tr("Stop after this", QbzSession.trRev), "icon": "circle-stop", "action": "stop-after" },
{ "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" },
]
if (row.isEphemeral !== true) {
// LOCAL/Plex rows drop it too: the queue's
// `add_to_playlist` refuses them
// (queue_qt.rs — a queue row's id is a
// library.db / Plex synthetic id and this
// panel has no source-aware resolver to
// turn it into a local-mode ref), so the
// entry would render and no-op. Absent
// beats dead; the LOCAL surfaces that DO
// have the resolver offer it instead
// (local/LocalTrackRow.qml).
if (row.isLocal !== true)
items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" })
items.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" })
items.push({ "label": root.favState(row) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.favState(row) ? "heart-filled" : "heart", "action": "favorite" })
}
return items
}
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: rmiArea.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: rmiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
rowMenu.close()
var a = modelData.action
if (a === "remove") QbzQueue.queueRemoveUpcoming(rowIndex)
else if (a === "stop-after") QbzQueue.queueToggleStopAfter(row.id)
else if (a === "remove-after") QbzQueue.queueRemoveAllAfter(rowIndex)
else if (a === "add-to-playlist") QbzQueue.queueAddToPlaylist(rowIndex)
else if (a === "track-info") qrRoot.openTrackInfo()
else if (a === "favorite") root.toggleFav(row)
}
}
}
}
}
}
}
// Row body: click plays, >6px press-drag reorders (QueueSidebar
// .slint's `ta` does both from one TouchArea — one area per row, not
// two stacked).
MouseArea {
id: qrArea
anchors.fill: parent
// BEHIND the row's own controls. Declaration order IS z-order in
// QML and this fills the row from the last declaration, so at the
// default z it sat on top of the ⋯ button and swallowed its press
// — the menu never opened, the row just played. Same fix (and the
// same reason the .slint declares its TouchArea BEFORE the
// content) as rows/TrackRow.qml's trArea.
z: -1
hoverEnabled: true
// Arrow on a dead row, for the reason rows/TrackRow.qml gives: the
// pointer must not promise a play the click below refuses.
cursorShape: qrRoot.pulledDead ? Qt.ArrowCursor : Qt.PointingHandCursor
// THE reason drag-reorder scrolled instead of reordering: the
// enclosing Flickable filters its children's mouse events and
// grabs the press once the pointer moves past
// QStyleHints.startDragDistance (~10px), after which this area
// gets `canceled`, never `released`. Keeping the grab here is the
// Qt equivalent of QueueSidebar.slint's `interactive: false` on
// the queue Flickable — but scoped to the row and without that
// side effect: Qt's Flickable also drops WHEEL events when it is
// non-interactive, so wheel + scrollbar keep working, and only a
// press that starts ON an UP NEXT row is denied to the flick.
// (Consequence, same as the Slint: no drag-scroll while
// reordering — pagination covers long lists.)
preventStealing: qrRoot.reorderable
property real downY: 0
property bool dragging: false
// A release that ended a drag must NOT also play the track
// (`clicked` fires after `released`).
property bool suppressClick: false
onPressed: function (mouse) {
qrArea.downY = qrArea.mapToItem(null, mouse.x, mouse.y).y
qrArea.dragging = false
}
onPositionChanged: function (mouse) {
if (!qrRoot.reorderable || !qrArea.pressed)
return
// WINDOW space for both the threshold and the slot, so a
// wheel-scroll mid-gesture cannot corrupt either.
var winY = qrArea.mapToItem(null, mouse.x, mouse.y).y
if (!qrArea.dragging && Math.abs(winY - qrArea.downY) > 6) {
qrArea.dragging = true
root.reorderActive = true
root.reorderFrom = qrRoot.rowIndex
root.reorderOver = qrRoot.rowIndex
}
if (qrArea.dragging) {
root.reorderPointerY = winY
root.reorderOver = root.slotFromPointer(winY)
}
}
onReleased: {
if (!qrArea.dragging)
return
qrArea.dragging = false
qrArea.suppressClick = true
root.commitReorder()
}
// Safety net (the .slint keeps one too): if the grab is lost
// mid-drag the release never arrives here, and without this the
// panel would stay stuck in reorderActive with a ghost pinned to
// the last pointer position.
onCanceled: {
if (!qrArea.dragging)
return
qrArea.dragging = false
qrArea.suppressClick = true
root.commitReorder()
}
onClicked: {
if (qrArea.suppressClick) {
qrArea.suppressClick = false
return
}
// D4 again: a dead row is inert on click. The ⋯ menu is
// untouched, so "Remove from queue" — the one thing the user
// actually wants to do with this row — still works, and the
// press-drag reorder above still works too (it commits before
// this handler ever runs).
if (qrRoot.pulledDead)
return
if (inQueue) QbzQueue.queuePlayUpcoming(rowIndex)
else QbzQueue.queuePlayHistory(rowIndex)
}
}
// Drop indicator — the .slint draws one 2px accent line at the
// insertion slot, hidden on the two no-op slots (from, from+1). Here
// it rides the row that OWNS the slot (its top edge, and the last
// row's bottom edge for the append slot), which needs no overlay
// geometry and stays correct across the section headers.
Rectangle {
visible: qrRoot.reorderable && root.reorderActive
&& root.reorderOver === qrRoot.rowIndex
&& root.reorderOver !== root.reorderFrom
&& root.reorderOver !== root.reorderFrom + 1
y: -1
width: parent.width
height: 2
radius: 1
color: theme.accent
}
Rectangle {
visible: qrRoot.reorderable && root.reorderActive
&& root.reorderOver === root.upcoming.length
&& qrRoot.rowIndex === root.upcoming.length - 1
&& root.reorderFrom !== root.upcoming.length - 1
y: parent.height - 1
width: parent.width
height: 2
radius: 1
color: theme.accent
}
// The SAME two lines for an EXTERNAL drop (a row dragged in from a
// track list). Driven by the shared `dragOverQueueIndex` instead of
// the panel-local `reorderOver`, and with none of the no-op-slot
// suppression: every slot is a real destination for a row that is not
// in the queue yet, including the two a reorder would ignore.
Rectangle {
visible: root.queueDropHot && QbzShell.dragOverQueueIndex === qrRoot.rowIndex
y: -1
width: parent.width
height: 2
radius: 1
color: theme.accent
}
Rectangle {
visible: root.queueDropHot
&& QbzShell.dragOverQueueIndex === root.upcoming.length
&& qrRoot.rowIndex === root.upcoming.length - 1
y: parent.height - 1
width: parent.width
height: 2
radius: 1
color: theme.accent
}
// --- Track info (rows/TrackRow.qml's lazy-Loader idiom) -------------
// A full Popup tree per delegate would cost 40 of them on a full page,
// so it is activated on demand and torn down on close (Qt.callLater so
// the Popup is not destroyed inside its own signal).
Loader {
id: trackInfoLoader
active: false
sourceComponent: TrackInfoModal { }
}
Connections {
target: trackInfoLoader.item
ignoreUnknownSignals: true
function onClosed() { Qt.callLater(function () { trackInfoLoader.active = false }) }
}
function openTrackInfo() {
if (!qrRoot.row || !qrRoot.row.id)
return
trackInfoLoader.active = true
trackInfoLoader.item.openFor(qrRoot.row.id)
}
}
Column {
anchors.fill: parent
spacing: 0
// --- Header: tabs + close button -------------------------------
Item {
width: parent.width
height: 44
Row {
anchors.left: parent.left
anchors.leftMargin: theme.spacingMd
anchors.right: closeBtn.left
anchors.rightMargin: theme.spacingSm
anchors.verticalCenter: parent.verticalCenter
height: 28
spacing: 14
QbzIconButton {
btnSize: 28
iconSize: 15
name: "panel-right-open"
tooltip: tips
tooltipKey: "queue-view-open"
tooltipText: QbzSession.tr("Listen list", QbzSession.trRev)
onClicked: QbzShell.navigateTo("queue-view")
}
QueueTab {
text: QbzSession.tr("Queue", QbzSession.trRev)
active: root.tab === 0
onClicked: root.tab = 0
}
Text {
text: "|"
color: theme.textDisabled
font.pixelSize: theme.fontBody
height: parent.height
verticalAlignment: Text.AlignVCenter
}
QueueTab {
text: QbzSession.tr("History", QbzSession.trRev)
active: root.tab === 1
onClicked: root.tab = 1
}
}
Rectangle {
id: closeBtn
anchors.right: parent.right
anchors.rightMargin: theme.spacingSm
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: theme.radiusSm
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "x"
width: 15
height: 15
anchors.centerIn: parent
tintName: closeArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.toggleQueue()
}
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Body --------------------------------------------------------
Item {
width: parent.width
height: parent.height - 45 - footerBlock.height
// ===== Queue tab =====
Flickable {
visible: root.tab === 0
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: queueBody.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: queueBody
width: parent.width
padding: 10
spacing: 12
// NOW PLAYING section.
Column {
visible: root.currentRow !== null
width: parent.width - 20
spacing: 10
Text {
text: QbzSession.tr("NOW PLAYING", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Rectangle {
width: parent.width
height: 44
radius: theme.radiusSm
// Ambient-aware: the one opaque card left in a translucent sidebar.
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
Row {
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 9
Rectangle {
width: 34
height: 34
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard)
// No clip: RoundedImage self-confines.
RoundedImage {
anchors.fill: parent
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
radius: 4
}
QbzSkeleton {
variant: "art"
anchors.fill: parent
blockRadius: 4
visible: root.currentRow
? root.coverPending(root.currentRow.artUrl) : false
phase: root.skelPhase
settleMs: root.artSettleMs
}
}
Column {
// A Row SKIPS invisible children, so the
// heart's 28px + its 9px gap come back to
// the text when it is hidden.
width: parent.width - 34 - 9
- (root.currentEphemeral ? 0 : 28 + 9)
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.currentRow ? root.currentRow.title : ""
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.currentRow ? root.currentRow.artist : ""
color: theme.textMuted
font.pixelSize: 10
elide: Text.ElideRight
}
}
// Favorite toggle. HIDDEN on an ephemeral
// track: there is no DB row to favourite, and
// an ephemeral play must leave nothing behind
// (QueueSidebar.slint:563-565).
Rectangle {
visible: !root.currentEphemeral
width: 28
height: 28
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: npFavArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: root.favState(root.currentRow) ? "heart-filled" : "heart"
width: 17
height: 17
tintName: root.favState(root.currentRow) ? "favorite" : (npFavArea.containsMouse ? "textPrimary" : "muted")
}
MouseArea {
id: npFavArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleFav(root.currentRow)
}
}
}
}
}
// UP NEXT section.
Column {
id: upNextSection
visible: (doc.upcomingTotal || 0) > 0
width: parent.width - 20
spacing: 8
Text {
text: QbzSession.tr("UP NEXT", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Repeater {
id: upcomingRepeater
model: root.upcoming
delegate: Column {
id: upDelegate
required property var modelData
required property int index
width: parent ? parent.width : 0
// Row center in window space — slotFromPointer()
// reads it off the Repeater's items.
function rowCenterY() { return qrow.rowCenterY() }
// #442 section header above the row.
Row {
visible: modelData.section !== ""
height: 22
spacing: 6
QbzIcon {
visible: modelData.section === "next-in-queue"
name: "list-start"
width: 13
height: 13
anchors.verticalCenter: parent.verticalCenter
tintName: "accent"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.section === "next-in-queue"
? QbzSession.tr("Next in queue", QbzSession.trRev) : QbzSession.tr("Next up", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
}
QueueRow {
id: qrow
width: parent.width
row: modelData
rowIndex: index
showNumber: true
inQueue: true
reorderable: true
}
}
}
// Paginator (plain text controls, no pills).
Row {
visible: (doc.pageCount || 1) > 1
width: parent.width
anchors.horizontalCenter: parent.horizontalCenter
spacing: theme.spacingMd
Item { width: (parent.width - paginator.width) / 2; height: 1 }
Row {
id: paginator
spacing: theme.spacingMd
QbzIconButton { btnSize: 30
name: "chevron-left"
iconSize: 15
btnEnabled: (doc.page || 0) > 0
onClicked: QbzQueue.queueSetPage((doc.page || 0) - 1)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Page {} of {}", QbzSession.trRev).replace("{}", (doc.page || 0) + 1).replace("{}", doc.pageCount || 1)
color: theme.textMuted
font.pixelSize: 12
}
QbzIconButton { btnSize: 30
name: "chevron-right"
iconSize: 15
btnEnabled: (doc.page || 0) < (doc.pageCount || 1) - 1
onClicked: QbzQueue.queueSetPage((doc.page || 0) + 1)
}
}
Item { width: (parent.width - paginator.width) / 2; height: 1 }
}
}
// Empty state — no current track and no upcoming.
Column {
visible: root.queueEmpty
width: parent.width - 20
topPadding: 48 - 10
spacing: 6
Text {
width: parent.width
text: QbzSession.tr("Your queue is empty", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzSession.tr("Play an album or track to get started", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
// No-results state — a search is active but nothing
// matched. Distinct from the empty queue above: the queue
// still has tracks, they are just filtered out, so telling
// the user to "play an album to get started" would be a lie.
Text {
visible: root.noSearchResults
width: parent.width - 20
topPadding: 32 - 10
text: QbzSession.tr("No tracks match your search", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
}
}
}
// ===== History tab =====
Flickable {
visible: root.tab === 1
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: historyBody.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: historyBody
width: parent.width
padding: 10
spacing: 8
Text {
text: QbzSession.tr("RECENTLY PLAYED", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Repeater {
model: root.historyRows
delegate: QueueRow {
width: parent.width
row: modelData
rowIndex: index
showNumber: false
inQueue: false
}
}
Text {
visible: root.historyRows.length === 0
width: parent.width
topPadding: 48 - 10
text: QbzSession.tr("Nothing played yet", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
horizontalAlignment: Text.AlignHCenter
}
}
}
}
// --- Footer: count + actions + inline search (Queue tab) ---------
Column {
id: footerBlock
visible: root.tab === 0 && !root.queueEmpty
width: parent.width
spacing: 0
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Text {
visible: (doc.upcomingTotal || 0) > 0
width: parent.width
leftPadding: theme.spacingMd
rightPadding: theme.spacingMd
topPadding: 8
text: (doc.upcomingTotal === 1
? QbzSession.tr("Showing {} of {} track.", QbzSession.trRev)
: QbzSession.tr("Showing {} of {} tracks.", QbzSession.trRev))
.replace("{}", doc.pageEnd || 0).replace("{}", doc.upcomingTotal || 0)
color: theme.textMuted
font.pixelSize: 11
}
Row {
id: footerRow
width: parent.width
leftPadding: theme.spacingMd
rightPadding: theme.spacingMd
topPadding: 10
bottomPadding: 10
spacing: theme.spacingXs
// The row's usable width — `parent.width` here is the PANEL
// width, padding included; using it raw is what pushed the
// search field out of the 300px column.
readonly property int contentWidth:
width - leftPadding - rightPadding
// Action 1 — Clear queue.
QbzIconButton { btnSize: 30
name: "trash-list"
onClicked: QbzQueue.queueClear()
}
// Action 2 — Save the queue as a playlist: seeds the shared
// Add-to-Playlist picker with the whole queue, whose inline
// "Create new playlist" row is what names it. HIDDEN while an
// ephemeral track plays — an ephemeral queue cannot be
// persisted (QueueSidebar.slint:848-858).
QbzIconButton { btnSize: 30
name: "add-to-list"
visible: !root.currentEphemeral
onClicked: QbzQueue.queueSaveAsPlaylist()
}
// Action 3 — infinite play (accent while armed).
QbzIconButton { btnSize: 30
name: "infinity"
active: doc.infinitePlay === true
onClicked: QbzQueue.queueToggleInfinitePlay()
}
// Action 4 — sleep timer. Accent while armed; the popover
// opens ABOVE the footer with the preset picker / countdown.
QbzIconButton {
id: sleepBtn
btnSize: 30
name: "clock"
active: QbzQueue.sleepActive
onClicked: sleepMenu.opened ? sleepMenu.close() : sleepMenu.open()
Popup {
id: sleepMenu
// Anchored to the button's top-left and pushed up by
// its own height, so it grows upward out of the footer
// (the .slint hard-codes x:-188 y:-214 for the same
// effect; here the height is not a magic number).
x: -188
y: -height - 6
width: 220
padding: 0
// A click INSIDE must not dismiss it, or a preset
// could never be picked before Set is pressed — the
// same reason the .slint sets close-on-click-outside.
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
background: Rectangle {
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
}
// Local until Set is pressed: the preset choice is a
// draft, and the reference keeps it in UI state too.
property int selectedIndex: 0
property string customMinutes: "60"
contentItem: Column {
padding: 12
spacing: 10
// Armed: countdown + Cancel.
Column {
visible: QbzQueue.sleepActive
width: 196
spacing: 8
Text {
text: QbzSession.tr("Stops in", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
}
Text {
text: QbzQueue.sleepRemaining
color: theme.accent
font.pixelSize: 16
font.weight: theme.weightSemibold
}
Rectangle {
width: parent.width
height: 32
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Text {
anchors.fill: parent
text: QbzSession.tr("Cancel Timer", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: cancelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
QbzQueue.sleepTimerCancel()
sleepMenu.close()
}
}
}
}
// Idle: preset picker (+ custom minutes) + Set.
Column {
visible: !QbzQueue.sleepActive
width: 196
spacing: 10
Text {
text: QbzSession.tr("Stop playback after:", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
}
Column {
width: parent.width
spacing: 2
Repeater {
model: [
QbzSession.tr("30 min", QbzSession.trRev),
QbzSession.tr("1 hr", QbzSession.trRev),
QbzSession.tr("2 hr", QbzSession.trRev),
QbzSession.tr("3 hr", QbzSession.trRev),
QbzSession.tr("5 hr", QbzSession.trRev),
QbzSession.tr("Custom…", QbzSession.trRev)
]
delegate: Rectangle {
required property var modelData
required property int index
width: parent ? parent.width : 0
height: 30
radius: theme.radiusSm
color: sleepMenu.selectedIndex === index
? theme.accent
: (presetArea.containsMouse ? theme.surfaceHover : "transparent")
Text {
x: 10
width: parent.width - 20
height: parent.height
text: modelData
color: sleepMenu.selectedIndex === index
? theme.accentText : theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
MouseArea {
id: presetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: sleepMenu.selectedIndex = index
}
}
}
}
// Custom minutes — revealed by the last preset.
// A bare TextInput in a styled Rectangle, the
// AddToMixtapeModal convention (and what the
// .slint does): QbzLineEdit has no numeric arm.
Rectangle {
width: parent.width
height: 32
visible: sleepMenu.selectedIndex === 5
radius: theme.radiusSm
border.width: 1
border.color: customInput.activeFocus ? theme.accent : theme.borderSubtle
color: theme.surfaceElevated
TextInput {
QbzTextEditMenu { }
id: customInput
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 34
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
clip: true
selectByMouse: true
// Digits only, so "Set" can never be
// handed something `parseInt` reads as
// NaN (the Rust side clamps to
// [1, 1440] on top of this).
validator: IntValidator { bottom: 1; top: 1440 }
inputMethodHints: Qt.ImhDigitsOnly
text: sleepMenu.customMinutes
onTextEdited: sleepMenu.customMinutes = text
Binding {
target: customInput
property: "text"
value: sleepMenu.customMinutes
when: !customInput.activeFocus
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("min", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
}
}
Rectangle {
width: parent.width
height: 34
radius: theme.radiusSm
color: setArea.containsMouse ? theme.accentHover : theme.accent
Text {
anchors.fill: parent
text: QbzSession.tr("Set Timer", QbzSession.trRev)
color: theme.accentText
font.pixelSize: 12
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
MouseArea {
id: setArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var mins = [30, 60, 120, 180, 300][sleepMenu.selectedIndex]
if (mins === undefined)
mins = parseInt(sleepMenu.customMinutes, 10)
if (!isNaN(mins) && mins > 0)
QbzQueue.sleepTimerSet(mins)
sleepMenu.close()
}
}
}
}
}
}
}
// Filler so the collapsed magnifier sits at the right edge;
// the field opens LEFT across it. One gap per visible control
// (save-as-playlist disappears on an ephemeral queue).
Item {
width: Math.max(0, footerRow.contentWidth
- (root.currentEphemeral ? 3 : 4) * 30 - 30
- (root.currentEphemeral ? 4 : 5) * theme.spacingXs)
height: 1
}
// Inline queue search (wired) — the shared expandable control.
QbzLineEdit {
searchMode: true
expandable: true
sm: true
text: doc.searchQuery || ""
// Bound to the PANEL, never wider: it opens leftward over
// the four action buttons, exactly like the Slint
// ExpandableSearch does over its neighbours.
openWidth: Math.max(90, footerRow.contentWidth)
placeholder: QbzSession.tr("Search queue", QbzSession.trRev)
onEdited: function (v) { QbzQueue.queueSetSearch(v) }
}
}
}
}
// Floating drag ghost (QueueSidebar.slint:1098-1130) — a pill following
// the pointer with the dragged track's title, so it is clear something is
// being moved. A free child of the panel ROOT (not of the Column, which
// would stack it), declared LAST so it renders above the list, and with
// no mouse target of its own. The live pointer window-Y is converted into
// panel-local space here; mapToItem() is re-evaluated on every
// reorderPointerY change, which is the only thing that moves it.
Rectangle {
visible: root.reorderActive && root.reorderFrom >= 0
&& root.reorderFrom < root.upcoming.length
x: theme.spacingMd
y: root.reorderPointerY - root.mapToItem(null, 0, 0).y - height / 2
width: root.width - 2 * theme.spacingMd
height: 34
radius: theme.radiusSm
color: Qt.rgba(theme.surfaceElevated.r, theme.surfaceElevated.g,
theme.surfaceElevated.b, 0.95)
border.width: 1
border.color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.95)
// Alpha folded into the fill/border instead of a container `opacity`:
// an opacity node is its own batch group, and this ghost is live for
// the whole drag. Every theme builds these tokens opaque (checked
// across crates/qbz-theme/src), so this is the same pixel.
Text {
anchors.fill: parent
anchors.leftMargin: theme.spacingSm
anchors.rightMargin: theme.spacingSm
verticalAlignment: Text.AlignVCenter
text: parent.visible ? (root.upcoming[root.reorderFrom].title || "") : ""
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
}
}
// --- Empty-queue drop prompt (owner ask 2026-08-10) -------------------
// A drop is an ADD, never a play — dropping onto a running queue must not
// hijack it. But onto an EMPTY queue that leaves the user one click short
// of what they meant, so the panel asks. A flyout, not a modal: the
// question is low-stakes and belongs to this panel, and a full-window
// scrim for "start playing?" would be heavier than the decision.
//
// Rides the panel's top edge, under the header, and takes no input away
// from the list while hidden (`visible` gates the whole item).
Rectangle {
id: dropPlayPrompt
visible: QbzQueue.dropPlayPrompt
anchors.top: parent.top
anchors.topMargin: 92
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 32
height: promptCol.implicitHeight + 24
radius: theme.radiusMd
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
z: 50
// Swallow clicks so a press on the prompt never reaches a queue row.
MouseArea { anchors.fill: parent }
Column {
id: promptCol
x: 12
y: 12
width: parent.width - 24
spacing: 10
Text {
width: parent.width
text: QbzSession.tr("Start playing now?", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
wrapMode: Text.WordWrap
}
Row {
anchors.right: parent.right
spacing: 8
IconTextButton {
label: QbzSession.tr("Not now", QbzSession.trRev)
hasIcon: false
onClicked: QbzQueue.queueAnswerDropPrompt(false)
}
// Accent-filled primary, the QbzConfirmModal shape.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: playLabel.implicitWidth + 28
height: 32
radius: theme.radiusSm
color: playArea.containsMouse ? theme.accentHover : theme.accent
Text {
id: playLabel
anchors.centerIn: parent
text: QbzSession.tr("Play now", QbzSession.trRev)
color: theme.accentGlyphColor
font.pixelSize: 13
font.weight: theme.weightSemibold
}
MouseArea {
id: playArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzQueue.queueAnswerDropPrompt(true)
}
}
}
}
}
QbzTooltip {
id: tips
anchors.fill: parent
z: 4000
}
}