Files
qbz/crates/qbz-qt/qml/views/QueueView.qml
T
vicrodh 408a7af0c9 feat(qt): queue rows slide into place instead of the list being rebuilt
The Listen list and the sidebar queue now reach their views through
QbzKeyedModel. In the Listen list a removed track fades out while the
rows below slide up, "play next" slides the queue open, a history row
trimmed above the window leaves the rows on screen where they are, and a
committed drag lands the row where it was dropped while its neighbours
slide (the chevrons keep the animated swap). Identity is the track
(source + id, repeats by occurrence); phase, section and phaseIndex are
data, so a track ending updates rows in place and moves nothing. The
follow-playing position and the cover window now run after the model
holds the new rows. A search is a different list and is replaced at once.

The sidebar's Up Next and Recently played are Repeaters in Columns, which
have no remove transition: a leaving row disappears and the rows below
slide up, a joining row fades in, and a page or search change is laid out
synchronously without animation. Drop slots count the Repeater's own
items.

tst_keyed_model gains the Repeater-in-a-Column case (silent first page and
page change, sliding rows that keep their items, fading insert).

cc
2026-09-14 05:56:58 -06:00

1108 lines
48 KiB
QML

// QueueView — a chronological, virtualized projection of playback history,
// the current track and the complete upcoming sequence. The core queue keeps
// its streaming semantics; history becomes playable again only when the user
// activates or drags one of its rows back below Now Playing.
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
import "../assets/kiosk-art.js" as KioskArt
Rectangle {
id: root
property bool kioskHost: false
QbzTheme { id: theme }
color: theme.ambientOn ? "transparent" : theme.surfaceMain
radius: theme.radiusMd
readonly property var doc: {
try {
return JSON.parse(QbzQueue.extendedQueueJson)
} catch (e) {
return ({ "rows": [], "currentIndex": -1, "historyCount": 0,
"upcomingCount": 0, "stopAfterId": "",
"infinitePlay": false, "searchQuery": "" })
}
}
readonly property var rows: root.doc.rows || []
readonly property bool searchActive: (root.doc.searchQuery || "") !== ""
readonly property int firstUpcoming: (root.doc.historyCount || 0)
+ (root.doc.currentIndex >= 0 ? 1 : 0)
readonly property var currentRow: root.doc.currentIndex >= 0
? root.rows[root.doc.currentIndex] : null
readonly property bool hasPlayingRow: root.doc.currentIndex >= 0
&& root.doc.currentIndex < root.rows.length
readonly property int actionRailWidth: kioskHost ? 72 : 52
// The list's model (2026-09-14): queue changes reach the ListView as
// removes / inserts / moves instead of a new array, so removing a track
// fades that row out while the rest slide up, "play next" slides the
// queue open, a history row trimmed above the window leaves the rows on
// screen where they are, and nothing is rebuilt from the top.
//
// Identity is the track (source + id); a repeat of the same track is its
// next occurrence. Phase, section and phaseIndex are data, not identity:
// when a track ends, the chronological order does not change, only which
// row is current, so those rows update in place and nothing moves. A
// search is a different list (the scope), replaced at once.
QbzKeyedModel {
id: rowsModel
rows: root.rows
keyOf: function (row) { return (row.source || "") + ":" + row.id }
scope: root.doc.searchQuery || ""
onReconciled: {
// The follow and the covers read view indices: run them once the
// view holds the new rows.
root.scheduleVisibleCovers()
if (root.hasPlayingRow && root.followPlaying)
root.followPlayingRow()
}
}
// A committed drop lands the dragged row where the pointer left it; only
// its neighbours slide. The chevrons keep the move animation (a swap).
property bool dropSettling: false
Timer {
id: dropSettle
interval: 600
onTriggered: root.dropSettling = false
}
// Timing for the chronological projection currently on screen. History is
// fully elapsed, the current row contributes the player's live position,
// and upcoming rows are fully remaining. A filtered listen list therefore
// reports the duration of exactly the rows its count describes.
readonly property var queueTiming: {
var total = 0
var elapsed = 0
for (var i = 0; i < root.rows.length; i++) {
var row = root.rows[i] || ({})
var secs = Math.max(0, Math.floor(row.durationSecs || 0))
total += secs
if (row.phase === "history")
elapsed += secs
else if (row.phase === "current")
elapsed += Math.min(secs, Math.max(0, QbzPlayer.npElapsedSecs))
}
return ({ "total": total, "elapsed": Math.min(total, elapsed),
"remaining": Math.max(0, total - elapsed) })
}
function compactDuration(secs) {
var value = Math.max(0, Math.floor(secs || 0))
var hours = Math.floor(value / 3600)
var minutes = Math.floor((value % 3600) / 60)
if (hours > 0)
return hours + "h " + minutes + "m"
return minutes + "m"
}
function clockDuration(secs) {
var value = Math.max(0, Math.floor(secs || 0))
var hours = Math.floor(value / 3600)
var minutes = Math.floor((value % 3600) / 60)
var seconds = value % 60
var mm = (hours > 0 && minutes < 10 ? "0" : "") + minutes
var ss = (seconds < 10 ? "0" : "") + seconds
return hours > 0 ? hours + ":" + mm + ":" + ss : minutes + ":" + ss
}
readonly property string queueTimingTooltip:
QbzSession.tr("Elapsed: {}", QbzSession.trRev)
.replace("{}", root.clockDuration(root.queueTiming.elapsed))
+ "\n"
+ QbzSession.tr("Remaining: {}", QbzSession.trRev)
.replace("{}", root.clockDuration(root.queueTiming.remaining))
onQueueTimingTooltipChanged: {
if (queueSummaryHover.containsMouse)
tips.showAbove(queueSummary, "queue-timing", root.queueTimingTooltip)
}
// Opt-in follow mode. It survives playback-driven document updates but
// yields immediately to any user scroll or queue-ordering gesture, so a
// track transition can never yank the viewport away while the user is
// arranging another part of the list.
property bool followPlaying: false
property bool programmaticListPosition: false
function cancelPlayingFollow() {
root.followPlaying = false
}
function positionListAt(index, mode) {
if (index < 0 || index >= root.rows.length)
return
root.programmaticListPosition = true
queueList.positionViewAtIndex(index, mode)
Qt.callLater(function () { root.programmaticListPosition = false })
}
function focusOnPlaying() {
if (!root.hasPlayingRow)
return
root.followPlaying = true
root.positionListAt(root.doc.currentIndex, ListView.Center)
}
function followPlayingRow() {
if (root.followPlaying && root.hasPlayingRow)
root.positionListAt(root.doc.currentIndex, ListView.Center)
}
function goToTop() {
root.cancelPlayingFollow()
if (root.rows.length > 0)
root.positionListAt(0, ListView.Beginning)
}
Component.onCompleted: {
rowsModel.views = [queueList]
QbzQueue.queueExtendedOpened()
root.scheduleVisibleCovers()
}
Component.onDestruction: {
QbzShell.dragInlineVisual = false
QbzQueue.queueExtendedClosed()
}
onDocChanged: {
root.scheduleVisibleCovers()
if (!root.hasPlayingRow)
root.cancelPlayingFollow()
}
onSearchActiveChanged: if (root.searchActive) root.cancelPlayingFollow()
// ----------------------------- artwork ------------------------------
property var coverMap: ({})
// URLs in the current visible artwork window. Unlike the old
// `askedCovers`, this is not a permanent latch: a first miss against a
// waking NAS must be allowed to settle on a later bounded attempt.
property var wantedCovers: ({})
property int artRetriesLeft: 0
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
if (root.wantedCovers[key] !== true)
return
var next = Object.assign({}, root.coverMap)
next[key] = path
root.coverMap = next
}
}
function scheduleVisibleCovers() {
root.artRetriesLeft = 2
artDispatch.restart()
}
function requestVisibleCovers() {
// View indices index the rows the model holds, not a newer `rows`.
var shown = rowsModel.published
if (shown.length === 0 || queueList.height <= 0)
return
var first = queueList.indexAt(4, queueList.contentY + 2)
if (first < 0)
first = 0
var last = queueList.indexAt(4, queueList.contentY + queueList.height - 2)
if (last < first)
last = Math.min(shown.length - 1, first + 16)
first = Math.max(0, first - 3)
last = Math.min(shown.length - 1, last + 3)
var urls = []
var wanted = ({})
for (var i = first; i <= last; i++) {
var url = root.artUrl(shown[i])
if (url !== "" && !root.coverMap[url] && wanted[url] !== true) {
wanted[url] = true
urls.push(url)
}
}
// Assign before crossing into Rust: warm local hits can answer on the
// next Qt turn, and the signal guard above must already know the key.
root.wantedCovers = wanted
if (urls.length > 0) {
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
if (root.artRetriesLeft > 0) {
root.artRetriesLeft--
artRetry.restart()
}
}
}
Timer {
id: artDispatch
interval: 70
repeat: false
onTriggered: root.requestVisibleCovers()
}
Timer {
id: artRetry
interval: 350
repeat: false
onTriggered: root.requestVisibleCovers()
}
// The current cover already has an authoritative resolved path on the
// player bridge. Reuse it for every row carrying that exact URL while the
// generic url-keyed artwork echo settles. This matters for adjacent tracks
// from one album: a missed/late echo otherwise leaves all of them as dark
// tiles even though the NPB is visibly rendering the same cached file.
function artUrl(row) {
var url = row ? (row.artUrl || "") : ""
return root.kioskHost ? KioskArt.sizedUrl(url, Math.ceil(44 * Screen.devicePixelRatio)) : url
}
function coverPath(row) {
var url = root.artUrl(row)
var resolved = root.coverMap[url] || ""
if (resolved !== "")
return resolved
if (url !== "" && root.currentRow
&& url === (root.currentRow.artUrl || "")
&& QbzPlayer.npArtworkPath !== "")
return QbzPlayer.npArtworkPath
return ""
}
// ----------------------------- actions ------------------------------
function rowBlocked(row) {
return QbzQConnect.qconnectConnected && row.qconnectCompatible !== true
}
function dragAllowed(row) {
return !root.searchActive && row.phase !== "current" && !root.rowBlocked(row)
}
function sectionText(index, row) {
if (index === 0 && row.phase === "history")
return QbzSession.tr("HISTORY", QbzSession.trRev)
if (row.phase === "current")
return QbzSession.tr("NOW PLAYING", QbzSession.trRev)
if (row.section === "next-in-queue")
return QbzSession.tr("NEXT IN QUEUE", QbzSession.trRev)
if (row.section === "next-up")
return QbzSession.tr("NEXT UP", QbzSession.trRev)
return ""
}
// 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)
}
}
function queueMenu(row) {
var t = QbzSession.tr
var r = QbzSession.trRev
var items = []
if (row.phase === "upcoming") {
items.push({ "label": t("Remove from queue", r), "icon": "trash-2",
"action": "remove", "external": true })
items.push({ "label": row.id === (root.doc.stopAfterId || "")
? t("Cancel stop after this", r) : t("Stop after this", r),
"icon": "circle-stop", "action": "stop-after", "external": true })
items.push({ "label": t("Remove all after", r), "icon": "list-x",
"action": "remove-after", "external": true })
} else if (row.phase === "history" && !root.rowBlocked(row)) {
items.push({ "label": t("Play now", r), "icon": "play-fill", "action": "play" })
} else if (row.phase === "current") {
items.push({ "label": row.id === (root.doc.stopAfterId || "")
? t("Cancel stop after this", r) : t("Stop after this", r),
"icon": "circle-stop", "action": "stop-after", "external": true })
}
if (root.rowCanGoAlbum(row))
items.push({ "label": t("Go to album", r), "icon": "disc-3",
"action": "go-album", "external": true })
if (root.rowCanGoArtist(row))
items.push({ "label": t("Go to artist", r), "icon": "user",
"action": "go-artist", "external": true })
if (row.isEphemeral !== true) {
// Local rows too (2026-09-14): the queue resolves them to picker
// refs by source (queue_qt::picker_payload_for).
items.push({ "label": t("Add to playlist", r), "icon": "list-plus",
"action": "add-to-playlist", "external": true })
items.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
if (row.isLocal !== true)
items.push({ "label": row.isFavorite === true
? t("Remove from Library", r) : t("Add to Library", r),
"icon": row.isFavorite === true ? "heart-filled" : "heart",
"action": "favorite" })
}
if (root.kioskHost && !root.searchActive && row.phase !== "current" && !root.rowBlocked(row)) {
items.push({label: t("Move up", r), action: "kiosk-up", external: true})
items.push({label: t("Move down", r), action: "kiosk-down", external: true})
}
return items
}
function rowPlay(row) {
if (root.rowBlocked(row))
return
if (row.phase === "current")
QbzPlayer.togglePlay()
else
QbzQueue.queueExtendedPlay(row.phase, row.phaseIndex, row.id)
}
function menuAction(row, action) {
if (action === "kiosk-up") { root.moveQueueRow(row, -1); return }
if (action === "kiosk-down") { root.moveQueueRow(row, 1); return }
if (action === "remove")
QbzQueue.queueRemoveUpcomingFlat(row.phaseIndex)
else if (action === "remove-after")
QbzQueue.queueRemoveAllAfterFlat(row.phaseIndex)
else if (action === "stop-after")
QbzQueue.queueToggleStopAfter(row.id)
else if (action === "add-to-playlist")
QbzPlaylistPicker.openForTrack(row.id)
else if (action === "go-album")
root.goToAlbum(row)
else if (action === "go-artist")
root.goToArtist(row)
}
function historyVisualIndex(row) {
return (root.doc.historyCount || 0) - 1 - row.phaseIndex
}
// The chevrons and pointer drops use insertion slots, not destination row
// indices. Upcoming stays inside Upcoming. History chevrons reorder the
// chronological past; a pointer drag may additionally cross Now Playing
// and move that exact occurrence back into Upcoming.
function moveQueueRow(row, delta) {
if (!row || root.searchActive || root.rowBlocked(row))
return
root.cancelPlayingFollow()
var from
var count
var targetPhase = row.phase
if (row.phase === "upcoming") {
from = row.phaseIndex
count = root.doc.upcomingCount || 0
} else if (row.phase === "history") {
from = root.historyVisualIndex(row)
count = root.doc.historyCount || 0
} else {
return
}
if ((delta < 0 && from <= 0) || (delta > 0 && from >= count - 1))
return
var slot = delta < 0 ? from - 1 : from + 2
QbzQueue.queueExtendedDrop(row.phase, row.phaseIndex, row.id,
targetPhase, slot)
}
// -------------------------- local reorder ----------------------------
property string dragPhase: ""
property int dragPhaseIndex: -1
property string dragTrackId: ""
property var dragRow: null
property int dragRowNumber: 0
property string dropPhase: ""
property int dropSlot: -1
property real dropLineY: -1
property bool dropHot: false
readonly property var dragDisplayItem: root.dragRow
? Object.assign({}, root.dragRow, { "artPath": root.coverPath(root.dragRow) })
: ({})
function beginQueueDrag(row, visualIndex) {
root.cancelPlayingFollow()
root.dragPhase = row.phase
root.dragPhaseIndex = row.phaseIndex
root.dragTrackId = row.id
root.dragRow = row
root.dragRowNumber = visualIndex + 1
root.dropPhase = ""
root.dropSlot = -1
root.dropHot = false
}
function clearInlineDrop() {
root.dropHot = false
root.dropPhase = ""
root.dropSlot = -1
QbzShell.dragInlineVisual = false
}
function recomputeDrop() {
if (!QbzShell.dragActive || root.dragTrackId === "" || root.searchActive) {
root.clearInlineDrop()
return
}
var contentPoint = queueList.mapFromItem(null, QbzShell.dragX, QbzShell.dragY)
if (contentPoint.x < 0 || contentPoint.x > queueList.width
|| contentPoint.y < 0 || contentPoint.y > queueList.height) {
root.clearInlineDrop()
return
}
var cy = queueList.contentY + contentPoint.y
var index = queueList.indexAt(Math.max(1, queueList.width / 2), cy)
var insertion
if (index < 0) {
insertion = cy <= queueList.originY ? 0 : root.rows.length
} else {
var target = queueList.itemAtIndex(index)
insertion = target && cy >= target.y + target.height / 2 ? index + 1 : index
}
var historyCount = root.doc.historyCount || 0
var hasCurrent = root.doc.currentIndex >= 0
if (root.dragPhase === "history"
&& (insertion < historyCount
|| (hasCurrent && insertion === historyCount))) {
root.dropPhase = "history"
root.dropSlot = Math.max(0, Math.min(historyCount, insertion))
} else {
insertion = Math.max(root.firstUpcoming, insertion)
root.dropPhase = "upcoming"
root.dropSlot = Math.max(0, Math.min(root.doc.upcomingCount || 0,
insertion - root.firstUpcoming))
}
root.dropHot = true
QbzShell.dragInlineVisual = true
var fullIndex = root.dropPhase === "history"
? root.dropSlot : root.firstUpcoming + root.dropSlot
var lineItem = fullIndex < root.rows.length ? queueList.itemAtIndex(fullIndex) : null
if (lineItem)
root.dropLineY = queueList.y + lineItem.y - queueList.contentY
else {
var last = queueList.itemAtIndex(root.rows.length - 1)
root.dropLineY = last ? queueList.y + last.y + last.height
- queueList.contentY
: listHost.height / 2
}
}
function finishQueueDrag() {
var shouldCommit = root.dropHot && root.dropSlot >= 0
var phase = root.dragPhase
var phaseIndex = root.dragPhaseIndex
var trackId = root.dragTrackId
var targetPhase = root.dropPhase
var slot = root.dropSlot
root.dragPhase = ""
root.dragPhaseIndex = -1
root.dragTrackId = ""
root.dragRow = null
root.dragRowNumber = 0
root.dropPhase = ""
root.dropSlot = -1
root.dropHot = false
QbzShell.dragInlineVisual = false
if (shouldCommit) {
root.dropSettling = true
dropSettle.restart()
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId,
targetPhase, slot)
}
}
function dragProxyY() {
var p = listHost.mapFromItem(null, QbzShell.dragX, QbzShell.dragY)
return Math.max(queueList.y, Math.min(queueList.y + queueList.height - 50,
p.y - 25))
}
Connections {
target: QbzShell
function onDragXChanged() { root.recomputeDrop() }
function onDragYChanged() { root.recomputeDrop() }
function onDragActiveChanged() {
if (QbzShell.dragActive)
root.recomputeDrop()
else if (root.dragTrackId !== "")
root.finishQueueDrag()
}
}
Timer {
interval: 32
repeat: true
running: QbzShell.dragActive && root.dragTrackId !== "" && root.dropHot
onTriggered: {
var p = listHost.mapFromItem(null, QbzShell.dragX, QbzShell.dragY)
if (p.y < 34)
queueList.contentY = Math.max(queueList.originY, queueList.contentY - 18)
else if (p.y > listHost.height - 34)
queueList.contentY = Math.min(queueList.originY
+ Math.max(0, queueList.contentHeight - queueList.height),
queueList.contentY + 18)
root.recomputeDrop()
}
}
CardMenu {
id: kioskMenu
kioskHost: root.kioskHost
menuWidth: Math.min(root.width - 24, 340)
entries: [
{label: QbzSession.tr("Clear", QbzSession.trRev), action: "clear", enabled: root.rows.length > 0},
{label: QbzSession.tr("Add to Playlist", QbzSession.trRev), action: "save", enabled: root.rows.length > 0 && !(root.currentRow && root.currentRow.isEphemeral)},
{label: QbzSession.tr("Continuous playback", QbzSession.trRev), action: "infinite"},
{label: QbzSession.tr("30 min", QbzSession.trRev), action: "30"},
{label: QbzSession.tr("1 hr", QbzSession.trRev), action: "60"},
{label: QbzSession.tr("2 hr", QbzSession.trRev), action: "120"},
{label: QbzSession.tr("3 hr", QbzSession.trRev), action: "180"},
{label: QbzSession.tr("5 hr", QbzSession.trRev), action: "300"},
{label: QbzSession.tr("Custom…", QbzSession.trRev), action: "custom"},
{label: QbzSession.tr("Cancel Timer", QbzSession.trRev), action: "cancel", enabled: QbzQueue.sleepActive}
]
onPicked: function(action) {
if (action === "clear") QbzQueue.queueClear()
else if (action === "save") QbzQueue.queueSaveAsPlaylist()
else if (action === "infinite") QbzQueue.queueToggleInfinitePlay()
else if (action === "cancel") QbzQueue.sleepTimerCancel()
else if (action === "custom") customSleep.open()
else QbzQueue.sleepTimerSet(parseInt(action, 10))
}
}
// ------------------------------- UI ---------------------------------
Column {
anchors.fill: parent
spacing: 0
Rectangle {
width: parent.width
height: root.kioskHost ? 64 : 54
topLeftRadius: theme.radiusMd
topRightRadius: theme.radiusMd
color: theme.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
Row {
visible: root.kioskHost
x: 16
height: 64
spacing: 12
Text { width: 140; height: 64; text: QbzSession.tr("Listen list", QbzSession.trRev); color: theme.textPrimary; font.pixelSize: 20; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter }
QbzLineEdit { kioskHost: root.kioskHost; anchors.verticalCenter: parent.verticalCenter; width: Math.max(120, root.width - 256); searchMode: true; text: root.doc.searchQuery || ""; placeholder: QbzSession.tr("Search queue", QbzSession.trRev); onEdited: function(value) { QbzQueue.queueSetSearch(value) } }
SettingsButton { id: kioskQueueActions; kioskHost: root.kioskHost; btnHeight: 64; minWidth: 64; text: "⋯"; onClicked: kioskMenu.openBelowRight(kioskQueueActions) }
}
Row {
visible: !root.kioskHost
anchors.left: parent.left
anchors.leftMargin: theme.spacingMd
anchors.verticalCenter: parent.verticalCenter
spacing: theme.spacingSm
QbzIconButton {
btnSize: 32
name: "panel-right-close"
tooltip: tips
tooltipKey: "queue-view-close"
tooltipText: QbzSession.tr("Back", QbzSession.trRev)
onClicked: QbzShell.navigateBack()
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.width >= 720
text: QbzSession.tr("Listen list", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Text {
id: queueSummary
anchors.verticalCenter: parent.verticalCenter
visible: root.width >= 900
text: root.rows.length + " " + QbzSession.tr("tracks", QbzSession.trRev)
+ " • " + root.compactDuration(root.queueTiming.total)
color: theme.textMuted
font.pixelSize: 11
MouseArea {
id: queueSummaryHover
anchors.fill: parent
hoverEnabled: true
onContainsMouseChanged: tips.hover(containsMouse, queueSummary,
"queue-timing", root.queueTimingTooltip)
}
}
}
Row {
visible: !root.kioskHost
anchors.right: parent.right
anchors.rightMargin: theme.spacingMd
anchors.verticalCenter: parent.verticalCenter
spacing: theme.spacingXs
QbzLineEdit {
width: Math.max(110, Math.min(220, root.width
- (root.width >= 720 ? 490 : 390)))
searchMode: true
text: root.doc.searchQuery || ""
placeholder: QbzSession.tr("Search queue", QbzSession.trRev)
onEdited: function (value) { QbzQueue.queueSetSearch(value) }
}
QbzIconButton {
btnSize: 32
name: "trash-list"
btnEnabled: root.rows.length > 0
tooltip: tips
tooltipKey: "queue-view-clear"
tooltipText: QbzSession.tr("Clear", QbzSession.trRev)
onClicked: QbzQueue.queueClear()
}
QbzIconButton {
btnSize: 32
name: "add-to-list"
btnEnabled: root.rows.length > 0
&& !(root.currentRow && root.currentRow.isEphemeral === true)
tooltip: tips
tooltipKey: "queue-view-save"
tooltipText: QbzSession.tr("Add to Playlist", QbzSession.trRev)
onClicked: QbzQueue.queueSaveAsPlaylist()
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Continuous playback", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 11
}
QbzToggle {
anchors.verticalCenter: parent.verticalCenter
checked: root.doc.infinitePlay === true
onToggled: QbzQueue.queueToggleInfinitePlay()
}
}
QbzIconButton {
id: sleepButton
btnSize: 32
name: "clock"
active: QbzQueue.sleepActive
tooltip: tips
tooltipKey: "queue-view-sleep"
tooltipText: QbzSession.tr("Set Timer", QbzSession.trRev)
onClicked: sleepMenu.openAtCursor(sleepButton, 0, sleepButton.height)
CardMenu {
id: sleepMenu
menuWidth: 196
entries: QbzQueue.sleepActive
? [{ "label": QbzSession.tr("Cancel Timer", QbzSession.trRev),
"icon": "x", "action": "cancel" }]
: [
{ "label": QbzSession.tr("30 min", QbzSession.trRev), "icon": "clock", "action": "30" },
{ "label": QbzSession.tr("1 hr", QbzSession.trRev), "icon": "clock", "action": "60" },
{ "label": QbzSession.tr("2 hr", QbzSession.trRev), "icon": "clock", "action": "120" },
{ "label": QbzSession.tr("3 hr", QbzSession.trRev), "icon": "clock", "action": "180" },
{ "label": QbzSession.tr("5 hr", QbzSession.trRev), "icon": "clock", "action": "300" },
{ "sep": true },
{ "label": QbzSession.tr("Custom…", QbzSession.trRev), "icon": "clock", "action": "custom" }
]
onPicked: function (action) {
if (action === "cancel")
QbzQueue.sleepTimerCancel()
else if (action === "custom")
customSleep.open()
else
QbzQueue.sleepTimerSet(parseInt(action, 10))
}
}
Popup {
id: customSleep
parent: root.kioskHost ? root : sleepButton
x: root.kioskHost ? Math.max(8, root.width - width - 16) : -184
y: root.kioskHost ? 64 : sleepButton.height + 6
width: 216
padding: 12
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
}
contentItem: Column {
spacing: 8
Text {
text: QbzSession.tr("Stop playback after:", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
}
Row {
spacing: 8
Rectangle {
width: 116
height: root.kioskHost ? 44 : 32
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: customMinutes.activeFocus
? theme.accent : theme.borderSubtle
TextInput {
QbzTextEditMenu { }
id: customMinutes
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
text: "60"
color: theme.textPrimary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
validator: IntValidator { bottom: 1; top: 1440 }
inputMethodHints: Qt.ImhDigitsOnly
selectByMouse: true
}
}
Rectangle {
width: 64
height: root.kioskHost ? 44 : 32
radius: theme.radiusSm
color: setSleepArea.containsMouse ? theme.accentHover : theme.accent
Text {
anchors.centerIn: parent
text: QbzSession.tr("Set", QbzSession.trRev)
color: theme.accentText
font.pixelSize: 12
}
MouseArea {
id: setSleepArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var minutes = parseInt(customMinutes.text, 10)
if (!isNaN(minutes) && minutes > 0)
QbzQueue.sleepTimerSet(minutes)
customSleep.close()
}
}
}
}
}
}
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Item {
id: listHost
width: parent.width
height: parent.height - (root.kioskHost ? 65 : 55)
ListView {
id: queueList
anchors.left: parent.left
anchors.right: queueScroll.left
// Reserve a narrow action rail so the two always-visible
// floating buttons never cover a row or the scrollbar.
anchors.rightMargin: root.actionRailWidth
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.leftMargin: theme.spacingMd
anchors.topMargin: theme.spacingSm
anchors.bottomMargin: theme.spacingSm
clip: true
spacing: 3
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
model: rowsModel
add: QbzRowAdd { enabled: rowsModel.animate }
remove: QbzRowRemove { enabled: rowsModel.animate }
move: QbzRowDisplaced { enabled: rowsModel.animate && !root.dropSettling }
displaced: QbzRowDisplaced { enabled: rowsModel.animate }
onContentYChanged: root.scheduleVisibleCovers()
onMovementStarted: {
if (!root.programmaticListPosition)
root.cancelPlayingFollow()
}
delegate: Item {
id: rowHost
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: rowsModel.row(rowKey, rowRev)
readonly property string heading: root.sectionText(index, modelData)
readonly property var displayItem: Object.assign({}, modelData, {
"artPath": root.coverPath(modelData)
})
readonly property bool isDragSource: root.dragTrackId !== ""
&& root.dragPhase === modelData.phase
&& root.dragPhaseIndex === modelData.phaseIndex
width: queueList.width
height: (heading !== "" ? 24 : 0) + trackRow.height
ListView.onPooled: {
trackRow.recycleActive = false
trackRow.releaseForReuse()
}
ListView.onReused: {
// A transition cut short can pool the row half faded.
rowHost.opacity = 1
rowHost.scale = 1
trackRow.recycleActive = true
}
Text {
visible: rowHost.heading !== ""
x: theme.spacingSm
width: parent.width - 2 * theme.spacingSm
height: 24
text: rowHost.heading
color: rowHost.modelData.phase === "current"
? theme.accent : theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
}
TrackRow {
id: trackRow
kioskHost: root.kioskHost
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
item: rowHost.displayItem
number: rowHost.index + 1
showArtwork: true
showAlbum: root.width >= 720
showFavorite: false
showDownload: false
showMenu: true
showReorder: !root.kioskHost && !root.searchActive
&& (rowHost.modelData.phase === "upcoming"
|| rowHost.modelData.phase === "history")
&& !root.rowBlocked(rowHost.modelData)
canMoveUp: rowHost.modelData.phase === "upcoming"
? rowHost.modelData.phaseIndex > 0
: (rowHost.modelData.phase === "history"
&& root.historyVisualIndex(rowHost.modelData) > 0)
canMoveDown: rowHost.modelData.phase === "upcoming"
? rowHost.modelData.phaseIndex
< (root.doc.upcomingCount || 0) - 1
: (rowHost.modelData.phase === "history"
&& root.historyVisualIndex(rowHost.modelData)
< (root.doc.historyCount || 0) - 1)
zebra: true
artistLink: true
draggable: root.dragAllowed(rowHost.modelData)
reorderDrag: true
inlineDragVisual: true
playBlocked: root.rowBlocked(rowHost.modelData)
activeBackground: true
overrideActiveMatch: true
activeMatch: rowHost.modelData.phase === "current"
leadingMarkerIcon: rowHost.modelData.id === (root.doc.stopAfterId || "")
? "circle-stop" : ""
menuEntriesOverride: root.queueMenu(rowHost.modelData)
artPending: (rowHost.modelData.artUrl || "") !== ""
&& root.coverPath(rowHost.modelData) === ""
skelPhase: (Math.floor(Math.abs(QbzShell.pulseMs) / 900) % 2) === 1
artSettleMs: 2500
// Keep the MouseArea alive until release while the
// complete visual row is carried by `dragProxy`.
opacity: rowHost.isDragSource ? 0 : 1
onPlayRequested: root.rowPlay(rowHost.modelData)
onMoveUpRequested: root.moveQueueRow(rowHost.modelData, -1)
onMoveDownRequested: root.moveQueueRow(rowHost.modelData, 1)
onBodyDragStarted: root.beginQueueDrag(rowHost.modelData, rowHost.index)
onMenuActionRequested: function (action) {
root.menuAction(rowHost.modelData, action)
}
}
}
}
QbzScrollBar {
id: queueScroll
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
target: queueList
onUserScrollStarted: root.cancelPlayingFollow()
}
// Always-visible viewport actions. They occupy the reserved rail,
// while QbzContextMenu reparents to Overlay.overlay and therefore
// remains above them when opened near the lower-right corner.
Column {
id: viewportActions
z: 15
anchors.right: queueScroll.left
anchors.rightMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: theme.spacingMd
spacing: 8
Rectangle {
width: root.kioskHost ? 64 : 36
height: root.kioskHost ? 64 : 36
radius: theme.radiusSm
color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard)
border.width: 1
border.color: root.followPlaying ? theme.accent
: theme.borderSubtle
QbzIconButton {
anchors.centerIn: parent
btnSize: root.kioskHost ? 64 : 34
iconSize: 16
name: "disc-3"
active: root.followPlaying
btnEnabled: root.hasPlayingRow
tooltip: tips
tooltipKey: "queue-focus-playing"
tooltipText: QbzSession.tr("Focus on playing",
QbzSession.trRev)
onClicked: root.focusOnPlaying()
}
}
Rectangle {
width: root.kioskHost ? 64 : 36
height: root.kioskHost ? 64 : 36
radius: theme.radiusSm
color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard)
border.width: 1
border.color: theme.borderSubtle
QbzIconButton {
anchors.centerIn: parent
btnSize: root.kioskHost ? 64 : 34
iconSize: 16
name: "chevron-up"
btnEnabled: root.rows.length > 0
tooltip: tips
tooltipKey: "queue-go-top"
tooltipText: QbzSession.tr("Go to top", QbzSession.trRev)
onClicked: root.goToTop()
}
}
}
// QueueView's drag visual is the row itself, not the generic
// title/subtitle pill. The source delegate stays alive but fully
// transparent so it retains the mouse grab; this identical row
// follows the pointer over the list. Leaving the list hides it and
// re-enables AppShell's compact ghost for sidebar playlist drops.
TrackRow {
id: dragProxy
visible: QbzShell.dragActive && root.dropHot && root.dragRow !== null
enabled: false
z: 20
x: queueList.x
y: root.dragProxyY()
width: queueList.width
item: root.dragDisplayItem
number: root.dragRowNumber
showArtwork: true
showAlbum: root.width >= 720
showFavorite: false
showDownload: false
showMenu: true
showReorder: root.dragRow !== null
&& (root.dragRow.phase === "upcoming"
|| root.dragRow.phase === "history")
&& !root.searchActive
&& !root.rowBlocked(root.dragRow)
canMoveUp: root.dragRow !== null
&& (root.dragRow.phase === "upcoming"
? root.dragRow.phaseIndex > 0
: (root.dragRow.phase === "history"
&& root.historyVisualIndex(root.dragRow) > 0))
canMoveDown: root.dragRow !== null
&& (root.dragRow.phase === "upcoming"
? root.dragRow.phaseIndex < (root.doc.upcomingCount || 0) - 1
: (root.dragRow.phase === "history"
&& root.historyVisualIndex(root.dragRow)
< (root.doc.historyCount || 0) - 1))
zebra: true
artistLink: true
draggable: false
reorderDrag: false
playBlocked: root.dragRow !== null && root.rowBlocked(root.dragRow)
activeBackground: true
overrideActiveMatch: true
activeMatch: root.dragRow !== null && root.dragRow.phase === "current"
leadingMarkerIcon: root.dragRow !== null
&& root.dragRow.id === (root.doc.stopAfterId || "")
? "circle-stop" : ""
menuEntriesOverride: root.dragRow !== null
? root.queueMenu(root.dragRow) : []
artPending: root.dragRow !== null
&& (root.dragRow.artUrl || "") !== ""
&& root.coverPath(root.dragRow) === ""
skelPhase: (Math.floor(Math.abs(QbzShell.pulseMs) / 900) % 2) === 1
artSettleMs: 2500
}
Rectangle {
visible: root.dropHot && root.dropLineY >= 0
z: 30
x: queueList.x + theme.spacingSm
y: Math.max(0, Math.min(parent.height - height, root.dropLineY))
width: queueList.width - 2 * theme.spacingSm
height: 2
radius: 1
color: theme.accent
}
QbzEmptyState {
visible: root.rows.length === 0
anchors.centerIn: parent
iconName: root.searchActive ? "search" : "list-music"
title: root.searchActive
? QbzSession.tr("No tracks match your search", QbzSession.trRev)
: QbzSession.tr("Your queue is empty", QbzSession.trRev)
body: root.searchActive ? ""
: QbzSession.tr("Play an album or track to get started", QbzSession.trRev)
}
}
}
QbzTooltip {
id: tips
anchors.fill: parent
z: 4000
}
}