Files
qbz/crates/qbz-qt/qml/shell/QueuePanel.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

cc
2026-09-14 00:49:54 -06:00

1614 lines
78 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).
// Go to album / Go to artist, by SOURCE (2026-09-14). A catalog row routes
// through its catalog ids (title and artist ride along as the
// unavailable-page hint); a local row — file, Plex, Jellyfin, Subsonic —
// through the queue's NAVIGATION KEY (local_playback.rs stamps the album
// key the card itself opens, prefixed for media servers) and the artist
// NAME into the Local Library Artists tab, the local surfaces' own routes
// (local/LocalTrackRow.qml). A row without the key or the name shows no
// entry.
function rowCanGoAlbum(row) {
return (row.albumId || "") !== ""
}
function rowCanGoArtist(row) {
return row.isLocal === true ? (row.artist || "") !== "" : (row.artistId || "") !== ""
}
function goToAlbum(row) {
if (!rowCanGoAlbum(row))
return
if (row.isLocal === true) {
QbzLocal.openAlbum(row.albumId)
QbzShell.navigateTo("localalbum")
} else {
QbzAlbum.openAlbumFrom(row.albumId, row.album || "", row.artist || "")
}
}
function goToArtist(row) {
if (!rowCanGoArtist(row))
return
if (row.isLocal === true) {
QbzLocal.openArtistByName(row.artist)
QbzShell.navigateTo("local")
} else {
QbzArtist.openArtist(row.artistId)
}
}
// 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 (root.rowCanGoAlbum(row))
items.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" })
if (root.rowCanGoArtist(row))
items.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" })
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)
else if (a === "go-album") root.goToAlbum(row)
else if (a === "go-artist") root.goToArtist(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.surfaceElevatedHover : 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
}
}