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/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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
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 });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user