Both queue surfaces (the sidebar panel and the Listen list view) gain the two entries, routed by source the way the local rows already do: a catalog row opens the album through its catalog id, with its title and artist riding along as the unavailable-page hint, and the artist by id; a local row (file, Plex, Jellyfin, Subsonic) opens the album through the queue's navigation key — the key local_playback stamps so it opens the same page the card does — and the artist by name through the Local Library Artists tab. A row without the key or the name shows no entry. 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.surfaceHover : theme.surfaceElevated
|
|
Text {
|
|
anchors.fill: parent
|
|
text: QbzSession.tr("Cancel Timer", QbzSession.trRev)
|
|
color: theme.textSecondary
|
|
font.pixelSize: 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
MouseArea {
|
|
id: cancelArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
QbzQueue.sleepTimerCancel()
|
|
sleepMenu.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Idle: preset picker (+ custom minutes) + Set.
|
|
Column {
|
|
visible: !QbzQueue.sleepActive
|
|
width: 196
|
|
spacing: 10
|
|
Text {
|
|
text: QbzSession.tr("Stop playback after:", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
Column {
|
|
width: parent.width
|
|
spacing: 2
|
|
Repeater {
|
|
model: [
|
|
QbzSession.tr("30 min", QbzSession.trRev),
|
|
QbzSession.tr("1 hr", QbzSession.trRev),
|
|
QbzSession.tr("2 hr", QbzSession.trRev),
|
|
QbzSession.tr("3 hr", QbzSession.trRev),
|
|
QbzSession.tr("5 hr", QbzSession.trRev),
|
|
QbzSession.tr("Custom…", QbzSession.trRev)
|
|
]
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
required property int index
|
|
width: parent ? parent.width : 0
|
|
height: 30
|
|
radius: theme.radiusSm
|
|
color: sleepMenu.selectedIndex === index
|
|
? theme.accent
|
|
: (presetArea.containsMouse ? theme.surfaceHover : "transparent")
|
|
Text {
|
|
x: 10
|
|
width: parent.width - 20
|
|
height: parent.height
|
|
text: modelData
|
|
color: sleepMenu.selectedIndex === index
|
|
? theme.accentText : theme.textPrimary
|
|
font.pixelSize: 12
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
MouseArea {
|
|
id: presetArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: sleepMenu.selectedIndex = index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Custom minutes — revealed by the last preset.
|
|
// A bare TextInput in a styled Rectangle, the
|
|
// AddToMixtapeModal convention (and what the
|
|
// .slint does): QbzLineEdit has no numeric arm.
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 32
|
|
visible: sleepMenu.selectedIndex === 5
|
|
radius: theme.radiusSm
|
|
border.width: 1
|
|
border.color: customInput.activeFocus ? theme.accent : theme.borderSubtle
|
|
color: theme.surfaceElevated
|
|
TextInput {
|
|
QbzTextEditMenu { }
|
|
id: customInput
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 9
|
|
anchors.rightMargin: 34
|
|
color: theme.textPrimary
|
|
font.pixelSize: 12
|
|
verticalAlignment: Text.AlignVCenter
|
|
clip: true
|
|
selectByMouse: true
|
|
// Digits only, so "Set" can never be
|
|
// handed something `parseInt` reads as
|
|
// NaN (the Rust side clamps to
|
|
// [1, 1440] on top of this).
|
|
validator: IntValidator { bottom: 1; top: 1440 }
|
|
inputMethodHints: Qt.ImhDigitsOnly
|
|
text: sleepMenu.customMinutes
|
|
onTextEdited: sleepMenu.customMinutes = text
|
|
Binding {
|
|
target: customInput
|
|
property: "text"
|
|
value: sleepMenu.customMinutes
|
|
when: !customInput.activeFocus
|
|
}
|
|
}
|
|
Text {
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 9
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("min", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 34
|
|
radius: theme.radiusSm
|
|
color: setArea.containsMouse ? theme.accentHover : theme.accent
|
|
Text {
|
|
anchors.fill: parent
|
|
text: QbzSession.tr("Set Timer", QbzSession.trRev)
|
|
color: theme.accentText
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
MouseArea {
|
|
id: setArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
var mins = [30, 60, 120, 180, 300][sleepMenu.selectedIndex]
|
|
if (mins === undefined)
|
|
mins = parseInt(sleepMenu.customMinutes, 10)
|
|
if (!isNaN(mins) && mins > 0)
|
|
QbzQueue.sleepTimerSet(mins)
|
|
sleepMenu.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filler so the collapsed magnifier sits at the right edge;
|
|
// the field opens LEFT across it. One gap per visible control
|
|
// (save-as-playlist disappears on an ephemeral queue).
|
|
Item {
|
|
width: Math.max(0, footerRow.contentWidth
|
|
- (root.currentEphemeral ? 3 : 4) * 30 - 30
|
|
- (root.currentEphemeral ? 4 : 5) * theme.spacingXs)
|
|
height: 1
|
|
}
|
|
// Inline queue search (wired) — the shared expandable control.
|
|
QbzLineEdit {
|
|
searchMode: true
|
|
expandable: true
|
|
sm: true
|
|
text: doc.searchQuery || ""
|
|
// Bound to the PANEL, never wider: it opens leftward over
|
|
// the four action buttons, exactly like the Slint
|
|
// ExpandableSearch does over its neighbours.
|
|
openWidth: Math.max(90, footerRow.contentWidth)
|
|
placeholder: QbzSession.tr("Search queue", QbzSession.trRev)
|
|
onEdited: function (v) { QbzQueue.queueSetSearch(v) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Floating drag ghost (QueueSidebar.slint:1098-1130) — a pill following
|
|
// the pointer with the dragged track's title, so it is clear something is
|
|
// being moved. A free child of the panel ROOT (not of the Column, which
|
|
// would stack it), declared LAST so it renders above the list, and with
|
|
// no mouse target of its own. The live pointer window-Y is converted into
|
|
// panel-local space here; mapToItem() is re-evaluated on every
|
|
// reorderPointerY change, which is the only thing that moves it.
|
|
Rectangle {
|
|
visible: root.reorderActive && root.reorderFrom >= 0
|
|
&& root.reorderFrom < root.upcoming.length
|
|
x: theme.spacingMd
|
|
y: root.reorderPointerY - root.mapToItem(null, 0, 0).y - height / 2
|
|
width: root.width - 2 * theme.spacingMd
|
|
height: 34
|
|
radius: theme.radiusSm
|
|
color: Qt.rgba(theme.surfaceElevated.r, theme.surfaceElevated.g,
|
|
theme.surfaceElevated.b, 0.95)
|
|
border.width: 1
|
|
border.color: Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.95)
|
|
// Alpha folded into the fill/border instead of a container `opacity`:
|
|
// an opacity node is its own batch group, and this ghost is live for
|
|
// the whole drag. Every theme builds these tokens opaque (checked
|
|
// across crates/qbz-theme/src), so this is the same pixel.
|
|
Text {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: theme.spacingSm
|
|
anchors.rightMargin: theme.spacingSm
|
|
verticalAlignment: Text.AlignVCenter
|
|
text: parent.visible ? (root.upcoming[root.reorderFrom].title || "") : ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 12
|
|
font.weight: theme.weightMedium
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
// --- Empty-queue drop prompt (owner ask 2026-08-10) -------------------
|
|
// A drop is an ADD, never a play — dropping onto a running queue must not
|
|
// hijack it. But onto an EMPTY queue that leaves the user one click short
|
|
// of what they meant, so the panel asks. A flyout, not a modal: the
|
|
// question is low-stakes and belongs to this panel, and a full-window
|
|
// scrim for "start playing?" would be heavier than the decision.
|
|
//
|
|
// Rides the panel's top edge, under the header, and takes no input away
|
|
// from the list while hidden (`visible` gates the whole item).
|
|
Rectangle {
|
|
id: dropPlayPrompt
|
|
visible: QbzQueue.dropPlayPrompt
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 92
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: parent.width - 32
|
|
height: promptCol.implicitHeight + 24
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceElevated
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
z: 50
|
|
|
|
// Swallow clicks so a press on the prompt never reaches a queue row.
|
|
MouseArea { anchors.fill: parent }
|
|
|
|
Column {
|
|
id: promptCol
|
|
x: 12
|
|
y: 12
|
|
width: parent.width - 24
|
|
spacing: 10
|
|
Text {
|
|
width: parent.width
|
|
text: QbzSession.tr("Start playing now?", QbzSession.trRev)
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontBody
|
|
font.weight: theme.weightMedium
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
Row {
|
|
anchors.right: parent.right
|
|
spacing: 8
|
|
IconTextButton {
|
|
label: QbzSession.tr("Not now", QbzSession.trRev)
|
|
hasIcon: false
|
|
onClicked: QbzQueue.queueAnswerDropPrompt(false)
|
|
}
|
|
// Accent-filled primary, the QbzConfirmModal shape.
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: playLabel.implicitWidth + 28
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
color: playArea.containsMouse ? theme.accentHover : theme.accent
|
|
Text {
|
|
id: playLabel
|
|
anchors.centerIn: parent
|
|
text: QbzSession.tr("Play now", QbzSession.trRev)
|
|
color: theme.accentGlyphColor
|
|
font.pixelSize: 13
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
MouseArea {
|
|
id: playArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzQueue.queueAnswerDropPrompt(true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
QbzTooltip {
|
|
id: tips
|
|
anchors.fill: parent
|
|
z: 4000
|
|
}
|
|
}
|