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
1614 lines
78 KiB
QML
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
|
|
}
|
|
}
|