feat(slint): add Large now-playing-bar mode (NPB mode B)

Fourth NPB layout: a cover + spectrum dock pinned flush to the bottom-left
of the open sidebar (forming an L), with the bottom bar reusing the New
layout (window-centred transport, full-width inset seekbar, right cluster)
minus the cover. Gated on the open sidebar (npb-mode 3 && sidebar-state 0);
cycling the sidebar out of the open state reverts live to New while keeping
the persisted choice.

- SidebarNowPlayingDock: AppShell root overlay, cover flush to the window
  bottom-left, compact spectrum band (Bars 28-mirrored / Waveform / Energy)
  above it, single top-right eye toggle to show/hide it. FFT reuses
  VisualizerState; when hidden the sidebar list reclaims the space.
- Extract AudioStamp into a shared exported component (consumed by the Small
  and Large bars); in Large it sits at a constant gap left of the right
  cluster, out of the left song card.
- Persist the visualizer on/off and the spectrum mode in ui_prefs; restore
  at startup and prime the FFT tap after visualizer::install().
- Reduce the horizontal volume slider width 10% across all NPB modes.

cc
This commit is contained in:
vicrodh committed 2026-06-21 14:53:33 -06:00
1 parent b1ac402cf7
commit 9d6b3533bc
10 files changed
+683 -154

No files matched your search

+75 -5
View File
@@ -4429,11 +4429,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.set_system_font(font.into());
}
// Now-playing bar layout (New = 0 / Classic = 1) — restore the persisted
// choice before the shell renders so the bar opens in the right mode.
window
.global::<ShellState>()
.set_npb_mode(crate::ui_prefs::npb_mode_index(&crate::ui_prefs::load().npb_mode));
// Now-playing bar layout (New = 0 / Classic = 1 / Small = 2 / Large = 3) —
// restore the persisted choice before the shell renders so the bar opens in
// the right mode. The sidebar defaults open (state 0), so a persisted "large"
// restores a valid Large; if some future startup leaves the sidebar closed,
// `large-active` stays false and the dock simply doesn't mount.
let restored_prefs = crate::ui_prefs::load();
let restored_npb = crate::ui_prefs::npb_mode_index(&restored_prefs.npb_mode);
{
let shell = window.global::<ShellState>();
shell.set_npb_mode(restored_npb);
// Large dock toggles — restore the persisted visualizer state + spectrum
// choice (default ON / Bars) before the shell renders.
shell.set_large_visualizer_on(restored_prefs.large_visualizer);
shell.set_large_spectrum_mode(crate::ui_prefs::large_spectrum_mode_index(
&restored_prefs.large_spectrum_mode,
));
}
// NOTE: the FFT tap is primed AFTER visualizer::install() (further below) — not
// here — because install() registers the `on_set_enabled` handler this call
// depends on.
window
.global::<AppearanceState>()
.set_album_header_gradient(crate::ui_prefs::load().album_header_gradient);
@@ -4489,6 +4504,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// opens. Must run on the UI thread before window.run().
visualizer::install(&window, &app_runtime);
// Prime the FFT tap if we restored straight into Large with the visualizer ON.
// This MUST run AFTER visualizer::install() — install() registers the
// `on_set_enabled` handler, so an earlier invoke would no-op (the cause of the
// dock spectrum sitting idle on the very first playback until the eye toggle
// was pressed). The AppShell `changed viz-should-run` handler covers every
// later transition; this only seeds the initial value.
{
let shell = window.global::<ShellState>();
if shell.get_large_active() && shell.get_large_visualizer_on() {
window.global::<VisualizerState>().invoke_set_enabled(true);
}
}
// WGPU UNDERLAY SPIKE: capture Slint's own wgpu Device/Queue at RenderingSetup
// so shader_underlay allocates its texture + submits on the SAME device Slint
// renders with (mandatory for Image::try_from). The render itself happens in
@@ -6600,6 +6628,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
("npb-view", "miniplayer") => {
crate::miniplayer::enter();
}
// Large (mode 3) — docks the cover + spectrum at the bottom of
// the OPEN sidebar, so force the sidebar to its open state before
// switching. The FFT tap is driven by AppShell's combined
// `viz-should-run` changed handler (Large active OR immersive
// open); forcing the state below makes `large-active` true, which
// turns the tap on.
("npb-view", "large") => {
if let Some(w) = weak.upgrade() {
let shell = w.global::<ShellState>();
shell.set_sidebar_state(0);
shell.set_npb_mode(crate::ui_prefs::npb_mode_index("large"));
let mut prefs = crate::ui_prefs::load();
prefs.npb_mode = "large".to_string();
crate::ui_prefs::save(&prefs);
}
}
("npb-view", mode @ ("new" | "classic" | "small")) => {
if let Some(w) = weak.upgrade() {
w.global::<ShellState>()
@@ -6609,6 +6653,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
crate::ui_prefs::save(&prefs);
}
}
// Large dock: visualizer on/off toggle (the cover's eye button).
// Routed through Rust so the choice persists in ui_prefs; the
// AppShell viz-should-run handler idles the FFT tap when off.
("npb-large", "viz-toggle") => {
if let Some(w) = weak.upgrade() {
let shell = w.global::<ShellState>();
let on = !shell.get_large_visualizer_on();
shell.set_large_visualizer_on(on);
let mut prefs = crate::ui_prefs::load();
prefs.large_visualizer = on;
crate::ui_prefs::save(&prefs);
}
}
// Large dock: cycle the spectrum visualization (Bars -> Waveform
// -> Energy), persisted in ui_prefs.
("npb-large", "spectrum-cycle") => {
if let Some(w) = weak.upgrade() {
let shell = w.global::<ShellState>();
let next = (shell.get_large_spectrum_mode() + 1).rem_euclid(3);
shell.set_large_spectrum_mode(next);
let mut prefs = crate::ui_prefs::load();
prefs.large_spectrum_mode =
crate::ui_prefs::large_spectrum_mode_key(next).to_string();
crate::ui_prefs::save(&prefs);
}
}
// Track Info modal — opened from the NPB (i) button, the
// song-card title, or a TrackRow context menu. Qobuz tracks
// only (the id must be a real catalog u64).
+41 -3
View File
@@ -117,10 +117,18 @@ pub struct UiPrefs {
/// Streaming-quality key — one of `STREAMING_QUALITIES[*].key`.
#[serde(default = "default_streaming_quality")]
pub streaming_quality: String,
/// Now-playing bar layout: `"new"` | `"classic"` | `"small"`. Maps to
/// `ShellState.npb-mode` (0 / 1 / 2).
/// Now-playing bar layout: `"new"` | `"classic"` | `"small"` | `"large"`.
/// Maps to `ShellState.npb-mode` (0 / 1 / 2 / 3).
#[serde(default = "default_npb_mode")]
pub npb_mode: String,
/// Large-NPB dock visualizer on/off (the cover's top-right eye toggle).
/// Persisted so a user who dislikes the spectrum keeps it off across runs.
#[serde(default = "default_large_visualizer")]
pub large_visualizer: bool,
/// Large-NPB dock spectrum visualization: `"bars"` | `"waveform"` | `"energy"`.
/// Maps to `ShellState.large-spectrum-mode` (0 / 1 / 2).
#[serde(default = "default_large_spectrum_mode")]
pub large_spectrum_mode: String,
/// Whether album/artist detail headers use artwork-derived backdrops.
#[serde(default = "default_album_header_gradient")]
pub album_header_gradient: bool,
@@ -193,6 +201,14 @@ fn default_npb_mode() -> String {
DEFAULT_NPB_MODE.to_string()
}
fn default_large_visualizer() -> bool {
true
}
fn default_large_spectrum_mode() -> String {
"bars".to_string()
}
fn default_album_header_gradient() -> bool {
DEFAULT_ALBUM_HEADER_GRADIENT
}
@@ -221,6 +237,8 @@ impl Default for UiPrefs {
Self {
streaming_quality: default_streaming_quality(),
npb_mode: default_npb_mode(),
large_visualizer: default_large_visualizer(),
large_spectrum_mode: default_large_spectrum_mode(),
album_header_gradient: default_album_header_gradient(),
intelligent_search: default_intelligent_search(),
immersive_search_action: default_immersive_search_action(),
@@ -239,15 +257,35 @@ impl Default for UiPrefs {
}
/// Map a persisted npb-mode key to the `ShellState.npb-mode` int
/// (New = 0, Classic = 1, Small = 2). Unknown keys fall back to New.
/// (New = 0, Classic = 1, Small = 2, Large = 3). Unknown keys fall back to New.
pub fn npb_mode_index(key: &str) -> i32 {
match key {
"classic" => 1,
"small" => 2,
"large" => 3,
_ => 0,
}
}
/// Map a persisted Large-dock spectrum key to `ShellState.large-spectrum-mode`
/// (Bars = 0, Waveform = 1, Energy = 2). Unknown keys fall back to Bars.
pub fn large_spectrum_mode_index(key: &str) -> i32 {
match key {
"waveform" => 1,
"energy" => 2,
_ => 0,
}
}
/// Inverse of [`large_spectrum_mode_index`] — the persisted key for an int mode.
pub fn large_spectrum_mode_key(index: i32) -> &'static str {
match index {
1 => "waveform",
2 => "energy",
_ => "bars",
}
}
/// Resolve `<data_dir>/qbz/ui_prefs.json`.
fn prefs_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json"))
@@ -389,7 +389,9 @@ component ImmersivePlayerBar inherits Rectangle {
mouse-cursor: pointer;
clicked => {
ImmersiveState.open = false;
VisualizerState.set-enabled(false);
// Keep the tap alive if the Large NPB dock still needs
// its spectrum; only idle it when nothing else does.
VisualizerState.set-enabled(ShellState.large-active && ShellState.large-visualizer-on);
}
}
QbzIcon {
@@ -1025,7 +1027,8 @@ component ImmersiveWindowControls inherits Rectangle {
icon: @image-url("../assets/icons/x.svg");
clicked => {
ImmersiveState.open = false;
VisualizerState.set-enabled(false);
// Keep the tap alive for the Large NPB dock if it is active.
VisualizerState.set-enabled(ShellState.large-active && ShellState.large-visualizer-on);
}
}
@@ -1126,7 +1129,8 @@ export component ImmersiveView inherits Rectangle {
key-pressed(event) => {
if (event.text == Key.Escape) {
ImmersiveState.open = false;
VisualizerState.set-enabled(false);
// Keep the tap alive for the Large NPB dock if it is active.
VisualizerState.set-enabled(ShellState.large-active && ShellState.large-visualizer-on);
return accept;
}
// 'g' (GPU) cycles the shader scene 0 -> 1 -> 2 -> 3 -> 0 (Off ->
+43 -8
View File
@@ -8,8 +8,9 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, ShellState, OfflineState, PlaylistState, ImmersiveState, SearchActions } from "../state.slint";
import { NavState, ContentView, ShellState, OfflineState, PlaylistState, ImmersiveState, SearchActions, VisualizerState } from "../state.slint";
import { Sidebar } from "Sidebar.slint";
import { SidebarNowPlayingDock } from "SidebarNowPlayingDock.slint";
import { HeaderBar } from "HeaderBar.slint";
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
import { TooltipOverlay } from "TooltipOverlay.slint";
@@ -127,6 +128,21 @@ export component AppShell inherits Rectangle {
SearchActions.cortinilla-dismiss();
}
// FFT visualizer tap — single source of truth. The tap (frontend-agnostic
// producer in qbz-audio, driven by crate::visualizer) must run whenever the
// immersive overlay OR the Large-NPB sidebar dock is showing a spectrum, and
// idle otherwise (no capture, no FFT cost). This `changed` catches every
// transition: immersive open/close, Large enter/exit, and the sidebar
// cycling out of "open" (which reverts Large in cycle-sidebar). The Large
// menu arm and immersive-open path in Rust also prime it for the initial
// value (changed only fires on transitions). Immersive's own close handlers
// defer to `large-active` so closing it never kills an active Large dock.
property <bool> viz-should-run: ImmersiveState.open
|| (ShellState.large-active && ShellState.large-visualizer-on);
changed viz-should-run => {
VisualizerState.set-enabled(self.viz-should-run);
}
// Bottom layer: catches the mouse back / forward buttons for history
// navigation. Declared first so the real UI sits on top of it.
TouchArea {
@@ -201,13 +217,15 @@ export component AppShell inherits Rectangle {
// shell). `root.height` is the AppShell height (pinned to
// the window in app.slint).
//
// NOTE: the bar height is mode-aware. New + Classic use the
// full `Layout.player-bar-height` (112px); Small (npb-mode 2)
// collapses the bar to one header-tall row (42px), so the
// content area gains ~70px. Computed inline (mirrors the same
// expression in PlayerBar.slint `height:`) to keep `Layout`
// a pure leaf independent of ShellState. Large (mode 3) will
// add its own branch here when it lands.
// NOTE: the bar height is mode-aware. New + Classic + Large
// use the full `Layout.player-bar-height` (112px); only Small
// (npb-mode 2) collapses the bar to one header-tall row
// (42px), so the content area gains ~70px. Computed inline
// (mirrors the same expression in PlayerBar.slint `height:`)
// to keep `Layout` a pure leaf independent of ShellState.
// Large (mode 3) keeps the full bar height — its only
// restructuring (cover + spectrum) lives inside the sidebar,
// not in this content-height math.
height: root.height - Layout.header-height
- (ShellState.npb-mode == 2 ? Layout.header-height : Layout.player-bar-height)
- 8px;
@@ -468,6 +486,23 @@ export component AppShell inherits Rectangle {
}
}
// Large-NPB cover + spectrum dock — a ROOT overlay pinned flush to the window
// bottom-left (the L's vertical arm), painting over the bar's surface-card in
// that corner. Declared AFTER the outer VerticalLayout so it stacks above the
// bar, and BEFORE HeaderMenuOverlay so header flyouts (and every modal/toast
// below) still stack above it (ADR-009 declaration-order z). Only mounted
// while Large is active (sidebar open + npb-mode 3). x/width give equal L/R
// gutters (16 + 208 + 16 = 240); y pins the cover flush to the window bottom.
// The dock self-derives its height (must equal ShellState.large-dock-height).
if ShellState.large-active: SidebarNowPlayingDock {
width: ShellState.large-dock-width;
x: 16px;
y: root.height - ShellState.large-dock-height;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
// Header nav-tab dropdown menus. Declared LAST so it draws on top of
// the header and the content region. A plain in-window overlay (not
// a PopupWindow) — it does not grab the pointer, so the header tabs
+122
View File
@@ -0,0 +1,122 @@
// Inline 2-row AudioStamp (owner redesign — NO format icon). Extracted from
// PlayerBarSmall.slint so BOTH the Small bar and the Large bar (centre cluster)
// can mount the same component; the def is now exported.
//
// Row 1 (focus): [TIER] [detail] e.g. HI-RES 24-bit / 96 kHz
// Row 2 (secondary): [BACKEND] [MODE] the two LEDs, smaller, below.
//
// The quality line is the BRIGHTER one (detail = text-primary, tier =
// text-secondary, ~9px) so the LEDs (~6px, below) don't steal the focus. The
// tier label is built MANUALLY from NowPlayingState.quality-tier (no
// QualityBadgeFull — that one carries the icon + stacks tier/detail vertically).
//
// No pills (ADR-008): backend LED blue when active, mode LED green when active,
// muted grey otherwise. Right-aligned + width-clamped (via the parent's
// max-width) so it never blows out the control row. The cast/remote variants
// are kept, re-fit under the new 2-row structure.
import { Theme } from "../foundation/semantic-colors.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { NowPlayingState } from "../state.slint";
export component AudioStamp inherits VerticalLayout {
spacing: 1px;
alignment: center;
horizontal-stretch: 0;
// Manual tier label: hires→HI-RES, mp3→MP3, lossless→LOSSLESS, else→CD.
property <string> tier-label: NowPlayingState.quality-tier == "hires"
? "HI-RES"
: (NowPlayingState.quality-tier == "mp3"
? "MP3"
: (NowPlayingState.quality-tier == "lossless" ? "LOSSLESS" : "CD"));
// === Row 1 — quality, INLINE + BRIGHT (the focus) ====================
HorizontalLayout {
spacing: 5px;
alignment: end;
Text {
text: root.tier-label;
font-size: 9px;
font-weight: 700;
color: Theme.text-secondary;
vertical-alignment: center;
letter-spacing: 0.3px;
}
Text {
text: NowPlayingState.quality-detail;
font-size: 9px;
font-weight: 600;
color: Theme.text-primary;
vertical-alignment: center;
overflow: elide;
}
}
// === Row 2 — the LEDs (secondary), BELOW the quality line ============
// Local output → backend + mode labels side-by-side, ~6px, muted unless
// active. Hidden while remote/cast (those variants take the slot instead).
if !NowPlayingState.is-remote && !NowPlayingState.cast-active: HorizontalLayout {
spacing: 5px;
alignment: end;
Text {
text: NowPlayingState.output-backend-label;
font-size: 7px;
font-weight: 700;
color: NowPlayingState.output-backend-active
? #5b8def
: Theme.text-muted;
vertical-alignment: center;
overflow: elide;
}
Text {
text: NowPlayingState.output-mode-label;
font-size: 7px;
font-weight: 700;
color: NowPlayingState.output-mode-active
? #3fae6a
: Theme.text-muted;
vertical-alignment: center;
overflow: elide;
}
}
// Local-network cast (Chromecast / DLNA) → a single purple protocol LED,
// re-fit on row 2 under the quality line.
if NowPlayingState.cast-active && !NowPlayingState.is-remote: HorizontalLayout {
alignment: end;
Text {
text: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST";
font-size: 7px;
font-weight: 700;
color: #a855f7;
vertical-alignment: center;
}
}
// QConnect renderer-name caption — shown only when a PEER renderer owns
// playback AND a name is known (existing cast-target field; no new state).
// Sits on row 2 under the quality line.
if NowPlayingState.is-remote && NowPlayingState.cast-target != "": HorizontalLayout {
spacing: 3px;
alignment: end;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/monitor-speaker.svg");
width: 10px;
height: 10px;
tint: #e0b341;
}
}
Text {
text: NowPlayingState.cast-target;
font-size: 8px;
font-weight: 600;
color: #e0b341;
vertical-alignment: center;
overflow: elide;
max-width: 220px;
}
}
}
+71 -8
View File
@@ -19,6 +19,7 @@ import { IconButton, PlayButton } from "../primitives/BarControls.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { SeekBar } from "SeekBar.slint";
import { SongCard } from "SongCard.slint";
import { AudioStamp } from "AudioStamp.slint";
import { TransportControls } from "TransportControls.slint";
import { NowPlayingState, ShellState, AppearanceState, SettingsState, TooltipState, QconnectDevState, QconnectDevice, CastState, CastActions } from "../state.slint";
@@ -163,9 +164,12 @@ export component PlayerBar inherits Rectangle {
VerticalLayout {
Rectangle { height: 6px; }
// Full-width seek bar (6px side margins, times at the ends).
// Full-width seek bar (6px side margins, times at the ends). In Large the
// seek arm is LEFT-INSET by the sidebar/cover column width so the bar
// begins to the right of the docked cover (the L's horizontal arm). Gate
// on large-active (it already asserts the sidebar is open at 240).
HorizontalLayout {
padding-left: 6px;
padding-left: ShellState.large-active ? ShellState.sidebar-rendered-width : 6px;
padding-right: 6px;
SeekBar {
height: 20px;
@@ -191,6 +195,12 @@ export component PlayerBar inherits Rectangle {
// so clipping is safe.
HorizontalLayout {
vertical-stretch: 1;
// NOTE: the row is NOT inset in Large — keeping padding-left==
// padding-right (6px) is what keeps the centre transport on the
// WINDOW centre (symmetric side columns). In Large only the LEFT
// song card's CONTENT is shifted right to clear the docked cover
// (see the SongCard `x`/`width` below); the seekbar above is inset
// separately. PLAY stays dead-centre, matching the owner's mock.
padding-left: 6px;
padding-right: 6px;
spacing: 0px;
@@ -207,7 +217,11 @@ export component PlayerBar inherits Rectangle {
// stamp to the right wall. Vertical centring is handled by the
// card's own sub-columns. Do NOT wrap it in an `alignment:
// start` layout — that forces preferred sizing and kills both.
if ShellState.npb-mode == 0: SongCard {
// New (0) AND Large (3) share this left song card. Large hides
// the cover (`show-art: false`) because the cover lives in the
// sidebar dock; everything else (title / context / audio stamp)
// is identical, so the layout is reused verbatim.
if ShellState.npb-mode == 0 || ShellState.npb-mode == 3: SongCard {
// EXPLICIT height (= controls-row height). Without it the
// inner `height: root.height` formed a binding loop that sized
// the card to the TEXT content (~50px) instead of the cover
@@ -217,6 +231,21 @@ export component PlayerBar inherits Rectangle {
height: Layout.player-bar-height - 38px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
art-size: Layout.player-bar-height - 38px;
// Large: the cover is docked in the sidebar, so drop it here.
show-art: ShellState.npb-mode != 3;
// Large: the quality badge + LEDs move OUT of the song card to
// the SMALL inline AudioStamp on the right column's left wall.
// New mode 0 shares this branch and KEEPS its badges.
show-badges: ShellState.npb-mode != 3;
// Large: shift the card RIGHT so it clears the docked cover
// overlay (which occupies the bottom-left ~sidebar-width). The
// row itself is NOT inset (PLAY stays window-centred), so only
// this card's content moves. New mode 0 keeps x:0/full width
// (identical to the previous fill default).
x: ShellState.large-active ? (ShellState.sidebar-rendered-width - 6px) : 0px;
width: ShellState.large-active
? (parent.width - (ShellState.sidebar-rendered-width - 6px))
: parent.width;
// Homologate the stamp with classic: full-size badge + LEDs,
// not the compact variant.
compact-stamp: false;
@@ -259,8 +288,11 @@ export component PlayerBar inherits Rectangle {
Rectangle {
width: (parent.width - 12px) * root.col-centre;
clip: true;
// New: centred transport, PLAY on the window centre.
if ShellState.npb-mode == 0: VerticalLayout {
// New (0) AND Large (3): centred transport, PLAY on the window
// centre. Large keeps PLAY centred too (the AudioStamp moves to
// the right column's left wall, NOT into this centred group — so
// PLAY never drifts off the window centre).
if ShellState.npb-mode == 0 || ShellState.npb-mode == 3: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
@@ -329,9 +361,31 @@ export component PlayerBar inherits Rectangle {
width: (parent.width - 12px) * root.col-side;
clip: true;
HorizontalLayout {
alignment: end;
spacing: 1px;
// LEADING stretch pushes everything that follows to the right wall
// in EVERY mode (replaces the old `alignment: end`; with no stamp
// it is identical). Because the stretch is BEFORE the stamp, the
// stamp travels glued to the icon cluster at a CONSTANT gap as the
// window grows — the gap no longer widens with screen size.
Rectangle { horizontal-stretch: 1; }
// Large: the SMALL AudioStamp sits just LEFT of the icon cluster,
// separated by a FIXED gap (so its distance to the buttons stays
// constant at any window width). horizontal-stretch:0 so it keeps
// its own size and rides the right-packed group.
if ShellState.npb-mode == 3: VerticalLayout {
alignment: center;
horizontal-stretch: 0;
AudioStamp {
max-width: 140px;
}
}
if ShellState.npb-mode == 3: Rectangle {
width: 12px;
horizontal-stretch: 0;
}
// Qobuz Connect — golden when remote; opens the connect flyout.
VerticalLayout {
alignment: center;
@@ -700,7 +754,12 @@ export component PlayerBar inherits Rectangle {
ContextMenuItem {
icon: @image-url("../assets/icons/layout-grid.svg");
label: @tr("Large");
enabled: false;
selected: ShellState.npb-mode == 3;
// Always selectable: Large docks the cover +
// spectrum in the open sidebar, and the Rust
// "large" arm forces the sidebar open, so it
// works from any current sidebar state.
enabled: true;
clicked => {
root.media-action("npb-view", "", "large");
}
@@ -771,7 +830,11 @@ export component PlayerBar inherits Rectangle {
alignment: center;
opacity: root.vol-locked ? 0.4 : 1.0;
QbzSlider {
width: 90px;
// -10% width (owner: tighten the horizontal volume in all
// NPB modes to free a little room). Shared by New/Classic/
// Large (this slider is outside any npb-mode conditional).
// 90 -> 81.
width: 81px;
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
+12 -122
View File
@@ -46,6 +46,7 @@ import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { SeekBar } from "SeekBar.slint";
import { SongCard } from "SongCard.slint";
import { TransportControls } from "TransportControls.slint";
import { AudioStamp } from "AudioStamp.slint";
import { NowPlayingState, ShellState, SettingsState, QconnectDevState, CastState, CastActions } from "../state.slint";
// Qobuz Connect button — monitor-speaker glyph; golden over a soft golden tint
@@ -86,126 +87,6 @@ component ConnectButton inherits Rectangle {
}
}
// Inline 2-row AudioStamp (owner redesign — NO format icon):
//
// Row 1 (focus): [TIER] [detail] e.g. HI-RES 24-bit / 96 kHz
// Row 2 (secondary): [BACKEND] [MODE] the two LEDs, smaller, below.
//
// The quality line is the BRIGHTER one (detail = text-primary, tier =
// text-secondary, ~9px) so the LEDs (~6px, below) don't steal the focus. The
// tier label is built MANUALLY from NowPlayingState.quality-tier (no
// QualityBadgeFull — that one carries the icon + stacks tier/detail vertically).
//
// No pills (ADR-008): backend LED blue when active, mode LED green when active,
// muted grey otherwise. Right-aligned + width-clamped (via the parent's
// max-width) so it never blows out the control row. The cast/remote variants
// are kept, re-fit under the new 2-row structure.
component AudioStamp inherits VerticalLayout {
spacing: 1px;
alignment: center;
horizontal-stretch: 0;
// Manual tier label: hires→HI-RES, mp3→MP3, lossless→LOSSLESS, else→CD.
property <string> tier-label: NowPlayingState.quality-tier == "hires"
? "HI-RES"
: (NowPlayingState.quality-tier == "mp3"
? "MP3"
: (NowPlayingState.quality-tier == "lossless" ? "LOSSLESS" : "CD"));
// === Row 1 — quality, INLINE + BRIGHT (the focus) ====================
HorizontalLayout {
spacing: 5px;
alignment: end;
Text {
text: root.tier-label;
font-size: 9px;
font-weight: 700;
color: Theme.text-secondary;
vertical-alignment: center;
letter-spacing: 0.3px;
}
Text {
text: NowPlayingState.quality-detail;
font-size: 9px;
font-weight: 600;
color: Theme.text-primary;
vertical-alignment: center;
overflow: elide;
}
}
// === Row 2 — the LEDs (secondary), BELOW the quality line ============
// Local output → backend + mode labels side-by-side, ~6px, muted unless
// active. Hidden while remote/cast (those variants take the slot instead).
if !NowPlayingState.is-remote && !NowPlayingState.cast-active: HorizontalLayout {
spacing: 5px;
alignment: end;
Text {
text: NowPlayingState.output-backend-label;
font-size: 7px;
font-weight: 700;
color: NowPlayingState.output-backend-active
? #5b8def
: Theme.text-muted;
vertical-alignment: center;
overflow: elide;
}
Text {
text: NowPlayingState.output-mode-label;
font-size: 7px;
font-weight: 700;
color: NowPlayingState.output-mode-active
? #3fae6a
: Theme.text-muted;
vertical-alignment: center;
overflow: elide;
}
}
// Local-network cast (Chromecast / DLNA) → a single purple protocol LED,
// re-fit on row 2 under the quality line.
if NowPlayingState.cast-active && !NowPlayingState.is-remote: HorizontalLayout {
alignment: end;
Text {
text: NowPlayingState.cast-protocol == "dlna" ? "DLNA" : "CAST";
font-size: 7px;
font-weight: 700;
color: #a855f7;
vertical-alignment: center;
}
}
// QConnect renderer-name caption — shown only when a PEER renderer owns
// playback AND a name is known (existing cast-target field; no new state).
// Sits on row 2 under the quality line.
if NowPlayingState.is-remote && NowPlayingState.cast-target != "": HorizontalLayout {
spacing: 3px;
alignment: end;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/monitor-speaker.svg");
width: 10px;
height: 10px;
tint: #e0b341;
}
}
Text {
text: NowPlayingState.cast-target;
font-size: 8px;
font-weight: 600;
color: #e0b341;
vertical-alignment: center;
overflow: elide;
// Generous cap — there is empty space to the LEFT of the right-aligned
// stamp, so the renderer name should use it instead of truncating at
// 72px. The right zone clips, so a very long name still can't reach the
// centre transport; `elide` handles the extreme case.
max-width: 220px;
}
}
}
// Thin vertical separator reused inside the controls row (left zone: song-card
// vs time column; right zone: AudioStamp vs the icon cluster). A 1px line,
// border-subtle, ~60% of the row height, vertically centred. The horizontal
@@ -887,7 +768,14 @@ export component PlayerBarSmall inherits Rectangle {
ContextMenuItem {
icon: @image-url("../assets/icons/layout-grid.svg");
label: @tr("Large");
enabled: false;
selected: ShellState.npb-mode == 3;
// Large is gated on the open sidebar (the
// cover + spectrum dock there). Switching
// from Small forces the sidebar open (Rust
// npb-view "large" arm), so it stays
// selectable regardless of the current
// sidebar state.
enabled: true;
clicked => {
root.media-action("npb-view", "", "large");
}
@@ -1034,7 +922,9 @@ export component PlayerBarSmall inherits Rectangle {
alignment: center;
opacity: root.vol-locked ? 0.5 : 1.0;
QbzSlider {
width: 80px;
// -10% width (owner: tighten the horizontal volume in
// all NPB modes to free a little room). 80 -> 72.
width: 72px;
minimum: 0;
maximum: 100;
value: NowPlayingState.volume * 100;
+26 -1
View File
@@ -11,6 +11,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
import { Layout } from "../foundation/layout.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { SidebarPlaylistCollage } from "../primitives/SidebarPlaylistCollage.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
@@ -640,7 +641,14 @@ export component Sidebar inherits Rectangle {
animate width { duration: 160ms; easing: ease-in-out; }
VerticalLayout {
padding: ShellState.sidebar-mini ? 8px : Spacing.md;
// Per-side padding so Large can zero ONLY the bottom: the reserved dock
// band (and the AppShell overlay aligned to it) then reaches the true
// window bottom-left, letting the cover sit flush (the L corner). L/R/top
// stay exactly as before, so the cover keeps equal left/right gutters.
padding-left: ShellState.sidebar-mini ? 8px : Spacing.md;
padding-right: ShellState.sidebar-mini ? 8px : Spacing.md;
padding-top: ShellState.sidebar-mini ? 8px : Spacing.md;
padding-bottom: ShellState.large-active ? 0px : (ShellState.sidebar-mini ? 8px : Spacing.md);
spacing: Spacing.md;
// ---- Section nav — each row opens its dropdown as a right flyout,
@@ -1009,6 +1017,23 @@ export component Sidebar inherits Rectangle {
if SidebarState.playlists-collapsed: Rectangle {
vertical-stretch: 1;
}
// ---- Large-NPB dock space reservation -----------------------------
// The cover + spectrum dock is now an AppShell-ROOT overlay pinned flush
// to the window bottom-left (so the cover reaches the true window edge,
// past where the bar starts — the L's vertical arm). Here we only RESERVE
// its vertical band so the playlist Flickable above (vertical-stretch:1)
// shrinks and never renders beneath it. When the visualizer toggles off,
// `large-dock-height` shrinks and the list reclaims the freed space.
//
// IMPORTANT: the dock overlay extends `player-bar-height` BELOW the
// sidebar row (its cover sits over the bar's bottom-left corner — the L).
// So only the IN-SIDEBAR portion (large-dock-height - bar height) must be
// reserved here; reserving the full height would leave a phantom gap above
// the spectrum once the playlist list fills.
if ShellState.large-active: Rectangle {
height: ShellState.large-dock-height - Layout.player-bar-height;
}
}
// The drag-to-resize handle was removed: the sidebar is now a fixed
// three-state control (open / mini / closed) cycled from the header
@@ -0,0 +1,245 @@
// Large NPB (mode 3) cover + spectrum dock — the L's vertical arm. Mounted as a
// ROOT overlay by AppShell, pinned flush to the window bottom-left (NOT inside
// the sidebar VL anymore — that floated it above the bar). Layout, bottom-up: a
// square album cover (= the fed width) at the very bottom; ABOVE it a compact
// spectrum band (half the old height, more bars) that the user can hide via a
// top-right toggle on the cover.
//
// Owner refinements (2026-06-21):
// - The cover sits FLUSH to the window bottom-left, equal L/R gutters (the
// AppShell overlay feeds x=16, width=240-32). art-size = root.width.
// - The only cover overlay is ONE top-right eye button toggling the visualizer
// (ShellState.large-visualizer-on). The old [Small,Default] mode switches are
// gone (use the Now-Playing-view menu to leave Large).
// - When the visualizer is OFF the spectrum band collapses (root height shrinks)
// so the sidebar's playlist list reclaims the space.
// - Both toggles persist: the eye fires ("npb-large", _, "viz-toggle") and the
// spectrum click fires ("npb-large", _, "spectrum-cycle"); Rust writes ui_prefs
// and mirrors back into ShellState (Slint never mutates them directly).
// - Shorter band, MORE bars: Bars is a 28-column MIRRORED spectrum; Waveform is
// 48 columns; Energy is the 5 semantic bands.
//
// Visuals reuse the immersive pipeline: VisualizerState (FFT producer; tap gated
// by AppShell's viz-should-run) + the album-derived ImmersiveState.spectrum-
// primary/secondary (repopulated every track in playback.rs). No audio path
// touched.
//
// Slint gotchas honored: `parent` is never read in a root binding (dims via
// root.width); no `opacity:` on a container holding the absolutely-positioned
// bars (it corrupts placement); bar count/visibility is driven only from bound
// model values, never a `changed` handler.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { NowPlayingState, VisualizerState, ImmersiveState, ShellState } from "../state.slint";
// One spectrum bar that grows UP from the bottom edge. Amplitude (0..1) is
// already smoothed + clamped in Rust; only a short ease settles the step.
component VBar inherits Rectangle {
in property <length> slot-x;
in property <length> bar-width;
in property <length> area-h;
in property <float> amp;
property <length> bar-h: Math.max(2px, root.amp * root.area-h);
animate bar-h { duration: 90ms; easing: ease; }
x: 0; y: 0; width: 0px; height: 0px;
Rectangle {
x: root.slot-x;
width: root.bar-width;
height: root.bar-h;
y: root.area-h - self.height;
border-radius: 1px;
background: @linear-gradient(0deg,
ImmersiveState.spectrum-primary 0%, ImmersiveState.spectrum-secondary 100%);
}
}
// A centered waveform column: a vertical sliver whose height tracks |sample|,
// mirrored about the band's vertical center.
component WaveCol inherits Rectangle {
in property <length> slot-x;
in property <length> bar-width;
in property <length> area-h;
in property <float> amp; // 0..1 absolute magnitude
property <length> col-h: Math.max(2px, root.amp * root.area-h);
animate col-h { duration: 70ms; easing: ease; }
x: 0; y: 0; width: 0px; height: 0px;
Rectangle {
x: root.slot-x;
width: root.bar-width;
height: root.col-h;
y: (root.area-h - self.height) / 2;
border-radius: 1px;
background: ImmersiveState.spectrum-primary;
}
}
export component SidebarNowPlayingDock inherits Rectangle {
callback media-action(string, string, string);
// Square cover = the dock's (sidebar content) width. Compact spectrum above.
property <length> art-size: root.width;
property <length> spectrum-h: 46px; // half the original 92px (owner: shorter band)
// Reserve cover + (when ON) the spectrum band + gap, plus the top hairline
// pad. MUST equal ShellState.large-dock-height in BOTH toggle states so the
// AppShell overlay (y = root.height - large-dock-height) keeps the cover flush.
height: ShellState.large-visualizer-on
? (root.spectrum-h + 10px + root.art-size + 9px)
: (root.art-size + 9px);
// Bars: 28 MIRRORED columns = 14 active source bins x2 (the 16-bin FFT
// excludes {1,15}; SpectrumPanel parity). More bars than the old straight 14.
property <[int]> active-bins: [0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
property <length> bar-slot: root.width / 28;
// Waveform: 48 downsampled L columns (indices 0..235 step 5).
property <[int]> wave-indices: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55,
60, 65, 70, 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135,
140, 145, 150, 155, 160, 165, 170, 175, 180, 185, 190, 195, 200, 205,
210, 215, 220, 225, 230, 235];
property <length> wave-slot: root.width / 48;
property <length> energy-slot: root.width / 5;
// Top hairline divider between the playlists and the dock.
Rectangle {
x: 0; y: 0;
width: root.width;
height: 1px;
background: Theme.border-subtle;
}
VerticalLayout {
padding-top: 9px;
spacing: 10px;
// ---- Spectrum band (only while the visualizer is ON) --------------
if ShellState.large-visualizer-on: spectrum := Rectangle {
height: root.spectrum-h;
border-radius: Radius.md;
background: #00000059;
clip: true;
// Mode 0 — Bars (28-column MIRRORED, album-gradient).
if ShellState.large-spectrum-mode == 0: Rectangle {
width: parent.width;
height: parent.height;
// Left half (columns 0..13).
for src[i] in root.active-bins: VBar {
slot-x: i * root.bar-slot + 1px;
bar-width: root.bar-slot - 2px;
area-h: spectrum.height;
amp: VisualizerState.bars.length > src
? Math.max(0.0, Math.min(1.0, VisualizerState.bars[src]))
: 0.0;
}
// Right half (mirror, columns 27..14).
for src[i] in root.active-bins: VBar {
slot-x: (27 - i) * root.bar-slot + 1px;
bar-width: root.bar-slot - 2px;
area-h: spectrum.height;
amp: VisualizerState.bars.length > src
? Math.max(0.0, Math.min(1.0, VisualizerState.bars[src]))
: 0.0;
}
}
// Mode 1 — Waveform (centered, 48 downsampled L columns).
if ShellState.large-spectrum-mode == 1: Rectangle {
width: parent.width;
height: parent.height;
for idx[i] in root.wave-indices: WaveCol {
slot-x: i * root.wave-slot + 1px;
bar-width: root.wave-slot - 2px;
area-h: spectrum.height;
amp: VisualizerState.waveform.length > idx
? Math.min(1.0, Math.abs(VisualizerState.waveform[idx]))
: 0.0;
}
}
// Mode 2 — Energy (5 semantic bands, sub-bass..air).
if ShellState.large-spectrum-mode == 2: Rectangle {
width: parent.width;
height: parent.height;
for band[i] in [0, 1, 2, 3, 4]: VBar {
slot-x: i * root.energy-slot + 3px;
bar-width: root.energy-slot - 6px;
area-h: spectrum.height;
amp: VisualizerState.energy.length > band
? Math.max(0.0, Math.min(1.0, VisualizerState.energy[band]))
: 0.0;
}
}
// Click the band → cycle Bars -> Waveform -> Energy (persisted in Rust).
spectrum-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.media-action("npb-large", "", "spectrum-cycle"); }
}
}
// ---- Album cover (square) + top-right visualizer toggle -----------
art := Rectangle {
height: root.art-size;
border-radius: Radius.md;
clip: true;
drop-shadow-blur: 24px;
drop-shadow-color: #00000080;
drop-shadow-offset-y: 4px;
background: Theme.surface-main;
if NowPlayingState.has-track
&& (NowPlayingState.artwork-large.width > 0 || NowPlayingState.artwork.width > 0): Image {
source: NowPlayingState.artwork-large.width > 0
? NowPlayingState.artwork-large
: NowPlayingState.artwork;
image-fit: cover;
width: parent.width;
height: parent.height;
}
// Placeholder glyph when no cover yet.
if !(NowPlayingState.artwork-large.width > 0 || NowPlayingState.artwork.width > 0): QbzIcon {
source: @image-url("../assets/icons/music.svg");
width: root.art-size * 0.32;
height: root.art-size * 0.32;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
// Hover tracker over the whole cover — reveals the toggle on hover;
// the toggle ALSO stays visible while the visualizer is OFF so it can
// be turned back on.
cover-ta := TouchArea {
mouse-cursor: default;
}
// Single top-right toggle: show/hide the spectrum band (eye/eye-off).
// Fires the persisted media-action; its own TouchArea wins over cover-ta.
Rectangle {
width: 28px;
height: 28px;
x: parent.width - self.width - 8px;
y: 8px;
border-radius: 8px;
background: vt.has-hover ? #ffffff2e : #00000080;
border-width: 1px;
border-color: #ffffff33;
opacity: (cover-ta.has-hover || vt.has-hover || !ShellState.large-visualizer-on) ? 1.0 : 0.0;
animate opacity { duration: 140ms; }
QbzIcon {
source: ShellState.large-visualizer-on
? @image-url("../assets/icons/eye.svg")
: @image-url("../assets/icons/eye-off.svg");
width: 15px;
height: 15px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
vt := TouchArea {
mouse-cursor: pointer;
clicked => { root.media-action("npb-large", "", "viz-toggle"); }
}
}
}
}
}
+41 -4
View File
@@ -3250,9 +3250,17 @@ export global ShellState {
: 240px;
// True while in the icons-only mini state — gates label/header rendering.
out property <bool> sidebar-mini: root.sidebar-state == 1;
// Advance the sidebar through its three states.
// Advance the sidebar through its three states. Large NPB (mode 3) is gated
// on the OPEN sidebar (it docks the cover + spectrum at the sidebar bottom);
// so when the cycle leaves the open state while Large is active, the bar
// reverts LIVE to New (0). The persisted ui_prefs key stays "large", so a
// later relaunch (sidebar defaults open) restores Large — the revert is a
// compatibility fallback, not a preference change.
public function cycle-sidebar() {
root.sidebar-state = Math.mod(root.sidebar-state + 1, 3);
if (root.sidebar-state != 0 && root.npb-mode == 3) {
root.npb-mode = 0;
}
}
// Right-side panels — independent now: Queue and Lyrics can BOTH be open
// at once, stacked vertically in a shared right column (Feishin-style). Each
@@ -3280,10 +3288,39 @@ export global ShellState {
// Section navigation placement: the sections (Discover/Library/Local
// Library/My QBZ) now live in the sidebar and open right-flyout menus.
in-out property <bool> nav-in-sidebar: true;
// Now-playing bar layout mode: 0 = New (default), 1 = Classic. Persisted
// in ui_prefs.json (`npb_mode` "new"|"classic"); set on startup from the
// store and on the player bar's "Now Playing view" menu select.
// Now-playing bar layout mode: 0 = New (default), 1 = Classic, 2 = Small,
// 3 = Large. Persisted in ui_prefs.json (`npb_mode`
// "new"|"classic"|"small"|"large"); set on startup from the store and on the
// player bar's "Now Playing view" menu select.
in-out property <int> npb-mode: 0;
// Large NPB (mode 3) is only valid while the sidebar is OPEN (state 0): the
// cover + spectrum dock lives at the bottom of the open sidebar. Views read
// this to mount the dock and to drive the FFT visualizer tap.
out property <bool> large-active: root.npb-mode == 3 && root.sidebar-state == 0;
// Large dock column = open sidebar (240) minus its VL 16px*2 padding => 208px.
// Equal L/R gutters fall out: 16 + 208 + 16 = 240. (16px == Spacing.md; kept a
// literal here so state.slint stays free of a foundation import — if Spacing.md
// or the 240 open width ever change, update this and AppShell's `x: 16px`.)
out property <length> large-dock-width: root.sidebar-rendered-width - 2 * 16px;
// Total bottom band the dock occupies — MUST byte-for-byte equal
// SidebarNowPlayingDock's self-derived `height` in BOTH visualizer states
// (AppShell pins the overlay via `y: root.height - large-dock-height`, so a
// mismatch un-flushes the cover). ON = spectrum 46 + 10 gap + square cover +
// 9 top pad; OFF = cover + 9 (spectrum band reclaimed by the sidebar list).
out property <length> large-dock-height: root.large-visualizer-on
? (46px + 10px + root.large-dock-width + 9px)
: (root.large-dock-width + 9px);
// Large dock spectrum visualization (the click-cycle is now 3-way; the old
// "Off" state is handled by the toggle below): 0 = Bars (mirrored), 1 =
// Waveform, 2 = Energy blocks. PERSISTED in ui_prefs (`large_spectrum_mode`)
// via the Rust ("npb-large", _, "spectrum-cycle") media-action — Slint never
// mutates it directly, so the choice survives restarts.
in-out property <int> large-spectrum-mode: 0;
// Large dock visualizer on/off (the top-right eye toggle on the cover).
// PERSISTED in ui_prefs (`large_visualizer`) via ("npb-large", _,
// "viz-toggle"). When off, the spectrum band collapses and the sidebar
// content reclaims the space; the FFT tap idles (AppShell viz-should-run).
in-out property <bool> large-visualizer-on: true;
}
// Authenticated user, for the header user element.