From b10ff98620c37f55f218cd2ea7b85f87fb2f4848 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:22:22 -0600 Subject: [PATCH] =?UTF-8?q?feat(qbz-qt):=20phase=209=20=E2=80=94=20Queue?= =?UTF-8?q?=20homologation=20+=20Lyrics=20side=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue panel 1:1: History tab (click replays per queue.rs semantics), #442 'Next in queue'/'Next up' section markers from manual_next_count, 40-row pagination + live search + page clamp, row actions (play, remove, remove-all-after, heart), press-drag reorder via move_track, clear queue, plural-aware footer count, exact empty states. New Lyrics panel: qbz-lyrics service + per-user cache wired, Qobuz-first chain with LRCLIB fallback, synced line view with line-level highlight + centered auto-follow + click-to-seek, exact status bodies. Both panels stack Feishin-style in the right column; NPB lyrics button wired. km --- crates/Cargo.lock | 1 + crates/qbz-qt/Cargo.toml | 1 + crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/AppShell.qml | 33 +- crates/qbz-qt/qml/LyricsPanel.qml | 337 ++++++++++++ crates/qbz-qt/qml/NowPlayingBarSmall.qml | 6 +- crates/qbz-qt/qml/QueuePanel.qml | 499 +++++++++++++++--- .../qml/assets/icons/accent/add-to-list.svg | 12 + .../qbz-qt/qml/assets/icons/accent/clock.svg | 4 + .../qml/assets/icons/accent/infinity.svg | 3 + .../qbz-qt/qml/assets/icons/accent/list-x.svg | 7 + .../icons/accent/sliders-horizontal.svg | 23 + .../qml/assets/icons/accent/trash-2.svg | 7 + .../qml/assets/icons/accent/trash-list.svg | 4 + .../qml/assets/icons/black/add-to-list.svg | 12 + .../qbz-qt/qml/assets/icons/black/clock.svg | 4 + .../qml/assets/icons/black/infinity.svg | 3 + .../qbz-qt/qml/assets/icons/black/list-x.svg | 7 + .../assets/icons/black/sliders-horizontal.svg | 23 + .../qbz-qt/qml/assets/icons/black/trash-2.svg | 7 + .../qml/assets/icons/black/trash-list.svg | 4 + .../qml/assets/icons/favorite/add-to-list.svg | 12 + .../qml/assets/icons/favorite/clock.svg | 4 + .../qml/assets/icons/favorite/infinity.svg | 3 + .../qml/assets/icons/favorite/list-x.svg | 7 + .../qml/assets/icons/favorite/mic-vocal.svg | 17 + .../icons/favorite/sliders-horizontal.svg | 23 + .../qml/assets/icons/favorite/trash-2.svg | 7 + .../qml/assets/icons/favorite/trash-list.svg | 4 + .../qml/assets/icons/muted/add-to-list.svg | 12 + .../qbz-qt/qml/assets/icons/muted/clock.svg | 4 + .../qml/assets/icons/muted/infinity.svg | 3 + .../qbz-qt/qml/assets/icons/muted/list-x.svg | 7 + .../assets/icons/muted/sliders-horizontal.svg | 23 + .../qbz-qt/qml/assets/icons/muted/trash-2.svg | 7 + .../qml/assets/icons/muted/trash-list.svg | 4 + .../qml/assets/icons/primary/add-to-list.svg | 12 + .../qbz-qt/qml/assets/icons/primary/clock.svg | 4 + .../qml/assets/icons/primary/infinity.svg | 3 + .../qml/assets/icons/primary/list-x.svg | 7 + .../icons/primary/sliders-horizontal.svg | 23 + .../qml/assets/icons/primary/trash-2.svg | 7 + .../qml/assets/icons/primary/trash-list.svg | 4 + .../assets/icons/secondary/add-to-list.svg | 12 + .../qml/assets/icons/secondary/clock.svg | 4 + .../qml/assets/icons/secondary/infinity.svg | 3 + .../qml/assets/icons/secondary/list-x.svg | 7 + .../icons/secondary/sliders-horizontal.svg | 23 + .../qml/assets/icons/secondary/trash-2.svg | 7 + .../qml/assets/icons/secondary/trash-list.svg | 4 + .../qml/assets/icons/warning/add-to-list.svg | 12 + .../qbz-qt/qml/assets/icons/warning/clock.svg | 4 + .../qml/assets/icons/warning/infinity.svg | 3 + .../qml/assets/icons/warning/list-x.svg | 7 + .../icons/warning/sliders-horizontal.svg | 23 + .../qml/assets/icons/warning/trash-2.svg | 7 + .../qml/assets/icons/warning/trash-list.svg | 4 + crates/qbz-qt/src/bridge.rs | 83 +++ crates/qbz-qt/src/home_qt.rs | 14 +- crates/qbz-qt/src/lyrics_qt.rs | 186 +++++++ crates/qbz-qt/src/main.rs | 88 ++- crates/qbz-qt/src/playback_qt.rs | 63 +-- crates/qbz-qt/src/queue_qt.rs | 287 ++++++++++ 63 files changed, 1888 insertions(+), 148 deletions(-) create mode 100644 crates/qbz-qt/qml/LyricsPanel.qml create mode 100644 crates/qbz-qt/qml/assets/icons/accent/add-to-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/clock.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/infinity.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/list-x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/sliders-horizontal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/trash-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/trash-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/add-to-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/clock.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/infinity.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/list-x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/sliders-horizontal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/trash-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/trash-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/add-to-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/clock.svg create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/infinity.svg create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/list-x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/mic-vocal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/sliders-horizontal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/trash-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/trash-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/add-to-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/clock.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/infinity.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/list-x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/sliders-horizontal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/trash-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/trash-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/add-to-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/clock.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/infinity.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/list-x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/sliders-horizontal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/trash-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/trash-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/add-to-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/clock.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/infinity.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/list-x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/sliders-horizontal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/trash-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/trash-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/add-to-list.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/clock.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/infinity.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/list-x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/sliders-horizontal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/trash-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/trash-list.svg create mode 100644 crates/qbz-qt/src/lyrics_qt.rs create mode 100644 crates/qbz-qt/src/queue_qt.rs 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; +}