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

+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