Files
qbz/crates/qbz-qt/qml/views/QueueView.qml
T
vicrodh 1c45c7f2b9 fix(qt): saving the queue as a playlist works with local rows, and offline
queue_qt::save_as_playlist skipped every local and ephemeral row and,
with no catalog ids left, returned without a word — a queue of Local
Library tracks opened nothing from either the queue panel or the Listen
list, online or offline. The module's own note said the picker's local
mode existed and only the row-to-ref resolver was missing.

local_playlist_qt::local_picker_ref_for_queue_track now builds that ref
from a QueueTrack by source (a file its row id, Plex its rating key,
Jellyfin/Subsonic the server item id, an offline copy its row id from
the hint slot; an ephemeral row nothing), and the queue carries ids and
refs together: only local rows open the picker in local mode, a mix
carries the new Payload::Mixed, which the picker routes as the two halves
would be on their own — both writes onto a local playlist, the sidecar
plus the API (duplicate check included) onto a Qobuz one, a local
playlist on create while offline. The per-row "Add to playlist" entry
shows for local rows on both queue surfaces too.

cc
2026-09-14 01:54:57 -06:00

1060 lines
46 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
// 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: {
QbzQueue.queueExtendedOpened()
root.scheduleVisibleCovers()
}
Component.onDestruction: {
QbzShell.dragInlineVisual = false
QbzQueue.queueExtendedClosed()
}
onDocChanged: {
root.scheduleVisibleCovers()
if (!root.hasPlayingRow)
root.cancelPlayingFollow()
else if (root.followPlaying)
Qt.callLater(function () { root.followPlayingRow() })
}
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() {
if (root.rows.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(root.rows.length - 1, first + 16)
first = Math.max(0, first - 3)
last = Math.min(root.rows.length - 1, last + 3)
var urls = []
var wanted = ({})
for (var i = first; i <= last; i++) {
var url = root.artUrl(root.rows[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)
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: root.rows
onContentYChanged: root.scheduleVisibleCovers()
onMovementStarted: {
if (!root.programmaticListPosition)
root.cancelPlayingFollow()
}
delegate: Item {
id: rowHost
required property var modelData
required property int index
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: 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
}
}