feat(queue): add extended queue view

cx
This commit is contained in:
vicrodh committed 2026-08-22 20:01:47 -06:00
1 parent eed377af1f
commit c7038a6858
11 files changed
+1194 -56

No files matched your search

+1
View File
@@ -602,6 +602,7 @@ fn main() {
"qml/views/LocalLibraryView.qml", "qml/views/LocalLibraryView.qml",
"qml/views/PlaylistRail.qml", "qml/views/PlaylistRail.qml",
"qml/views/PlaylistView.qml", "qml/views/PlaylistView.qml",
"qml/views/QueueView.qml",
"qml/views/SearchView.qml", "qml/views/SearchView.qml",
"qml/views/SectionRail.qml", "qml/views/SectionRail.qml",
"qml/shell/CastPicker.qml", "qml/shell/CastPicker.qml",
+67 -16
View File
@@ -118,6 +118,11 @@ Rectangle {
/// It is also the gate for `preventStealing` on the row-body drag — see /// It is also the gate for `preventStealing` on the row-body drag — see
/// the DRAG vs. DRAG-TO-SCROLL block at the bottom of this file. /// the DRAG vs. DRAG-TO-SCROLL block at the bottom of this file.
property bool showReorder: false property bool showReorder: false
/// Use the row body's vertical reorder gesture without drawing the
/// playlist-specific chevron gutter. QueueView owns its insertion line
/// and consumes the shared drag coordinates; all existing rows inherit
/// `showReorder` and therefore keep their current gesture policy.
property bool reorderDrag: root.showReorder
/// Ends of the list: the reference dims nothing, but a chevron that /// Ends of the list: the reference dims nothing, but a chevron that
/// renders and no-ops is the defect class this round is fixing, so the /// renders and no-ops is the defect class this round is fixing, so the
/// first row's ↑ and the last row's ↓ are drawn disabled instead. /// first row's ↑ and the last row's ↓ are drawn disabled instead.
@@ -129,6 +134,23 @@ Rectangle {
// `playlist add-tracks` as a QOBUZ catalog id — a local row dropped on a // `playlist add-tracks` as a QOBUZ catalog id — a local row dropped on a
// playlist would silently add an unrelated catalog track. // playlist would silently add an unrelated catalog track.
property bool draggable: true property bool draggable: true
/// A live transport guard separate from catalogue availability. The full
/// queue uses it when QConnect is active: incompatible rows stay visible
/// and keep their non-transport menu actions, but cannot play or drag.
property bool playBlocked: false
/// Full queue only: give the now-playing row a persistent themed fill in
/// addition to TrackRow's existing pill/equalizer indicator.
property bool activeBackground: false
/// QueueView can distinguish duplicate ids across history/current/upcoming
/// and nominate the one structural current row as active.
property bool overrideActiveMatch: false
property bool activeMatch: false
/// Optional queue-state marker that replaces the idle number cell.
property string leadingMarkerIcon: ""
/// QueueView needs queue-operation entries rather than the catalog-wide
/// TrackContextMenu inventory. Null preserves the standard model.
property var menuEntriesOverride: null
signal menuActionRequested(string action)
/// Multi-select arm (primitives/TrackRow.slint:58 `multi-select-mode`): /// Multi-select arm (primitives/TrackRow.slint:58 `multi-select-mode`):
/// the leading play/number cell becomes a selection checkbox, the WHOLE /// the leading play/number cell becomes a selection checkbox, the WHOLE
/// row body is a selection target, and the row's own gestures (play, /// row body is a selection target, and the row's own gestures (play,
@@ -409,6 +431,8 @@ Rectangle {
/// as-is to avoid a visible dark-theme stripe shift"). /// as-is to avoid a visible dark-theme stripe shift").
readonly property color rowHoverBg: theme.alphaTiers.length > 0 readonly property color rowHoverBg: theme.alphaTiers.length > 0
? theme.alphaTier(8) : (theme.isDark ? "#14ffffff" : "#14000000") ? theme.alphaTier(8) : (theme.isDark ? "#14ffffff" : "#14000000")
readonly property color rowActiveBg: theme.alphaTiers.length > 0
? theme.alphaTier(10) : (theme.isDark ? "#1affffff" : "#1a000000")
width: parent ? parent.width : 0 width: parent ? parent.width : 0
height: 50 height: 50
@@ -417,7 +441,9 @@ Rectangle {
// does the same on its unavailable rows): a row that lights up under the // does the same on its unavailable rows): a row that lights up under the
// pointer reads as clickable, and this one is not. The zebra stripe stays // pointer reads as clickable, and this one is not. The zebra stripe stays
// — it is structure, not affordance. // — it is structure, not affordance.
color: (hovered && !root.pulledDead) color: (root.activeBackground && root.isActive)
? rowActiveBg
: (hovered && !root.pulledDead && !root.playBlocked)
? rowHoverBg ? rowHoverBg
: (zebra && number % 2 === 0 ? "#07ffffff" : "transparent") : (zebra && number % 2 === 0 ? "#07ffffff" : "transparent")
@@ -443,10 +469,12 @@ Rectangle {
// the second test is inert for every other source and cannot light a row // the second test is inert for every other source and cannot light a row
// by colliding with an id from another space. Mirrors the reference's // by colliding with an id from another space. Mirrors the reference's
// TrackRow.slint:116-118 / TrackPlayCell.slint:85-87. // TrackRow.slint:116-118 / TrackPlayCell.slint:85-87.
readonly property bool isActive: (item.id || "") !== "" readonly property bool isActive: root.overrideActiveMatch
&& (QbzPlayer.npTrackId === (item.id || "") ? root.activeMatch
|| (QbzPlayer.npLocalTrackId !== "" : ((item.id || "") !== ""
&& QbzPlayer.npLocalTrackId === (item.id || ""))) && (QbzPlayer.npTrackId === (item.id || "")
|| (QbzPlayer.npLocalTrackId !== ""
&& QbzPlayer.npLocalTrackId === (item.id || ""))))
// The animated now-playing indicator pref (AppearanceState // The animated now-playing indicator pref (AppearanceState
// .play-indicator-animation; the toggle lives in AppearanceSettings). // .play-indicator-animation; the toggle lives in AppearanceSettings).
@@ -556,7 +584,7 @@ Rectangle {
// should fade. 0.5 is views/purchases/PurchaseListRow.qml's value — // should fade. 0.5 is views/purchases/PurchaseListRow.qml's value —
// the only precedent for this state in the tree, so the two screens // the only precedent for this state in the tree, so the two screens
// agree. // agree.
opacity: root.pulledDead ? 0.5 : 1.0 opacity: (root.pulledDead || root.playBlocked) ? 0.5 : 1.0
// --- Reorder gutter (primitives/TrackRow.slint:270-311) ---------- // --- Reorder gutter (primitives/TrackRow.slint:270-311) ----------
// The LEADING cell on a reorderable list: a 22px column with an // The LEADING cell on a reorderable list: a 22px column with an
@@ -637,7 +665,7 @@ Rectangle {
// A dead row never offers the play disc: the affordance IS the // A dead row never offers the play disc: the affordance IS the
// claim that clicking does something. // claim that clicking does something.
readonly property bool showOverlay: (root.hovered || root.isActive) readonly property bool showOverlay: (root.hovered || root.isActive)
&& !root.pulledDead && !root.pulledDead && !root.playBlocked
// `accent-ring` (:108): the playing row's static form — only with // `accent-ring` (:108): the playing row's static form — only with
// the animation pref OFF; with it ON the eq bars carry the state. // the animation pref OFF; with it ON the eq bars carry the state.
readonly property bool accentRing: !root.playIndicatorAnim readonly property bool accentRing: !root.playIndicatorAnim
@@ -685,6 +713,7 @@ Rectangle {
} }
Text { Text {
visible: !playCell.showOverlay && !root.selectMode && !root.pulledDead visible: !playCell.showOverlay && !root.selectMode && !root.pulledDead
&& root.leadingMarkerIcon === ""
anchors.centerIn: parent anchors.centerIn: parent
text: root.number text: root.number
color: theme.textMuted color: theme.textMuted
@@ -778,7 +807,7 @@ Rectangle {
// :234-243), so disabling the row body is not enough: without // :234-243), so disabling the row body is not enough: without
// the `pulledDead` term, clicking the alert glyph would start // the `pulledDead` term, clicking the alert glyph would start
// a play that cannot succeed. // a play that cannot succeed.
enabled: !root.selectMode && !root.pulledDead enabled: !root.selectMode && !root.pulledDead && !root.playBlocked
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
@@ -828,7 +857,8 @@ Rectangle {
// Muted, not just dimmed: the Row's 0.5 opacity fades the // Muted, not just dimmed: the Row's 0.5 opacity fades the
// whole cell uniformly, and the TITLE is the part that has // whole cell uniformly, and the TITLE is the part that has
// to stop reading as live content. // to stop reading as live content.
color: root.pulledDead ? theme.textMuted : theme.textPrimary color: (root.pulledDead || root.playBlocked)
? theme.textMuted : theme.textPrimary
font.pixelSize: 14 font.pixelSize: 14
font.weight: theme.weightMedium font.weight: theme.weightMedium
elide: Text.ElideRight elide: Text.ElideRight
@@ -944,8 +974,17 @@ Rectangle {
text: root.item.duration || "" text: root.item.duration || ""
color: theme.textMuted color: theme.textMuted
font.pixelSize: 12 font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
QbzIcon {
visible: !playCell.showOverlay && !root.selectMode
&& root.leadingMarkerIcon !== ""
anchors.centerIn: parent
name: root.leadingMarkerIcon
width: 13
height: 13
tintName: "accent"
}
// Quality (92px) — the BARE badge, 1:1 with primitives/TrackRow.slint // Quality (92px) — the BARE badge, 1:1 with primitives/TrackRow.slint
// 578-592: a 92px cell, `alignment: center` on both axes, holding a // 578-592: a 92px cell, `alignment: center` on both axes, holding a
// QualityBadgeFull with `show-icon: false` + `bare: true`. That is the // QualityBadgeFull with `show-icon: false` + `bare: true`. That is the
@@ -1147,6 +1186,8 @@ Rectangle {
} }
function menuModel() { function menuModel() {
if (root.menuEntriesOverride !== null)
return root.menuEntriesOverride
var t = QbzSession.tr var t = QbzSession.tr
var r = QbzSession.trRev var r = QbzSession.trRev
var m = [] var m = []
@@ -1231,6 +1272,15 @@ Rectangle {
} }
function menuAction(a) { function menuAction(a) {
if (root.menuEntriesOverride !== null) {
for (var i = 0; i < root.menuEntriesOverride.length; i++) {
var entry = root.menuEntriesOverride[i]
if (entry.action === a && entry.external === true) {
root.menuActionRequested(a)
return
}
}
}
if (a === "play") root.playRequested() if (a === "play") root.playRequested()
else if (a === "next") root.enqueueRequested("next") else if (a === "next") root.enqueueRequested("next")
else if (a === "later") root.enqueueRequested("later") else if (a === "later") root.enqueueRequested("later")
@@ -1394,7 +1444,8 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.RightButton
// An Arrow over a dead row, whatever the surface's click policy — the // An Arrow over a dead row, whatever the surface's click policy — the
// pointer must not promise a play that is blocked below. // pointer must not promise a play that is blocked below.
cursorShape: (!root.pulledDead && (root.clickPlays || root.selectMode)) cursorShape: (!root.pulledDead && !root.playBlocked
&& (root.clickPlays || root.selectMode))
? Qt.PointingHandCursor : Qt.ArrowCursor ? Qt.PointingHandCursor : Qt.ArrowCursor
// Keep the grab so the enclosing Flickable cannot turn our drag into a // Keep the grab so the enclosing Flickable cannot turn our drag into a
// scroll — see the block above. // scroll — see the block above.
@@ -1416,7 +1467,7 @@ Rectangle {
// later, `onCanceled` fired, and the drag died at roughly the row // later, `onCanceled` fired, and the drag died at roughly the row
// boundary — never reaching the sidebar. Arming at 6px and claiming // boundary — never reaching the sidebar. Arming at 6px and claiming
// the grab there wins the race by construction, because 6 < 10. // the grab there wins the race by construction, because 6 < 10.
preventStealing: (root.showReorder && root.draggable && !root.selectMode) preventStealing: (root.reorderDrag && root.draggable && !root.selectMode)
|| root.dragging || root.dragging
onPressed: function (mouse) { onPressed: function (mouse) {
if (mouse.button === Qt.RightButton) { if (mouse.button === Qt.RightButton) {
@@ -1437,7 +1488,7 @@ Rectangle {
// playlist would add a track that cannot play, which is precisely // playlist would add a track that cannot play, which is precisely
// the state this treatment exists to stop spreading. // the state this treatment exists to stop spreading.
if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable if (!pressed || !(pressedButtons & Qt.LeftButton) || !root.draggable
|| root.selectMode || root.pulledDead) return || root.selectMode || root.pulledDead || root.playBlocked) return
const g = mapToItem(null, mouse.x, mouse.y) const g = mapToItem(null, mouse.x, mouse.y)
const dx = mouse.x - root.downPos.x const dx = mouse.x - root.downPos.x
const dy = mouse.y - root.downPos.y const dy = mouse.y - root.downPos.y
@@ -1458,7 +1509,7 @@ Rectangle {
// rejects it FOR REORDER, where it "separates nothing". Here it // rejects it FOR REORDER, where it "separates nothing". Here it
// separates everything, which is why this arm exists and that one // separates everything, which is why this arm exists and that one
// does not. // does not.
const armed = root.showReorder const armed = root.reorderDrag
? (Math.abs(dx) > 6 || Math.abs(dy) > 6) ? (Math.abs(dx) > 6 || Math.abs(dy) > 6)
: (Math.abs(dx) > 6 && Math.abs(dx) > Math.abs(dy)) : (Math.abs(dx) > 6 && Math.abs(dx) > Math.abs(dy))
if (!root.dragging && armed) { if (!root.dragging && armed) {
@@ -1493,7 +1544,7 @@ Rectangle {
// leading cell, and the bulk bar's "Select all" reaches it // leading cell, and the bulk bar's "Select all" reaches it
// regardless. A row the user cannot tick by clicking but CAN tick // regardless. A row the user cannot tick by clicking but CAN tick
// by hitting the 14px disc is the worse of the two states. // by hitting the 14px disc is the worse of the two states.
if (root.pulledDead) { if (root.pulledDead || root.playBlocked) {
mouse.accepted = true mouse.accepted = true
return return
} }
+14 -6
View File
@@ -231,6 +231,13 @@ Rectangle {
// Animated 3-state width lives inside the component. // Animated 3-state width lives inside the component.
} }
// QueueView temporarily takes the queue drawer's visual slot without
// rewriting the user's logical toggle. Leaving the view therefore
// restores the exact sidebar state that was active before entering it;
// Lyrics remains independent and may stay open beside the full view.
readonly property bool queueSidebarVisible: QbzShell.queueOpen
&& QbzShell.currentView !== "queue-view"
// Right-side panel column — Queue and/or Lyrics, stacked vertically in // Right-side panel column — Queue and/or Lyrics, stacked vertically in
// a shared 300px column (Feishin-style, AppShell.slint:684-707). Each // a shared 300px column (Feishin-style, AppShell.slint:684-707). Each
// is toggled from its bar button and closed from its own X; the column // is toggled from its bar button and closed from its own X; the column
@@ -240,7 +247,8 @@ Rectangle {
anchors.right: parent.right anchors.right: parent.right
anchors.top: header.bottom anchors.top: header.bottom
anchors.bottom: npb.top anchors.bottom: npb.top
width: (QbzShell.queueOpen || QbzShell.lyricsOpen) ? theme.queuePanelWidth : 0 width: (root.queueSidebarVisible || QbzShell.lyricsOpen)
? theme.queuePanelWidth : 0
clip: true clip: true
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
@@ -257,17 +265,17 @@ Rectangle {
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
height: QbzShell.lyricsOpen height: QbzShell.lyricsOpen
? (QbzShell.queueOpen ? parent.height / 2 : 0) ? (root.queueSidebarVisible ? parent.height / 2 : 0)
: parent.height : parent.height
active: QbzShell.queueOpen active: root.queueSidebarVisible
visible: active visible: active
sourceComponent: QueuePanel { } sourceComponent: QueuePanel { }
} }
Rectangle { Rectangle {
visible: QbzShell.queueOpen && QbzShell.lyricsOpen visible: root.queueSidebarVisible && QbzShell.lyricsOpen
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
y: QbzShell.lyricsOpen && QbzShell.queueOpen ? parent.height / 2 : 0 y: QbzShell.lyricsOpen && root.queueSidebarVisible ? parent.height / 2 : 0
height: 1 height: 1
color: theme.borderSubtle color: theme.borderSubtle
} }
@@ -276,7 +284,7 @@ Rectangle {
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
height: QbzShell.lyricsOpen height: QbzShell.lyricsOpen
? (QbzShell.queueOpen ? parent.height / 2 - 1 : parent.height) ? (root.queueSidebarVisible ? parent.height / 2 - 1 : parent.height)
: 0 : 0
visible: QbzShell.lyricsOpen visible: QbzShell.lyricsOpen
} }
@@ -181,6 +181,7 @@ Item {
: v === "settings" ? "../settings/SettingsView.qml" : v === "settings" ? "../settings/SettingsView.qml"
: v === "search" : v === "search"
? (root.kiosk ? "../kiosk/KioskSearch.qml" : "../views/SearchView.qml") ? (root.kiosk ? "../kiosk/KioskSearch.qml" : "../views/SearchView.qml")
: v === "queue-view" ? "../views/QueueView.qml"
: v === "playlist" ? "../views/PlaylistView.qml" : v === "playlist" ? "../views/PlaylistView.qml"
: v === "discoverbrowse" ? "../views/DiscoverBrowseView.qml" : v === "discoverbrowse" ? "../views/DiscoverBrowseView.qml"
: v === "playlistbrowse" ? "../views/PlaylistBrowseView.qml" : v === "playlistbrowse" ? "../views/PlaylistBrowseView.qml"
+7
View File
@@ -769,6 +769,12 @@ Rectangle {
height: 28 height: 28
spacing: 14 spacing: 14
QbzIconButton {
btnSize: 28
iconSize: 15
name: "maximize-2"
onClicked: QbzShell.navigateTo("queue-view")
}
QueueTab { QueueTab {
text: QbzSession.tr("Queue", QbzSession.trRev) text: QbzSession.tr("Queue", QbzSession.trRev)
active: root.tab === 0 active: root.tab === 0
@@ -1400,6 +1406,7 @@ Rectangle {
searchMode: true searchMode: true
expandable: true expandable: true
sm: true sm: true
text: doc.searchQuery || ""
// Bound to the PANEL, never wider: it opens leftward over // Bound to the PANEL, never wider: it opens leftward over
// the four action buttons, exactly like the Slint // the four action buttons, exactly like the Slint
// ExpandableSearch does over its neighbours. // ExpandableSearch does over its neighbours.
+567
View File
@@ -0,0 +1,567 @@
// 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"
Rectangle {
id: root
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
Component.onCompleted: {
QbzQueue.queueExtendedOpened()
artDispatch.restart()
}
Component.onDestruction: QbzQueue.queueExtendedClosed()
onDocChanged: artDispatch.restart()
// ----------------------------- artwork ------------------------------
property var coverMap: ({})
property var askedCovers: ({})
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
if (root.askedCovers[key] !== true)
return
var next = Object.assign({}, root.coverMap)
next[key] = path
root.coverMap = next
}
}
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 asked = Object.assign({}, root.askedCovers)
for (var i = first; i <= last; i++) {
var url = root.rows[i].artUrl || ""
if (url !== "" && asked[url] !== true && !root.coverMap[url]) {
asked[url] = true
urls.push(url)
}
}
root.askedCovers = asked
if (urls.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
}
Timer {
id: artDispatch
interval: 70
repeat: false
onTriggered: root.requestVisibleCovers()
}
// ----------------------------- 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 ""
}
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 (row.isEphemeral !== true) {
if (row.isLocal !== true)
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" })
}
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 === "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)
}
// -------------------------- local reorder ----------------------------
property string dragPhase: ""
property int dragPhaseIndex: -1
property string dragTrackId: ""
property int dropSlot: -1
property real dropLineY: -1
property bool dropHot: false
function beginQueueDrag(row) {
root.dragPhase = row.phase
root.dragPhaseIndex = row.phaseIndex
root.dragTrackId = row.id
root.dropSlot = -1
root.dropHot = false
}
function recomputeDrop() {
if (!QbzShell.dragActive || root.dragTrackId === "" || root.searchActive) {
root.dropHot = false
root.dropSlot = -1
return
}
var p = listHost.mapFromItem(null, QbzShell.dragX, QbzShell.dragY)
if (p.x < 0 || p.x > queueList.width || p.y < 0 || p.y > listHost.height) {
root.dropHot = false
root.dropSlot = -1
return
}
var contentPoint = queueList.mapFromItem(null, QbzShell.dragX, QbzShell.dragY)
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
}
insertion = Math.max(root.firstUpcoming, insertion)
root.dropSlot = Math.max(0, Math.min(root.doc.upcomingCount || 0,
insertion - root.firstUpcoming))
root.dropHot = true
var fullIndex = 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 slot = root.dropSlot
root.dragPhase = ""
root.dragPhaseIndex = -1
root.dragTrackId = ""
root.dropSlot = -1
root.dropHot = false
if (shouldCommit)
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId, slot)
}
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()
}
}
// ------------------------------- UI ---------------------------------
Column {
anchors.fill: parent
spacing: 0
Rectangle {
width: parent.width
height: 54
color: theme.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
Row {
anchors.left: parent.left
anchors.leftMargin: theme.spacingMd
anchors.verticalCenter: parent.verticalCenter
spacing: theme.spacingSm
QbzIconButton {
btnSize: 32
name: "chevron-left"
onClicked: QbzShell.navigateBack()
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.width >= 720
text: QbzSession.tr("Queue View", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.width >= 900
text: root.rows.length + " " + QbzSession.tr("tracks", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
}
}
Row {
anchors.right: parent.right
anchors.rightMargin: theme.spacingMd
anchors.verticalCenter: parent.verticalCenter
spacing: theme.spacingXs
QbzLineEdit {
width: Math.max(130, Math.min(240, root.width
- (root.width >= 720 ? 330 : 210)))
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
onClicked: QbzQueue.queueClear()
}
QbzIconButton {
btnSize: 32
name: "add-to-list"
btnEnabled: root.rows.length > 0
&& !(root.currentRow && root.currentRow.isEphemeral === true)
onClicked: QbzQueue.queueSaveAsPlaylist()
}
QbzIconButton {
btnSize: 32
name: "infinity"
active: root.doc.infinitePlay === true
onClicked: QbzQueue.queueToggleInfinitePlay()
}
QbzIconButton {
id: sleepButton
btnSize: 32
name: "clock"
active: QbzQueue.sleepActive
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
x: -184
y: 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: 32
radius: theme.radiusSm
color: theme.surfaceElevated
border.width: 1
border.color: customMinutes.activeFocus
? theme.accent : theme.borderSubtle
TextInput {
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: 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 - 55
ListView {
id: queueList
anchors.left: parent.left
anchors.right: queueScroll.left
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: artDispatch.restart()
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.coverMap[modelData.artUrl || ""] || ""
})
width: queueList.width
height: (heading !== "" ? 24 : 0) + 50
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
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
zebra: true
artistLink: true
draggable: root.dragAllowed(rowHost.modelData)
reorderDrag: 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.coverMap[rowHost.modelData.artUrl]
skelPhase: (Math.floor(Math.abs(QbzShell.pulseMs) / 900) % 2) === 1
artSettleMs: 2500
onPlayRequested: root.rowPlay(rowHost.modelData)
onBodyDragStarted: root.beginQueueDrag(rowHost.modelData)
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
}
Rectangle {
visible: root.dropHot && root.dropLineY >= 0
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)
}
}
}
}
+48
View File
@@ -1339,6 +1339,54 @@ pub(crate) fn queue_play_upcoming_flat(index: i32) {
spawn(async move { queue_qt::play_upcoming_flat(&runtime, index.max(0) as usize).await }); spawn(async move { queue_qt::play_upcoming_flat(&runtime, index.max(0) as usize).await });
} }
pub(crate) fn queue_extended_opened() {
queue_qt::extended_opened();
let runtime = app();
spawn(async move { queue_qt::publish(&runtime).await });
}
pub(crate) fn queue_extended_play(phase: String, index: i32, expected_id: String) {
let Ok(track_id) = expected_id.parse::<u64>() else {
return;
};
let runtime = app();
spawn(async move {
queue_qt::play_extended(&runtime, &phase, index.max(0) as usize, track_id).await
});
}
pub(crate) fn queue_extended_drop(
phase: String,
index: i32,
expected_id: String,
slot: i32,
) {
let Ok(track_id) = expected_id.parse::<u64>() else {
return;
};
let runtime = app();
spawn(async move {
queue_qt::drop_extended(
&runtime,
&phase,
index.max(0) as usize,
track_id,
slot.max(0) as usize,
)
.await
});
}
pub(crate) fn queue_remove_upcoming_flat(index: i32) {
let runtime = app();
spawn(async move { queue_qt::remove_upcoming_flat(&runtime, index.max(0) as usize).await });
}
pub(crate) fn queue_remove_all_after_flat(index: i32) {
let runtime = app();
spawn(async move { queue_qt::remove_all_after_flat(&runtime, index.max(0) as usize).await });
}
pub(crate) fn queue_remove_upcoming(index: i32) { pub(crate) fn queue_remove_upcoming(index: i32) {
let runtime = app(); let runtime = app();
spawn(async move { queue_qt::remove_upcoming(&runtime, index.max(0) as usize).await }); spawn(async move { queue_qt::remove_upcoming(&runtime, index.max(0) as usize).await });
+1 -1
View File
@@ -23,7 +23,7 @@
//! `playlistbrowse`, `recentalbums`, `mostplayedalbums`, `label`, //! `playlistbrowse`, `recentalbums`, `mostplayedalbums`, `label`,
//! `labelreleases`, `mix`, `mixtapes`, `collections`, `mixtapedetail`, //! `labelreleases`, `mix`, `mixtapes`, `collections`, `mixtapedetail`,
//! `discobuilder`, `blacklist`, `playlistmanager`, `nowplaying`, `scene`, //! `discobuilder`, `blacklist`, `playlistmanager`, `nowplaying`, `scene`,
//! `musician`, `purchases`, `purchase-album`. //! `musician`, `purchases`, `purchase-album`, `queue-view`.
//! //!
//! `scene` (the "artists from the same place" discovery view) and `musician` //! `scene` (the "artists from the same place" discovery view) and `musician`
//! both carry a required context that only their opener has, so neither is in //! both carry a required context that only their opener has, so neither is in
+13 -1
View File
@@ -96,7 +96,7 @@ type Runtime = Arc<AppRuntime<LoggingAdapter>>;
/// QConnect's cloud queue accepts Qobuz catalog ids only. An offline Qobuz /// QConnect's cloud queue accepts Qobuz catalog ids only. An offline Qobuz
/// download is still the same catalog id and is eligible; every server/file /// download is still the same catalog id and is eligible; every server/file
/// source is not, even when its numeric id happens to look like a Qobuz id. /// source is not, even when its numeric id happens to look like a Qobuz id.
fn is_qconnect_queue_track(track: &qbz_models::QueueTrack) -> bool { pub(crate) fn is_qconnect_queue_track(track: &qbz_models::QueueTrack) -> bool {
if track.id == 0 { if track.id == 0 {
return false; return false;
} }
@@ -132,6 +132,17 @@ fn is_qconnect_queue_track(track: &qbz_models::QueueTrack) -> bool {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub(crate) mod publish { pub(crate) mod publish {
use cxx_qt_lib::QString; use cxx_qt_lib::QString;
use std::sync::atomic::{AtomicBool, Ordering};
/// Rust-side mirror of the bridge bit. Queue projections are assembled off
/// the Qt thread, so they cannot read `QbzQConnect.qconnectConnected`
/// directly. Keeping the mirror at the one publish funnel makes the QML
/// greyout and the Rust action guards agree on the same session edge.
static CONNECTED: AtomicBool = AtomicBool::new(false);
pub(crate) fn is_connected() -> bool {
CONNECTED.load(Ordering::SeqCst)
}
/// One device-picker row (the Slint `QconnectDevice` struct), serialized /// One device-picker row (the Slint `QconnectDevice` struct), serialized
/// onto `QbzQConnect.devices_json` by [`devices`] (same JSON precedent as /// onto `QbzQConnect.devices_json` by [`devices`] (same JSON precedent as
@@ -150,6 +161,7 @@ pub(crate) mod publish {
/// `NowPlayingState.qconnect-connected` -> `QbzQConnect.qconnect_connected` /// `NowPlayingState.qconnect-connected` -> `QbzQConnect.qconnect_connected`
/// (the golden ConnectButton badge). /// (the golden ConnectButton badge).
pub(crate) fn connected(connected: bool) { pub(crate) fn connected(connected: bool) {
CONNECTED.store(connected, Ordering::SeqCst);
crate::qconnect_bridge::ui(move |mut b| { crate::qconnect_bridge::ui(move |mut b| {
b.as_mut().set_qconnect_connected(connected); b.as_mut().set_qconnect_connected(connected);
}); });
+71
View File
@@ -38,6 +38,10 @@ pub mod qbz_queue {
// + pagination + #442 section markers). Supersedes `queueModel`. // + pagination + #442 section markers). Supersedes `queueModel`.
#[qproperty(QString, queue_json)] #[qproperty(QString, queue_json)]
// Full chronological queue projection used only by QueueView. It is
// opt-in so ordinary sidebar publishes stay paged and cheap.
#[qproperty(QString, extended_queue_json)]
// --- Immersive coverflow (2026-08-02 immersive-port contract §4.4) -- // --- Immersive coverflow (2026-08-02 immersive-port contract §4.4) --
// `{"index":i32,"tracks":[{id,title,artist,artUrl}]}` over the FULL // `{"index":i32,"tracks":[{id,title,artist,artUrl}]}` over the FULL
// flat queue ([history oldest-first, NOW, upcoming]); `index` = flat // flat queue ([history oldest-first, NOW, upcoming]); `index` = flat
@@ -94,6 +98,29 @@ pub mod qbz_queue {
#[qinvokable] #[qinvokable]
fn queue_play_upcoming_flat(self: Pin<&mut QbzQueue>, index: i32); fn queue_play_upcoming_flat(self: Pin<&mut QbzQueue>, index: i32);
#[qinvokable] #[qinvokable]
fn queue_extended_opened(self: Pin<&mut QbzQueue>);
#[qinvokable]
fn queue_extended_closed(self: Pin<&mut QbzQueue>);
#[qinvokable]
fn queue_extended_play(
self: Pin<&mut QbzQueue>,
phase: QString,
index: i32,
expected_id: QString,
);
#[qinvokable]
fn queue_extended_drop(
self: Pin<&mut QbzQueue>,
phase: QString,
index: i32,
expected_id: QString,
slot: i32,
);
#[qinvokable]
fn queue_remove_upcoming_flat(self: Pin<&mut QbzQueue>, index: i32);
#[qinvokable]
fn queue_remove_all_after_flat(self: Pin<&mut QbzQueue>, index: i32);
#[qinvokable]
fn queue_remove_upcoming(self: Pin<&mut QbzQueue>, index: i32); fn queue_remove_upcoming(self: Pin<&mut QbzQueue>, index: i32);
#[qinvokable] #[qinvokable]
fn queue_remove_all_after(self: Pin<&mut QbzQueue>, index: i32); fn queue_remove_all_after(self: Pin<&mut QbzQueue>, index: i32);
@@ -143,6 +170,7 @@ type QListQVariant = QList<QVariant>;
pub struct QbzQueueRust { pub struct QbzQueueRust {
queue_model: QListQVariant, queue_model: QListQVariant,
queue_json: QString, queue_json: QString,
extended_queue_json: QString,
coverflow_json: QString, coverflow_json: QString,
drop_play_prompt: bool, drop_play_prompt: bool,
sleep_active: bool, sleep_active: bool,
@@ -154,6 +182,9 @@ impl Default for QbzQueueRust {
Self { Self {
queue_model: QListQVariant::default(), queue_model: QListQVariant::default(),
queue_json: QString::from("{}"), queue_json: QString::from("{}"),
extended_queue_json: QString::from(
r#"{"rows":[],"currentIndex":-1,"historyCount":0,"upcomingCount":0,"stopAfterId":"","infinitePlay":false,"searchQuery":""}"#,
),
// Full-shape default (trap 15): QML reads `.tracks.length` in the // Full-shape default (trap 15): QML reads `.tracks.length` in the
// pre-publish frame. // pre-publish frame.
coverflow_json: QString::from(r#"{"index":0,"tracks":[]}"#), coverflow_json: QString::from(r#"{"index":0,"tracks":[]}"#),
@@ -205,6 +236,46 @@ impl qbz_queue::QbzQueue {
crate::queue_play_upcoming_flat(index); crate::queue_play_upcoming_flat(index);
} }
pub fn queue_extended_opened(self: Pin<&mut Self>) {
crate::queue_extended_opened();
}
pub fn queue_extended_closed(self: Pin<&mut Self>) {
crate::queue_qt::extended_closed();
}
pub fn queue_extended_play(
self: Pin<&mut Self>,
phase: QString,
index: i32,
expected_id: QString,
) {
crate::queue_extended_play(phase.to_string(), index, expected_id.to_string());
}
pub fn queue_extended_drop(
self: Pin<&mut Self>,
phase: QString,
index: i32,
expected_id: QString,
slot: i32,
) {
crate::queue_extended_drop(
phase.to_string(),
index,
expected_id.to_string(),
slot,
);
}
pub fn queue_remove_upcoming_flat(self: Pin<&mut Self>, index: i32) {
crate::queue_remove_upcoming_flat(index);
}
pub fn queue_remove_all_after_flat(self: Pin<&mut Self>, index: i32) {
crate::queue_remove_all_after_flat(index);
}
pub fn queue_remove_upcoming(self: Pin<&mut Self>, index: i32) { pub fn queue_remove_upcoming(self: Pin<&mut Self>, index: i32) {
crate::queue_remove_upcoming(index); crate::queue_remove_upcoming(index);
} }
+404 -32
View File
@@ -39,6 +39,8 @@ pub struct QueueRow {
#[serde(rename = "artistId")] #[serde(rename = "artistId")]
pub artist_id: String, pub artist_id: String,
pub album: String, pub album: String,
#[serde(rename = "albumId")]
pub album_id: String,
pub duration: String, pub duration: String,
#[serde(rename = "qualityTier")] #[serde(rename = "qualityTier")]
pub quality_tier: String, pub quality_tier: String,
@@ -54,6 +56,14 @@ pub struct QueueRow {
/// library.db row id, not a Qobuz catalog id. /// library.db row id, not a Qobuz catalog id.
#[serde(rename = "isLocal")] #[serde(rename = "isLocal")]
pub is_local: bool, pub is_local: bool,
/// Origin word carried by the queue track. TrackRow uses this to keep
/// catalog and local id spaces apart in its shared context-menu logic.
pub source: String,
/// Qobuz Connect accepts catalog-backed ids only. The extended queue keeps
/// incompatible rows visible as history/sequence context, but greys and
/// skips them while Connect is active.
#[serde(rename = "qconnectCompatible")]
pub qconnect_compatible: bool,
/// #442 section header drawn ABOVE this row: "" | "next-in-queue" | /// #442 section header drawn ABOVE this row: "" | "next-in-queue" |
/// "next-up" (only on the unfiltered list). /// "next-up" (only on the unfiltered list).
pub section: String, pub section: String,
@@ -125,6 +135,35 @@ struct ViewState {
page: usize, page: usize,
} }
/// One row in the full queue projection. `phase_index` is deliberately in the
/// core's own coordinate space: history is most-recent-first there even though
/// this view renders it oldest-first, while upcoming is already play order.
#[derive(Clone, Serialize)]
struct ExtendedQueueRow {
#[serde(flatten)]
row: QueueRow,
phase: &'static str,
#[serde(rename = "phaseIndex")]
phase_index: usize,
}
#[derive(Default, Serialize)]
struct ExtendedQueueDoc {
rows: Vec<ExtendedQueueRow>,
#[serde(rename = "currentIndex")]
current_index: i32,
#[serde(rename = "historyCount")]
history_count: usize,
#[serde(rename = "upcomingCount")]
upcoming_count: usize,
#[serde(rename = "stopAfterId")]
stop_after_id: String,
#[serde(rename = "infinitePlay")]
infinite_play: bool,
#[serde(rename = "searchQuery")]
search_query: String,
}
static VIEW: Mutex<ViewState> = Mutex::new(ViewState { static VIEW: Mutex<ViewState> = Mutex::new(ViewState {
search: String::new(), search: String::new(),
page: 0, page: 0,
@@ -205,6 +244,7 @@ fn row_from(track: &QueueTrack, favorites: &HashSet<u64>) -> QueueRow {
artist: track.artist.clone(), artist: track.artist.clone(),
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(), artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
album: track.album.clone(), album: track.album.clone(),
album_id: track.album_id.clone().unwrap_or_default(),
duration: fmt_duration(track.duration_secs), duration: fmt_duration(track.duration_secs),
quality_tier: tier, quality_tier: tier,
quality_detail: crate::home_qt::quality_detail_from_parts( quality_detail: crate::home_qt::quality_detail_from_parts(
@@ -215,6 +255,11 @@ fn row_from(track: &QueueTrack, favorites: &HashSet<u64>) -> QueueRow {
art_url: track.artwork_url.clone().unwrap_or_default(), art_url: track.artwork_url.clone().unwrap_or_default(),
is_favorite, is_favorite,
is_local: track.is_local, is_local: track.is_local,
source: track
.source
.clone()
.unwrap_or_else(|| if track.is_local { "local" } else { "qobuz" }.to_string()),
qconnect_compatible: crate::qconnect_qt::is_qconnect_queue_track(track),
section: String::new(), section: String::new(),
// Both halves of the reference's test (`queue.rs:148`): the queue // Both halves of the reference's test (`queue.rs:148`): the queue
// track's own source tag, and the id-range check for a row that // track's own source tag, and the id-range check for a row that
@@ -369,6 +414,104 @@ fn publish_coverflow(state: &qbz_models::QueueState) {
/// unconditional, this is the function that needs the cache. /// unconditional, this is the function that needs the cache.
static LAST_MINI_QUEUE_JSON: Mutex<String> = Mutex::new(String::new()); static LAST_MINI_QUEUE_JSON: Mutex<String> = Mutex::new(String::new());
/// The full projection is intentionally built only while its view exists. A
/// restored queue may contain thousands of tracks; the sidebar document stays
/// paged at 40 and should not pay to serialize the full sequence in the normal
/// shell path.
static EXTENDED_OPEN: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static LAST_EXTENDED_JSON: Mutex<String> = Mutex::new(String::new());
fn extended_rows(
state: &qbz_models::QueueState,
favorites: &HashSet<u64>,
query: &str,
) -> (Vec<ExtendedQueueRow>, i32) {
let mut rows = Vec::with_capacity(
state.history.len() + usize::from(state.current_track.is_some()) + state.upcoming.len(),
);
let mut current_index = -1;
let mut push = |track: &QueueTrack, phase, phase_index, section: &'static str| {
if query.is_empty() || matches_query(track, query) {
if phase == "current" {
current_index = rows.len() as i32;
}
let mut row = row_from(track, favorites);
row.section = section.to_string();
rows.push(ExtendedQueueRow {
row,
phase,
phase_index,
});
}
};
// Core history is newest-first; the view reads as one chronological
// sequence, so the oldest played track is first.
for (index, track) in state.history.iter().enumerate().rev() {
push(track, "history", index, "");
}
if let Some(track) = state.current_track.as_ref() {
push(track, "current", 0, "");
}
for (index, track) in state.upcoming.iter().enumerate() {
let section = if query.is_empty() && state.manual_next_count > 0 && index == 0 {
"next-in-queue"
} else if query.is_empty() && index == state.manual_next_count {
"next-up"
} else {
""
};
push(track, "upcoming", index, section);
}
(rows, current_index)
}
fn publish_extended(
state: &qbz_models::QueueState,
favorites: &HashSet<u64>,
stop_after_id: &str,
search: &str,
) {
use std::sync::atomic::Ordering;
if !EXTENDED_OPEN.load(Ordering::SeqCst) {
return;
}
let query = search.trim().to_lowercase();
let (rows, current_index) = extended_rows(state, favorites, &query);
let doc = ExtendedQueueDoc {
rows,
current_index,
history_count: state.history.len(),
upcoming_count: state.upcoming.len(),
stop_after_id: stop_after_id.to_string(),
infinite_play: is_infinite_play(),
search_query: search.to_string(),
};
let json = serde_json::to_string(&doc).unwrap_or_else(|_| {
r#"{"rows":[],"currentIndex":-1,"historyCount":0,"upcomingCount":0,"stopAfterId":"","infinitePlay":false,"searchQuery":""}"#.into()
});
let mut last = LAST_EXTENDED_JSON.lock().unwrap();
if *last == json {
return;
}
last.clear();
last.push_str(&json);
crate::queue_bridge::ui(move |mut b| {
b.as_mut()
.set_extended_queue_json(QString::from(json.as_str()));
});
}
pub fn extended_opened() {
EXTENDED_OPEN.store(true, std::sync::atomic::Ordering::SeqCst);
}
pub fn extended_closed() {
EXTENDED_OPEN.store(false, std::sync::atomic::Ordering::SeqCst);
}
/// Build + publish the miniplayer's navigable queue document /// Build + publish the miniplayer's navigable queue document
/// (`{"currentId":"…","rows":[…]}` — `mini_qt::mini_queue_doc`). /// (`{"currentId":"…","rows":[…]}` — `mini_qt::mini_queue_doc`).
/// ///
@@ -536,6 +679,7 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
.await .await
.map(|id| id.to_string()) .map(|id| id.to_string())
.unwrap_or_default(); .unwrap_or_default();
publish_extended(&state, &favorites, &stop_after_id, &search);
let doc = QueueDoc { let doc = QueueDoc {
has_current: current.is_some(), has_current: current.is_some(),
@@ -641,6 +785,88 @@ pub async fn play_upcoming_flat(runtime: &Arc<AppRuntime<LoggingAdapter>>, upcom
} }
} }
async fn play_extended_upcoming(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
upcoming_index: usize,
expected_id: u64,
) {
let state = runtime.core().get_queue_state_full().await;
let Some(track) = state.upcoming.get(upcoming_index) else {
return;
};
if track.id != expected_id {
log::warn!("[qbz-qt] queue: upcoming changed during extended activation");
return;
}
if crate::qconnect_qt::publish::is_connected()
&& !crate::qconnect_qt::is_qconnect_queue_track(track)
{
log::info!(
"[qbz-qt] queue: skipped QConnect-incompatible upcoming row {}",
track.id
);
return;
}
if let Some(service) = crate::qconnect_qt::service() {
match service.play_remote_renderer_track_if_active(track.id).await {
Ok(true) => return,
Ok(false) => {}
Err(error) => {
log::warn!("[qbz-qt] queue: extended remote play failed: {error}");
return;
}
}
}
play_upcoming_flat(runtime, upcoming_index).await;
}
/// Activate a row from the chronological projection. Upcoming keeps its queue-
/// wide index. A played row is cloned back into slot zero, then activated, so
/// the remaining upcoming sequence survives instead of being replaced by the
/// History tab's single-track queue.
pub async fn play_extended(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
phase: &str,
phase_index: usize,
expected_id: u64,
) {
match phase {
"upcoming" => {
play_extended_upcoming(runtime, phase_index, expected_id).await;
}
"history" => {
let state = runtime.core().get_queue_state_full().await;
let Some(track) = state.history.get(phase_index).cloned() else {
return;
};
if track.id != expected_id {
log::warn!("[qbz-qt] queue: history changed during activation");
return;
}
if crate::qconnect_qt::publish::is_connected()
&& !crate::qconnect_qt::is_qconnect_queue_track(&track)
{
log::info!("[qbz-qt] queue: skipped QConnect-incompatible history row {}", track.id);
return;
}
match insert_queue_track_at(runtime, track.clone(), 0).await {
Ok(InsertOutcome::Remote) => {
if let Some(service) = crate::qconnect_qt::service() {
if let Err(error) = service.play_remote_renderer_track_if_active(track.id).await {
log::warn!("[qbz-qt] queue: requeued remote history play failed: {error}");
}
}
}
Ok(InsertOutcome::Local) => {
play_extended_upcoming(runtime, 0, track.id).await;
}
Err(error) => log::warn!("[qbz-qt] queue: requeue history failed: {error}"),
}
}
_ => {}
}
}
pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) { pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else { let Some(&upcoming_index) = current_page_indices(runtime).await.get(page_index) else {
log::warn!("[qbz-qt] queue: remove_upcoming {page_index} out of range"); log::warn!("[qbz-qt] queue: remove_upcoming {page_index} out of range");
@@ -650,6 +876,18 @@ pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_ind
publish(runtime).await; publish(runtime).await;
} }
pub async fn remove_upcoming_flat(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
upcoming_index: usize,
) {
let state = runtime.core().get_queue_state_full().await;
if upcoming_index >= state.upcoming.len() {
return;
}
runtime.core().remove_upcoming_track(upcoming_index).await;
publish(runtime).await;
}
/// Drop every UPCOMING row that cannot play, and report how many went. /// Drop every UPCOMING row that cannot play, and report how many went.
/// ///
/// The last half of "an unavailable track is never blocking": it is skipped, /// The last half of "an unavailable track is never blocking": it is skipped,
@@ -698,6 +936,18 @@ pub async fn remove_all_after(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_in
publish(runtime).await; publish(runtime).await;
} }
pub async fn remove_all_after_flat(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
upcoming_index: usize,
) {
let state = runtime.core().get_queue_state_full().await;
if upcoming_index >= state.upcoming.len() {
return;
}
runtime.core().remove_upcoming_after(upcoming_index).await;
publish(runtime).await;
}
/// Drag reorder — move the PAGE-LOCAL row `from_page` to the insertion slot /// Drag reorder — move the PAGE-LOCAL row `from_page` to the insertion slot
/// `to_slot` (0..=page_len), the contract of `QueueSidebar.slint`'s /// `to_slot` (0..=page_len), the contract of `QueueSidebar.slint`'s
/// `QueueState.reorder`. Both ends resolve to QUEUE-WIDE upcoming indices /// `QueueState.reorder`. Both ends resolve to QUEUE-WIDE upcoming indices
@@ -755,6 +1005,111 @@ pub async fn move_track(
publish(runtime).await; publish(runtime).await;
} }
/// Full-view reorder: both coordinates are queue-wide upcoming positions, so
/// no sidebar page/search state participates.
pub async fn move_track_flat(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
from: usize,
to_slot: usize,
) {
let state = runtime.core().get_queue_state_full().await;
if from >= state.upcoming.len() || to_slot > state.upcoming.len() {
return;
}
if from == to_slot || from + 1 == to_slot {
return;
}
if let Some(service) = crate::qconnect_qt::service() {
match service.reorder_upcoming_if_remote(from, to_slot).await {
Ok(true) => return,
Ok(false) => {}
Err(error) => {
log::warn!("[qbz-qt] queue: extended reorder handoff failed: {error}");
return;
}
}
}
runtime.core().move_track(from, to_slot).await;
publish(runtime).await;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InsertOutcome {
Local,
Remote,
}
async fn insert_queue_track_at(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track: QueueTrack,
to_slot: usize,
) -> Result<InsertOutcome, String> {
if let Some(service) = crate::qconnect_qt::service() {
if service
.insert_at_slot_on_peer_if_active(track.id, track.source.as_deref(), to_slot)
.await
{
return Ok(InsertOutcome::Remote);
}
}
let added_castable =
crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&track));
runtime.core().add_track(track).await;
let upcoming_len = runtime.core().get_queue_state_full().await.upcoming.len();
if upcoming_len > 0 {
let from = upcoming_len - 1;
let slot = to_slot.min(from);
if from != slot {
runtime.core().move_track(from, slot).await;
}
}
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
publish(runtime).await;
Ok(InsertOutcome::Local)
}
/// Commit a drag from the chronological view. Upcoming rows move; history rows
/// are reinserted (not removed from history) at the dropped upcoming slot.
pub async fn drop_extended(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
phase: &str,
phase_index: usize,
expected_id: u64,
to_slot: usize,
) {
match phase {
"upcoming" => {
let state = runtime.core().get_queue_state_full().await;
if state.upcoming.get(phase_index).map(|track| track.id) != Some(expected_id) {
log::warn!("[qbz-qt] queue: upcoming changed during extended drag");
return;
}
move_track_flat(runtime, phase_index, to_slot).await;
}
"history" => {
let state = runtime.core().get_queue_state_full().await;
let Some(track) = state.history.get(phase_index).cloned() else {
return;
};
if track.id != expected_id {
log::warn!("[qbz-qt] queue: history changed during extended drag");
return;
}
if crate::qconnect_qt::publish::is_connected()
&& !crate::qconnect_qt::is_qconnect_queue_track(&track)
{
log::info!("[qbz-qt] queue: skipped QConnect-incompatible history drag {}", track.id);
return;
}
if let Err(error) = insert_queue_track_at(runtime, track, to_slot).await {
log::warn!("[qbz-qt] queue: extended history drop failed: {error}");
}
}
_ => {}
}
}
/// Ask the panel whether to start playing after an empty-queue drop, or clear /// Ask the panel whether to start playing after an empty-queue drop, or clear
/// the ask. Split from the insert so a MULTI-row drop raises exactly one /// the ask. Split from the insert so a MULTI-row drop raises exactly one
/// prompt, after the last row has landed. /// prompt, after the last row has landed.
@@ -817,21 +1172,6 @@ pub async fn insert_dragged_track(
return Ok(()); return Ok(());
}; };
// QConnect CONTROLLER mode (contract §7): the peer owns the queue, so the
// insert is routed there — AT THE DROPPED POSITION. `CtrlSrvrQueueInsertTracks`
// carries `insert_after`, the same field play-next and play-later steer
// with, so the slot survives the routing instead of collapsing to an
// append. `slot` is already in the visible-upcoming space the peer
// projection uses, so it crosses unchanged.
if let Some(svc) = crate::qconnect_qt::service() {
if svc
.insert_at_slot_on_peer_if_active(track_id, qt.source.as_deref(), to_slot)
.await
{
return Ok(());
}
}
// Resolve the drop slot BEFORE the add, while the page still describes the // Resolve the drop slot BEFORE the add, while the page still describes the
// queue the user was looking at. Past the last visible row means "after // queue the user was looking at. Past the last visible row means "after
// everything on this page". // everything on this page".
@@ -844,23 +1184,7 @@ pub async fn insert_dragged_track(
}, },
}; };
let added_castable = crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&qt)); let _ = insert_queue_track_at(runtime, qt, to_upcoming).await?;
runtime.core().add_track(qt).await;
// The appended row is the last of upcoming; re-read rather than assume,
// because `add_track` lands relative to `tracks` and the upcoming window
// depends on `current_index`.
let upcoming_len = runtime.core().get_queue_state_full().await.upcoming.len();
if upcoming_len == 0 {
return Ok(());
}
let from_upcoming = upcoming_len - 1;
if from_upcoming != to_upcoming {
runtime.core().move_track(from_upcoming, to_upcoming).await;
}
crate::playback_qt::sync_qconnect_after_add(added_castable).await;
publish(runtime).await;
Ok(()) Ok(())
} }
@@ -1180,4 +1504,52 @@ mod tests {
let (flat4, _) = coverflow_flat(&h1, Some(&cur), &up_swap); let (flat4, _) = coverflow_flat(&h1, Some(&cur), &up_swap);
assert_ne!(coverflow_seq_hash(&flat4), coverflow_seq_hash(&flat3)); assert_ne!(coverflow_seq_hash(&flat4), coverflow_seq_hash(&flat3));
} }
#[test]
fn extended_projection_is_chronological_and_keeps_core_indices() {
let state = qbz_models::QueueState {
current_track: Some(track(4, "now")),
current_index: Some(3),
upcoming: vec![track(5, "manual"), track(6, "source")],
history: vec![track(3, "h-new"), track(2, "h-mid"), track(1, "h-old")],
shuffle: false,
repeat: qbz_models::RepeatMode::Off,
total_tracks: 6,
stop_after_track_id: None,
manual_next_count: 1,
};
let (rows, current_index) = extended_rows(&state, &HashSet::new(), "");
let ids: Vec<&str> = rows.iter().map(|row| row.row.id.as_str()).collect();
assert_eq!(ids, ["1", "2", "3", "4", "5", "6"]);
assert_eq!(current_index, 3);
assert_eq!(rows[0].phase, "history");
assert_eq!(rows[0].phase_index, 2);
assert_eq!(rows[2].phase_index, 0);
assert_eq!(rows[4].row.section, "next-in-queue");
assert_eq!(rows[5].row.section, "next-up");
}
#[test]
fn extended_search_filters_all_phases_without_reindexing_actions() {
let state = qbz_models::QueueState {
current_track: Some(track(2, "current miss")),
current_index: Some(1),
upcoming: vec![track(3, "needle upcoming")],
history: vec![track(1, "needle history")],
shuffle: false,
repeat: qbz_models::RepeatMode::Off,
total_tracks: 3,
stop_after_track_id: None,
manual_next_count: 0,
};
let (rows, current_index) =
extended_rows(&state, &HashSet::new(), "needle");
assert_eq!(current_index, -1);
assert_eq!(rows.len(), 2);
assert_eq!((rows[0].phase, rows[0].phase_index), ("history", 0));
assert_eq!((rows[1].phase, rows[1].phase_index), ("upcoming", 0));
assert!(rows.iter().all(|row| row.row.section.is_empty()));
}
} }