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
1686 lines
71 KiB
Plaintext
1686 lines
71 KiB
Plaintext
// 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;
|
||
}
|
||
}
|