feat(queue): add extended queue view
cx
This commit is contained in:
11 files changed
+1194
-56
No files matched your search
@@ -602,6 +602,7 @@ fn main() {
|
||||
"qml/views/LocalLibraryView.qml",
|
||||
"qml/views/PlaylistRail.qml",
|
||||
"qml/views/PlaylistView.qml",
|
||||
"qml/views/QueueView.qml",
|
||||
"qml/views/SearchView.qml",
|
||||
"qml/views/SectionRail.qml",
|
||||
"qml/shell/CastPicker.qml",
|
||||
|
||||
@@ -118,6 +118,11 @@ Rectangle {
|
||||
/// 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.
|
||||
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
|
||||
/// 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.
|
||||
@@ -129,6 +134,23 @@ Rectangle {
|
||||
// `playlist add-tracks` as a QOBUZ catalog id — a local row dropped on a
|
||||
// playlist would silently add an unrelated catalog track.
|
||||
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`):
|
||||
/// the leading play/number cell becomes a selection checkbox, the WHOLE
|
||||
/// 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").
|
||||
readonly property color rowHoverBg: theme.alphaTiers.length > 0
|
||||
? 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
|
||||
height: 50
|
||||
@@ -417,7 +441,9 @@ Rectangle {
|
||||
// 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
|
||||
// — it is structure, not affordance.
|
||||
color: (hovered && !root.pulledDead)
|
||||
color: (root.activeBackground && root.isActive)
|
||||
? rowActiveBg
|
||||
: (hovered && !root.pulledDead && !root.playBlocked)
|
||||
? rowHoverBg
|
||||
: (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
|
||||
// by colliding with an id from another space. Mirrors the reference's
|
||||
// TrackRow.slint:116-118 / TrackPlayCell.slint:85-87.
|
||||
readonly property bool isActive: (item.id || "") !== ""
|
||||
&& (QbzPlayer.npTrackId === (item.id || "")
|
||||
|| (QbzPlayer.npLocalTrackId !== ""
|
||||
&& QbzPlayer.npLocalTrackId === (item.id || "")))
|
||||
readonly property bool isActive: root.overrideActiveMatch
|
||||
? root.activeMatch
|
||||
: ((item.id || "") !== ""
|
||||
&& (QbzPlayer.npTrackId === (item.id || "")
|
||||
|| (QbzPlayer.npLocalTrackId !== ""
|
||||
&& QbzPlayer.npLocalTrackId === (item.id || ""))))
|
||||
|
||||
// The animated now-playing indicator pref (AppearanceState
|
||||
// .play-indicator-animation; the toggle lives in AppearanceSettings).
|
||||
@@ -556,7 +584,7 @@ Rectangle {
|
||||
// should fade. 0.5 is views/purchases/PurchaseListRow.qml's value —
|
||||
// the only precedent for this state in the tree, so the two screens
|
||||
// agree.
|
||||
opacity: root.pulledDead ? 0.5 : 1.0
|
||||
opacity: (root.pulledDead || root.playBlocked) ? 0.5 : 1.0
|
||||
|
||||
// --- Reorder gutter (primitives/TrackRow.slint:270-311) ----------
|
||||
// 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
|
||||
// claim that clicking does something.
|
||||
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
|
||||
// the animation pref OFF; with it ON the eq bars carry the state.
|
||||
readonly property bool accentRing: !root.playIndicatorAnim
|
||||
@@ -685,6 +713,7 @@ Rectangle {
|
||||
}
|
||||
Text {
|
||||
visible: !playCell.showOverlay && !root.selectMode && !root.pulledDead
|
||||
&& root.leadingMarkerIcon === ""
|
||||
anchors.centerIn: parent
|
||||
text: root.number
|
||||
color: theme.textMuted
|
||||
@@ -778,7 +807,7 @@ Rectangle {
|
||||
// :234-243), so disabling the row body is not enough: without
|
||||
// the `pulledDead` term, clicking the alert glyph would start
|
||||
// a play that cannot succeed.
|
||||
enabled: !root.selectMode && !root.pulledDead
|
||||
enabled: !root.selectMode && !root.pulledDead && !root.playBlocked
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
@@ -828,7 +857,8 @@ Rectangle {
|
||||
// Muted, not just dimmed: the Row's 0.5 opacity fades the
|
||||
// whole cell uniformly, and the TITLE is the part that has
|
||||
// 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.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
@@ -944,8 +974,17 @@ Rectangle {
|
||||
text: root.item.duration || ""
|
||||
color: theme.textMuted
|
||||
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
|
||||
// 578-592: a 92px cell, `alignment: center` on both axes, holding a
|
||||
// QualityBadgeFull with `show-icon: false` + `bare: true`. That is the
|
||||
@@ -1147,6 +1186,8 @@ Rectangle {
|
||||
}
|
||||
|
||||
function menuModel() {
|
||||
if (root.menuEntriesOverride !== null)
|
||||
return root.menuEntriesOverride
|
||||
var t = QbzSession.tr
|
||||
var r = QbzSession.trRev
|
||||
var m = []
|
||||
@@ -1231,6 +1272,15 @@ Rectangle {
|
||||
}
|
||||
|
||||
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()
|
||||
else if (a === "next") root.enqueueRequested("next")
|
||||
else if (a === "later") root.enqueueRequested("later")
|
||||
@@ -1394,7 +1444,8 @@ Rectangle {
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
// An Arrow over a dead row, whatever the surface's click policy — the
|
||||
// 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
|
||||
// Keep the grab so the enclosing Flickable cannot turn our drag into a
|
||||
// scroll — see the block above.
|
||||
@@ -1416,7 +1467,7 @@ Rectangle {
|
||||
// later, `onCanceled` fired, and the drag died at roughly the row
|
||||
// boundary — never reaching the sidebar. Arming at 6px and claiming
|
||||
// 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
|
||||
onPressed: function (mouse) {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
@@ -1437,7 +1488,7 @@ Rectangle {
|
||||
// playlist would add a track that cannot play, which is precisely
|
||||
// the state this treatment exists to stop spreading.
|
||||
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 dx = mouse.x - root.downPos.x
|
||||
const dy = mouse.y - root.downPos.y
|
||||
@@ -1458,7 +1509,7 @@ Rectangle {
|
||||
// rejects it FOR REORDER, where it "separates nothing". Here it
|
||||
// separates everything, which is why this arm exists and that one
|
||||
// does not.
|
||||
const armed = root.showReorder
|
||||
const armed = root.reorderDrag
|
||||
? (Math.abs(dx) > 6 || Math.abs(dy) > 6)
|
||||
: (Math.abs(dx) > 6 && Math.abs(dx) > Math.abs(dy))
|
||||
if (!root.dragging && armed) {
|
||||
@@ -1493,7 +1544,7 @@ Rectangle {
|
||||
// leading cell, and the bulk bar's "Select all" reaches it
|
||||
// regardless. A row the user cannot tick by clicking but CAN tick
|
||||
// by hitting the 14px disc is the worse of the two states.
|
||||
if (root.pulledDead) {
|
||||
if (root.pulledDead || root.playBlocked) {
|
||||
mouse.accepted = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -231,6 +231,13 @@ Rectangle {
|
||||
// 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
|
||||
// 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
|
||||
@@ -240,7 +247,8 @@ Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: header.bottom
|
||||
anchors.bottom: npb.top
|
||||
width: (QbzShell.queueOpen || QbzShell.lyricsOpen) ? theme.queuePanelWidth : 0
|
||||
width: (root.queueSidebarVisible || QbzShell.lyricsOpen)
|
||||
? theme.queuePanelWidth : 0
|
||||
clip: true
|
||||
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
|
||||
|
||||
@@ -257,17 +265,17 @@ Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
height: QbzShell.lyricsOpen
|
||||
? (QbzShell.queueOpen ? parent.height / 2 : 0)
|
||||
? (root.queueSidebarVisible ? parent.height / 2 : 0)
|
||||
: parent.height
|
||||
active: QbzShell.queueOpen
|
||||
active: root.queueSidebarVisible
|
||||
visible: active
|
||||
sourceComponent: QueuePanel { }
|
||||
}
|
||||
Rectangle {
|
||||
visible: QbzShell.queueOpen && QbzShell.lyricsOpen
|
||||
visible: root.queueSidebarVisible && QbzShell.lyricsOpen
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
y: QbzShell.lyricsOpen && QbzShell.queueOpen ? parent.height / 2 : 0
|
||||
y: QbzShell.lyricsOpen && root.queueSidebarVisible ? parent.height / 2 : 0
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
@@ -276,7 +284,7 @@ Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: QbzShell.lyricsOpen
|
||||
? (QbzShell.queueOpen ? parent.height / 2 - 1 : parent.height)
|
||||
? (root.queueSidebarVisible ? parent.height / 2 - 1 : parent.height)
|
||||
: 0
|
||||
visible: QbzShell.lyricsOpen
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ Item {
|
||||
: v === "settings" ? "../settings/SettingsView.qml"
|
||||
: v === "search"
|
||||
? (root.kiosk ? "../kiosk/KioskSearch.qml" : "../views/SearchView.qml")
|
||||
: v === "queue-view" ? "../views/QueueView.qml"
|
||||
: v === "playlist" ? "../views/PlaylistView.qml"
|
||||
: v === "discoverbrowse" ? "../views/DiscoverBrowseView.qml"
|
||||
: v === "playlistbrowse" ? "../views/PlaylistBrowseView.qml"
|
||||
|
||||
@@ -769,6 +769,12 @@ Rectangle {
|
||||
height: 28
|
||||
spacing: 14
|
||||
|
||||
QbzIconButton {
|
||||
btnSize: 28
|
||||
iconSize: 15
|
||||
name: "maximize-2"
|
||||
onClicked: QbzShell.navigateTo("queue-view")
|
||||
}
|
||||
QueueTab {
|
||||
text: QbzSession.tr("Queue", QbzSession.trRev)
|
||||
active: root.tab === 0
|
||||
@@ -1400,6 +1406,7 @@ Rectangle {
|
||||
searchMode: true
|
||||
expandable: true
|
||||
sm: true
|
||||
text: doc.searchQuery || ""
|
||||
// Bound to the PANEL, never wider: it opens leftward over
|
||||
// the four action buttons, exactly like the Slint
|
||||
// ExpandableSearch does over its neighbours.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
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) {
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::remove_upcoming(&runtime, index.max(0) as usize).await });
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! `playlistbrowse`, `recentalbums`, `mostplayedalbums`, `label`,
|
||||
//! `labelreleases`, `mix`, `mixtapes`, `collections`, `mixtapedetail`,
|
||||
//! `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`
|
||||
//! both carry a required context that only their opener has, so neither is in
|
||||
|
||||
@@ -96,7 +96,7 @@ type Runtime = Arc<AppRuntime<LoggingAdapter>>;
|
||||
/// 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
|
||||
/// 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 {
|
||||
return false;
|
||||
}
|
||||
@@ -132,6 +132,17 @@ fn is_qconnect_queue_track(track: &qbz_models::QueueTrack) -> bool {
|
||||
// ---------------------------------------------------------------------------
|
||||
pub(crate) mod publish {
|
||||
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
|
||||
/// onto `QbzQConnect.devices_json` by [`devices`] (same JSON precedent as
|
||||
@@ -150,6 +161,7 @@ pub(crate) mod publish {
|
||||
/// `NowPlayingState.qconnect-connected` -> `QbzQConnect.qconnect_connected`
|
||||
/// (the golden ConnectButton badge).
|
||||
pub(crate) fn connected(connected: bool) {
|
||||
CONNECTED.store(connected, Ordering::SeqCst);
|
||||
crate::qconnect_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_qconnect_connected(connected);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,10 @@ pub mod qbz_queue {
|
||||
// + pagination + #442 section markers). Supersedes `queueModel`.
|
||||
#[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) --
|
||||
// `{"index":i32,"tracks":[{id,title,artist,artUrl}]}` over the FULL
|
||||
// flat queue ([history oldest-first, NOW, upcoming]); `index` = flat
|
||||
@@ -94,6 +98,29 @@ pub mod qbz_queue {
|
||||
#[qinvokable]
|
||||
fn queue_play_upcoming_flat(self: Pin<&mut QbzQueue>, index: i32);
|
||||
#[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);
|
||||
#[qinvokable]
|
||||
fn queue_remove_all_after(self: Pin<&mut QbzQueue>, index: i32);
|
||||
@@ -143,6 +170,7 @@ type QListQVariant = QList<QVariant>;
|
||||
pub struct QbzQueueRust {
|
||||
queue_model: QListQVariant,
|
||||
queue_json: QString,
|
||||
extended_queue_json: QString,
|
||||
coverflow_json: QString,
|
||||
drop_play_prompt: bool,
|
||||
sleep_active: bool,
|
||||
@@ -154,6 +182,9 @@ impl Default for QbzQueueRust {
|
||||
Self {
|
||||
queue_model: QListQVariant::default(),
|
||||
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
|
||||
// pre-publish frame.
|
||||
coverflow_json: QString::from(r#"{"index":0,"tracks":[]}"#),
|
||||
@@ -205,6 +236,46 @@ impl qbz_queue::QbzQueue {
|
||||
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) {
|
||||
crate::queue_remove_upcoming(index);
|
||||
}
|
||||
|
||||
+404
-32
@@ -39,6 +39,8 @@ pub struct QueueRow {
|
||||
#[serde(rename = "artistId")]
|
||||
pub artist_id: String,
|
||||
pub album: String,
|
||||
#[serde(rename = "albumId")]
|
||||
pub album_id: String,
|
||||
pub duration: String,
|
||||
#[serde(rename = "qualityTier")]
|
||||
pub quality_tier: String,
|
||||
@@ -54,6 +56,14 @@ pub struct QueueRow {
|
||||
/// library.db row id, not a Qobuz catalog id.
|
||||
#[serde(rename = "isLocal")]
|
||||
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" |
|
||||
/// "next-up" (only on the unfiltered list).
|
||||
pub section: String,
|
||||
@@ -125,6 +135,35 @@ struct ViewState {
|
||||
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 {
|
||||
search: String::new(),
|
||||
page: 0,
|
||||
@@ -205,6 +244,7 @@ fn row_from(track: &QueueTrack, favorites: &HashSet<u64>) -> QueueRow {
|
||||
artist: track.artist.clone(),
|
||||
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
album: track.album.clone(),
|
||||
album_id: track.album_id.clone().unwrap_or_default(),
|
||||
duration: fmt_duration(track.duration_secs),
|
||||
quality_tier: tier,
|
||||
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(),
|
||||
is_favorite,
|
||||
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(),
|
||||
// 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
|
||||
@@ -369,6 +414,104 @@ fn publish_coverflow(state: &qbz_models::QueueState) {
|
||||
/// unconditional, this is the function that needs the cache.
|
||||
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
|
||||
/// (`{"currentId":"…","rows":[…]}` — `mini_qt::mini_queue_doc`).
|
||||
///
|
||||
@@ -536,6 +679,7 @@ pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
.await
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default();
|
||||
publish_extended(&state, &favorites, &stop_after_id, &search);
|
||||
|
||||
let doc = QueueDoc {
|
||||
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) {
|
||||
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");
|
||||
@@ -650,6 +876,18 @@ pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_ind
|
||||
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.
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
|
||||
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
|
||||
/// `to_slot` (0..=page_len), the contract of `QueueSidebar.slint`'s
|
||||
/// `QueueState.reorder`. Both ends resolve to QUEUE-WIDE upcoming indices
|
||||
@@ -755,6 +1005,111 @@ pub async fn move_track(
|
||||
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
|
||||
/// the ask. Split from the insert so a MULTI-row drop raises exactly one
|
||||
/// prompt, after the last row has landed.
|
||||
@@ -817,21 +1172,6 @@ pub async fn insert_dragged_track(
|
||||
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
|
||||
// queue the user was looking at. Past the last visible row means "after
|
||||
// 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));
|
||||
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;
|
||||
let _ = insert_queue_track_at(runtime, qt, to_upcoming).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1180,4 +1504,52 @@ mod tests {
|
||||
let (flat4, _) = coverflow_flat(&h1, Some(&cur), &up_swap);
|
||||
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()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user