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+"];