// 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 } }