feat(qbz-qt): phase 18 — all four NPB modes + ViewMode flyout

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
This commit is contained in:
vicrodh committed 2026-07-28 05:02:11 -06:00
1 parent 8c1ff34205
commit c59c883ac1
25 files changed
+1004 -6

No files matched your search

+3
View File
@@ -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",
+52 -3
View File
@@ -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 {
+16
View File
@@ -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"
}
}
+11 -3
View File
@@ -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 }
}
+615
View File
@@ -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 }
}
+112
View File
@@ -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.
}
}
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 9V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M21 9H3" />
<path d="M21 15H3" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 9V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M21 9H3" />
<path d="M21 15H3" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 9V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M21 9H3" />
<path d="M21 15H3" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 9V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M21 9H3" />
<path d="M21 15H3" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 9V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M21 9H3" />
<path d="M21 15H3" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 9V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M21 9H3" />
<path d="M21 15H3" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 9V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M21 9H3" />
<path d="M21 15H3" />
</svg>

After

Width:  |  Height:  |  Size: 284 B

+28
View File
@@ -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);
}
+13
View File
@@ -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) {
+25
View File
@@ -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<NowPlayingModel> = 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);
+6
View File
@@ -613,6 +613,12 @@ pub(crate) async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>
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,
+60
View File
@@ -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::<serde_json::Value>(&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+"];