feat(slint): immersive Static mode (mode 1) - faithful, responsive

New StaticPanel.slint: 1:1 port of StaticPanel.svelte. The calm, centered
now-playing card over the moving atmosphere. NON-reactive (no VisualizerState
energy, no glow halo, no breathing) - the only motion is the fixed-period
4-bar equalizer (animation-tick, shown only while playing), faithful to source.

- Responsive art reused from Album Reactive (height-driven + width*0.55 cap +
  clamp 180..460), reserve 364px = pad-top 52 + spacing 20 + info ~160 +
  player clearance 132 (no *1.25 since Static doesn't breathe). Metadata never
  clips under the player.
- Metadata: now-playing indicator / title 28/700 / artist 18 / italic album /
  centered QualityBadgeFull - matching the sibling and the Svelte source.
- ImmersiveView mounts it on ImmersiveState.mode == 1 (replaces the placeholder).
  Modes 0/2/3 untouched.

Accepted parity drops (match the shipped sibling + state model): explicit
badge omitted (no NowPlayingState.explicit field), no hover lift, label not
uppercased.

cc
This commit is contained in:
vicrodh committed 2026-06-13 08:40:19 -06:00
1 parent 786301a2e4
commit 2f89bbb207
2 files changed
+227 -1

No files matched your search

@@ -7,6 +7,7 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
import { NowPlayingState, VisualizerState, ImmersiveState, SettingsState } from "../state.slint";
import { ImmersiveAtmosphere } from "ImmersiveAtmosphere.slint";
import { AlbumReactivePanel } from "AlbumReactivePanel.slint";
import { StaticPanel } from "StaticPanel.slint";
component GhostButton inherits Rectangle {
in property <image> icon;
@@ -558,7 +559,14 @@ export component ImmersiveView inherits Rectangle {
viewport-width: root.width;
viewport-height: root.height;
}
// TODO(S3): Static mode foreground (mode == 1) — atmosphere-only for now.
// (2b) Static mode foreground (mode == 1) — calm centered now-playing
// card over the atmosphere. Same conditional-mount pattern (ADR-010).
if ImmersiveState.mode == 1: StaticPanel {
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
}
// TODO(S4): Coverflow mode foreground (mode == 2) — atmosphere-only for now.
// TODO(S5): Spectrum mode foreground (mode == 3) — atmosphere-only for now.
@@ -0,0 +1,218 @@
// Static foreground (Immersive mode 1) — a faithful port of the Tauri
// `src/lib/components/immersive/panels/StaticPanel.svelte` (232 lines).
//
// A calm, centered now-playing card drawn over the moving ImmersiveAtmosphere
// background. NON-reactive: it does NOT read VisualizerState.energy at all (no
// art-scale, no glow halo, no bass/global energy). The ONLY motion is the 4-bar
// equalizer inside the "Now Playing" indicator — a fixed-period CSS loop in the
// source (NOT energy-driven), so it is allowed in the static panel and is kept
// for a faithful port.
//
// `parent` is NEVER read in the root binding (Slint gotcha); viewport dims are
// passed in as properties. `opacity:` is NEVER set on the root (it corrupts
// absolute positioning); the fade lives on the player/ModeBar chrome instead.
//
// DIFF vs AlbumReactivePanel (dropped): global-energy / bass-energy / art-scale
// / glow-spread / glow-opacity props, the glow Rectangle, the reactive grow
// `animate`, and the *1.25 breathing footprint. KEPT: EqualizerBars, the
// now-playing indicator (if playing), title/artist/album/quality, the centered
// VerticalLayout (pad-top 52 / pad-bottom 132), the artwork image-fit cover +
// fallback, and the static art drop shadow.
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { NowPlayingState } from "../state.slint";
// Four-bar "now playing" equalizer. A continuous loop driven by the global
// animation-tick() (pull-based) — NOT a Timer. Verbatim copy of the
// AlbumReactivePanel.slint EqualizerBars (lines 36-85); duplicated here on
// purpose so this session does not touch AlbumReactivePanel. (Follow-up: lift
// into a shared immersive/EqualizerBars.slint imported by both panels.)
component EqualizerBars inherits Rectangle {
in property <color> tint: #7c3aed;
width: 18px;
height: 14px;
// 0.8s loop (Svelte `equalize 0.8s`): tick ms -> phase 0..1.
property <float> t: Math.mod(animation-tick() / 1ms, 800.0) / 800.0;
// scaleY 0.3..1.0 sweep (Svelte keyframes 0/100% -> 0.3, 50% -> 1.0).
function wave(phase: float) -> float {
return 0.3 + 0.7 * (0.5 - 0.5 * Math.cos((Math.mod(self.t + phase, 1.0)) * 6.283185 * 1rad));
}
HorizontalLayout {
alignment: start;
spacing: 2px;
// bar 1: delay 0.0, base 60%
Rectangle {
width: 3px;
y: parent.height - self.height;
height: parent.height * 0.6 * root.wave(0.0);
border-radius: 1px;
background: root.tint;
}
// bar 2: delay 0.2, base 100%
Rectangle {
width: 3px;
y: parent.height - self.height;
height: parent.height * 1.0 * root.wave(0.25);
border-radius: 1px;
background: root.tint;
}
// bar 3: delay 0.1, base 40%
Rectangle {
width: 3px;
y: parent.height - self.height;
height: parent.height * 0.4 * root.wave(0.125);
border-radius: 1px;
background: root.tint;
}
// bar 4: delay 0.3, base 80%
Rectangle {
width: 3px;
y: parent.height - self.height;
height: parent.height * 0.8 * root.wave(0.375);
border-radius: 1px;
background: root.tint;
}
}
}
export component StaticPanel inherits Rectangle {
in property <length> viewport-width: 1280px;
in property <length> viewport-height: 720px;
// Base art box. Tauri uses `min(52vh, 460px)`, but the Slint group must also
// leave room for the metadata block + the floating player, or the quality
// badge gets clipped under the bar. Static has NO breathing growth, so the
// height budget divides by 1.0 (no /1.25) and the panel gap is 20px (not 40).
// Reserve = pad-top(52) + spacing(20) + info(160) + player-clearance(132) =
// 364px. Player clearance 132 is KEPT identical to AlbumReactivePanel (same
// floating ImmersivePlayerBar); if it changes, BOTH the 132 padding-bottom
// below and this 364 reserve must move together. Clamped to 180..460px;
// width cap 0.55 captures the Svelte narrow-window 55vw intent.
property <length> base-art: Math.max(180px,
Math.min(Math.min(root.viewport-height - 364px, root.viewport-width * 0.55), 460px));
// Fixed size — no art-scale.
property <length> art-size: root.base-art;
width: root.viewport-width;
height: root.viewport-height;
background: transparent;
// Vertical stack: artwork block, then the track-info block. Matches the
// Svelte column (.static-panel: flex column, centered, gap 20px). Centered
// as a group in the viewport.
VerticalLayout {
x: 0px;
y: 0px;
width: root.viewport-width;
height: root.viewport-height;
// pad-top 52 = header+controls offset for true visual center (Svelte).
// pad-bottom 132 = floating-player clearance (owner-mandated, matches
// AlbumReactivePanel; the Svelte 88 assumes a different player).
padding-top: 52px;
padding-bottom: 132px;
alignment: center;
spacing: 20px;
// --- Artwork (static, no glow, no grow) ----------------------------
artwork-block := Rectangle {
// Fixed footprint — no *1.25 (no breathing).
height: root.base-art;
horizontal-stretch: 1;
// Static drop shadow mirrors the Svelte .artwork-container
// box-shadow (the two-layer CSS shadow collapses to one blur in
// Slint — same accepted gap as AlbumReactivePanel).
art := Rectangle {
width: root.art-size;
height: root.art-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: 8px;
clip: true;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
drop-shadow-offset-y: 8px;
Image {
source: NowPlayingState.artwork-large.width > 0
? NowPlayingState.artwork-large
: NowPlayingState.artwork;
image-fit: cover;
width: parent.width;
height: parent.height;
}
}
}
// --- Track info ----------------------------------------------------
info-block := VerticalLayout {
alignment: center;
spacing: 6px;
// "Now Playing" indicator: equalizer bars + label. Shown only while
// playing (Svelte {#if isPlaying}).
if NowPlayingState.playing: HorizontalLayout {
alignment: center;
spacing: 8px;
padding-bottom: 8px;
VerticalLayout {
alignment: center;
EqualizerBars {
tint: #7c3aed;
}
}
Text {
text: @tr("Now Playing");
color: #7c3aed;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
vertical-alignment: center;
}
}
// Track title. (Explicit badge OMITTED: NowPlayingState has no
// `explicit` field and the sibling AlbumReactivePanel omits it too.)
Text {
text: NowPlayingState.title;
color: #ffffff;
font-size: 28px;
font-weight: 700;
horizontal-alignment: center;
overflow: elide;
}
// Artist.
Text {
text: NowPlayingState.artist;
color: #ffffffb3;
font-size: 18px;
horizontal-alignment: center;
overflow: elide;
}
// Album (italic, dimmer). Shown only when present (Svelte {#if album}).
if NowPlayingState.album != "": Text {
text: NowPlayingState.album;
color: #ffffff80;
font-size: 14px;
font-italic: true;
horizontal-alignment: center;
overflow: elide;
}
// Quality badge (Svelte QualityBadgeStatic full mode). Self-hides
// when tier == "". Centered under the text.
HorizontalLayout {
alignment: center;
padding-top: 12px;
QualityBadgeFull {
tier: NowPlayingState.quality-tier;
detail: NowPlayingState.quality-detail;
}
}
}
}
}