Files
qbz/crates/qbz-ui/ui/immersive/ImmersiveView.slint
T
vicrodh 8c2f84b798 feat(shell): per-GPU selector, ambient scene in immersive, glass carousels/viewall
Smoke round 5 (owner feedback). Checkpoint.

- Preferred GPU selector (Settings > Renderer, Linux/wgpu): lists each detected
  GPU by name ("Auto" + e.g. "NVIDIA … (discrete)" / "Intel … (integrated)"),
  persisted (ui_prefs.gpu_power by device name), applied at startup via the wgpu
  adapter PowerPreference (WGPU_POWER_PREF env still wins). Fixes the iGPU running
  hot under the shader background on hybrid laptops — pick the discrete GPU.
  New gpu_adapters() enumerates+dedups adapters; gpu_power_from_prefs /
  _options / _seed_index / _value_for_index.
- Ambient metaball added to the IMMERSIVE scene picker (mode 7, wgpu-only opt-in);
  the non-shader atmosphere stays the fallback for GL/software tiers.
- Glass treatment on the surfaces that were still opaque: carousel < > nav
  buttons (6 carousels), "Filter by category" pill, "Filter by genre" buttons,
  and the PlaylistBrowseView (View-all) header band + category radio row.
- i18n: "Preferred GPU" + description + discrete/integrated + restart toast (8
  locales).

Base: 76ea5a82. Prior checkpoint: a0f5cf2b.

cc
2026-07-17 10:54:45 -06:00

1686 lines
71 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ImmersiveView base: reusable Kawarp-like atmosphere background +
// Tauri-shaped compact player with auto-hide. Foreground panels will be
// rebuilt on top of this base after the background/player are approved.
import { Radius } from "../foundation/radius.slint";
import { Typography } from "../foundation/typography.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { NowPlayingState, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, SettingsState, LyricsState, QueueState, TrackInfoActions, SuggestionsActions, WindowControlActions, ShellState, UiFocusState } from "../state.slint";
import { ImmersiveSearchCortinilla } from "ImmersiveSearchCortinilla.slint";
import { ImmersiveAtmosphere } from "ImmersiveAtmosphere.slint";
import { AlbumReactivePanel } from "AlbumReactivePanel.slint";
import { StaticPanel } from "StaticPanel.slint";
import { CoverflowPanel } from "CoverflowPanel.slint";
import { SpectrumPanel } from "SpectrumPanel.slint";
import { ImmersiveWaveBedPanel } from "ImmersiveWaveBedPanel.slint";
import { ImmersiveLyricsFocusPanel } from "ImmersiveLyricsFocusPanel.slint";
import { ImmersiveQueuePanel } from "ImmersiveQueuePanel.slint";
import { ImmersiveTrackInfo } from "ImmersiveTrackInfo.slint";
import { ImmersiveTrackInfoPanel } from "ImmersiveTrackInfoPanel.slint";
import { ImmersiveSuggestionsPanel } from "ImmersiveSuggestionsPanel.slint";
import { ImmersiveSongCard } from "ImmersiveSongCard.slint";
import { ImmersiveSpectralOverlay } from "ImmersiveSpectralOverlay.slint";
import { LyricsLinesView } from "../shell/LyricsLinesView.slint";
component GhostButton inherits Rectangle {
in property <image> icon;
in property <bool> active: false;
in property <bool> enabled: true;
in property <length> icon-size: 12px;
callback clicked();
width: 28px;
height: 28px;
border-radius: 14px;
opacity: root.enabled ? 1.0 : 0.4;
background: root.active ? #ffffff40 : transparent;
QbzIcon {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active || ta.has-hover ? #ffffff : #ffffffa6;
}
ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
component NavButton inherits Rectangle {
in property <image> icon;
callback clicked();
width: 28px;
height: 28px;
border-radius: 14px;
background: ta.has-hover ? #ffffff40 : #ffffff26;
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
component PlayDisc inherits Rectangle {
callback clicked();
width: 48px;
height: 48px;
border-radius: 24px;
background: ta.has-hover ? #ffffffe6 : #ffffff;
drop-shadow-blur: 12px;
drop-shadow-color: #0000004d;
drop-shadow-offset-y: 4px;
QbzIcon {
source: NowPlayingState.playing
? @image-url("../assets/icons/pause.svg")
: @image-url("../assets/icons/play-fill.svg");
width: 20px;
height: 20px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px
+ (NowPlayingState.playing ? 0px : 1px);
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #000000;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
component TinyBar inherits Rectangle {
in property <float> value: 0.0;
in property <color> fill: #ffffff;
callback changed(float);
height: 10px;
background: transparent;
Rectangle {
x: 0px;
width: root.width;
y: 4px;
height: 2px;
border-radius: 1px;
background: #ffffff2e;
}
Rectangle {
x: 0px;
y: 4px;
height: 2px;
width: parent.width * Math.max(0.0, Math.min(1.0, root.value));
border-radius: 1px;
background: root.fill;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.changed(Math.max(0.0, Math.min(1.0, self.mouse-x / self.width)));
}
moved => {
if (self.pressed) {
root.changed(Math.max(0.0, Math.min(1.0, self.mouse-x / self.width)));
}
}
}
}
// Volume slider mirroring Tauri's VolumeSlider.svelte: thin white-on-glass
// track, hover-revealed thumb, drag-time percent bubble, and a locked state
// that pins the fill to 100% and dims/no-ops (bit-perfect ALSA-Direct or a
// QConnect peer that disallows remote volume). NOTE: `opacity:` is intentionally
// NOT applied to the root (it would corrupt absolute positioning); inner content
// is dimmed instead.
component VolumeBar inherits Rectangle {
in property <float> value: 0.0; // 0..1
in property <bool> locked: false;
callback changed(float); // emits 0..1 — live (every drag tick)
callback released(float); // emits 0..1 — drag-end only (persist)
// The visual fill fraction: pinned to 100% when locked (matches Tauri).
property <float> shown: root.locked ? 1.0 : Math.max(0.0, Math.min(1.0, root.value));
height: 12px;
background: transparent;
// Drag-time / hover percent readout. Hidden when locked.
Rectangle {
x: root.width - self.width;
y: -26px;
width: 34px;
height: 20px;
border-radius: 6px;
background: #000000b3;
visible: !root.locked && (ta.has-hover || ta.pressed);
Text {
text: Math.round(root.shown * 100) + "";
color: #ffffff;
font-size: 11px;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
x: 0; y: 0;
width: parent.width;
height: parent.height;
}
}
// Track background.
Rectangle {
x: 0px;
y: 5px;
width: root.width;
height: 2px;
border-radius: 1px;
background: root.locked ? #ffffff1f : #ffffff33;
}
// Fill.
Rectangle {
x: 0px;
y: 5px;
height: 2px;
width: root.width * root.shown;
border-radius: 1px;
background: root.locked ? #ffffff66 : #ffffffb3;
}
// Hover-revealed thumb. Position is computed from `root.width`, not from
// layout-managed geometry, so no recursion/binding-loop risk.
Rectangle {
x: root.width * root.shown - self.width / 2;
y: 0px;
width: 12px;
height: 12px;
border-radius: 6px;
background: #ffffff;
opacity: (!root.locked && (ta.has-hover || ta.pressed)) ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
}
ta := TouchArea {
mouse-cursor: root.locked ? default : pointer;
clicked => {
if (!root.locked) {
root.changed(Math.max(0.0, Math.min(1.0, self.mouse-x / self.width)));
}
}
moved => {
if (!root.locked && self.pressed) {
root.changed(Math.max(0.0, Math.min(1.0, self.mouse-x / self.width)));
}
}
// Persist only the final value on drag-end (matches the NPB sliders'
// released -> persist-volume; live changes still fire via changed).
pointer-event(e) => {
if (!root.locked && e.kind == PointerEventKind.up) {
root.released(Math.max(0.0, Math.min(1.0, self.mouse-x / self.width)));
}
}
}
}
component ImmersivePlayerBar inherits Rectangle {
in property <length> viewport-width: 720px;
callback media-action(string, string, string);
callback wake();
// Volume lock, identical to PlayerBar.slint:154-155 / Tauri's
// handleVolumeChange formula:
// (isAlsaDirectHw && !peerActive) || (peerActive && !remoteVolumeAllowed)
// The local ALSA-Direct (bit-perfect) lock is LIFTED while controlling a
// peer (the `!is-remote` term) so the user can still drive the renderer's
// volume; the peer half is the sink-pushed NowPlayingState.volume-locked.
property <bool> vol-locked: (SettingsState.backend-is-alsa && SettingsState.alsa-plugin-is-hw && !NowPlayingState.is-remote)
|| NowPlayingState.volume-locked;
// 600 (was 720): trims 120px of dead space — the left group is left-anchored,
// the right group right-anchored, the playback group centered, so a narrower
// bar pulls each gap in by ~60px (owner request).
width: Math.min(root.viewport-width - 32px, 600px);
height: 90px;
border-radius: 16px;
background: #00000080;
border-width: 1px;
border-color: #ffffff2e;
drop-shadow-blur: 32px;
drop-shadow-color: #00000066;
drop-shadow-offset-y: 8px;
// Left utility group.
GhostButton {
x: 24px;
y: 22px;
// Reflect the live favorite state (filled + active when in library), same
// source as the main TransportControls heart — it was a static glyph.
icon: QueueState.now-playing-favorite
? @image-url("../assets/icons/heart-filled.svg")
: @image-url("../assets/icons/heart.svg");
active: QueueState.now-playing-favorite;
enabled: NowPlayingState.has-track && !NowPlayingState.is-ephemeral;
clicked => { QueueState.toggle-now-playing-favorite(); }
}
GhostButton {
x: 56px;
y: 22px;
icon: @image-url("../assets/icons/shuffle.svg");
active: NowPlayingState.shuffle;
clicked => { NowPlayingState.toggle-shuffle(); }
}
GhostButton {
x: 88px;
y: 22px;
icon: @image-url("../assets/icons/repeat.svg");
active: NowPlayingState.repeat-mode != 0;
clicked => { NowPlayingState.cycle-repeat(); }
}
GhostButton {
x: 120px;
y: 22px;
icon: @image-url("../assets/icons/infinity.svg");
active: QueueState.infinite-play;
clicked => { QueueState.toggle-infinite-play(); }
}
// Playback group: positioned from the play disc so play is exactly centered.
Text {
x: root.width / 2 - 108px;
y: 31px;
width: 42px;
text: NowPlayingState.elapsed;
color: #ffffffbf;
font-size: 11px;
horizontal-alignment: right;
}
NavButton {
x: root.width / 2 - 64px;
y: 22px;
icon: @image-url("../assets/icons/skip-back.svg");
clicked => { NowPlayingState.previous(); }
}
PlayDisc {
x: root.width / 2 - 24px;
y: 12px;
clicked => { NowPlayingState.toggle-play(); }
}
NavButton {
x: root.width / 2 + 36px;
y: 22px;
icon: @image-url("../assets/icons/skip-forward.svg");
clicked => { NowPlayingState.next(); }
}
Text {
x: root.width / 2 + 76px;
y: 31px;
width: 46px;
text: NowPlayingState.remaining;
color: #ffffffbf;
font-size: 11px;
}
// Right volume/menu group.
GhostButton {
x: root.width - 156px;
y: 22px;
// Under lock the fill pins to 100%, so the icon must NOT show the
// muted/zero glyph (matches Tauri VolumeSlider: Volume2 when locked).
icon: (!root.vol-locked && (NowPlayingState.muted || NowPlayingState.volume == 0))
? @image-url("../assets/icons/volume-x.svg")
: @image-url("../assets/icons/volume-2.svg");
enabled: !root.vol-locked;
clicked => {
if (!root.vol-locked) {
NowPlayingState.toggle-mute();
}
}
}
VolumeBar {
x: root.width - 112px;
y: 31px;
width: 64px;
value: NowPlayingState.volume;
locked: root.vol-locked;
changed(v) => {
if (!root.vol-locked) {
NowPlayingState.set-volume(v);
}
}
released(v) => {
if (!root.vol-locked) {
NowPlayingState.persist-volume(v);
}
}
}
Rectangle {
x: root.width - 42px;
y: 22px;
width: 28px;
height: 28px;
border-radius: 14px;
background: menu-ta.has-hover ? #ffffff26 : transparent;
QbzIcon {
source: @image-url("../assets/icons/ellipsis.svg");
width: 12px;
height: 12px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffffa6;
}
menu-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.wake();
player-menu.show();
}
}
player-menu := PopupWindow {
x: -172px;
y: -58px;
width: 200px;
height: 46px;
close-policy: close-on-click;
Rectangle {
background: #000000cc;
border-radius: 12px;
border-width: 1px;
border-color: #ffffff2e;
Rectangle {
x: 6px;
y: 6px;
width: parent.width - 12px;
height: 34px;
border-radius: Radius.sm;
background: exit-ta.has-hover ? #ffffff1f : transparent;
exit-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
ImmersiveState.open = 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 {
source: @image-url("../assets/icons/x.svg");
width: 14px;
height: 14px;
x: 10px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffffcc;
}
Text {
x: 34px;
y: 0px;
height: parent.height;
width: parent.width - 44px;
text: @tr("Exit Immersive");
color: #ffffffcc;
font-size: 13px;
vertical-alignment: center;
}
}
}
}
}
TinyBar {
x: 16px;
y: 71px;
height: 10px;
width: root.width - 32px;
value: NowPlayingState.progress;
changed(v) => { NowPlayingState.seek(Math.min(v, NowPlayingState.seekable-max)); }
}
}
// ===========================================================================
// HEADER (overhaul 2026-06-19): a single TWO-LEVEL dropdown (TV-glyph trigger)
// replaces the old ModeBar (focus/split toggle + tabs) AND ShaderBar. The
// dropdown's Level-1 = the "Focus" / "Split" group headers; Level-2 = each
// group's child rows. A row sets the relevant ImmersiveState fields, fires the
// same per-panel entry trigger the old tabs fired (so the fetch/load-on-entry
// behavior is preserved), and closes the popup. No pills (ADR-008).
// ===========================================================================
// One grouped section header inside the view menu ("Focus" / "Split").
component ImmersiveMenuGroupHeader inherits Rectangle {
in property <string> label;
height: 28px;
HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 8px;
padding-bottom: 4px;
alignment: start;
Text {
text: root.label;
color: #ffffff80;
font-size: 11px;
font-weight: 700;
vertical-alignment: center;
// ALL-CAPS section-header look per the UI alignment standard
// (GroupHeader: 11px, semibold, wide tracking, muted).
letter-spacing: 1.5px;
}
}
}
// One selectable child row inside the view menu (Level-2). Icon + label, with a
// flat active highlight (no pill — ADR-008) and a trailing dot when active.
component ImmersiveMenuRow inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <bool> active: false;
callback clicked();
property <color> fg: root.active ? #ffffff : (ta.has-hover ? #fffffff2 : #ffffffcc);
height: 34px;
border-radius: 8px;
background: root.active ? #ffffff26 : (ta.has-hover ? #ffffff14 : transparent);
animate background { duration: 120ms; }
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 10px;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
tint: root.fg;
}
}
Text {
text: root.label;
color: root.fg;
font-size: 13px;
font-weight: root.active ? 600 : 500;
vertical-alignment: center;
horizontal-stretch: 1;
}
// Trailing active marker (small filled dot) — single-choice cue.
if root.active: VerticalLayout {
alignment: center;
Rectangle {
width: 7px;
height: 7px;
border-radius: 4px;
background: #ffffff;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// The grouped two-level menu itself (Level-1 group headers + Level-2 rows).
// Declared as a PopupWindow so it stacks above the chrome; it must be hosted as
// a NON-root child of a wrapper (PopupWindow cannot sit directly under a for/if
// — see DiscHeaderMenu's note), which `ImmersiveViewMenu` provides. Each row
// re-emits the same entry trigger the old ModeBar tabs did, via the menu's own
// callbacks (the parent wires them to the *State.panel-opened() / load calls).
component ImmersiveViewMenuPopup inherits PopupWindow {
callback lyrics-entered();
callback queue-entered();
callback trackinfo-entered();
callback suggestions-entered();
callback picked();
property <bool> is-focus: ImmersiveState.view-mode == 0;
property <int> sh: ImmersiveState.shader-mode;
property <int> md: ImmersiveState.mode;
property <int> sp: ImmersiveState.split-panel;
width: 256px;
height: menu-body.preferred-height;
// close-on-click dismisses the menu on ANY click, including a row tap —
// so a child selection both applies its state and closes the popup (the
// same idiom the player ⋯ menu + DiscContextMenu use).
close-policy: close-on-click;
Rectangle {
background: #0b0b0ef7;
border-radius: 16px;
border-width: 1px;
border-color: #ffffff2e;
drop-shadow-blur: 36px;
drop-shadow-color: #000000a6;
drop-shadow-offset-y: 12px;
menu-body := VerticalLayout {
padding: 8px;
spacing: 2px;
// ---- FOCUS group ----
ImmersiveMenuGroupHeader { label: @tr("FOCUS"); }
ImmersiveMenuRow {
icon: @image-url("../assets/icons/audio-lines.svg");
label: @tr("Album Reactive");
active: root.is-focus && root.sh == 0 && root.md == 0;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 0; ImmersiveState.mode = 0;
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/image.svg");
label: @tr("Static");
active: root.is-focus && root.sh == 0 && root.md == 1;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 0; ImmersiveState.mode = 1;
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Coverflow");
active: root.is-focus && root.sh == 0 && root.md == 2;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 0; ImmersiveState.mode = 2;
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/chart-no-axes-column.svg");
label: @tr("Spectrum");
active: root.is-focus && root.sh == 0 && root.md == 3;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 0; ImmersiveState.mode = 3;
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/audio-lines.svg");
label: @tr("Wave Bed");
active: root.is-focus && root.sh == 0 && root.md == 6;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 0; ImmersiveState.mode = 6;
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/mic-vocal.svg");
label: @tr("Lyrics");
active: root.is-focus && root.sh == 0 && root.md == 4;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 0; ImmersiveState.mode = 4;
root.lyrics-entered();
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Queue");
active: root.is-focus && root.sh == 0 && root.md == 5;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 0; ImmersiveState.mode = 5;
root.queue-entered();
root.picked();
}
}
// Shader scenes need the wgpu underlay — on the femtovg-GL /
// software tiers they render black, so the whole group is hidden
// (ImmersiveState.shader-scenes-available is seeded from Rust).
if ImmersiveState.shader-scenes-available: ImmersiveMenuRow {
icon: @image-url("../assets/icons/chart-no-axes-column.svg");
label: @tr("Plasma");
active: root.is-focus && root.sh == 1;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 1;
root.picked();
}
}
if ImmersiveState.shader-scenes-available: ImmersiveMenuRow {
icon: @image-url("../assets/icons/disc-3.svg");
label: @tr("Tunnel");
active: root.is-focus && root.sh == 2;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 2;
root.picked();
}
}
if ImmersiveState.shader-scenes-available: ImmersiveMenuRow {
icon: @image-url("../assets/icons/audio-lines.svg");
label: @tr("Aurora");
active: root.is-focus && root.sh == 3;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 3;
root.picked();
}
}
// Ambient metaball (mode 7) — the same album-colored amoeba as the
// app-wide dynamic background, offered here as an opt-in immersive
// scene for strong GPUs; the non-shader atmosphere stays the default
// fallback for the GL/software tiers (shader-scenes-available gates it).
if ImmersiveState.shader-scenes-available: ImmersiveMenuRow {
icon: @image-url("../assets/icons/audio-lines.svg");
label: @tr("Ambient");
active: root.is-focus && root.sh == 7;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 7;
root.picked();
}
}
if ImmersiveState.shader-scenes-available: ImmersiveMenuRow {
icon: @image-url("../assets/icons/chart-no-axes-column.svg");
label: @tr("Spectral Ribbon");
active: root.is-focus && root.sh == 4;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 4;
root.picked();
}
}
if ImmersiveState.shader-scenes-available: ImmersiveMenuRow {
icon: @image-url("../assets/icons/radio.svg");
label: @tr("Line Bed");
active: root.is-focus && root.sh == 5;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 5;
root.picked();
}
}
// POC — Liquid Spectrum (shader-mode 6) didn't pan out; PARKED for a
// future pass (not deleted). Re-enable = uncomment this row AND bump
// the `g`-cycle Math.mod back to 7. The shader (liquid_spectrum.wgsl)
// + Rust wiring (shader_underlay.rs SHADER_SOURCES[4] / render_frame
// mode 6) stay live but unreachable.
/*
ImmersiveMenuRow {
icon: @image-url("../assets/icons/circle-dot.svg");
label: @tr("Liquid Spectrum");
active: root.is-focus && root.sh == 6;
clicked => {
ImmersiveState.view-mode = 0; ImmersiveState.shader-mode = 6;
root.picked();
}
}
*/
// Divider between the two groups.
Rectangle { height: 11px;
Rectangle {
y: 5px; height: 1px;
width: parent.width - 20px;
x: 10px;
background: #ffffff1f;
}
}
// ---- SPLIT group ----
ImmersiveMenuGroupHeader { label: @tr("SPLIT"); }
ImmersiveMenuRow {
icon: @image-url("../assets/icons/mic-vocal.svg");
label: @tr("Lyrics");
active: !root.is-focus && root.sp == 0;
clicked => {
ImmersiveState.view-mode = 1; ImmersiveState.shader-mode = 0; ImmersiveState.split-panel = 0;
root.lyrics-entered();
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/info.svg");
label: @tr("Track Info");
active: !root.is-focus && root.sp == 1;
clicked => {
ImmersiveState.view-mode = 1; ImmersiveState.shader-mode = 0; ImmersiveState.split-panel = 1;
root.trackinfo-entered();
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/radio.svg");
label: @tr("Suggestions");
active: !root.is-focus && root.sp == 2;
clicked => {
ImmersiveState.view-mode = 1; ImmersiveState.shader-mode = 0; ImmersiveState.split-panel = 2;
root.suggestions-entered();
root.picked();
}
}
ImmersiveMenuRow {
icon: @image-url("../assets/icons/list-music.svg");
label: @tr("Queue");
active: !root.is-focus && root.sp == 3;
clicked => {
ImmersiveState.view-mode = 1; ImmersiveState.shader-mode = 0; ImmersiveState.split-panel = 3;
root.queue-entered();
root.picked();
}
}
}
}
}
// TV/monitor-glyph trigger button + the two-level view menu. The wrapper makes
// the PopupWindow a non-root child (mandatory in Slint). Forwards the per-panel
// entry triggers up so the parent can fire the same load callbacks the old tabs
// fired. `monitor.svg` is the closest existing display glyph (no dedicated TV
// icon in ui/assets/icons/).
component ImmersiveViewMenu inherits Rectangle {
callback lyrics-entered();
callback queue-entered();
callback trackinfo-entered();
callback suggestions-entered();
callback wake();
// Exposed so the HUD auto-hide stays awake while the pointer rests on it.
out property <bool> hovered: trigger-ta.has-hover;
width: 44px;
height: 36px;
border-radius: 18px;
background: trigger-ta.has-hover ? #000000a6 : #00000080;
border-width: 1px;
border-color: trigger-ta.has-hover ? #ffffff42 : #ffffff2e;
animate background, border-color { duration: 120ms; }
drop-shadow-blur: 32px;
drop-shadow-color: #00000066;
drop-shadow-offset-y: 8px;
QbzIcon {
source: @image-url("../assets/icons/monitor.svg");
width: 18px;
height: 18px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: trigger-ta.has-hover ? #ffffff : #ffffffcc;
}
trigger-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.wake();
view-menu.show();
}
}
view-menu := ImmersiveViewMenuPopup {
// Opens just below the trigger, left edges aligned.
x: 0px;
y: 42px;
lyrics-entered => { root.lyrics-entered(); }
queue-entered => { root.queue-entered(); }
trackinfo-entered => { root.trackinfo-entered(); }
suggestions-entered => { root.suggestions-entered(); }
picked => { root.wake(); }
}
}
// INTERACTIVE immersive search field (header center). Glass-styled rounded input
// (18px radius, dark translucent surface) wrapped in a FocusScope so the
// immersive cortinilla can be driven by the keyboard WITHOUT stealing focus from
// the input — the exact idiom from HeaderBar.slint's `search-scope`. The
// FocusScope owns focus, `forward-focus: input` routes typing to the TextInput,
// and its key-pressed runs FIRST: Return activates the keyboard selection,
// Escape dismisses, and arrows are REJECTED (the controller captures Up/Down at
// the winit level when search-open, so we must NOT also fire move-selection here
// — just reject so the single-line input doesn't do a weird cursor move). A
// future controller flips ImmersiveState.search-open true on type; this field
// only fires ImmersiveSearchActions.live(text).
component ImmersiveSearchPlaceholder inherits Rectangle {
height: 36px;
border-radius: 18px;
background: #00000080;
border-width: 1px;
border-color: #ffffff26;
drop-shadow-blur: 32px;
drop-shadow-color: #00000066;
drop-shadow-offset-y: 8px;
search-scope := FocusScope {
width: 100%;
height: 100%;
forward-focus: input;
// Enter activates the CURRENT keyboard selection: a row when one is
// arrow-highlighted (search-selected-index >= 0). Immersive has no
// "Full results" page, so with no selection Enter is a no-op (the search
// stays open). Hover never writes search-selected-index, so a stale hover
// can't hijack Enter.
function on-enter() {
if (ImmersiveState.search-selected-index >= 0) {
ImmersiveSearchActions.row-clicked(ImmersiveState.search-selected-index);
}
}
key-pressed(event) => {
// Return activates the selection; Escape dismisses — both WITHOUT the
// input losing focus. Arrows are rejected (the controller captures
// them at winit level when search-open); everything else (letters,
// backspace, …) reaches the input.
if (event.text == Key.Return) {
search-scope.on-enter();
return accept;
}
if (event.text == Key.Escape) {
if (ImmersiveState.search-open) {
ImmersiveSearchActions.dismiss();
return accept;
}
return reject;
}
// DownArrow / UpArrow: reject so the input cursor doesn't move oddly;
// the controller's winit capture owns selection movement.
return reject;
}
// Magnifier — absolutely LEFT (no layout space) so the input/placeholder
// text centers against the FULL field width regardless of the helpers.
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 15px;
height: 15px;
x: 16px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff80;
}
// Full-width input — text ALWAYS centered against the whole field (every
// helper is absolute, so the centering never shifts when the × appears).
input := TextInput {
width: 100%;
height: 100%;
color: #ffffffcc;
font-size: 13px;
vertical-alignment: center;
horizontal-alignment: center;
single-line: true;
// Two-way bound to a DEDICATED raw-text property so the controller can
// clear the field after a result is activated (consistency with the
// main cortinilla). Separate from the trimmed `search-query`. Typing
// still flows through `edited`.
text <=> ImmersiveState.search-input-text;
accepted => { search-scope.on-enter(); }
edited => { ImmersiveSearchActions.live(self.text); }
// On focus-IN: route focus to the wrapping scope (so its key-pressed
// intercepts Return/Escape) and re-open the cortinilla with a usable
// query. A direct click focuses the TextInput, NOT the scope — so
// route focus back to the scope; `forward-focus: input` immediately
// hands the cursor back here, so typing is unaffected.
changed has-focus => {
UiFocusState.text-input-focused = self.has-focus;
if (self.has-focus) {
search-scope.focus();
// Refresh only while the results panel is already open —
// focus can land here without user intent (window
// re-activation / focus reassignment on unmount), and an
// ungated live() resurrected the panel out of nowhere.
// Typing still opens it through `edited` above.
if (self.text != "" && ImmersiveState.search-open) {
ImmersiveSearchActions.live(self.text);
}
}
}
}
Text {
visible: input.text == "";
text: @tr("Search");
width: 100%;
height: 100%;
color: #ffffff80;
font-size: 13px;
vertical-alignment: center;
horizontal-alignment: center;
}
// Clear (×) — ALWAYS pinned to the far-right edge (absolute). Shown only
// when the field has text.
if input.text != "": Rectangle {
width: 18px;
height: 18px;
x: parent.width - self.width - 14px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
clear-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
input.text = "";
search-scope.focus();
ImmersiveSearchActions.dismiss();
}
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: clear-ta.has-hover ? #ffffff : #ffffff80;
}
}
// Enter hint — to the LEFT of the clear (×), absolutely positioned so it
// never shifts the centered text. Shown only while the search is open
// (which implies text exists, so the × is present at the right edge); its
// right edge sits 8px left of the × (× left edge = width - 32).
if ImmersiveState.search-open: Text {
text: "↵ " + @tr("Enter");
color: #ffffff80;
font-size: 11px;
font-weight: Typography.medium;
vertical-alignment: center;
x: parent.width - 40px - self.width;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
}
}
}
// One round control button for the immersive window-controls cluster (ported
// 1:1 from Tauri's ImmersiveHeader .window-btn). 32px circle, flat hover.
component ImmersiveWindowBtn inherits Rectangle {
in property <image> icon;
in property <bool> danger: false;
// Disabled while the cluster is collapsed so the off-screen / faded buttons
// never steal hover from the circle trigger or fire a stray click.
in property <bool> active: true;
// Exposed so the cluster's `any-hover` stays true while the pointer is over a
// button (a child TouchArea otherwise steals has-hover from the bg area).
out property <bool> hovered: ta.has-hover;
callback clicked();
width: 32px;
height: 32px;
border-radius: 16px;
background: ta.has-hover
? (root.danger ? #ef4444b3 : #ffffff26)
: transparent;
animate background { duration: 120ms; }
QbzIcon {
source: root.icon;
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: ta.has-hover
? #ffffff
: (root.danger ? #ff8f8fd9 : #ffffffd9);
}
ta := TouchArea {
enabled: root.active;
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// Header-right window-controls cluster (overhaul 2026-06-19): a COLLAPSED CIRCLE
// (sliders glyph, 36px) pinned to the header's RIGHT edge by default. On hover it
// EXPANDS LEFTWARD into a glass capsule revealing TWO controls — Fullscreen toggle
// and Close-app — with the RIGHT EDGE FIXED so the growth never pushes the
// centered search (declaration order keeps it on top per ADR-009; on narrow
// windows the capsule simply overlays nearby space, it never displaces search).
//
// The two ACTIVE controls are Fullscreen toggle and Close (exit the immersive
// overlay). The window-management controls (Minimize, Maximize/Restore, Drag,
// Close-app) are PARKED as commented-out blocks below — owner: "mientras no
// hagamos el qbz titlebar, quitamos maximizar/restaurar minimizar y drag ...
// déjalos comentados para cuando se haga eso". Their WindowControlActions
// callbacks + Rust wiring stay intact; restoring = uncomment the blocks and grow
// `expanded-width` / `row-width` to fit them.
//
// Hover source: a TouchArea's `has-hover` covering the whole capsule region
// (`TouchArea.moved` only fires while pressed — project animation/hover gotcha).
// Width is ABSOLUTELY computed and right-anchored by the PARENT (this component
// reports a fixed collapsed footprint via its own width binding to `expanded`,
// and the parent pins `x: root.width - pad - self.width`). Collapsed = one circle
// (36px); expanded = circle + two 32px buttons + gaps (~120px). Width animates.
component ImmersiveWindowControls inherits Rectangle {
// A permanent CIRCLE handle pinned to the right edge, with its OWN full-area
// TouchArea so the WHOLE circle is hover/click sensitive. On hover (or
// click-to-pin) the capsule grows LEFTWARD and the action buttons reveal to
// the LEFT of the circle — they NEVER overlap it, so while collapsed they
// cannot steal hover or fire a stray click (the old bug: the faded X button
// sat over the circle and exited immersive on click, and stole the hover so
// only a sliver of the circle reacted). Right edge fixed → never pushes the
// centered search.
// Collapsed footprint matches the view-menu button (44x36) so the two top
// controls read as twins; expands leftward on hover.
property <length> collapsed-width: 44px;
property <length> expanded-width: 112px;
// Click-to-open latch (so it opens even if a hover didn't register).
in-out property <bool> pinned: false;
// Expanded while the pointer is over ANY part — the gap-filling bg area, the
// circle, or a button. ORing the buttons' `hovered` prevents the hover-steal
// flicker when the pointer moves from the bg onto a button.
property <bool> any-hover: bg-ta.has-hover || circle-ta.has-hover
|| fs-btn.hovered || exit-btn.hovered;
in-out property <bool> expanded: root.pinned || root.any-hover;
// Exposed so the ImmersiveView HUD can keep the chrome awake while the
// pointer rests on this cluster (the auto-hide must not fire over it).
out property <bool> hovered: root.any-hover;
height: 36px;
width: root.expanded ? root.expanded-width : root.collapsed-width;
animate width { duration: 150ms; easing: ease-in-out; }
border-radius: 18px;
background: #00000080;
border-width: 1px;
border-color: root.expanded ? #ffffff42 : #ffffff2e;
animate border-color { duration: 150ms; }
drop-shadow-blur: 32px;
drop-shadow-color: #00000066;
drop-shadow-offset-y: 8px;
clip: true;
// Gap-filling hover background (bottom of the stack), so moving between the
// circle and the buttons never crosses a dead zone. No click handler.
bg-ta := TouchArea {
x: 0px;
y: 0px;
width: parent.width;
height: parent.height;
}
// Action buttons, revealed to the LEFT of the circle. Right edge sits just
// left of the 40px circle slot; collapsed (40px) the row is at x ~ -67px,
// fully off the left edge (clipped) — so it NEVER overlaps the circle.
// Disabled + faded while collapsed.
Rectangle {
x: root.width - 107px; // 67px row + 40px circle slot
y: 2px;
width: 67px; // exit(32) + 3 + fullscreen(32)
height: 32px;
opacity: root.expanded ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
// Close — EXITS the immersive overlay (not close-app; close-app is a
// titlebar function, parked below). Plain X, same path as Escape.
exit-btn := ImmersiveWindowBtn {
x: 0px;
y: 0px;
active: root.expanded;
icon: @image-url("../assets/icons/x.svg");
clicked => {
ImmersiveState.open = false;
// Keep the tap alive for the Large NPB dock if it is active.
VisualizerState.set-enabled(ShellState.large-active && ShellState.large-visualizer-on);
}
}
// Fullscreen toggle — glyph swaps on WindowControlActions.is-fullscreen.
fs-btn := ImmersiveWindowBtn {
x: 35px; // 32 + 3 gap
y: 0px;
active: root.expanded;
icon: WindowControlActions.is-fullscreen
? @image-url("../assets/icons/minimize-2.svg")
: @image-url("../assets/icons/maximize-2.svg");
clicked => { WindowControlActions.toggle-fullscreen(); }
}
}
// The permanent circle handle (always visible, rightmost). Its own TouchArea
// covers the FULL circle: hover expands, click toggles the pin. Declared
// LAST so it sits on top of its area (clicks/hover land on circle-ta here).
Rectangle {
// Right-anchored while expanded (the buttons reveal to its left); CENTERED
// while collapsed so the glyph sits dead-center like the view-menu button.
x: root.expanded ? root.width - self.width - 2px : (root.width - self.width) / 2;
y: 2px;
width: 32px;
height: 32px;
circle-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.pinned = !root.pinned; }
}
QbzIcon {
source: @image-url("../assets/icons/sliders-horizontal.svg");
width: 18px;
height: 18px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: (circle-ta.has-hover || root.expanded) ? #ffffff : #ffffffcc;
}
}
// --- PARKED for the qbz custom titlebar (uncomment when it lands) ---------
// Owner removed the window-management controls from the immersive header
// until the qbz titlebar exists. To restore: add them inside the buttons row
// to the LEFT of `exit-btn` (lower x), widen `expanded-width` (+~35px each)
// and the row width. Callbacks live in WindowControlActions (global + Rust
// wiring, both intact):
// Minimize: minus.svg -> WindowControlActions.minimize()
// Maximize: copy/square.svg -> WindowControlActions.toggle-maximize()
// (glyph swaps on WindowControlActions.is-maximized)
// Drag: move.svg -> WindowControlActions.drag-move()
// Close app: x.svg, danger -> WindowControlActions.close-app()
}
export component ImmersiveView inherits Rectangle {
callback media-action(string, string, string);
property <bool> chrome-visible: true;
// True while the pointer rests on a chrome region — the top header band
// (controls/views buttons + search) or the bottom player bar. Uses the
// winit-tracked GLOBAL pointer (always updated, even over a button's own
// TouchArea — unlike the bottom `hover` area, which loses tracking there),
// so the auto-hide never dims the HUD while the cursor is on its controls.
property <bool> pointer-in-chrome: ShellState.pointer-in-window
&& (ShellState.pointer-y <= 64px || ShellState.pointer-y >= root.height - 132px);
background: #0a0a0b;
clip: true;
// The component is conditionally mounted (it exists only while the overlay is
// OPEN), so init == the overlay opening. Rust applies the configured default
// view here (pinned view, or restore-last). Per the precedent this is the
// only `init` on the root.
init => { ImmersiveActions.opened(); }
// Fingerprint of the active view (view-mode * 100 + mode * 10 + split-panel).
// Any change while the overlay is open fires `view-changed()` so Rust can
// persist the current view for the "Remember last" default.
property <int> view-sig: ImmersiveState.view-mode * 100 + ImmersiveState.mode * 10 + ImmersiveState.split-panel;
changed view-sig => { ImmersiveActions.view-changed(); }
function wake() {
root.chrome-visible = true;
hide-timer.running = false;
hide-timer.running = true;
}
// While the immersive search is open the HUD must NOT dim (the field +
// cortinilla must stay visible/typeable). Wake on open so the chrome is up
// the moment the dropdown appears; the hide-timer's guard keeps it up.
property <bool> search-open-mirror: ImmersiveState.search-open;
changed search-open-mirror => {
if (root.search-open-mirror) {
root.wake();
}
}
keys := FocusScope {
key-pressed(event) => {
if (event.text == Key.Escape) {
ImmersiveState.open = 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..5 (Off -> Plasma -> Tunnel ->
// Aurora -> Spectral Ribbon -> Line Bed -> Off). Liquid Spectrum (mode
// 6) is a PARKED POC — to bring it back, change the Math.mod to 7 and
// uncomment its picker row above. The header picker is the discoverable control.
// No-op on the GL/software tiers (no wgpu underlay = black scenes).
if (event.text == "g" || event.text == "G") {
if (ImmersiveState.shader-scenes-available) {
ImmersiveState.shader-mode = Math.mod(ImmersiveState.shader-mode + 1, 6);
root.wake();
}
return accept;
}
return reject;
}
focus-timer := Timer {
interval: 30ms;
running: true;
triggered => {
self.running = false;
keys.focus();
root.wake();
}
}
}
hide-timer := Timer {
interval: 6000ms;
triggered => {
// Keep the HUD up while the immersive search is open OR while the
// pointer rests on a chrome region (controls/views/search/player bar).
if (!ImmersiveState.search-open && !root.pointer-in-chrome) {
root.chrome-visible = false;
}
}
}
// Pointer movement wakes the chrome. `TouchArea.moved` only fires while
// pressed in Slint, so watch mouse-x/mouse-y changes instead.
hover := TouchArea {
changed mouse-x => { root.wake(); }
changed mouse-y => { root.wake(); }
}
// (0) WGPU UNDERLAY SPIKE: the GPU fragment-shader texture, the bottom-most
// layer when a shader scene is active. ADR-010 conditional mount. When
// shader-mode > 0 it REPLACES the atmosphere (layer 1) and the mode panels
// (layer 2) so the shader is the visible background; the chrome below stays
// on top. Driven at ~30 fps from src/visualizer.rs -> shader_underlay.
if ImmersiveState.shader-mode > 0: Image {
x: 0px;
y: 0px;
width: 100%;
height: 100%;
image-fit: fill;
source: ImmersiveState.shader-texture;
}
// Spectral Ribbon (shader-mode 4): green axes + labels + stream header over
// the GPU heatmap (responsive — positions are viewport fractions matching
// the shader's plot rect in spectral_ribbon.wgsl).
if ImmersiveState.shader-mode == 4: ImmersiveSpectralOverlay {
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
}
// (1) Bottom: moving blurred-art atmosphere. Hidden while a shader scene is
// active (the shader owns the background layer instead).
if ImmersiveState.shader-mode == 0: ImmersiveAtmosphere {
source-image: ImmersiveState.bg-image;
fallback-image: NowPlayingState.artwork-large.width > 0
? NowPlayingState.artwork-large
: NowPlayingState.artwork;
// Only animate while playing: paused = frozen warp, no display-rate redraws.
animated: NowPlayingState.playing;
dim: 0.15;
}
// (2) FOCUS foreground (view-mode == 0): switches on ImmersiveState.mode via
// conditional mount (ADR-010). Mode 0 = Album Reactive, 1 = Static,
// 2 = Coverflow, 3 = Spectrum, 4 = Lyrics-focus. Each gates additionally on
// `view-mode == 0` so the SPLIT branch (below) owns the screen instead.
if ImmersiveState.shader-mode == 0 && ImmersiveState.view-mode == 0 && ImmersiveState.mode == 0: AlbumReactivePanel {
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
}
// (2b) Static mode foreground (mode == 1) — calm centered now-playing
// card over the atmosphere. Same conditional-mount pattern (ADR-010).
if ImmersiveState.shader-mode == 0 && ImmersiveState.view-mode == 0 && ImmersiveState.mode == 1: StaticPanel {
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
}
// (2c) Coverflow mode foreground (mode == 2) — a CHEAP jukebox PYRAMID of
// flat scaled cards (now-playing centered + on top, 3 past left + 3 upcoming
// right; step-down scale + per-depth black-overlay attenuation; cards snap on
// click, no 3D / reflection / gradient / 500ms reflow). True-3D rotateY/
// perspective remains deferred to a wgpu/Skia session. Same conditional-mount
// + viewport-prop contract as the siblings (ADR-010).
if ImmersiveState.shader-mode == 0 && ImmersiveState.view-mode == 0 && ImmersiveState.mode == 2: CoverflowPanel {
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
}
// (2d) Spectrum mode foreground (mode == 3) — faithful 16-bar mirrored cube
// spectrum (14 active bins x 2 mirror = 28 columns) over a solid-black
// canvas, with the same centered now-playing card as the siblings. Reads
// VisualizerState.bars (pre-smoothed + clamped in Rust). set-enabled is
// ALREADY driven on open (the mode-0 AlbumReactivePanel reads
// VisualizerState.energy from the same enabled tap). Same conditional-mount +
// viewport-prop contract as the siblings (ADR-010).
if ImmersiveState.shader-mode == 0 && ImmersiveState.view-mode == 0 && ImmersiveState.mode == 3: SpectrumPanel {
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
}
// (2d.2) Wave Bed foreground (mode == 6) — centered, VERTICAL-mirror waveform
// columns (owner: NOT the X-mirror Spectrum). Full-bleed; the bottom-right
// song card is mounted below, the same way the shader scenes get it (ADR-010
// conditional mount + viewport-prop contract).
if ImmersiveState.shader-mode == 0 && ImmersiveState.view-mode == 0 && ImmersiveState.mode == 6: ImmersiveWaveBedPanel {
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
}
// (2e) Lyrics-focus foreground (mode == 4) — one giant centered current
// line, cross-faded on change. Same conditional-mount + viewport-prop
// contract as the siblings (ADR-010). `init` kicks the lyrics fetch (mirror
// of LyricsSidebar's init -> panel-opened()) so entering this tab loads the
// doc once; cache-served / no-op when already fetched.
if ImmersiveState.shader-mode == 0 && ImmersiveState.view-mode == 0 && ImmersiveState.mode == 4: ImmersiveLyricsFocusPanel {
init => { LyricsState.panel-opened(); }
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
}
// (2f) Queue-focus foreground (mode == 5) — the SAME ImmersiveQueuePanel as
// the split placement, full-screen (centered: false). Queue + History
// sub-tabs. `init` pulls a fresh queue snapshot (mirror of the sidebar's
// init -> panel-opened()). Same conditional-mount + viewport-prop contract
// as the siblings (ADR-010).
if ImmersiveState.shader-mode == 0 && ImmersiveState.view-mode == 0 && ImmersiveState.mode == 5: ImmersiveQueuePanel {
init => { QueueState.panel-opened(); }
x: 0px;
y: 0px;
viewport-width: root.width;
viewport-height: root.height;
centered: false;
}
// (2-split) SPLIT layout (view-mode == 1): artwork + metadata LEFT, the
// active data panel RIGHT (ADR-010 conditional mount). The atmosphere stays
// behind it (layer 1). Checkpoint A ships only the Lyrics split panel.
if ImmersiveState.shader-mode == 0 && ImmersiveState.view-mode == 1: HorizontalLayout {
// 55/45 split (art-left larger, never beyond 60/40): the two columns
// carry horizontal-stretch WEIGHTS (11 : 9 ≈ 55 : 45) so the layout
// distributes width by ratio — the RIGHT data panel always receives a
// real, usable ~45% width. It used to collapse to a sliver because both
// halves shared horizontal-stretch 1 while the LEFT's intrinsic content
// (the centered artwork + the ImmersiveTrackInfo block) out-measured it,
// so the layout handed the LEFT almost everything. `col-h` is the
// available column height (viewport minus the header/player clearance),
// used to bound the artwork square.
property <length> col-h: root.height - 56px - 132px;
x: 0px;
y: 0px;
width: root.width;
height: root.height;
// Top offset for the header, bottom for the floating player — mirrors
// the focus panels' clearance so nothing tucks under the chrome.
padding-top: 56px;
padding-bottom: 132px;
padding-left: 56px;
padding-right: 56px;
spacing: 48px;
// LEFT (50%): artwork + shared metadata block, centered in its half.
// EXPLICIT half-width (root.width minus the 56+56 padding and 48 spacing,
// halved) gives a true 50/50 — horizontal-stretch weights did not, because
// the left's intrinsic content out-measured the right and skewed the split.
left-col := VerticalLayout {
property <length> col-h: parent.col-h;
width: (root.width - 160px) / 2;
alignment: center;
spacing: 28px;
// Artwork. Large + responsive: a square bounded by this column's
// resolved width and the available height (minus a budget for the
// metadata block below), floored so it never shrinks to a thumbnail.
// `self.width` is the layout-resolved column width; it only drives
// `height`, never the column's own width, so there's no loop.
left-art := Rectangle {
// Native source resolution (physical px) of the active cover, so
// the art is never UPSCALED beyond its own size on a large window
// (the cause of the blurry oversized cover when the window leaves
// the responsive/constrained range).
property <length> src-native:
(NowPlayingState.artwork-large.width > 0
? NowPlayingState.artwork-large.width
: NowPlayingState.artwork.width) * 1px;
property <length> art-size:
Math.max(240px,
Math.min(self.width, left-col.col-h - 200px, self.src-native));
height: self.art-size;
horizontal-stretch: 1;
art-inner := Rectangle {
width: parent.art-size;
height: parent.art-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: 12px;
clip: true;
drop-shadow-blur: 40px;
drop-shadow-color: #00000099;
drop-shadow-offset-y: 12px;
Image {
source: NowPlayingState.artwork-large.width > 0
? NowPlayingState.artwork-large
: NowPlayingState.artwork;
image-fit: cover;
width: parent.width;
height: parent.height;
}
}
}
// Shared metadata block (reused from the focus panels). Wrapped in a
// subtle dark plate so the title/artist text reads cleanly over the
// variable atmosphere instead of washing out — the art above stays
// prominent (no plate of its own; its drop-shadow carries it). The
// plate is full column width with 22px side gutters so the elided
// Text rows have a real width to truncate against.
meta-plate := Rectangle {
// Glass plate removed (owner: looked bad) — the metadata sits
// directly on the atmosphere; ImmersiveTrackInfo carries its own
// text shadows for legibility.
horizontal-stretch: 1;
height: meta-block.preferred-height + 28px;
meta-block := VerticalLayout {
padding-top: 14px;
padding-bottom: 14px;
padding-left: 22px;
padding-right: 22px;
alignment: center;
ImmersiveTrackInfo {
equalizer-tint: #7c3aed;
}
}
}
}
// RIGHT (~45%): the active split panel sits inside a semi-opaque dark
// rounded BACKING so any mounted panel's text reads cleanly over the
// variable atmosphere (root-cause fix for the washed-out Track Info /
// Suggestions / Queue / Lyrics labels). #000000 @ ~58% alpha, hairline
// #ffffff1a border, 16px radius, 20px inner padding. The mounted panel
// receives the backing's INNER box (column width/height minus 2×pad)
// via viewport-width/viewport-height so it never overflows the corners.
// horizontal-stretch 9 (vs the left's 11) yields the 45% share.
right-plate := Rectangle {
// The panel's inner padding. The mounted panel's content box
// (`right-inner`) subtracts this on both axes so nothing tucks under
// the edges.
property <length> pad: 20px;
width: (root.width - 160px) / 2;
// Glass legibility backing removed (owner: looked bad) — the split
// panel sits directly on the atmosphere.
clip: true;
// Inner content box, inset by the backing padding on all sides.
right-inner := Rectangle {
x: right-plate.pad;
y: right-plate.pad;
width: right-plate.width - right-plate.pad * 2;
height: right-plate.height - right-plate.pad * 2;
clip: true;
if ImmersiveState.split-panel == 0: LyricsLinesView {
init => { LyricsState.panel-opened(); }
width: 100%;
height: 100%;
// Immersive sizing — larger than the 280px sidebar. Computed
// from window width (no clamp()/vw in Slint).
side-padding: 8px;
line-gap: Math.clamp(16px, root.width * 0.014, 26px);
size-inactive: Math.clamp(24px, root.width * 0.018, 34px);
size-active: Math.clamp(28px, root.width * 0.022, 42px);
dimming-mode: LyricsState.dimming-mode;
auto-follow: true;
uppercase: LyricsState.uppercase;
show-attribution: false;
font-name: LyricsState.font-index == 1 ? "LINE Seed JP"
: LyricsState.font-index == 2 ? "Montserrat"
: LyricsState.font-index == 3 ? "Noto Sans"
: LyricsState.font-index == 4 ? "Source Sans 3"
: "";
// Solid white active line in immersive (data-panels.md §2).
active-color: #ffffff;
}
// Checkpoint C: the inline Track Info credits panel, centered in
// the right half (split-only, data-panels §7). `init` populates
// TrackInfoState for the current track WITHOUT opening the modal
// (TrackInfoActions.load-inline -> open stays false). A
// `changed NowPlayingState.track-id` guard re-fires the load so the
// panel refreshes when the track changes while it's open (track-id
// is a string — safe to watch, no layout geometry involved).
if ImmersiveState.split-panel == 1: ImmersiveTrackInfoPanel {
property <string> live-track-id: NowPlayingState.track-id;
init => { TrackInfoActions.load-inline(NowPlayingState.track-id); }
changed live-track-id => { TrackInfoActions.load-inline(self.live-track-id); }
width: 100%;
height: 100%;
viewport-width: right-inner.width;
viewport-height: right-inner.height;
centered: true;
}
// Checkpoint D: the live Suggestions panel (artist playlists +
// Song Radio + recommended tracks), centered in the right half
// (split-only, data-panels §6). `init` kicks the live artist load
// for the current track; a `changed NowPlayingState.track-id`
// guard re-fires the load so the panel refreshes when the track
// changes while it's open (track-id is a string — safe to watch,
// no layout geometry involved).
if ImmersiveState.split-panel == 2: ImmersiveSuggestionsPanel {
property <string> live-track-id: NowPlayingState.track-id;
init => { SuggestionsActions.load(NowPlayingState.track-id); }
changed live-track-id => { SuggestionsActions.load(self.live-track-id); }
width: 100%;
height: 100%;
viewport-width: right-inner.width;
viewport-height: right-inner.height;
centered: true;
}
// Checkpoint B: the Queue + History panel, centered in the right
// half. Same component as the focus mount; `centered: true` fills
// the slot (data-panels §4 — one QueuePanel in both placements).
if ImmersiveState.split-panel == 3: ImmersiveQueuePanel {
init => { QueueState.panel-opened(); }
width: 100%;
height: 100%;
viewport-width: right-inner.width;
viewport-height: right-inner.height;
centered: true;
}
}
}
}
// Immersive song card — minimal metadata (1:1 of the Tauri ImmersiveSongCard),
// shown bottom-right ONLY over the shader backgrounds (Plasma/Tunnel/Aurora);
// the focus panels render their own metadata. Always-on (not chrome-faded).
if (ImmersiveState.shader-mode > 0 || (ImmersiveState.view-mode == 0 && ImmersiveState.mode == 6)) && NowPlayingState.has-track: ImmersiveSongCard {
x: root.width - self.width - 24px;
y: root.height - self.height - 24px;
}
// (3) Auto-hide compact player.
ImmersivePlayerBar {
viewport-width: root.width;
x: Math.round((root.width - self.width) / 2 / 1px) * 1px;
y: root.height - self.height - 24px;
opacity: root.chrome-visible ? 1.0 : 0.0;
animate opacity { duration: 300ms; }
wake => { root.wake(); }
media-action(kind, id, action) => { root.media-action(kind, id, action); }
}
// (4) Header (overhaul 2026-06-19): three ABSOLUTELY-positioned groups inside
// a fixed-geometry band so the SEARCH is dead-centered to the WINDOW (like the
// main UI), independent of the asymmetric left/right group widths:
// - LEFT : the view-menu trigger pinned to the band's left edge (x: pad).
// - CENTER: the search, `x = (band.width - search.width) / 2` — centered to
// the band (full header width), NOT to a stretch slot, so the
// asymmetric left trigger + right controls never push it off true
// center.
// - RIGHT : the controls capsule pinned to the band's RIGHT edge via the
// edge-pin rule (`x: band.width - pad - self.width`); because its
// width animates on hover-expand, anchoring on its OWN `self.width`
// keeps the right edge fixed while it grows leftward — and since it
// is declared LAST it stacks ABOVE the search (ADR-009), so on
// narrow windows it overlays the search rather than displacing it.
// A plain Rectangle band (not a layout) hosts the absolute positions. Shares
// the chrome-visible flag so the whole header fades/wakes with the player.
header-band := Rectangle {
x: 24px;
y: 16px;
width: root.width - 48px;
height: 36px;
background: transparent;
opacity: root.chrome-visible ? 1.0 : 0.0;
animate opacity { duration: 300ms; }
// LEFT: the TV-glyph two-level view dropdown, pinned to the left edge.
// Fires the same per-panel entry triggers the old ModeBar tabs did
// (load-on-entry preserved).
ImmersiveViewMenu {
x: 0px;
y: 0px;
lyrics-entered => {
LyricsState.panel-opened();
root.wake();
}
queue-entered => {
QueueState.panel-opened();
root.wake();
}
trackinfo-entered => {
TrackInfoActions.load-inline(NowPlayingState.track-id);
root.wake();
}
suggestions-entered => {
SuggestionsActions.load(NowPlayingState.track-id);
root.wake();
}
wake => { root.wake(); }
}
// CENTER: inert search placeholder, ABSOLUTELY centered to the band width
// (= header width). Bounded (max 420px) via Math.min — `max-width` is
// ignored on hand-positioned Rectangles. Declared before the controls so
// the controls capsule overlays it (never the reverse) on narrow windows.
search-box := ImmersiveSearchPlaceholder {
width: Math.min(header-band.width, 420px);
x: (header-band.width - self.width) / 2;
y: 0px;
}
// RIGHT: collapsed-circle controls capsule, right-anchored (edge-pin rule).
// `self.width` animates on hover; anchoring on it keeps the RIGHT EDGE
// fixed while the capsule expands leftward. Declared LAST → stacks on top.
ImmersiveWindowControls {
x: header-band.width - self.width;
y: 0px;
}
}
// (5) Immersive search cortinilla — the live as-you-type dropdown, anchored
// under the centered header search field. Declared LAST so it stacks ABOVE the
// panels + player bar (ADR-009); its own scrims dismiss on outside clicks while
// leaving the field clickable. window-* are passed in (the component never
// touches `parent` in a root binding). anchor-y = header-band y (16) + field
// height (36) + a 6px gap = 58px. bottom-reserved = player bar height (90) +
// its bottom inset (24) + a 24px breathing gap = 138px, so the panel never
// covers the compact player bar.
ImmersiveSearchCortinilla {
x: 0px;
y: 0px;
width: root.width;
height: root.height;
window-width: root.width;
window-height: root.height;
anchor-y: 58px;
bottom-reserved: 138px;
accent: ImmersiveState.lyrics-accent;
}
}