diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index b28cac7a7..6a50a6a3d 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -6626,6 +6626,7 @@ dependencies = [
"qbz-i18n",
"qbz-library",
"qbz-log",
+ "qbz-lyrics",
"qbz-models",
"qbz-offline-cache",
"qbz-qobuz",
diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml
index f9145b8b8..d7bfdf431 100644
--- a/crates/qbz-qt/Cargo.toml
+++ b/crates/qbz-qt/Cargo.toml
@@ -31,6 +31,7 @@ qbz-models = { path = "../qbz-models" }
qbz-cache = { path = "../qbz-cache" }
qbz-offline-cache = { path = "../qbz-offline-cache" }
qbz-library = { path = "../qbz-library" }
+qbz-lyrics = { path = "../qbz-lyrics" }
qbz-text-utils = { path = "../qbz-text-utils" }
qbz-credentials = { path = "../qbz-credentials" }
qbz-i18n = { path = "../qbz-i18n" }
diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index 9e18233d2..59ecb27c2 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -48,6 +48,7 @@ fn main() {
"qml/HeaderBar.qml",
"qml/NowPlayingBarSmall.qml",
"qml/QueuePanel.qml",
+ "qml/LyricsPanel.qml",
"qml/HomeView.qml",
"qml/LibraryView.qml",
"qml/QbzTheme.qml",
diff --git a/crates/qbz-qt/qml/AppShell.qml b/crates/qbz-qt/qml/AppShell.qml
index 0b314c4c3..dbaaab0d8 100644
--- a/crates/qbz-qt/qml/AppShell.qml
+++ b/crates/qbz-qt/qml/AppShell.qml
@@ -59,15 +59,16 @@ Rectangle {
// Animated 3-state width lives inside the component.
}
- // Right-side queue column — a layout sibling of the content (it shrinks
- // the content, no overlay), animated 0 <-> 300 like the Slint AppShell
- // column (160ms ease-in-out).
+ // 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
+ // is visible when either is open, animated 0 <-> 300 (160ms).
Rectangle {
id: queueColumn
anchors.right: parent.right
anchors.top: header.bottom
anchors.bottom: npb.top
- width: QbzBridge.queueOpen ? theme.queuePanelWidth : 0
+ width: (QbzBridge.queueOpen || QbzBridge.lyricsOpen) ? theme.queuePanelWidth : 0
clip: true
color: theme.surfaceCard
@@ -76,9 +77,31 @@ Rectangle {
}
QueuePanel {
- anchors.fill: parent
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ height: QbzBridge.lyricsOpen
+ ? (QbzBridge.queueOpen ? parent.height / 2 : 0)
+ : parent.height
visible: QbzBridge.queueOpen
}
+ Rectangle {
+ visible: QbzBridge.queueOpen && QbzBridge.lyricsOpen
+ anchors.left: parent.left
+ anchors.right: parent.right
+ y: QbzBridge.lyricsOpen && QbzBridge.queueOpen ? parent.height / 2 : 0
+ height: 1
+ color: theme.borderSubtle
+ }
+ LyricsPanel {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: QbzBridge.lyricsOpen
+ ? (QbzBridge.queueOpen ? parent.height / 2 - 1 : parent.height)
+ : 0
+ visible: QbzBridge.lyricsOpen
+ }
}
// Content frame — the rounded, inset surface-main panel (Radius.md,
diff --git a/crates/qbz-qt/qml/LyricsPanel.qml b/crates/qbz-qt/qml/LyricsPanel.qml
new file mode 100644
index 000000000..79aef5460
--- /dev/null
+++ b/crates/qbz-qt/qml/LyricsPanel.qml
@@ -0,0 +1,337 @@
+// Right-side Lyrics panel — QML port of
+// crates/qbz-ui/ui/shell/LyricsSidebar.slint.
+//
+// Header (LYRICS overline + "{title} - {artist}" meta + display-settings
+// trigger + close X), body by status (loading / no-lyrics / offline-miss
+// / error / the lines view), source attribution line. Synced rendering:
+// LINE-level highlight + auto-follow driven by the 1 Hz position feed
+// (QbzBridge.npElapsedSecs; the Slint engine ticks ~30 Hz and can fill
+// word-level karaoke — both noted POC-NOTEs). Lines with timestamps are
+// click-to-seek (LyricsLinesView behavior).
+//
+// POC-NOTEs: display settings flyout (size/dimming/uppercase/font/active
+// color — Slint defaults hardcoded here: 13/15px, accent, auto-follow
+// ON, no dimming of surrounding lines), the translate toggle, word-level
+// karaoke, the LRCLIB contribute link is rendered but opens via the
+// system browser like upstream.
+
+import QtQuick
+import QtQuick.Controls
+import com.blitzfc.qbz
+
+Rectangle {
+ id: root
+ color: "transparent"
+ clip: true
+
+ readonly property var doc: JSON.parse(QbzBridge.lyricsJson)
+ readonly property var lines: doc.lines || []
+ readonly property bool synced: doc.synced === true
+
+ // Active line (line-level sync, 1 Hz cadence).
+ readonly property int activeIndex: {
+ if (!synced) return -1
+ var nowMs = (QbzBridge.npElapsedSecs || 0) * 1000
+ var idx = -1
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].timeMs !== null && lines[i].timeMs <= nowMs) idx = i
+ }
+ return idx
+ }
+
+ QbzTheme { id: theme }
+
+ // Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
+ onActiveIndexChanged: followActive()
+ function followActive() {
+ if (root.activeIndex < 0 || linesColumn.children.length === 0) return
+ var row = linesColumn.children[Math.min(root.activeIndex, linesColumn.children.length - 1)]
+ var target = row.y + row.height / 2 - linesFlick.height / 2
+ target = Math.max(0, Math.min(target, linesFlick.contentHeight - linesFlick.height))
+ if (Math.abs(linesFlick.contentY - target) > 30) {
+ followAnim2.to = target
+ followAnim2.restart()
+ }
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: 0
+
+ // --- Header: overline/meta + controls trigger + close ----------
+ Item {
+ width: parent.width
+ height: 56
+
+ Column {
+ anchors.left: parent.left
+ anchors.leftMargin: theme.spacingSm
+ anchors.right: controlsRow.left
+ anchors.rightMargin: theme.spacingSm
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ Text {
+ text: QbzBridge.tr("LYRICS")
+ color: theme.textMuted
+ font.pixelSize: 11
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 1.5
+ }
+ Text {
+ visible: QbzBridge.npTitle !== ""
+ width: parent.width
+ text: QbzBridge.npTitle + " - " + QbzBridge.npArtist
+ color: theme.textSecondary
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ }
+ }
+ Row {
+ id: controlsRow
+ anchors.right: parent.right
+ anchors.rightMargin: theme.spacingSm
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+ // Display settings (IconButton) — opens the flyout stub.
+ Rectangle {
+ width: 32
+ height: 32
+ radius: theme.radiusSm
+ color: dsArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ anchors.centerIn: parent
+ name: "sliders-horizontal"
+ width: 16
+ height: 16
+ tintName: dsArea.containsMouse ? "primary" : "secondary"
+ }
+ MouseArea {
+ id: dsArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: dsFlyout.open()
+ }
+ }
+ // Close X (Slint-convention, homologated with Queue).
+ Rectangle {
+ width: 28
+ height: 28
+ radius: theme.radiusSm
+ color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ anchors.centerIn: parent
+ name: "x"
+ width: 15
+ height: 15
+ tintName: closeArea.containsMouse ? "primary" : "secondary"
+ }
+ MouseArea {
+ id: closeArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzBridge.toggleLyrics()
+ }
+ }
+ }
+ }
+ Rectangle {
+ width: parent.width
+ height: 1
+ color: theme.borderSubtle
+ }
+
+ // --- Body by status ----------------------------------------------
+ Item {
+ width: parent.width
+ height: parent.height - 57 - sourceLine.height
+
+ // Loading.
+ Column {
+ visible: doc.status === 1
+ anchors.centerIn: parent
+ spacing: 10
+ QbzSpinner { size: 24; anchors.horizontalCenter: parent.horizontalCenter }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: QbzBridge.tr("Fetching lyrics...")
+ color: theme.textMuted
+ font.pixelSize: theme.fontLegal
+ }
+ }
+
+ // No lyrics (idle / not-found / ready-with-zero-lines).
+ Column {
+ visible: doc.status === 0 || doc.status === 3 || (doc.status === 2 && root.lines.length === 0)
+ anchors.centerIn: parent
+ width: parent.width - 32
+ spacing: 12
+ Text {
+ width: parent.width
+ text: QbzBridge.tr("No lyrics available")
+ color: theme.textMuted
+ font.pixelSize: theme.fontLegal
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+ Column {
+ width: parent.width
+ spacing: 2
+ Text {
+ width: parent.width
+ text: QbzBridge.tr("Missing lyrics? You can contribute them to LRCLIB:")
+ color: theme.textMuted
+ font.pixelSize: 10
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: "lrclibup.boidu.dev"
+ color: lrcArea.containsMouse ? theme.accentHover : theme.accent
+ font.pixelSize: 10
+ MouseArea {
+ id: lrcArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Qt.openUrlExternally("https://lrclibup.boidu.dev/")
+ }
+ }
+ }
+ }
+
+ // Offline with nothing cached.
+ Text {
+ visible: doc.status === 5
+ anchors.centerIn: parent
+ width: parent.width - 32
+ text: QbzBridge.tr("Lyrics unavailable offline")
+ color: theme.textMuted
+ font.pixelSize: theme.fontLegal
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ // Error.
+ Text {
+ visible: doc.status === 4
+ anchors.centerIn: parent
+ width: parent.width - 32
+ text: doc.error || ""
+ color: "#e0564f"
+ font.pixelSize: theme.fontLegal
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+
+ // Lyrics lines (status 2, lines > 0).
+ Flickable {
+ id: linesFlick
+ visible: doc.status === 2 && root.lines.length > 0
+ anchors.fill: parent
+ clip: true
+ contentWidth: width
+ contentHeight: linesColumn.implicitHeight
+ boundsBehavior: Flickable.StopAtBounds
+
+ Column {
+ id: linesColumn
+ width: parent.width
+ leftPadding: theme.spacingMd
+ rightPadding: theme.spacingMd
+ topPadding: theme.spacingMd
+ bottomPadding: 48
+ spacing: 10
+
+ Repeater {
+ model: root.lines
+ delegate: Text {
+ required property var modelData
+ required property int index
+ property bool isActive: index === root.activeIndex
+
+ width: linesColumn.width
+ text: modelData.text
+ // Synced: active line = accent, 15px; the rest
+ // secondary 13px. Unsynced: uniform secondary.
+ color: !root.synced ? theme.textSecondary
+ : isActive ? theme.accent : theme.textSecondary
+ font.pixelSize: root.synced && isActive ? 15 : 13
+ font.weight: root.synced && isActive ? theme.weightSemibold : theme.weightRegular
+ wrapMode: Text.WordWrap
+ MouseArea {
+ anchors.fill: parent
+ enabled: modelData.timeMs !== null
+ cursorShape: modelData.timeMs !== null ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (modelData.timeMs !== null && QbzBridge.npDurationSecs > 0)
+ QbzBridge.seek(modelData.timeMs / 1000 / QbzBridge.npDurationSecs)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
+ NumberAnimation {
+ id: followAnim2
+ target: linesFlick
+ property: "contentY"
+ duration: 250
+ easing.type: Easing.InOutQuad
+ }
+
+ // --- Source attribution ------------------------------------------
+ Row {
+ id: sourceLine
+ visible: doc.status === 2 && root.lines.length > 0
+ width: parent.width
+ leftPadding: theme.spacingSm
+ rightPadding: theme.spacingSm
+ topPadding: 3
+ bottomPadding: 5
+ spacing: 3
+ Text {
+ text: QbzBridge.tr("Lyrics source") + ":"
+ color: theme.textMuted
+ font.pixelSize: 9
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ text: doc.provider === "lrclib" ? "LRCLIB"
+ : doc.provider === "ovh" ? "lyrics.ovh" : "Qobuz"
+ color: theme.accent
+ font.pixelSize: 9
+ font.weight: theme.weightSemibold
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+
+ // --- Display-settings flyout (visual stub — POC-NOTE: the prefs) ----
+ Popup {
+ id: dsFlyout
+ x: parent.width - 260
+ y: 64
+ width: 260
+ padding: 14
+ closePolicy: Popup.CloseOnPressOutside
+ background: Rectangle {
+ color: theme.surfaceCard
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderSubtle
+ }
+ contentItem: Text {
+ width: parent ? parent.width : 0
+ text: "QBZ Qt POC — display settings are not wired yet (Slint defaults apply)"
+ color: theme.textMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/NowPlayingBarSmall.qml
index 73fb18a34..9a7bca741 100644
--- a/crates/qbz-qt/qml/NowPlayingBarSmall.qml
+++ b/crates/qbz-qt/qml/NowPlayingBarSmall.qml
@@ -404,7 +404,11 @@ Rectangle {
BarIconBtn { name: "cast" }
BarIconBtn { name: "settings-2" }
BarIconBtn { name: "layout-grid" }
- BarIconBtn { name: "mic-vocal" }
+ BarIconBtn {
+ name: "mic-vocal"
+ active: QbzBridge.lyricsOpen
+ onClicked: QbzBridge.toggleLyrics()
+ }
Item { width: 4; height: 1 }
diff --git a/crates/qbz-qt/qml/QueuePanel.qml b/crates/qbz-qt/qml/QueuePanel.qml
index b9529aff2..e90a38ea7 100644
--- a/crates/qbz-qt/qml/QueuePanel.qml
+++ b/crates/qbz-qt/qml/QueuePanel.qml
@@ -1,20 +1,21 @@
// Right-side Queue panel — QML port of
// crates/qbz-ui/ui/shell/QueueSidebar.slint.
//
-// Header with the two tabs ("Queue" / "History"), the NOW PLAYING card,
-// the UP NEXT list, and the empty states. Phase 4: the Queue tab renders
-// the REAL queue (QbzBridge.queueModel — one JSON-encoded row per QVariant,
-// see the nesting POC-NOTE in playback_qt.rs `publish_queue`).
+// Tabs (Queue / History), NOW PLAYING card (live heart), UP NEXT with the
+// #442 "Next in queue" / "Next up" section markers, 40-row pagination,
+// live search filter, row actions (play / remove / remove-all-after /
+// heart), drag reorder (basic press-drag), footer (count line + Clear /
+// stubs + search field), History tab (thumbnail rows, click replays as a
+// fresh single-track queue), exact empty states. Data: QbzBridge.queueJson
+// (queue_qt.rs QueueDoc).
//
-// POC-NOTEs:
-// - The footer (count line + clear / save-as-playlist / infinite /
-// sleep-timer actions + inline queue search) is omitted — phase 4 brings
-// no queue mutations beyond play/next/previous.
-// - The History tab stays empty (no local play-history store in the POC).
-// - Row context menus, drag-reorder, pagination and the now-playing
-// favorite toggle are out of scope.
+// POC-NOTEs: save-as-playlist (opens a picker modal upstream — inert),
+// infinite-play + sleep timer engines, stop-after marker, row menu's
+// "Add to playlist" / "Track info" entries, ephemeral rows, the ghost
+// drag pill (rows move with a plain displaced animation instead).
import QtQuick
+import QtQuick.Controls
import com.blitzfc.qbz
Rectangle {
@@ -24,22 +25,39 @@ Rectangle {
// Queue (0) / History (1) — Slint QueueState.tab.
property int tab: 0
- // Parsed queue rows: [{id, title, artist, duration, artPath, current}].
- readonly property var rows: {
- var out = []
- for (var i = 0; i < QbzBridge.queueModel.length; i++) {
- try { out.push(JSON.parse(QbzBridge.queueModel[i])) } catch (e) {}
- }
- return out
- }
- readonly property var currentRow: rows.length > 0 && rows[0].current ? rows[0] : null
- readonly property var upcomingRows: currentRow ? rows.slice(1) : rows
- readonly property bool queueEmpty: rows.length === 0
+ readonly property var doc: JSON.parse(QbzBridge.queueJson)
+ readonly property var currentRow: doc.current || null
+ readonly property var upcoming: doc.upcoming || []
+ readonly property var historyRows: doc.history || []
+ readonly property bool queueEmpty: currentRow === null && (doc.upcomingTotal || 0) === 0
+
+ // url-keyed cover map (shared artwork pipeline).
+ property var coverMap: ({})
+
+ // Drag-reorder state (basic press-drag; ghost is the row's own y).
+ property int dragFrom: -1
QbzTheme { id: theme }
- // One tab — plain text, no pill (QBZ design rule): active brightest,
- // inactive dim, brighten on hover.
+ Connections {
+ target: QbzBridge
+ function onLibraryArtworkReady(key, path) {
+ var m = root.coverMap
+ m[key] = path
+ root.coverMap = Object.assign({}, m)
+ }
+ }
+ Component.onCompleted: dispatchCovers()
+ onDocChanged: dispatchCovers()
+ function dispatchCovers() {
+ var urls = []
+ if (currentRow && currentRow.artUrl) urls.push(currentRow.artUrl)
+ var i
+ for (i = 0; i < upcoming.length; i++) if (upcoming[i].artUrl) urls.push(upcoming[i].artUrl)
+ for (i = 0; i < historyRows.length; i++) if (historyRows[i].artUrl) urls.push(historyRows[i].artUrl)
+ if (urls.length > 0) QbzBridge.sidebarArtworkWindow(JSON.stringify(urls))
+ }
+
component QueueTab: Text {
property bool active: false
signal clicked()
@@ -60,17 +78,54 @@ Rectangle {
}
}
- // One queue row (QueueRow.slint): index or thumbnail, title, artist,
- // duration.
+ // Row action icon button (the panel's small IconButton).
+ component PanelIconBtn: Rectangle {
+ property string name: ""
+ property int iconSize: 17
+ property bool active: false
+ property bool btnEnabled: true
+ signal clicked()
+
+ width: 30
+ height: 30
+ radius: theme.radiusSm
+ color: (piArea.containsMouse && btnEnabled) ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ name: parent.name
+ width: parent.iconSize
+ height: parent.iconSize
+ anchors.centerIn: parent
+ tintName: !parent.btnEnabled ? "muted"
+ : parent.active ? "accent"
+ : piArea.containsMouse ? "primary" : "secondary"
+ }
+ MouseArea {
+ id: piArea
+ anchors.fill: parent
+ enabled: parent.btnEnabled
+ hoverEnabled: true
+ cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: parent.clicked()
+ }
+ }
+
+ // One UP NEXT / History row (QueueRow.slint).
component QueueRow: Rectangle {
property var row: ({})
property int rowIndex: 0
property bool showNumber: true
+ property bool inQueue: true
+
+ readonly property bool hovered: qrArea.containsMouse || menuArea.containsMouse
+ readonly property bool isActive: inQueue && QbzBridge.npTrackId !== "" && QbzBridge.npTrackId === row.id
height: 44
radius: theme.radiusSm
- color: qrArea.containsMouse ? theme.surfaceHover
+ color: hovered ? theme.surfaceHover
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
+ border.width: root.dragFrom === rowIndex ? 1 : 0
+ border.color: theme.accent
+ opacity: root.dragFrom === rowIndex ? 0.4 : 1.0
Row {
anchors.fill: parent
@@ -80,11 +135,12 @@ Rectangle {
anchors.bottomMargin: 4
spacing: 9
+ // Leading: track number (UP NEXT) or thumbnail (History).
Text {
visible: showNumber
width: 22
anchors.verticalCenter: parent.verticalCenter
- text: rowIndex + 1
+ text: rowIndex + 1 + (doc.page || 0) * 40
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
@@ -99,22 +155,42 @@ Rectangle {
clip: true
Image {
anchors.fill: parent
- source: row.artPath
+ source: root.coverMap[row.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
+
+ // Title + artist.
Column {
- width: parent.width - (showNumber ? 22 : 34) - 9 * 2 - durationText.implicitWidth
+ width: parent.width - (showNumber ? 22 : 34) - durText.width - 32 - 3 * 9
anchors.verticalCenter: parent.verticalCenter
spacing: 2
- Text {
- width: parent.width
- text: row.title
- color: theme.textPrimary
- font.pixelSize: 12
- font.weight: theme.weightMedium
- elide: Text.ElideRight
+ Row {
+ spacing: 5
+ Text {
+ text: row.title
+ color: theme.textPrimary
+ font.pixelSize: 12
+ font.weight: theme.weightMedium
+ elide: Text.ElideRight
+ width: Math.min(implicitWidth, parent.parent.width - (row.explicit ? 21 : 0))
+ }
+ Rectangle {
+ visible: row.explicit
+ width: 16
+ height: 16
+ radius: 3
+ anchors.verticalCenter: parent.verticalCenter
+ color: theme.surfaceElevated
+ Text {
+ anchors.centerIn: parent
+ text: "E"
+ color: theme.textMuted
+ font.pixelSize: 9
+ font.weight: theme.weightSemibold
+ }
+ }
}
Text {
width: parent.width
@@ -125,19 +201,107 @@ Rectangle {
}
}
Text {
- id: durationText
+ id: durText
anchors.verticalCenter: parent.verticalCenter
text: row.duration
color: theme.textMuted
font.pixelSize: 11
}
+ // ⋯ menu trigger (UP NEXT rows only; always visible, 32px hit).
+ Rectangle {
+ visible: inQueue
+ width: 32
+ height: 32
+ radius: theme.radiusSm
+ anchors.verticalCenter: parent.verticalCenter
+ color: menuArea.containsMouse ? theme.surfaceElevated : "transparent"
+ QbzIcon {
+ anchors.centerIn: parent
+ name: "ellipsis"
+ width: 16
+ height: 16
+ tintName: menuArea.containsMouse ? "primary" : "muted"
+ }
+ MouseArea {
+ id: menuArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: rowMenu.open()
+ }
+ Popup {
+ id: rowMenu
+ x: width - 196
+ y: 44
+ width: 196
+ padding: 5
+ closePolicy: Popup.CloseOnPressOutside
+ background: Rectangle {
+ color: theme.surfaceMain
+ radius: theme.radiusSm
+ border.width: 1
+ border.color: theme.borderMuted
+ }
+ contentItem: Column {
+ Repeater {
+ model: [
+ { "label": QbzBridge.tr("Remove from queue"), "icon": "trash-2", "action": "remove" },
+ { "label": QbzBridge.tr("Remove all after"), "icon": "list-x", "action": "remove-after" },
+ { "label": row.isFavorite ? QbzBridge.tr("Remove from Library") : QbzBridge.tr("Add to Library"), "icon": row.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
+ ]
+ delegate: Rectangle {
+ required property var modelData
+ width: parent ? parent.width : 0
+ height: 33
+ radius: 5
+ color: rmiArea.containsMouse ? theme.surfaceHover : "transparent"
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ spacing: 8
+ QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
+ Text {
+ height: parent.height
+ width: parent.width - 23
+ text: modelData.label
+ color: theme.textSecondary
+ font.pixelSize: 13
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ }
+ MouseArea {
+ id: rmiArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ rowMenu.close()
+ var a = modelData.action
+ if (a === "remove") QbzBridge.queueRemoveUpcoming(rowIndex)
+ else if (a === "remove-after") QbzBridge.queueRemoveAllAfter(rowIndex)
+ else if (a === "favorite") {
+ row.isFavorite = !row.isFavorite
+ QbzBridge.queueToggleFavorite("track", row.id)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
+
MouseArea {
id: qrArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
- // POC-NOTE: click-to-play / context menu out of scope.
+ onClicked: {
+ if (inQueue) QbzBridge.queuePlayUpcoming(rowIndex)
+ else QbzBridge.queuePlayHistory(rowIndex)
+ }
}
}
@@ -177,7 +341,6 @@ Rectangle {
onClicked: root.tab = 1
}
}
- // Close button (mirrors the LyricsSidebar X).
Rectangle {
id: closeBtn
anchors.right: parent.right
@@ -212,7 +375,7 @@ Rectangle {
// --- Body --------------------------------------------------------
Item {
width: parent.width
- height: parent.height - 45
+ height: parent.height - 45 - footerBlock.height
// ===== Queue tab =====
Flickable {
@@ -241,7 +404,6 @@ Rectangle {
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
- // Highlighted current-track card.
Rectangle {
width: parent.width
height: 44
@@ -263,13 +425,13 @@ Rectangle {
clip: true
Image {
anchors.fill: parent
- source: root.currentRow ? root.currentRow.artPath : ""
+ source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
- width: parent.width - 34 - 9
+ width: parent.width - 34 - 28 - 2 * 9
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
@@ -288,13 +450,40 @@ Rectangle {
elide: Text.ElideRight
}
}
+ // Favorite toggle (wired).
+ Rectangle {
+ width: 28
+ height: 28
+ radius: theme.radiusSm
+ anchors.verticalCenter: parent.verticalCenter
+ color: npFavArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ anchors.centerIn: parent
+ name: root.currentRow && root.currentRow.isFavorite ? "heart-filled" : "heart"
+ width: 17
+ height: 17
+ tintName: root.currentRow && root.currentRow.isFavorite ? "favorite" : (npFavArea.containsMouse ? "primary" : "muted")
+ }
+ MouseArea {
+ id: npFavArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (root.currentRow) {
+ root.currentRow.isFavorite = !root.currentRow.isFavorite
+ QbzBridge.queueToggleFavorite("track", root.currentRow.id)
+ }
+ }
+ }
+ }
}
}
}
// UP NEXT section.
Column {
- visible: root.upcomingRows.length > 0
+ visible: (doc.upcomingTotal || 0) > 0
width: parent.width - 20
spacing: 8
Text {
@@ -304,15 +493,90 @@ Rectangle {
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
+
Repeater {
- model: root.upcomingRows
- delegate: QueueRow {
+ model: root.upcoming
+ delegate: Column {
+ required property var modelData
+ required property int index
width: parent ? parent.width : 0
- row: modelData
- rowIndex: index
- showNumber: true
+ // #442 section header above the row.
+ Row {
+ visible: modelData.section !== ""
+ height: 22
+ spacing: 6
+ QbzIcon {
+ visible: modelData.section === "next-in-queue"
+ name: "list-start"
+ width: 13
+ height: 13
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "accent"
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.section === "next-in-queue"
+ ? QbzBridge.tr("Next in queue") : QbzBridge.tr("Next up")
+ color: theme.textMuted
+ font.pixelSize: 11
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 0.5
+ }
+ }
+ QueueRow {
+ width: parent.width
+ row: modelData
+ rowIndex: index
+ showNumber: true
+ inQueue: true
+ // Basic press-drag reorder (POC: no ghost pill).
+ MouseArea {
+ anchors.fill: parent
+ onPressAndHold: root.dragFrom = index
+ onReleased: {
+ if (root.dragFrom >= 0) {
+ var target = Math.max(0, Math.min(root.upcoming.length - 1,
+ Math.floor((parent.mapToItem(null, mouseX, mouseY).y - queueBody.mapToItem(null, 0, 0).y - 40) / 44)))
+ QbzBridge.queueMoveTrack((doc.page || 0) * 40 + root.dragFrom, (doc.page || 0) * 40 + target)
+ root.dragFrom = -1
+ }
+ }
+ }
+ }
}
}
+
+ // Paginator (plain text controls, no pills).
+ Row {
+ visible: (doc.pageCount || 1) > 1
+ width: parent.width
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: theme.spacingMd
+ Item { width: (parent.width - paginator.width) / 2; height: 1 }
+ Row {
+ id: paginator
+ spacing: theme.spacingMd
+ PanelIconBtn {
+ name: "chevron-left"
+ iconSize: 15
+ btnEnabled: (doc.page || 0) > 0
+ onClicked: QbzBridge.queueSetPage((doc.page || 0) - 1)
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: QbzBridge.tr("Page {} of {}").replace("{}", (doc.page || 0) + 1).replace("{}", doc.pageCount || 1)
+ color: theme.textMuted
+ font.pixelSize: 12
+ }
+ PanelIconBtn {
+ name: "chevron-right"
+ iconSize: 15
+ btnEnabled: (doc.page || 0) < (doc.pageCount || 1) - 1
+ onClicked: QbzBridge.queueSetPage((doc.page || 0) + 1)
+ }
+ }
+ Item { width: (parent.width - paginator.width) / 2; height: 1 }
+ }
}
// Empty state — no current track and no upcoming.
@@ -342,28 +606,127 @@ Rectangle {
}
// ===== History tab =====
- Column {
+ Flickable {
visible: root.tab === 1
anchors.fill: parent
- anchors.margins: 10
- spacing: 8
+ clip: true
+ contentWidth: width
+ contentHeight: historyBody.implicitHeight
+ boundsBehavior: Flickable.StopAtBounds
- Text {
- text: QbzBridge.tr("RECENTLY PLAYED")
- color: theme.textMuted
- font.pixelSize: 11
- font.weight: theme.weightSemibold
- font.letterSpacing: 0.5
- }
- // POC-NOTE: history is always empty until the local
- // play-history store is wired.
- Text {
+ Column {
+ id: historyBody
width: parent.width
- topPadding: 48 - 10
- text: QbzBridge.tr("Nothing played yet")
- color: theme.textMuted
- font.pixelSize: theme.fontBody
- horizontalAlignment: Text.AlignHCenter
+ padding: 10
+ spacing: 8
+
+ Text {
+ text: QbzBridge.tr("RECENTLY PLAYED")
+ color: theme.textMuted
+ font.pixelSize: 11
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 0.5
+ }
+ Repeater {
+ model: root.historyRows
+ delegate: QueueRow {
+ width: parent.width
+ row: modelData
+ rowIndex: index
+ showNumber: false
+ inQueue: false
+ }
+ }
+ Text {
+ visible: root.historyRows.length === 0
+ width: parent.width
+ topPadding: 48 - 10
+ text: QbzBridge.tr("Nothing played yet")
+ color: theme.textMuted
+ font.pixelSize: theme.fontBody
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+ }
+ }
+
+ // --- Footer: count + actions + inline search (Queue tab) ---------
+ Column {
+ id: footerBlock
+ visible: root.tab === 0 && !root.queueEmpty
+ width: parent.width
+ spacing: 0
+ Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
+ Text {
+ visible: (doc.upcomingTotal || 0) > 0
+ width: parent.width
+ leftPadding: theme.spacingMd
+ rightPadding: theme.spacingMd
+ topPadding: 8
+ text: (doc.upcomingTotal === 1
+ ? QbzBridge.tr("Showing {} of {} track.")
+ : QbzBridge.tr("Showing {} of {} tracks."))
+ .replace("{}", doc.pageEnd || 0).replace("{}", doc.upcomingTotal || 0)
+ color: theme.textMuted
+ font.pixelSize: 11
+ }
+ Row {
+ width: parent.width
+ leftPadding: theme.spacingMd
+ rightPadding: theme.spacingMd
+ topPadding: 10
+ bottomPadding: 10
+ spacing: theme.spacingXs
+
+ // Action 1 — Clear queue (wired).
+ PanelIconBtn {
+ name: "trash-list"
+ onClicked: QbzBridge.queueClear()
+ }
+ // Action 2 — Save as playlist (INERT: opens a picker modal
+ // upstream — POC-NOTE).
+ PanelIconBtn { name: "add-to-list" }
+ // Action 3 — infinite play (INERT engine — POC-NOTE).
+ PanelIconBtn { name: "infinity" }
+ // Action 4 — sleep timer (INERT engine — POC-NOTE).
+ PanelIconBtn { name: "clock" }
+
+ // Inline queue search field (wired).
+ Rectangle {
+ width: parent.width - 4 * 30 - 4 * theme.spacingXs
+ height: 30
+ radius: theme.radiusSm
+ color: theme.surfaceElevated
+ Row {
+ anchors.fill: parent
+ anchors.leftMargin: 9
+ anchors.rightMargin: 7
+ spacing: 6
+ QbzIcon {
+ name: "search"
+ width: 13
+ height: 13
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "muted"
+ }
+ TextInput {
+ width: parent.width - 20
+ height: parent.height
+ color: theme.textPrimary
+ font.pixelSize: 12
+ verticalAlignment: Text.AlignVCenter
+ clip: true
+ onTextEdited: QbzBridge.queueSetSearch(text)
+ Text {
+ visible: parent.text === ""
+ anchors.fill: parent
+ text: QbzBridge.tr("Search queue")
+ color: theme.textMuted
+ font.pixelSize: 12
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ }
}
}
}
diff --git a/crates/qbz-qt/qml/assets/icons/accent/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/accent/add-to-list.svg
new file mode 100644
index 000000000..7a1cb7674
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/add-to-list.svg
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/accent/clock.svg b/crates/qbz-qt/qml/assets/icons/accent/clock.svg
new file mode 100644
index 000000000..e29a759f6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/infinity.svg b/crates/qbz-qt/qml/assets/icons/accent/infinity.svg
new file mode 100644
index 000000000..a54d2b686
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/infinity.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-x.svg b/crates/qbz-qt/qml/assets/icons/accent/list-x.svg
new file mode 100644
index 000000000..78f10e002
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/list-x.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/accent/sliders-horizontal.svg
new file mode 100644
index 000000000..b1079723e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/sliders-horizontal.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/trash-2.svg b/crates/qbz-qt/qml/assets/icons/accent/trash-2.svg
new file mode 100644
index 000000000..6666b0977
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/trash-2.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/trash-list.svg b/crates/qbz-qt/qml/assets/icons/accent/trash-list.svg
new file mode 100644
index 000000000..b3633d789
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/trash-list.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/black/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/black/add-to-list.svg
new file mode 100644
index 000000000..05f434b1e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/add-to-list.svg
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/black/clock.svg b/crates/qbz-qt/qml/assets/icons/black/clock.svg
new file mode 100644
index 000000000..092727ac8
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/infinity.svg b/crates/qbz-qt/qml/assets/icons/black/infinity.svg
new file mode 100644
index 000000000..0d530ec92
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/infinity.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/list-x.svg b/crates/qbz-qt/qml/assets/icons/black/list-x.svg
new file mode 100644
index 000000000..90fee2d0f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/list-x.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/black/sliders-horizontal.svg
new file mode 100644
index 000000000..b64ec2728
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/sliders-horizontal.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/trash-2.svg b/crates/qbz-qt/qml/assets/icons/black/trash-2.svg
new file mode 100644
index 000000000..f176b5de6
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/trash-2.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/trash-list.svg b/crates/qbz-qt/qml/assets/icons/black/trash-list.svg
new file mode 100644
index 000000000..0faef23aa
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/trash-list.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/favorite/add-to-list.svg
new file mode 100644
index 000000000..6dab9461e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/add-to-list.svg
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/clock.svg b/crates/qbz-qt/qml/assets/icons/favorite/clock.svg
new file mode 100644
index 000000000..ab8bead23
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/infinity.svg b/crates/qbz-qt/qml/assets/icons/favorite/infinity.svg
new file mode 100644
index 000000000..5011a10f3
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/infinity.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/list-x.svg b/crates/qbz-qt/qml/assets/icons/favorite/list-x.svg
new file mode 100644
index 000000000..87d1f611f
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/list-x.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/favorite/mic-vocal.svg
new file mode 100644
index 000000000..1d59233cf
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/mic-vocal.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/favorite/sliders-horizontal.svg
new file mode 100644
index 000000000..e64e2e719
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/sliders-horizontal.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/trash-2.svg b/crates/qbz-qt/qml/assets/icons/favorite/trash-2.svg
new file mode 100644
index 000000000..4e44df1d8
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/trash-2.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/trash-list.svg b/crates/qbz-qt/qml/assets/icons/favorite/trash-list.svg
new file mode 100644
index 000000000..bf5a7e7e1
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/trash-list.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/muted/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/muted/add-to-list.svg
new file mode 100644
index 000000000..e6513867c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/add-to-list.svg
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/muted/clock.svg b/crates/qbz-qt/qml/assets/icons/muted/clock.svg
new file mode 100644
index 000000000..c08815417
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/infinity.svg b/crates/qbz-qt/qml/assets/icons/muted/infinity.svg
new file mode 100644
index 000000000..11d5a4447
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/infinity.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-x.svg b/crates/qbz-qt/qml/assets/icons/muted/list-x.svg
new file mode 100644
index 000000000..9c0729751
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/list-x.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/muted/sliders-horizontal.svg
new file mode 100644
index 000000000..b4e3b140c
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/sliders-horizontal.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/trash-2.svg b/crates/qbz-qt/qml/assets/icons/muted/trash-2.svg
new file mode 100644
index 000000000..22df967e7
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/trash-2.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/trash-list.svg b/crates/qbz-qt/qml/assets/icons/muted/trash-list.svg
new file mode 100644
index 000000000..ab8ebb77d
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/trash-list.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/primary/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/primary/add-to-list.svg
new file mode 100644
index 000000000..bb55e8871
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/add-to-list.svg
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/primary/clock.svg b/crates/qbz-qt/qml/assets/icons/primary/clock.svg
new file mode 100644
index 000000000..0cee7e1f7
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/infinity.svg b/crates/qbz-qt/qml/assets/icons/primary/infinity.svg
new file mode 100644
index 000000000..34398d904
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/infinity.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/list-x.svg b/crates/qbz-qt/qml/assets/icons/primary/list-x.svg
new file mode 100644
index 000000000..2398d0ec8
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/list-x.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/primary/sliders-horizontal.svg
new file mode 100644
index 000000000..6addb7e34
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/sliders-horizontal.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/trash-2.svg b/crates/qbz-qt/qml/assets/icons/primary/trash-2.svg
new file mode 100644
index 000000000..b8737b25b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/trash-2.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/trash-list.svg b/crates/qbz-qt/qml/assets/icons/primary/trash-list.svg
new file mode 100644
index 000000000..388ab12ea
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/trash-list.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/secondary/add-to-list.svg
new file mode 100644
index 000000000..aa001dfcc
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/add-to-list.svg
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/clock.svg b/crates/qbz-qt/qml/assets/icons/secondary/clock.svg
new file mode 100644
index 000000000..db275ab66
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/infinity.svg b/crates/qbz-qt/qml/assets/icons/secondary/infinity.svg
new file mode 100644
index 000000000..489d8cee1
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/infinity.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-x.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-x.svg
new file mode 100644
index 000000000..4f5f5e3fe
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/list-x.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/secondary/sliders-horizontal.svg
new file mode 100644
index 000000000..cf0c1f6a5
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/sliders-horizontal.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/trash-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/trash-2.svg
new file mode 100644
index 000000000..2a9012d7b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/trash-2.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/trash-list.svg b/crates/qbz-qt/qml/assets/icons/secondary/trash-list.svg
new file mode 100644
index 000000000..1bab8d86a
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/trash-list.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/warning/add-to-list.svg b/crates/qbz-qt/qml/assets/icons/warning/add-to-list.svg
new file mode 100644
index 000000000..26121fef3
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/add-to-list.svg
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/qml/assets/icons/warning/clock.svg b/crates/qbz-qt/qml/assets/icons/warning/clock.svg
new file mode 100644
index 000000000..0bc76b29d
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/clock.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/infinity.svg b/crates/qbz-qt/qml/assets/icons/warning/infinity.svg
new file mode 100644
index 000000000..74b2912dd
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/infinity.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-x.svg b/crates/qbz-qt/qml/assets/icons/warning/list-x.svg
new file mode 100644
index 000000000..51f34535d
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/list-x.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/sliders-horizontal.svg b/crates/qbz-qt/qml/assets/icons/warning/sliders-horizontal.svg
new file mode 100644
index 000000000..8266dabd3
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/sliders-horizontal.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/trash-2.svg b/crates/qbz-qt/qml/assets/icons/warning/trash-2.svg
new file mode 100644
index 000000000..e2b1458cf
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/trash-2.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/trash-list.svg b/crates/qbz-qt/qml/assets/icons/warning/trash-list.svg
new file mode 100644
index 000000000..9476fcd41
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/trash-list.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs
index b655cb0f3..73cd173f7 100644
--- a/crates/qbz-qt/src/bridge.rs
+++ b/crates/qbz-qt/src/bridge.rs
@@ -123,6 +123,17 @@ pub mod qbz_bridge {
// Now-playing track id (playing-row indicator in track lists).
#[qproperty(QString, np_track_id)]
+ // --- Lyrics panel (phase 9) ----------------------------------------
+ #[qproperty(bool, lyrics_open)]
+ // One JSON document (lyrics_qt.rs LyricsDoc: status/lines/synced/
+ // provider/error).
+ #[qproperty(QString, lyrics_json)]
+
+ // --- Queue panel (phase 9) -----------------------------------------
+ // One JSON document (queue_qt.rs QueueDoc: current/upcoming/history
+ // + pagination + #442 section markers). Supersedes `queueModel`.
+ #[qproperty(QString, queue_json)]
+
// --- Sidebar playlist tree ---------------------------------------
// One JSON document: the flattened entries (folders + playlists,
// expand/sort/search applied Rust-side — sidebar_qt.rs).
@@ -230,6 +241,32 @@ pub mod qbz_bridge {
#[qinvokable]
fn enqueue_artist_top(self: Pin<&mut QbzBridge>);
+ /// NPB lyrics button / lyrics panel close X.
+ #[qinvokable]
+ fn toggle_lyrics(self: Pin<&mut QbzBridge>);
+
+ /// Queue panel: tabs/pagination/search.
+ #[qinvokable]
+ fn queue_set_page(self: Pin<&mut QbzBridge>, page: i32);
+ #[qinvokable]
+ fn queue_set_search(self: Pin<&mut QbzBridge>, query: QString);
+ /// Row actions.
+ #[qinvokable]
+ fn queue_play_upcoming(self: Pin<&mut QbzBridge>, index: i32);
+ #[qinvokable]
+ fn queue_remove_upcoming(self: Pin<&mut QbzBridge>, index: i32);
+ #[qinvokable]
+ fn queue_remove_all_after(self: Pin<&mut QbzBridge>, index: i32);
+ #[qinvokable]
+ fn queue_move_track(self: Pin<&mut QbzBridge>, from: i32, to: i32);
+ #[qinvokable]
+ fn queue_play_history(self: Pin<&mut QbzBridge>, index: i32);
+ #[qinvokable]
+ fn queue_toggle_favorite(self: Pin<&mut QbzBridge>, kind: QString, id: QString);
+ /// Footer: Clear queue.
+ #[qinvokable]
+ fn queue_clear(self: Pin<&mut QbzBridge>);
+
/// Sidebar navigation: record a content view ("home" | "library")
/// and lazy-load its data on first visit.
#[qinvokable]
@@ -374,6 +411,9 @@ pub struct QbzBridgeRust {
artist_json: QString,
view_param_id: QString,
np_track_id: QString,
+ queue_json: QString,
+ lyrics_open: bool,
+ lyrics_json: QString,
}
impl Default for QbzBridgeRust {
@@ -431,6 +471,9 @@ impl Default for QbzBridgeRust {
artist_json: QString::from("{}"),
view_param_id: QString::default(),
np_track_id: QString::default(),
+ queue_json: QString::from("{}"),
+ lyrics_open: false,
+ lyrics_json: QString::from("{}"),
}
}
}
@@ -534,6 +577,46 @@ impl qbz_bridge::QbzBridge {
crate::play_album(album_id.to_string());
}
+ pub fn queue_set_page(self: Pin<&mut Self>, page: i32) {
+ crate::queue_set_page(page);
+ }
+
+ pub fn queue_set_search(self: Pin<&mut Self>, query: QString) {
+ crate::queue_set_search(query.to_string());
+ }
+
+ pub fn queue_play_upcoming(self: Pin<&mut Self>, index: i32) {
+ crate::queue_play_upcoming(index);
+ }
+
+ pub fn queue_remove_upcoming(self: Pin<&mut Self>, index: i32) {
+ crate::queue_remove_upcoming(index);
+ }
+
+ pub fn queue_remove_all_after(self: Pin<&mut Self>, index: i32) {
+ crate::queue_remove_all_after(index);
+ }
+
+ pub fn queue_move_track(self: Pin<&mut Self>, from: i32, to: i32) {
+ crate::queue_move_track(from, to);
+ }
+
+ pub fn queue_play_history(self: Pin<&mut Self>, index: i32) {
+ crate::queue_play_history(index);
+ }
+
+ pub fn queue_toggle_favorite(self: Pin<&mut Self>, kind: QString, id: QString) {
+ crate::queue_toggle_favorite(kind.to_string(), id.to_string());
+ }
+
+ pub fn queue_clear(self: Pin<&mut Self>) {
+ crate::queue_clear();
+ }
+
+ pub fn toggle_lyrics(self: Pin<&mut Self>) {
+ crate::toggle_lyrics();
+ }
+
pub fn play_album_shuffled(self: Pin<&mut Self>, album_id: QString) {
crate::play_album_shuffled(album_id.to_string());
}
diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs
index 812e0f9bf..b42b86c0b 100644
--- a/crates/qbz-qt/src/home_qt.rs
+++ b/crates/qbz-qt/src/home_qt.rs
@@ -218,14 +218,6 @@ fn build_sections(
out
}
-/// The last built sections (clone), for debug/probe entry points.
-static LAST_SECTIONS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new());
-
-pub fn with_sections(f: impl FnOnce(&[HomeSection]) -> R) -> R {
- let guard = LAST_SECTIONS.lock().unwrap();
- f(&guard)
-}
-
/// Fetch the discover index (UNFILTERED — genre filter out of scope) and
/// the personalized rails concurrently (mirrors home.rs's `join!`), then
/// map everything into plain rows.
@@ -246,14 +238,12 @@ where
release_watch.len(),
top_artists.len(),
);
- let built = build_sections(
+ Ok(build_sections(
response.containers,
favorite_albums,
release_watch,
top_artists,
- );
- *LAST_SECTIONS.lock().unwrap() = built.clone();
- Ok(built)
+ ))
}
// ---------------------------------------------------------------------------
diff --git a/crates/qbz-qt/src/lyrics_qt.rs b/crates/qbz-qt/src/lyrics_qt.rs
new file mode 100644
index 000000000..e2d64f486
--- /dev/null
+++ b/crates/qbz-qt/src/lyrics_qt.rs
@@ -0,0 +1,186 @@
+//! Lyrics side panel data layer — drives the `qbz-lyrics` crate
+//! (LyricsService: Qobuz primary + LRCLIB/OVH fallback chain + per-user
+//! SQLite cache, all self-contained) from the playback track-change hook.
+//!
+//! Lifecycle (mirrors `lyrics::init_for_user`): the service is built
+//! LAZILY on first use from the core's QobuzClient clone, and the per-user
+//! cache (`~/.cache/qbz/users//lyrics`) binds on every session
+//! activation. Fetches dedupe in-flight per track inside the crate.
+//!
+//! POC-NOTEs:
+//! - Word-level karaoke fill (the wsync word engine), translations
+//! (Qobuz v10 language targets + the translate toggle), the display
+//! settings flyout (size/dimming/uppercase/font/color prefs — the
+//! Slint defaults are hardcoded), MusicBrainz/AI providers: out of scope.
+//! - Highlight cadence is the 1 Hz playback poll (vs the Slint engine's
+//! ~30 Hz) — line-level highlight only.
+
+use std::sync::{Arc, OnceLock};
+
+use qbz_app::shell::AppRuntime;
+use qbz_app::user_data::UserDataPaths;
+use qbz_core::LoggingAdapter;
+use qbz_lyrics::service::{LyricsOutcome, LyricsRequest, LyricsService};
+use qbz_models::QueueTrack;
+use cxx_qt_lib::QString;
+use serde::Serialize;
+
+/// LyricsState.status semantics (LyricsSidebar renders by these).
+#[derive(Clone, Serialize)]
+pub struct LyricsLine {
+ #[serde(rename = "timeMs")]
+ pub time_ms: Option,
+ #[serde(rename = "endMs")]
+ pub end_ms: Option,
+ pub text: String,
+}
+
+#[derive(Default, Serialize)]
+pub struct LyricsDoc {
+ /// 0 idle / 1 loading / 2 ready / 3 not-found / 4 error / 5 offline-miss.
+ pub status: i32,
+ pub lines: Vec,
+ pub synced: bool,
+ pub provider: String,
+ pub error: String,
+}
+
+static SERVICE: OnceLock> = OnceLock::new();
+/// The user id the cache is currently bound for ("" = unbound).
+static CACHE_USER: std::sync::Mutex = std::sync::Mutex::new(String::new());
+
+/// Build (once) the service from the core's client clone, and bind the
+/// per-user cache on first use / user change.
+async fn service(runtime: &Arc>) -> Option> {
+ let service = match SERVICE.get() {
+ Some(s) => Arc::clone(s),
+ None => {
+ let client_lock = runtime.core().client();
+ let client = {
+ let guard = client_lock.read().await;
+ guard.as_ref().cloned()
+ }?;
+ let service = Arc::new(LyricsService::with_qobuz_client(Arc::new(client)));
+ let _ = SERVICE.set(Arc::clone(&service));
+ service
+ }
+ };
+ // Bind the per-user cache (idempotent per user). The std MutexGuard
+ // must NOT be held across the await (it would make the future !Send).
+ let needs_init = UserDataPaths::load_last_user_id()
+ .map(|uid| {
+ let bound = CACHE_USER.lock().unwrap();
+ *bound != uid.to_string()
+ })
+ .unwrap_or(false);
+ if needs_init {
+ if let Some(uid) = UserDataPaths::load_last_user_id() {
+ if let Some(dir) = dirs::cache_dir().map(|d| d.join("qbz").join("users").join(uid.to_string())) {
+ match service.init_at(&dir).await {
+ Ok(()) => {
+ log::info!("[qbz-qt] lyrics cache bound for user {uid}");
+ *CACHE_USER.lock().unwrap() = uid.to_string();
+ }
+ Err(e) => log::error!("[qbz-qt] lyrics cache init failed: {e}"),
+ }
+ }
+ }
+ }
+ Some(service)
+}
+
+fn publish(doc: LyricsDoc) {
+ let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
+ crate::ui(move |mut b| {
+ b.as_mut().set_lyrics_json(QString::from(json.as_str()));
+ });
+}
+
+/// Publish the loading state (status 1) — called on every track change.
+pub fn publish_loading() {
+ publish(LyricsDoc {
+ status: 1,
+ ..Default::default()
+ });
+}
+
+/// Publish the idle/empty state (track cleared).
+pub fn publish_idle() {
+ publish(LyricsDoc::default());
+}
+
+/// Fetch lyrics for the new current track (called from
+/// `refresh_now_playing` on every track change; the panel re-requests on
+/// open via the same path — a cached doc returns immediately).
+pub async fn load_for_track(
+ runtime: &Arc>,
+ track: &QueueTrack,
+) {
+ publish_loading();
+ let Some(service) = service(runtime).await else {
+ publish(LyricsDoc {
+ status: 4,
+ error: "lyrics service unavailable".to_string(),
+ ..Default::default()
+ });
+ return;
+ };
+ let request = LyricsRequest {
+ track_id: Some(track.id),
+ source: qbz_lyrics::service::LyricsSourceKind::Qobuz,
+ title: track.title.clone(),
+ artist: track.artist.clone(),
+ album: Some(track.album.clone()),
+ duration_secs: Some(track.duration_secs),
+ offline: crate::offline_fwd::engine().is_offline(),
+ language: None,
+ };
+ let track_id = track.id;
+ match service.get(request).await {
+ Ok(response) => {
+ // Stale-response guard (F2 parity): a slow fetch for a track
+ // that is no longer current is discarded by the caller's own
+ // generation check — see playback_qt (it awaits sequentially
+ // per track change, so this mostly covers rapid skips).
+ let _ = track_id;
+ match response.outcome {
+ LyricsOutcome::Found(result) => {
+ let doc = result.doc;
+ let n = doc.lines.len();
+ let provider = doc.provider.as_str().to_string();
+ let synced = doc.synced;
+ let lines: Vec = doc
+ .lines
+ .iter()
+ .map(|l| LyricsLine {
+ time_ms: l.time_ms,
+ end_ms: l.end_ms,
+ text: l.text.clone(),
+ })
+ .collect();
+ publish(LyricsDoc {
+ status: if lines.is_empty() { 3 } else { 2 },
+ synced,
+ provider: provider.clone(),
+ lines,
+ ..Default::default()
+ });
+ log::info!("[qbz-qt] lyrics: {n} lines ({provider}, synced={synced}) for track {track_id}");
+ }
+ LyricsOutcome::NotFound => publish(LyricsDoc {
+ status: 3,
+ ..Default::default()
+ }),
+ LyricsOutcome::NotAvailableOffline => publish(LyricsDoc {
+ status: 5,
+ ..Default::default()
+ }),
+ }
+ }
+ Err(e) => publish(LyricsDoc {
+ status: 4,
+ error: e,
+ ..Default::default()
+ }),
+ }
+}
diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs
index 013f37b1c..1b875b8bb 100644
--- a/crates/qbz-qt/src/main.rs
+++ b/crates/qbz-qt/src/main.rs
@@ -17,7 +17,9 @@ mod now_playing;
mod offline_fwd;
mod album_qt;
mod artist_qt;
+mod lyrics_qt;
mod playback_qt;
+mod queue_qt;
mod sidebar_qt;
use std::pin::Pin;
@@ -466,6 +468,78 @@ pub(crate) fn toggle_pin(kind: String, id: String, title: String, subtitle: Stri
}
}
+// ============================ Lyrics panel (phase 9) ======================
+
+/// NPB lyrics button / panel close: toggle the column section and — like
+/// LyricsState.panel-opened — re-request the current track's lyrics on
+/// open (cached docs return immediately). The static is the source of
+/// truth (the ui() hop is queued, not synchronous).
+static LYRICS_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+
+pub(crate) fn toggle_lyrics() {
+ let open = !LYRICS_OPEN.swap(!LYRICS_OPEN.load(std::sync::atomic::Ordering::SeqCst), std::sync::atomic::Ordering::SeqCst);
+ ui(move |mut b| {
+ b.as_mut().set_lyrics_open(open);
+ });
+ if open {
+ let runtime = app();
+ spawn(async move {
+ if let Some(track) = runtime.core().current_track().await {
+ lyrics_qt::load_for_track(&runtime, &track).await;
+ }
+ });
+ }
+}
+
+// ============================ Queue panel (phase 9) =======================
+
+pub(crate) fn queue_set_page(page: i32) {
+ queue_qt::set_page(page);
+ let runtime = app();
+ spawn(async move { queue_qt::publish(&runtime).await });
+}
+
+pub(crate) fn queue_set_search(query: String) {
+ queue_qt::set_search(&query);
+ let runtime = app();
+ spawn(async move { queue_qt::publish(&runtime).await });
+}
+
+pub(crate) fn queue_play_upcoming(index: i32) {
+ let runtime = app();
+ spawn(async move { queue_qt::play_upcoming(&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 });
+}
+
+pub(crate) fn queue_remove_all_after(index: i32) {
+ let runtime = app();
+ spawn(async move { queue_qt::remove_all_after(&runtime, index.max(0) as usize).await });
+}
+
+pub(crate) fn queue_move_track(from: i32, to: i32) {
+ let runtime = app();
+ spawn(async move { queue_qt::move_track(&runtime, from.max(0) as usize, to.max(0) as usize).await });
+}
+
+pub(crate) fn queue_play_history(index: i32) {
+ let runtime = app();
+ spawn(async move { queue_qt::play_history(&runtime, index.max(0) as usize).await });
+}
+
+pub(crate) fn queue_clear() {
+ let runtime = app();
+ spawn(async move { queue_qt::clear_queue(&runtime).await });
+}
+
+pub(crate) fn queue_toggle_favorite(kind: String, id: String) {
+ let runtime = app();
+ spawn(async move { queue_qt::toggle_favorite(&runtime, &kind, &id).await });
+}
+
/// AlbumView header Shuffle.
pub(crate) fn play_album_shuffled(album_id: String) {
let runtime = app();
@@ -802,20 +876,6 @@ fn publish_home_sections(sections: &[home_qt::HomeSection]) {
});
}
-/// Build the bridge's `queueModel` QVariantList from per-row JSON strings
-/// (each QVariant carries one QString — see the nesting POC-NOTE in
-/// playback_qt.rs `publish_queue`).
-pub(crate) fn json_rows_to_qvariant_list(
- rows: Vec,
-) -> cxx_qt_lib::QList {
- let mut list = cxx_qt_lib::QList::::default();
- for row in rows {
- list.append(::construct(&QString::from(
- row.as_str(),
- )));
- }
- list
-}
fn main() {
qbz_log::install("info");
diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs
index 391e7a6b0..0b60c102c 100644
--- a/crates/qbz-qt/src/playback_qt.rs
+++ b/crates/qbz-qt/src/playback_qt.rs
@@ -345,6 +345,10 @@ pub async fn previous(runtime: &Arc>) {
}
}
+pub(crate) async fn play_queue_track_public(runtime: &Arc>, track_id: u64) {
+ play_queue_track(runtime, track_id).await;
+}
+
async fn play_queue_track(runtime: &Arc>, track_id: u64) {
if let Err(e) = runtime
.core()
@@ -426,6 +430,7 @@ pub async fn refresh_now_playing(runtime: &Arc>) {
crate::ui(move |mut b| {
b.as_mut().set_np_track_id(QString::from(""));
});
+ crate::lyrics_qt::publish_idle();
return;
};
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
@@ -459,6 +464,13 @@ pub async fn refresh_now_playing(runtime: &Arc>) {
// Artwork through the same cache pipeline as Home (attach + background
// download + republish — single url here).
crate::artwork_qt::attach_now_playing(&track.artwork_url.clone().unwrap_or_default());
+ // Lyrics for the new track (loading state, then the doc).
+ crate::lyrics_qt::publish_loading();
+ let runtime = runtime.clone();
+ let track = track.clone();
+ crate::spawn(async move {
+ crate::lyrics_qt::load_for_track(&runtime, &track).await;
+ });
}
/// Quality tier + exact label from a queue track's bit depth / sample rate
@@ -478,55 +490,10 @@ fn quality_badge(track: &QueueTrack) -> (String, String) {
(tier, label)
}
-/// Publish the real queue into `queueModel` (now-playing row first, then
-/// up-next rows) as one QVariant per row.
-///
-/// POC-NOTE: rows are JSON-encoded strings inside the QVariants — the same
-/// cxx-qt-lib nesting limitation as homeSectionsJson (a QVariant cannot
-/// hold a QVariantMap in 0.7.3); QML parses each row.
+/// Publish the queue panel document (queue_qt.rs — the full QueueState
+/// port: sections, history, pagination, search).
pub async fn publish_queue(runtime: &Arc>) {
- let state = runtime.core().get_queue_state().await;
- #[derive(serde::Serialize)]
- struct QueueRow {
- id: String,
- title: String,
- artist: String,
- duration: String,
- #[serde(rename = "artPath")]
- art_path: String,
- current: bool,
- }
- let mut rows: Vec = Vec::new();
- let mut push = |track: &QueueTrack, current: bool| {
- rows.push(QueueRow {
- id: track.id.to_string(),
- title: track.title.clone(),
- artist: track.artist.clone(),
- duration: fmt_mmss(track.duration_secs),
- art_path: crate::artwork_qt::cached_path(
- &track.artwork_url.clone().unwrap_or_default(),
- ),
- current,
- });
- };
- if let Some(current) = state.current_track.as_ref() {
- push(current, true);
- }
- for track in &state.upcoming {
- push(track, false);
- }
- let json_rows: Vec = rows
- .iter()
- .map(|r| serde_json::to_string(r).unwrap_or_default())
- .collect();
- crate::ui(move |mut b| {
- b.as_mut()
- .set_queue_model(crate::json_rows_to_qvariant_list(json_rows));
- });
-}
-
-fn fmt_mmss(secs: u64) -> String {
- format!("{}:{:02}", secs / 60, secs % 60)
+ crate::queue_qt::publish(runtime).await;
}
// ---------------------------------------------------------------------------
diff --git a/crates/qbz-qt/src/queue_qt.rs b/crates/qbz-qt/src/queue_qt.rs
new file mode 100644
index 000000000..6b304cc58
--- /dev/null
+++ b/crates/qbz-qt/src/queue_qt.rs
@@ -0,0 +1,287 @@
+//! Queue panel controller — Slint-free port of the `QueueState` assembly
+//! side of `crates/qbz/src/queue.rs`: NOW PLAYING card, UP NEXT with the
+//! #442 "Next in queue" / "Next up" section markers (from the core's
+//! `manual_next_count`), 40-row pagination, live search filter, and the
+//! History tab. Mutations go straight to the core queue API; every one
+//! ends with a republish.
+//!
+//! POC-NOTEs:
+//! - QConnect remote reorder, the playlist picker (save-as-playlist opens
+//! a modal picker upstream), infinite-play engine, sleep timer, stop-after
+//! marker, ephemeral rows, coverflow: out of scope.
+//! - Row favorite seeds from the phase-5 library feed (Slint uses
+//! fav_cache — same truth).
+
+use std::sync::Mutex;
+
+use qbz_app::shell::AppRuntime;
+use qbz_core::LoggingAdapter;
+use cxx_qt_lib::QString;
+use qbz_models::QueueTrack;
+use serde::Serialize;
+use std::sync::Arc;
+
+/// queue.rs PAGE_SIZE.
+pub const PAGE_SIZE: usize = 40;
+
+#[derive(Clone, Default, Serialize)]
+pub struct QueueRow {
+ pub id: String,
+ pub title: String,
+ pub artist: String,
+ #[serde(rename = "artistId")]
+ pub artist_id: String,
+ pub album: String,
+ pub duration: String,
+ #[serde(rename = "qualityTier")]
+ pub quality_tier: String,
+ #[serde(rename = "qualityDetail")]
+ pub quality_detail: String,
+ pub explicit: bool,
+ #[serde(rename = "artUrl")]
+ pub art_url: String,
+ #[serde(rename = "isFavorite")]
+ pub is_favorite: bool,
+ /// #442 section header drawn ABOVE this row: "" | "next-in-queue" |
+ /// "next-up" (only on the unfiltered list).
+ pub section: String,
+}
+
+#[derive(Default, Serialize)]
+pub struct QueueDoc {
+ #[serde(rename = "hasCurrent")]
+ pub has_current: bool,
+ pub current: Option,
+ pub upcoming: Vec,
+ #[serde(rename = "upcomingTotal")]
+ pub upcoming_total: usize,
+ #[serde(rename = "upcomingRemaining")]
+ pub upcoming_remaining: usize,
+ pub history: Vec,
+ pub page: usize,
+ #[serde(rename = "pageCount")]
+ pub page_count: usize,
+ #[serde(rename = "pageStart")]
+ pub page_start: usize,
+ #[serde(rename = "pageEnd")]
+ pub page_end: usize,
+ pub shuffle: bool,
+ #[serde(rename = "repeatMode")]
+ pub repeat_mode: i32,
+}
+
+/// Panel view state (search + page), session-scope like the Slint view.
+#[derive(Default)]
+struct ViewState {
+ search: String,
+ page: usize,
+}
+
+static VIEW: Mutex = Mutex::new(ViewState {
+ search: String::new(),
+ page: 0,
+});
+
+/// queue.rs `display_title` (version suffix).
+fn display_title(track: &QueueTrack) -> String {
+ match track.version.as_deref().filter(|v| !v.is_empty()) {
+ Some(version) => format!("{} ({version})", track.title),
+ None => track.title.clone(),
+ }
+}
+
+fn fmt_duration(secs: u64) -> String {
+ format!("{}:{:02}", secs / 60, secs % 60)
+}
+
+fn row_from(track: &QueueTrack) -> QueueRow {
+ let is_favorite = crate::library_qt::with_library(|d| {
+ d.feed
+ .iter()
+ .any(|i| i.kind == "track" && i.id == track.id.to_string() && i.is_favorite)
+ })
+ .unwrap_or(false);
+ let tier = match track.bit_depth {
+ Some(d) if d >= 24 => "hires",
+ Some(_) => "cd",
+ None if track.hires => "hires",
+ None => "",
+ }
+ .to_string();
+ QueueRow {
+ id: track.id.to_string(),
+ title: display_title(track),
+ artist: track.artist.clone(),
+ artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
+ album: track.album.clone(),
+ duration: fmt_duration(track.duration_secs),
+ quality_tier: tier,
+ quality_detail: crate::home_qt::quality_detail_from_parts(
+ track.bit_depth,
+ track.sample_rate,
+ ),
+ explicit: track.parental_warning,
+ art_url: track.artwork_url.clone().unwrap_or_default(),
+ is_favorite,
+ section: String::new(),
+ }
+}
+
+/// queue.rs `paginate` (unit-tested upstream; port).
+fn paginate(total: usize, requested_page: usize) -> (usize, usize, usize, usize) {
+ let page_count = total.div_ceil(PAGE_SIZE).max(1);
+ let page = requested_page.min(page_count - 1);
+ let start = page * PAGE_SIZE;
+ let end = (start + PAGE_SIZE).min(total);
+ (page, page_count, start, end)
+}
+
+/// Build + publish the whole panel document.
+pub async fn publish(runtime: &Arc>) {
+ let state = runtime.core().get_queue_state_full().await;
+ let (search, requested_page) = {
+ let view = VIEW.lock().unwrap();
+ (view.search.clone(), view.page)
+ };
+ let query = search.trim().to_lowercase();
+
+ // --- UP NEXT (search-filtered, paged, #442 markers) ------------------
+ let filtered: Vec<&QueueTrack> = if query.is_empty() {
+ state.upcoming.iter().collect()
+ } else {
+ state
+ .upcoming
+ .iter()
+ .filter(|t| {
+ display_title(t).to_lowercase().contains(&query)
+ || t.artist.to_lowercase().contains(&query)
+ })
+ .collect()
+ };
+ let upcoming_total = filtered.len();
+ let (page, page_count, start, end) = paginate(upcoming_total, requested_page);
+ VIEW.lock().unwrap().page = page;
+
+ let page_rows: Vec = filtered[start..end]
+ .iter()
+ .enumerate()
+ .map(|(i, t)| {
+ let mut r = row_from(t);
+ if query.is_empty() {
+ let g = start + i;
+ if state.manual_next_count > 0 && g == 0 {
+ r.section = "next-in-queue".into();
+ } else if g == state.manual_next_count {
+ r.section = "next-up".into();
+ }
+ }
+ r
+ })
+ .collect();
+
+ let remaining = state
+ .current_index
+ .map(|idx| state.total_tracks.saturating_sub(idx + 1))
+ .unwrap_or(state.total_tracks);
+
+ let history: Vec = state.history.iter().map(row_from).collect();
+ let current = state.current_track.as_ref().map(row_from);
+
+ let doc = QueueDoc {
+ has_current: current.is_some(),
+ current,
+ upcoming: page_rows,
+ upcoming_total,
+ upcoming_remaining: remaining,
+ history,
+ page,
+ page_count,
+ page_start: if upcoming_total == 0 { 0 } else { start + 1 },
+ page_end: end,
+ shuffle: state.shuffle,
+ repeat_mode: match state.repeat {
+ qbz_models::RepeatMode::Off => 0,
+ qbz_models::RepeatMode::All => 1,
+ qbz_models::RepeatMode::One => 2,
+ },
+ };
+ let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
+ crate::ui(move |mut b| {
+ b.as_mut().set_queue_json(QString::from(json.as_str()));
+ });
+}
+
+// ---------------------------------------------------------------------------
+// View state + mutations (every mutation republishes)
+// ---------------------------------------------------------------------------
+
+pub fn set_search(query: &str) {
+ {
+ let mut view = VIEW.lock().unwrap();
+ view.search = query.to_string();
+ view.page = 0;
+ }
+}
+
+pub fn set_page(page: i32) {
+ VIEW.lock().unwrap().page = page.max(0) as usize;
+}
+
+pub async fn play_upcoming(runtime: &Arc>, page_index: usize) {
+ let start = VIEW.lock().unwrap().page * PAGE_SIZE;
+ let upcoming_index = start + page_index;
+ if let Some(track) = runtime.core().play_upcoming_at(upcoming_index).await {
+ crate::playback_qt::play_queue_track_public(runtime, track.id).await;
+ }
+}
+
+pub async fn remove_upcoming(runtime: &Arc>, page_index: usize) {
+ let start = VIEW.lock().unwrap().page * PAGE_SIZE;
+ let upcoming_index = start + page_index;
+ runtime.core().remove_upcoming_track(upcoming_index).await;
+ publish(runtime).await;
+}
+
+pub async fn remove_all_after(runtime: &Arc>, page_index: usize) {
+ let start = VIEW.lock().unwrap().page * PAGE_SIZE;
+ let upcoming_index = start + page_index;
+ runtime.core().remove_upcoming_after(upcoming_index).await;
+ publish(runtime).await;
+}
+
+/// Drag reorder with QUEUE-WIDE indices (the QML list is unfiltered when
+/// drag is enabled, so page-local == page*40 + row).
+pub async fn move_track(runtime: &Arc>, from: usize, to: usize) {
+ runtime.core().move_track(from, to).await;
+ publish(runtime).await;
+}
+
+/// History replay (queue.rs play_history: a fresh single-track queue).
+pub async fn play_history(runtime: &Arc>, index: usize) {
+ let state = runtime.core().get_queue_state_full().await;
+ let Some(track) = state.history.get(index).cloned() else {
+ log::warn!("[qbz-qt] queue: play_history {index} out of range");
+ return;
+ };
+ runtime.core().set_queue(vec![track.clone()], Some(0)).await;
+ crate::playback_qt::play_queue_track_public(runtime, track.id).await;
+}
+
+pub async fn clear_queue(runtime: &Arc>) {
+ // queue.rs clear_queue: drops everything INCLUDING the current track
+ // (keep_current: false) and stops playback.
+ runtime.core().clear_queue(false).await;
+ let _ = runtime.core().stop();
+ publish(runtime).await;
+ crate::playback_qt::refresh_now_playing(runtime).await;
+}
+
+/// Toggle a heart for a queue row (Qobuz favorite), then republish.
+pub async fn toggle_favorite(
+ runtime: &Arc>,
+ kind: &str,
+ id: &str,
+) {
+ let _ = crate::library_qt::toggle_favorite(runtime, kind, id).await;
+ publish(runtime).await;
+}