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:
10 files changed
+683
-154
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user