From c59c883ac1736dab65df317302995b7d8bb97ea6 Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Tue, 28 Jul 2026 05:02:11 -0600
Subject: [PATCH] =?UTF-8?q?feat(qbz-qt):=20phase=2018=20=E2=80=94=20all=20?=
=?UTF-8?q?four=20NPB=20modes=20+=20ViewMode=20flyout?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PlayerBar.qml covers New/Classic/Large (full-width seekbar with times,
responsive 3-column layout, glass Classic SongCard variant, right
cluster), NowPlayingBar seam Loader picks per mode; Small unchanged.
ViewMode flyout wired (New/Classic/Small/Large live, Miniplayer/
Immersive/Kiosk inert replicas), mode persisted to the shared
ui_prefs npb_mode key, Large forces the sidebar open and docks the
208px cover (SidebarNowPlayingDock equivalent); AppShell bar height
mode-aware (42/112). Empty and playing states verified per mode;
user's persisted small pref restored after probes.
km
---
crates/qbz-qt/build.rs | 3 +
crates/qbz-qt/qml/AppShell.qml | 55 +-
crates/qbz-qt/qml/NowPlayingBar.qml | 16 +
crates/qbz-qt/qml/NowPlayingBarSmall.qml | 14 +-
crates/qbz-qt/qml/PlayerBar.qml | 615 ++++++++++++++++++
crates/qbz-qt/qml/ViewModeMenu.qml | 112 ++++
.../icons/accent/picture-in-picture-2.svg | 4 +
.../qbz-qt/qml/assets/icons/accent/rows-3.svg | 5 +
.../icons/black/picture-in-picture-2.svg | 4 +
.../qbz-qt/qml/assets/icons/black/rows-3.svg | 5 +
.../icons/favorite/picture-in-picture-2.svg | 4 +
.../qml/assets/icons/favorite/rows-3.svg | 5 +
.../icons/muted/picture-in-picture-2.svg | 4 +
.../qbz-qt/qml/assets/icons/muted/rows-3.svg | 5 +
.../icons/primary/picture-in-picture-2.svg | 4 +
.../qml/assets/icons/primary/rows-3.svg | 5 +
.../icons/secondary/picture-in-picture-2.svg | 4 +
.../qml/assets/icons/secondary/rows-3.svg | 5 +
.../icons/warning/picture-in-picture-2.svg | 4 +
.../qml/assets/icons/warning/rows-3.svg | 5 +
crates/qbz-qt/src/bridge.rs | 28 +
crates/qbz-qt/src/main.rs | 13 +
crates/qbz-qt/src/now_playing.rs | 25 +
crates/qbz-qt/src/playback_qt.rs | 6 +
crates/qbz-qt/src/settings_qt.rs | 60 ++
25 files changed, 1004 insertions(+), 6 deletions(-)
create mode 100644 crates/qbz-qt/qml/NowPlayingBar.qml
create mode 100644 crates/qbz-qt/qml/PlayerBar.qml
create mode 100644 crates/qbz-qt/qml/ViewModeMenu.qml
create mode 100644 crates/qbz-qt/qml/assets/icons/accent/picture-in-picture-2.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/accent/rows-3.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/black/picture-in-picture-2.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/black/rows-3.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/picture-in-picture-2.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/rows-3.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/muted/picture-in-picture-2.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/muted/rows-3.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/primary/picture-in-picture-2.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/primary/rows-3.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/picture-in-picture-2.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/rows-3.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/warning/picture-in-picture-2.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/warning/rows-3.svg
diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index 9c132e275..6bde35837 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -47,6 +47,9 @@ fn main() {
"qml/Sidebar.qml",
"qml/HeaderBar.qml",
"qml/NowPlayingBarSmall.qml",
+ "qml/NowPlayingBar.qml",
+ "qml/PlayerBar.qml",
+ "qml/ViewModeMenu.qml",
"qml/QueuePanel.qml",
"qml/LyricsPanel.qml",
"qml/HomeView.qml",
diff --git a/crates/qbz-qt/qml/AppShell.qml b/crates/qbz-qt/qml/AppShell.qml
index 0db77fdc4..f23513568 100644
--- a/crates/qbz-qt/qml/AppShell.qml
+++ b/crates/qbz-qt/qml/AppShell.qml
@@ -58,13 +58,14 @@ Rectangle {
// the compositor's business).
}
- NowPlayingBarSmall {
+ NowPlayingBar {
id: npb
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
- height: theme.npbSmallHeight
- // Square corners (see the header note above).
+ // Mode-aware height (AppShell.slint:396): Small collapses to one
+ // header-tall row; New/Classic/Large keep the full 112px.
+ height: QbzBridge.npbMode === 2 ? theme.npbSmallHeight : 112
}
Sidebar {
@@ -151,6 +152,54 @@ Rectangle {
}
}
+ // --- Large NPB (mode 3) cover dock (phase 18) -------------------------
+ // The L's vertical arm: the square now-playing cover pinned flush to
+ // the window bottom-left over the sidebar (SidebarNowPlayingDock.slint,
+ // AppShell.slint:747). Only while Large is ACTIVE (mode 3 + sidebar
+ // open). The spectrum band above the cover is NOT rendered — it needs
+ // the FFT visualizer tap, which the POC does not wire (POC-NOTE).
+ readonly property bool largeActive: QbzBridge.npbMode === 3 && QbzBridge.sidebarState === 0
+ Rectangle {
+ visible: root.largeActive
+ x: 16
+ y: parent.height - 221
+ width: 208
+ height: 221
+ color: "transparent"
+ // Drop-shadow approximation (the Slint dock's 24px blur).
+ Rectangle {
+ x: 0
+ y: 17
+ width: 208
+ height: 208
+ radius: theme.radiusMd
+ color: "#66000000"
+ }
+ Rectangle {
+ x: 0
+ y: 13
+ width: 208
+ height: 208
+ radius: theme.radiusMd
+ color: ambientOn ? theme.surfaceMainA22 : theme.surfaceMain
+ clip: true
+ RoundedImage {
+ visible: QbzBridge.npHasTrack
+ anchors.fill: parent
+ source: QbzBridge.npArtworkPath
+ radius: theme.radiusMd
+ }
+ QbzIcon {
+ visible: !QbzBridge.npHasTrack
+ name: "music"
+ width: 66
+ height: 66
+ anchors.centerIn: parent
+ tintName: "muted"
+ }
+ }
+ }
+
// Search cortinilla (phase 15): the live-search dropdown overlay, LAST
// child so it renders above every surface (Cortinilla.slint's mount).
Cortinilla {
diff --git a/crates/qbz-qt/qml/NowPlayingBar.qml b/crates/qbz-qt/qml/NowPlayingBar.qml
new file mode 100644
index 000000000..5b4280710
--- /dev/null
+++ b/crates/qbz-qt/qml/NowPlayingBar.qml
@@ -0,0 +1,16 @@
+// NowPlayingBar SHELL — the mode seam (NowPlayingBar.slint, phase 18):
+// mounts NowPlayingBarSmall for mode 2 (Small) and the full PlayerBar for
+// modes 0 (New) / 1 (Classic) / 3 (Large). AppShell pins the height
+// mode-aware (42px Small / 112px otherwise).
+
+import QtQuick
+import com.blitzfc.qbz
+
+Item {
+ id: root
+
+ Loader {
+ anchors.fill: parent
+ source: QbzBridge.npbMode === 2 ? "NowPlayingBarSmall.qml" : "PlayerBar.qml"
+ }
+}
diff --git a/crates/qbz-qt/qml/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/NowPlayingBarSmall.qml
index 48fbbdd4e..618aa3f9d 100644
--- a/crates/qbz-qt/qml/NowPlayingBarSmall.qml
+++ b/crates/qbz-qt/qml/NowPlayingBarSmall.qml
@@ -1,6 +1,6 @@
// Bottom now-playing / transport bar — QML port of
-// crates/qbz-ui/ui/shell/PlayerBarSmall.slint (Mode C "Small", the only
-// variant in this POC).
+// crates/qbz-ui/ui/shell/PlayerBarSmall.slint (Mode C "Small"). Mounted by
+// the NowPlayingBar seam when npbMode == 2 (phase 18).
//
// 3px full-width top seekbar (3-color: background / cache / progress),
// then the symmetric 3-column controls row (~39px):
@@ -406,7 +406,12 @@ Rectangle {
BarIconBtn { name: "monitor-speaker" }
BarIconBtn { name: "cast" }
BarIconBtn { name: "settings-2" }
- BarIconBtn { name: "layout-grid" }
+ BarIconBtn {
+ id: smallViewBtn
+ name: "layout-grid"
+ active: smallViewMenu.opened
+ onClicked: smallViewMenu.openBelowRight(smallViewBtn)
+ }
BarIconBtn {
name: "mic-vocal"
active: QbzBridge.lyricsOpen
@@ -468,4 +473,7 @@ Rectangle {
}
}
}
+
+ // The Now-Playing-view mode menu (phase 18 — shared with PlayerBar).
+ ViewModeMenu { id: smallViewMenu }
}
diff --git a/crates/qbz-qt/qml/PlayerBar.qml b/crates/qbz-qt/qml/PlayerBar.qml
new file mode 100644
index 000000000..a632fd3d6
--- /dev/null
+++ b/crates/qbz-qt/qml/PlayerBar.qml
@@ -0,0 +1,615 @@
+// PlayerBar — the full 112px now-playing bar for modes 0 (New), 1
+// (Classic) and 3 (Large) — QML port of crates/qbz-ui/ui/shell/
+// PlayerBar.slint (phase 18). Mode 2 (Small) mounts NowPlayingBarSmall
+// instead (the NowPlayingBar seam picks the body).
+//
+// Composition (1:1): a full-width SeekBar with times (6px margins; in
+// Large the left edge insets by the sidebar width so the seek arm clears
+// the docked cover), then the controls row in three responsive symmetric
+// columns (the side columns share a fraction so PLAY lands on the window
+// centre at every width):
+// width < 1366 -> 39% | 22% | 39%
+// 1366..<1920 -> 30% | 40% | 30%
+// width >= 1920 -> 25% | 50% | 25%
+// Classic uses a fixed 32.4/35.2/32.4 split (col-side 0.324).
+//
+// New (0): LEFT SongCard · CENTER transport (filled disc) · RIGHT cluster
+// Classic (1): LEFT transport · CENTER glass SongCard (<=560px) · RIGHT cluster
+// Large (3): LEFT SongCard WITHOUT the cover (it lives in the sidebar
+// dock, shifted right to clear it) · CENTER transport ·
+// RIGHT small AudioStamp + cluster.
+//
+// Wired: transport (shuffle/prev/play/next/repeat), seek, volume+mute,
+// lyrics, queue, the Now-Playing-view flyout (npbSetMode), open
+// album/artist via the SongCard meta links. Inert (POC-NOTEs): Connect
+// (device flyout), track-info, the dot-LED output badges use the real
+// backend state but the volume LOCK (ALSA hw / remote) is not enforced.
+
+import QtQuick
+import QtQuick.Controls
+import com.blitzfc.qbz
+
+Rectangle {
+ id: root
+ color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
+ readonly property bool ambientOn: QbzBridge.ambientMode > 0 && QbzBridge.npHasTrack
+ readonly property bool largeActive: QbzBridge.npbMode === 3 && QbzBridge.sidebarState === 0
+ readonly property bool isClassic: QbzBridge.npbMode === 1
+
+ QbzTheme { id: theme }
+
+ // Responsive zones (PlayerBar.slint). Classic fixes col-side at 0.324;
+ // New/Large use the width-driven side fraction. Both keep the two side
+ // columns EQUAL so the centre column stays dead centre.
+ property real sideFrac: root.width >= 1920 ? 0.25 : (root.width >= 1366 ? 0.30 : 0.39)
+ property real colSide: isClassic ? 0.324 : sideFrac
+ property real colCentre: 1.0 - 2.0 * colSide
+ // The docked-cover width the Large seek arm + song card must clear
+ // (sidebar rendered width; 240 when open).
+ readonly property int dockWidth: largeActive ? 240 : 0
+
+ readonly property var settingsDoc: parseSettings()
+ function parseSettings() {
+ try {
+ return JSON.parse(QbzBridge.settingsJson)
+ } catch (e) {
+ return ({})
+ }
+ }
+ readonly property bool backendPipewire: settingsDoc.backendIsPipewire === true
+ readonly property bool dacPassthrough: settingsDoc.dacPassthrough === true
+
+ function fmt(secs) {
+ var m = Math.floor(secs / 60)
+ var s = Math.floor(secs % 60)
+ return m + ":" + (s < 10 ? "0" : "") + s
+ }
+
+ // --- Shared bits -------------------------------------------------------
+
+ // Square compact icon button (BarControls.IconButton).
+ component BarIconBtn: Rectangle {
+ property string name: ""
+ property int iconSize: 16
+ property bool active: false
+ property bool btnEnabled: true
+ signal clicked()
+
+ width: 32
+ height: 32
+ radius: theme.radiusSm
+ opacity: btnEnabled ? 1.0 : 0.3
+ color: (biArea.containsMouse && btnEnabled) ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ name: parent.name
+ width: parent.iconSize
+ height: parent.iconSize
+ anchors.centerIn: parent
+ tintName: parent.active ? "accent"
+ : (biArea.containsMouse && parent.btnEnabled) ? "primary" : "secondary"
+ }
+ MouseArea {
+ id: biArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (parent.btnEnabled) parent.clicked()
+ }
+ }
+
+ // Dot LED (circle-dot on / circle off) + constant-colour label — the
+ // New-mode output indicators (SongCard dot-leds).
+ component DotLed: Row {
+ property string label: ""
+ property bool on: false
+ spacing: 4
+ height: 12
+ Rectangle {
+ width: 8
+ height: 8
+ radius: 4
+ anchors.verticalCenter: parent.verticalCenter
+ color: parent.on ? theme.accent : "transparent"
+ border.width: 1
+ border.color: parent.on ? theme.accent : theme.textMuted
+ }
+ Text {
+ text: parent.label
+ color: theme.textMuted
+ font.pixelSize: 9
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 0.5
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+
+ // The audio stamp (quality badge + dot LEDs) — New mode keeps the full
+ // stamp inside the SongCard; Large mounts a compact one on the right
+ // column's left wall.
+ component AudioStamp: Row {
+ property bool showLeds: true
+ spacing: 8
+ height: 30
+ layoutDirection: Qt.RightToLeft
+
+ Row {
+ spacing: 5
+ layoutDirection: Qt.RightToLeft
+ anchors.verticalCenter: parent.verticalCenter
+ Text {
+ visible: QbzBridge.npQualityLabel !== ""
+ text: QbzBridge.npQualityLabel
+ font.pixelSize: 9
+ font.weight: theme.weightSemibold
+ color: theme.textPrimary
+ elide: Text.ElideRight
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Image {
+ visible: QbzBridge.npQualityTier === "hires"
+ source: "assets/hi-res.svg"
+ width: 42
+ height: 28
+ anchors.verticalCenter: parent.verticalCenter
+ sourceSize: Qt.size(84, 56)
+ fillMode: Image.PreserveAspectFit
+ }
+ Rectangle {
+ visible: QbzBridge.npQualityTier !== "hires" && QbzBridge.npQualityLabel !== ""
+ width: 30
+ height: 30
+ radius: 3
+ color: theme.surfaceElevated
+ border.width: 1
+ border.color: theme.borderSubtle
+ anchors.verticalCenter: parent.verticalCenter
+ QbzIcon { name: "cd"; width: 16; height: 16; anchors.centerIn: parent; tintName: "muted" }
+ }
+ }
+ Column {
+ visible: showLeds
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 1
+ DotLed { label: "PIPEWIRE"; on: root.backendPipewire }
+ DotLed { label: "DACPASS"; on: root.dacPassthrough }
+ }
+ }
+
+ // SongCard (SongCard.slint): cover + title + context meta + the in-card
+ // audio stamp. `glass` renders the Classic contained card.
+ component SongCard: Rectangle {
+ property bool showArt: true
+ property bool showBadges: true
+ property bool glass: false
+ property int artSize: glass ? 60 : 74
+ width: 200
+ height: glass ? 64 : 74
+ radius: glass ? 8 : 0
+ color: ambientOn ? "transparent" : (glass ? theme.surfaceElevated : "transparent")
+
+ Row {
+ anchors.left: parent.left
+ anchors.right: stamp.left
+ anchors.rightMargin: 4
+ height: parent.height
+ spacing: 0
+
+ Item {
+ visible: showArt
+ width: showArt ? artSize : 0
+ height: parent.height
+ Rectangle {
+ width: artSize
+ height: artSize
+ anchors.centerIn: parent
+ radius: 6
+ color: theme.surfaceElevated
+ clip: true
+ RoundedImage {
+ visible: QbzBridge.npHasTrack
+ anchors.fill: parent
+ source: QbzBridge.npArtworkPath
+ radius: 6
+ }
+ QbzIcon {
+ visible: !QbzBridge.npHasTrack
+ name: "music"
+ width: artSize * 0.5
+ height: artSize * 0.5
+ anchors.centerIn: parent
+ tintName: "muted"
+ }
+ // Fetch overlay (resolving/downloading).
+ Rectangle {
+ visible: QbzBridge.npHasTrack && QbzBridge.npLoading
+ anchors.fill: parent
+ radius: 6
+ color: "#aa000000"
+ QbzSpinner {
+ width: artSize * 0.5
+ height: artSize * 0.5
+ anchors.centerIn: parent
+ size: artSize * 0.5
+ }
+ }
+ }
+ }
+ Item { width: showArt ? 11 : 0; height: 1 }
+
+ Column {
+ width: parent.width - (showArt ? artSize + 11 : 0)
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+ Text {
+ width: parent.width
+ text: QbzBridge.npHasTrack ? QbzBridge.npTitle : QbzBridge.tr("Nothing playing")
+ color: theme.textPrimary
+ font.pixelSize: theme.fontBody
+ font.weight: theme.weightMedium
+ elide: Text.ElideRight
+ }
+ Row {
+ visible: QbzBridge.npHasTrack
+ spacing: 7
+ height: 18
+ // Context-stack icon ("Show track playing context" pref).
+ Rectangle {
+ visible: QbzBridge.showContextIcon
+ width: 16
+ height: 18
+ radius: 3
+ color: ctxArea.containsMouse ? theme.surfaceHover : "transparent"
+ QbzIcon {
+ name: "layers"
+ width: 13
+ height: 13
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: ctxArea.containsMouse ? "primary" : "muted"
+ }
+ MouseArea {
+ id: ctxArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: if (QbzBridge.npAlbumId !== "") QbzBridge.openAlbum(QbzBridge.npAlbumId)
+ }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: QbzBridge.npArtist
+ color: artistArea.containsMouse && QbzBridge.npArtistId !== "" ? theme.accent : theme.textMuted
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ MouseArea {
+ id: artistArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: QbzBridge.npArtistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (QbzBridge.npArtistId !== "") QbzBridge.openArtist(QbzBridge.npArtistId)
+ }
+ }
+ Rectangle {
+ width: 3
+ height: 3
+ radius: 1.5
+ color: theme.textMuted
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: QbzBridge.npAlbum
+ color: albumArea.containsMouse && QbzBridge.npAlbumId !== "" ? theme.accent : theme.textMuted
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ MouseArea {
+ id: albumArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: QbzBridge.npAlbumId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (QbzBridge.npAlbumId !== "") QbzBridge.openAlbum(QbzBridge.npAlbumId)
+ }
+ }
+ }
+ }
+ }
+ AudioStamp {
+ id: stamp
+ visible: showBadges && QbzBridge.npHasTrack
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ // Transport cluster (TransportControls.slint): shuffle · prev · play ·
+ // next · repeat. play-circle = the filled 44px accent disc (New/Large);
+ // classic-actions = plain glyphs, left-aligned (Classic).
+ component TransportControls: Row {
+ property bool playCircle: true
+ spacing: playCircle ? 12 : 4
+ height: 44
+
+ BarIconBtn {
+ name: "shuffle"
+ active: QbzBridge.npShuffle
+ btnEnabled: QbzBridge.npHasTrack
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: QbzBridge.toggleShuffle()
+ }
+ BarIconBtn {
+ name: "skip-back"
+ btnEnabled: QbzBridge.npHasTrack
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: QbzBridge.previous()
+ }
+ Rectangle {
+ width: playCircle ? 44 : 32
+ height: playCircle ? 44 : 32
+ radius: width / 2
+ anchors.verticalCenter: parent.verticalCenter
+ opacity: QbzBridge.npHasTrack ? 1.0 : 0.3
+ color: playCircle ? (playArea.containsMouse && QbzBridge.npHasTrack ? theme.accentHover : theme.accent)
+ : ((playArea.containsMouse && QbzBridge.npHasTrack) ? theme.surfaceHover : "transparent")
+ QbzIcon {
+ name: QbzBridge.npPlaying ? "pause" : "play-fill"
+ width: playCircle ? 20 : 16
+ height: playCircle ? 20 : 16
+ anchors.centerIn: parent
+ tintName: playCircle ? "black" : "primary"
+ }
+ MouseArea {
+ id: playArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: QbzBridge.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: if (QbzBridge.npHasTrack) QbzBridge.togglePlay()
+ }
+ }
+ BarIconBtn {
+ name: "skip-forward"
+ btnEnabled: QbzBridge.npHasTrack
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: QbzBridge.next()
+ }
+ BarIconBtn {
+ name: QbzBridge.npRepeatMode === 2 ? "repeat-1" : "repeat"
+ active: QbzBridge.npRepeatMode > 0
+ btnEnabled: QbzBridge.npHasTrack
+ anchors.verticalCenter: parent.verticalCenter
+ onClicked: QbzBridge.cycleRepeat()
+ }
+ }
+
+ // ============================ the bar =================================
+ Column {
+ anchors.fill: parent
+ spacing: 0
+
+ Item { width: 1; height: 6 }
+
+ // Full-width seek bar (times at the ends). In Large the seek arm is
+ // LEFT-INSET by the docked-cover width so it begins right of the
+ // dock (AppShell.slint's large-active padding).
+ Item {
+ width: parent.width
+ height: 20
+ Text {
+ x: root.dockWidth > 0 ? root.dockWidth + 6 : 6
+ anchors.verticalCenter: parent.verticalCenter
+ visible: QbzBridge.npHasTrack
+ text: root.fmt(QbzBridge.npElapsedSecs)
+ color: theme.textMuted
+ font.pixelSize: 11
+ }
+ Rectangle {
+ id: seekTrack
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: (root.dockWidth > 0 ? root.dockWidth + 6 : 6) + (QbzBridge.npHasTrack ? 46 : 0)
+ anchors.rightMargin: 6 + (QbzBridge.npHasTrack ? 46 : 0)
+ height: 4
+ radius: 2
+ anchors.verticalCenter: parent.verticalCenter
+ color: theme.surfaceElevated
+ Rectangle {
+ width: parent.width * Math.min(Math.max(QbzBridge.npCacheProgress, 0), 1)
+ height: parent.height
+ radius: 2
+ color: theme.borderMuted
+ }
+ Rectangle {
+ width: parent.width * Math.min(Math.max(QbzBridge.npProgress, 0), 1)
+ height: parent.height
+ radius: 2
+ color: theme.accent
+ }
+ Rectangle {
+ visible: QbzBridge.npHasTrack
+ width: 12
+ height: 12
+ radius: 6
+ color: theme.textPrimary
+ x: parent.width * Math.min(Math.max(QbzBridge.npProgress, 0), 1) - width / 2
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ Text {
+ anchors.right: parent.right
+ anchors.rightMargin: 6
+ anchors.verticalCenter: parent.verticalCenter
+ visible: QbzBridge.npHasTrack
+ text: "-" + root.fmt(Math.max(0, QbzBridge.npDurationSecs - QbzBridge.npElapsedSecs))
+ color: theme.textMuted
+ font.pixelSize: 11
+ }
+ MouseArea {
+ anchors.left: seekTrack.left
+ anchors.right: seekTrack.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ cursorShape: QbzBridge.npHasTrack ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onPressed: if (QbzBridge.npHasTrack) QbzBridge.seek(Math.min(Math.max(mouseX / width, 0), 1))
+ onPositionChanged: if (pressed && QbzBridge.npHasTrack) QbzBridge.seek(Math.min(Math.max(mouseX / width, 0), 1))
+ }
+ }
+
+ Item { width: 1; height: 6 }
+
+ // --- Controls: the responsive symmetric zones -----------------------
+ Item {
+ width: parent.width
+ height: parent.height - 38
+
+ // LEFT column.
+ Item {
+ anchors.left: parent.left
+ anchors.leftMargin: 6
+ width: (parent.width - 12) * root.colSide
+ height: parent.height
+ clip: true
+
+ // New (0) AND Large (3): the song card (Large drops the cover
+ // — it lives in the dock — and shifts right to clear it).
+ SongCard {
+ visible: !root.isClassic
+ x: root.largeActive ? root.dockWidth + 8 : 0
+ width: parent.width - x
+ anchors.verticalCenter: parent.verticalCenter
+ showArt: !root.largeActive
+ showBadges: !root.largeActive
+ }
+ // Classic: transport cluster hugging the left edge (plain
+ // play glyph, Tauri arrangement).
+ TransportControls {
+ visible: root.isClassic
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ playCircle: false
+ }
+ }
+
+ // CENTRE column.
+ Item {
+ x: 6 + (parent.width - 12) * root.colSide
+ width: (parent.width - 12) * root.colCentre
+ height: parent.height
+ clip: true
+
+ // New (0) AND Large (3): centred transport, PLAY on the
+ // window centre.
+ TransportControls {
+ visible: !root.isClassic
+ anchors.centerIn: parent
+ playCircle: true
+ }
+ // Classic: the contained glass song card (<=560px cap).
+ SongCard {
+ visible: root.isClassic
+ glass: true
+ width: Math.min(parent.width, 560)
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ // RIGHT column — secondary actions + volume, clustered at the
+ // right wall.
+ Item {
+ anchors.right: parent.right
+ anchors.rightMargin: 6
+ width: (parent.width - 12) * root.colSide
+ height: parent.height
+ clip: true
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 1
+
+ // Large: the SMALL AudioStamp just LEFT of the icon
+ // cluster at a fixed 12px gap (the badges move out of the
+ // song card in Large).
+ AudioStamp {
+ visible: root.largeActive && QbzBridge.npHasTrack
+ showLeds: false
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Item { visible: root.largeActive; width: 12; height: 1 }
+
+ // Qobuz Connect — inert (device flyout out of scope).
+ BarIconBtn { name: "element-connect" }
+
+ // Now-Playing view — the mode flyout (phase 18).
+ BarIconBtn {
+ name: "layout-grid"
+ active: viewMenu.opened
+ onClicked: viewMenu.openBelowRight(viewBtn)
+ id: viewBtn
+ }
+
+ // Lyrics.
+ BarIconBtn {
+ name: "mic-vocal"
+ active: QbzBridge.lyricsOpen
+ onClicked: QbzBridge.toggleLyrics()
+ }
+
+ Item { width: 6; height: 1 }
+
+ // Volume.
+ BarIconBtn {
+ name: QbzBridge.npMuted ? "volume-x" : "volume-2"
+ active: QbzBridge.npMuted
+ onClicked: QbzBridge.toggleMute()
+ }
+ Item {
+ width: 81
+ height: 32
+ Rectangle {
+ id: volTrack
+ width: parent.width
+ height: 4
+ radius: 2
+ anchors.verticalCenter: parent.verticalCenter
+ color: theme.surfaceElevated
+ Rectangle {
+ width: parent.width * QbzBridge.npVolume
+ height: parent.height
+ radius: 2
+ color: theme.accent
+ }
+ Rectangle {
+ width: 16
+ height: 16
+ radius: 8
+ color: theme.textPrimary
+ x: parent.width * QbzBridge.npVolume - width / 2
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onPressed: QbzBridge.setVolume(Math.min(Math.max(mouseX / width, 0), 1))
+ onPositionChanged: if (pressed)
+ QbzBridge.setVolume(Math.min(Math.max(mouseX / width, 0), 1))
+ }
+ }
+
+ Item { width: 6; height: 1 }
+
+ // Queue panel toggle.
+ BarIconBtn {
+ name: "list-ordered"
+ active: QbzBridge.queueOpen
+ onClicked: QbzBridge.toggleQueue()
+ }
+ }
+ }
+ }
+
+ Item { width: 1; height: 6 }
+ }
+
+ // The Now-Playing-view mode menu (shared with the Small bar).
+ ViewModeMenu { id: viewMenu }
+}
diff --git a/crates/qbz-qt/qml/ViewModeMenu.qml b/crates/qbz-qt/qml/ViewModeMenu.qml
new file mode 100644
index 000000000..b7035e466
--- /dev/null
+++ b/crates/qbz-qt/qml/ViewModeMenu.qml
@@ -0,0 +1,112 @@
+// Now-Playing-view mode menu (PlayerBar.slint layout-menu, phase 18) —
+// one QbzContextMenu shared by the full PlayerBar and the Small bar:
+// New / Classic / Small / Large (with the current mode checked), then the
+// inert window-mode rows (Miniplayer / Immersive / Kiosk — the Slint has
+// them; the POC has no such surfaces, POC-NOTE).
+
+import QtQuick
+import QtQuick.Controls
+import com.blitzfc.qbz
+
+QbzContextMenu {
+ id: root
+ menuWidth: 196
+
+ Repeater {
+ model: [
+ { "label": QbzBridge.tr("New"), "icon": "panel-left", "mode": 0 },
+ { "label": QbzBridge.tr("Classic"), "icon": "panel-right-close", "mode": 1 },
+ { "label": QbzBridge.tr("Small"), "icon": "rows-3", "mode": 2 },
+ { "label": QbzBridge.tr("Large"), "icon": "layout-grid", "mode": 3 },
+ ]
+ delegate: Rectangle {
+ required property var modelData
+ width: parent ? parent.width : 0
+ height: 33
+ radius: 5
+ color: miArea.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 - 22
+ text: modelData.label
+ color: theme.textSecondary
+ font.pixelSize: 13
+ verticalAlignment: Text.AlignVCenter
+ elide: Text.ElideRight
+ }
+ QbzIcon {
+ visible: QbzBridge.npbMode === modelData.mode
+ name: "check"
+ width: 13
+ height: 13
+ anchors.verticalCenter: parent.verticalCenter
+ tintName: "accent"
+ }
+ }
+ MouseArea {
+ id: miArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ root.close()
+ QbzBridge.npbSetMode(modelData.mode)
+ }
+ }
+ }
+ }
+
+ // Divider + the window-mode rows (Miniplayer / Immersive / Kiosk —
+ // present in the Slint menu; inert in the POC (no such surfaces).
+ Item {
+ width: parent ? parent.width : 0
+ height: 7
+ Rectangle {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ height: 1
+ color: theme.borderSubtle
+ }
+ }
+ Repeater {
+ model: [
+ { "label": QbzBridge.tr("Miniplayer"), "icon": "picture-in-picture-2" },
+ { "label": QbzBridge.tr("Immersive"), "icon": "maximize-2" },
+ { "label": QbzBridge.tr("Kiosk mode"), "icon": "hard-drive" },
+ ]
+ delegate: Rectangle {
+ required property var modelData
+ width: parent ? parent.width : 0
+ height: 33
+ radius: 5
+ opacity: 0.45
+ color: "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
+ text: modelData.label
+ color: theme.textSecondary
+ font.pixelSize: 13
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ // Inert (POC-NOTE): no miniplayer/immersive/kiosk surfaces.
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/assets/icons/accent/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/accent/picture-in-picture-2.svg
new file mode 100644
index 000000000..7a8988b0b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/picture-in-picture-2.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/accent/rows-3.svg b/crates/qbz-qt/qml/assets/icons/accent/rows-3.svg
new file mode 100644
index 000000000..98c14810d
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/rows-3.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/black/picture-in-picture-2.svg
new file mode 100644
index 000000000..63f2a5ddd
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/picture-in-picture-2.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/rows-3.svg b/crates/qbz-qt/qml/assets/icons/black/rows-3.svg
new file mode 100644
index 000000000..b04b41040
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/rows-3.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/favorite/picture-in-picture-2.svg
new file mode 100644
index 000000000..96e9cfc5d
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/picture-in-picture-2.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/rows-3.svg b/crates/qbz-qt/qml/assets/icons/favorite/rows-3.svg
new file mode 100644
index 000000000..077ed6462
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/rows-3.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/muted/picture-in-picture-2.svg
new file mode 100644
index 000000000..8066ed6ff
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/picture-in-picture-2.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/rows-3.svg b/crates/qbz-qt/qml/assets/icons/muted/rows-3.svg
new file mode 100644
index 000000000..e2d88a57e
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/rows-3.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/primary/picture-in-picture-2.svg
new file mode 100644
index 000000000..7542d7ad5
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/picture-in-picture-2.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/rows-3.svg b/crates/qbz-qt/qml/assets/icons/primary/rows-3.svg
new file mode 100644
index 000000000..82a2d0365
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/rows-3.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/secondary/picture-in-picture-2.svg
new file mode 100644
index 000000000..8afa5d231
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/picture-in-picture-2.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/rows-3.svg b/crates/qbz-qt/qml/assets/icons/secondary/rows-3.svg
new file mode 100644
index 000000000..b1efe13a5
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/rows-3.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/picture-in-picture-2.svg b/crates/qbz-qt/qml/assets/icons/warning/picture-in-picture-2.svg
new file mode 100644
index 000000000..468e250d8
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/picture-in-picture-2.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/rows-3.svg b/crates/qbz-qt/qml/assets/icons/warning/rows-3.svg
new file mode 100644
index 000000000..47822b858
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/rows-3.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs
index 44369b7ab..21dae2c41 100644
--- a/crates/qbz-qt/src/bridge.rs
+++ b/crates/qbz-qt/src/bridge.rs
@@ -126,6 +126,15 @@ pub mod qbz_bridge {
#[qproperty(QString, view_param_id)]
// Now-playing track id (playing-row indicator in track lists).
#[qproperty(QString, np_track_id)]
+ #[qproperty(QString, np_album)]
+ #[qproperty(QString, np_album_id)]
+ #[qproperty(QString, np_artist_id)]
+ // Now-playing bar mode (phase 18): 0 New / 1 Classic / 2 Small /
+ // 3 Large — the ui_prefs npb_mode key, live-switchable.
+ #[qproperty(i32, npb_mode)]
+ // "Show track playing context" pref (Playback settings) — feeds the
+ // SongCard layers icon.
+ #[qproperty(bool, show_context_icon)]
// --- Lyrics panel (phase 9) ----------------------------------------
#[qproperty(bool, lyrics_open)]
@@ -414,6 +423,11 @@ pub mod qbz_bridge {
/// falling back to the studio discography (playback.rs play_artist).
#[qinvokable]
fn play_artist_card(self: Pin<&mut QbzBridge>, artist_id: QString);
+ /// Now-Playing-view flyout: switch the bar mode (0 New / 1 Classic /
+ /// 2 Small / 3 Large) — persists ui_prefs.npb_mode; Large forces the
+ /// sidebar open (the Slint "large" arm).
+ #[qinvokable]
+ fn npb_set_mode(self: Pin<&mut QbzBridge>, mode: i32);
/// Emitted with the next page of a releases bucket.
#[qsignal]
fn release_section_ready(self: Pin<&mut QbzBridge>, release_type: QString, cards_json: QString, has_more: bool);
@@ -612,6 +626,11 @@ pub struct QbzBridgeRust {
artist_json: QString,
view_param_id: QString,
np_track_id: QString,
+ np_album: QString,
+ np_album_id: QString,
+ np_artist_id: QString,
+ npb_mode: i32,
+ show_context_icon: bool,
queue_json: QString,
lyrics_open: bool,
lyrics_json: QString,
@@ -699,6 +718,11 @@ impl Default for QbzBridgeRust {
artist_json: QString::from("{}"),
view_param_id: QString::default(),
np_track_id: QString::default(),
+ np_album: QString::default(),
+ np_album_id: QString::default(),
+ np_artist_id: QString::default(),
+ npb_mode: crate::settings_qt::npb_mode_index(),
+ show_context_icon: crate::settings_qt::show_context_icon(),
queue_json: QString::from("{}"),
lyrics_open: false,
lyrics_json: QString::from("{}"),
@@ -983,6 +1007,10 @@ impl qbz_bridge::QbzBridge {
crate::play_artist_card(artist_id.to_string());
}
+ pub fn npb_set_mode(self: Pin<&mut Self>, mode: i32) {
+ crate::npb_set_mode(mode);
+ }
+
pub fn settings_bool(self: Pin<&mut Self>, key: QString, value: bool) {
crate::settings_bool(key.to_string(), value);
}
diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs
index 6b00d674a..6b824c176 100644
--- a/crates/qbz-qt/src/main.rs
+++ b/crates/qbz-qt/src/main.rs
@@ -801,6 +801,19 @@ pub(crate) fn playlist_set_follow_by_id(playlist_id: u64, follow: bool) {
spawn(async move { playlist_qt::set_follow_by_id(&runtime, playlist_id, follow).await });
}
+/// Now-Playing-view flyout: switch + persist the bar mode. Large forces
+/// the sidebar open (the Slint "large" arm — the dock needs it).
+pub(crate) fn npb_set_mode(mode: i32) {
+ let mode = settings_qt::set_npb_mode(mode);
+ log::info!("[qbz-qt] npb_mode -> {mode}");
+ ui(move |mut b| {
+ if mode == 3 {
+ b.as_mut().set_sidebar_state(0);
+ }
+ b.as_mut().set_npb_mode(mode);
+ });
+}
+
/// Artist-card overlay play (ArtistGridCard): Popular tracks with the
/// studio-discography fallback.
pub(crate) fn play_artist_card(artist_id: String) {
diff --git a/crates/qbz-qt/src/now_playing.rs b/crates/qbz-qt/src/now_playing.rs
index f3aca112c..738c2dce1 100644
--- a/crates/qbz-qt/src/now_playing.rs
+++ b/crates/qbz-qt/src/now_playing.rs
@@ -16,6 +16,9 @@ pub struct NowPlayingModel {
pub has_track: bool,
pub title: String,
pub artist: String,
+ pub album: String,
+ pub album_id: String,
+ pub artist_id: String,
pub artwork_path: String,
pub elapsed_secs: i32,
pub duration_secs: i32,
@@ -40,6 +43,9 @@ impl Default for NowPlayingModel {
has_track: false,
title: String::new(),
artist: String::new(),
+ album: String::new(),
+ album_id: String::new(),
+ artist_id: String::new(),
artwork_path: String::new(),
elapsed_secs: 0,
duration_secs: 0,
@@ -60,6 +66,9 @@ static MODEL: Mutex = Mutex::new(NowPlayingModel {
has_track: false,
title: String::new(),
artist: String::new(),
+ album: String::new(),
+ album_id: String::new(),
+ artist_id: String::new(),
artwork_path: String::new(),
elapsed_secs: 0,
duration_secs: 0,
@@ -92,6 +101,11 @@ fn publish(m: &NowPlayingModel) {
b.as_mut().set_np_has_track(m.has_track);
b.as_mut().set_np_title(QString::from(m.title.as_str()));
b.as_mut().set_np_artist(QString::from(m.artist.as_str()));
+ b.as_mut().set_np_album(QString::from(m.album.as_str()));
+ b.as_mut()
+ .set_np_album_id(QString::from(m.album_id.as_str()));
+ b.as_mut()
+ .set_np_artist_id(QString::from(m.artist_id.as_str()));
b.as_mut()
.set_np_artwork_path(QString::from(m.artwork_path.as_str()));
b.as_mut().set_np_elapsed_secs(m.elapsed_secs);
@@ -157,6 +171,9 @@ pub fn set_playing(playing: bool) {
pub struct TrackMeta {
pub title: String,
pub artist: String,
+ pub album: String,
+ pub album_id: String,
+ pub artist_id: String,
pub duration_secs: i32,
pub quality_tier: String,
pub quality_label: String,
@@ -172,6 +189,9 @@ pub fn set_track(meta: TrackMeta) {
m.has_track = true;
m.title = meta.title;
m.artist = meta.artist;
+ m.album = meta.album;
+ m.album_id = meta.album_id;
+ m.artist_id = meta.artist_id;
m.duration_secs = meta.duration_secs;
m.elapsed_secs = 0;
m.quality_tier = meta.quality_tier;
@@ -242,6 +262,11 @@ fn publish_with_cache(m: &NowPlayingModel) {
b.as_mut().set_np_has_track(m.has_track);
b.as_mut().set_np_title(QString::from(m.title.as_str()));
b.as_mut().set_np_artist(QString::from(m.artist.as_str()));
+ b.as_mut().set_np_album(QString::from(m.album.as_str()));
+ b.as_mut()
+ .set_np_album_id(QString::from(m.album_id.as_str()));
+ b.as_mut()
+ .set_np_artist_id(QString::from(m.artist_id.as_str()));
b.as_mut()
.set_np_artwork_path(QString::from(m.artwork_path.as_str()));
b.as_mut().set_np_elapsed_secs(m.elapsed_secs);
diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs
index f56c5577c..de704f4a0 100644
--- a/crates/qbz-qt/src/playback_qt.rs
+++ b/crates/qbz-qt/src/playback_qt.rs
@@ -613,6 +613,12 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc
crate::now_playing::set_track(crate::now_playing::TrackMeta {
title,
artist: track.artist.clone(),
+ album: track.album.clone(),
+ album_id: track.album_id.clone().unwrap_or_default(),
+ artist_id: track
+ .artist_id
+ .map(|id| id.to_string())
+ .unwrap_or_default(),
duration_secs: track.duration_secs as i32,
quality_tier: tier,
quality_label: label,
diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs
index f9fd620f8..e0bc5f6ac 100644
--- a/crates/qbz-qt/src/settings_qt.rs
+++ b/crates/qbz-qt/src/settings_qt.rs
@@ -224,6 +224,66 @@ pub fn toggle_ambient_background() -> i32 {
if next { 1 } else { 0 }
}
+// ---------------------------------------------------------------------------
+// ui_prefs.json (now-playing bar mode, phase 18) — the Slint `npb_mode` key:
+// "new" | "classic" | "small" | "large" (ui_prefs.rs:357-360; maps to
+// ShellState.npb-mode 0/1/2/3). Same additive key patch.
+// ---------------------------------------------------------------------------
+
+/// The persisted mode as a bridge index (unknown keys fall back to "new").
+pub fn npb_mode_index() -> i32 {
+ let Some(path) = prefs_path() else {
+ return 0;
+ };
+ let key = std::fs::read_to_string(path)
+ .ok()
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .and_then(|v| v.get("npb_mode").and_then(|q| q.as_str().map(str::to_string)))
+ .unwrap_or_else(|| "new".to_string());
+ match key.as_str() {
+ "classic" => 1,
+ "small" => 2,
+ "large" => 3,
+ _ => 0,
+ }
+}
+
+fn npb_mode_key(index: i32) -> &'static str {
+ match index {
+ 1 => "classic",
+ 2 => "small",
+ 3 => "large",
+ _ => "new",
+ }
+}
+
+/// Persist a mode index (0-3) to the shared key; returns the index.
+pub fn set_npb_mode(index: i32) -> i32 {
+ let index = index.clamp(0, 3);
+ if let Some(path) = prefs_path() {
+ let mut value: serde_json::Value = std::fs::read_to_string(&path)
+ .ok()
+ .and_then(|text| serde_json::from_str(&text).ok())
+ .unwrap_or_else(|| serde_json::json!({}));
+ if let Some(obj) = value.as_object_mut() {
+ obj.insert(
+ "npb_mode".to_string(),
+ serde_json::Value::String(npb_mode_key(index).to_string()),
+ );
+ if let Ok(text) = serde_json::to_string_pretty(&value) {
+ let _ = std::fs::write(&path, text);
+ }
+ }
+ }
+ index
+}
+
+/// The "Show track playing context" pref (Playback settings; feeds the
+/// SongCard layers icon — SettingsState.show-context-icon).
+pub fn show_context_icon() -> bool {
+ with_playback(|s| s.get_preferences().map(|p| p.show_context_icon)).unwrap_or(false)
+}
+
pub const STREAMING_QUALITY_KEYS: &[&str] = &["mp3", "cd", "hires", "hires_plus"];
pub const STREAMING_QUALITY_LABELS: &[&str] = &["MP3", "CD Quality", "Hi-Res", "Hi-Res+"];