Files
qbz/crates/qbz-ui/ui/immersive/ImmersiveLyricsFocusPanel.slint
T
vicrodh c8ef2a1b1d refactor(slint): split qbz-slint into qbz-ui lib + qbz bin; reclaim qbz binary
Slint cannot split the .slint compilation across crates, but a Slint LIBRARY
crate exposes its generated Rust types to dependent crates (verified with a
minimal proof). So:

- NEW crate `qbz-ui` (lib): owns the whole .slint tree + build.rs + bundled
  translations; `slint::include_modules!()` + the generated types (AppWindow,
  MiniPlayerWindow, all globals/structs) are re-exported. Translation domain is
  now `qbz-ui` (the .po files renamed accordingly).
- `qbz-slint` (bin) -> renamed to `qbz` (package + `[[bin]] name = "qbz"`),
  reclaiming the binary name from the retired Tauri app. It depends on `qbz-ui`
  and does `pub use qbz_ui::*;` so existing `crate::AppWindow` / `crate::*State`
  references resolve unchanged.

This splits the single ~20 GB rustc into two crates (UI/codegen vs the 75k LOC
of logic) — the foundation for lower build peak, incremental caching, and the
upcoming per-feature crate extraction.

Also fixed for the move: qbz-i18n bundled-.po include paths, the font/WGSL
`include_bytes!`/`include_str!` paths (-> ../../qbz-ui/ui/), workspace members,
dev scripts (`-p qbz`), and the CI workflows. `cargo check -p qbz` is green.

cc
2026-06-25 17:51:09 -06:00

155 lines
6.7 KiB
Plaintext

// Lyrics-focus foreground (Immersive view-mode 0, mode 4) — a faithful port of
// the Tauri `src/lib/components/immersive/panels/LyricsFocusPanel.svelte`
// (recon data-panels.md §3).
//
// One giant centered CURRENT line, cross-faded on every line change. Reads
// LyricsState.lines[active-index].text directly; no karaoke fill here (whole-
// line swap only, just like the Svelte source which omits activeProgress).
//
// Entry animation (data-panels.md §3 "Animations"): Tauri replays a
// `fadeInUp` (translate-up + scale + opacity, with an animated blur). Per the
// recon Slint note we SKIP the blur (no cheap animatable per-element blur) and
// keep the translate-up + opacity "lite" variant as canonical. The replay is
// driven WITHOUT remounting: a plain-int watch on LyricsState.active-index
// (SAFE — never reads layout geometry, per the LyricsLinesView FOOTGUN) flips
// an `entering` flag that a one-shot Timer clears, and `y`/`opacity` animate
// between the two states.
//
// SLINT GOTCHAS honored: `parent` is NEVER read in a root binding (viewport
// dims are passed as props, like StaticPanel); no `opacity:` on the component
// ROOT (corrupts absolute positioning — the dim/fade lives on the inner text);
// no `changed x/y/width/height` watchers.
import { LyricsState, ImmersiveState } from "../state.slint";
export component ImmersiveLyricsFocusPanel inherits Rectangle {
in property <length> viewport-width: 1280px;
in property <length> viewport-height: 720px;
width: root.viewport-width;
height: root.viewport-height;
background: transparent;
// Current line text (empty if no active line yet).
property <string> current-line:
(LyricsState.active-index >= 0 && LyricsState.active-index < LyricsState.lines.length)
? LyricsState.lines[LyricsState.active-index].text
: "";
property <bool> has-current: root.current-line != "";
// Synced docs with no current line yet (before the first lyric) show the ♪.
property <bool> waiting:
LyricsState.synced && LyricsState.status == 2
&& LyricsState.lines.length > 0 && !root.has-current;
// Truly empty: ready/no-lyrics with zero lines, or idle.
property <bool> no-lyrics:
LyricsState.status == 3
|| (LyricsState.status == 2 && LyricsState.lines.length == 0);
// Responsive font size (data-panels.md §3: clamp(28px, 5vw, 56px)).
// Slint has no clamp()/vw — compute from viewport-width.
property <length> line-size:
Math.clamp(28px, root.viewport-width * 0.05, 56px);
// --- Entry-animation retrigger ------------------------------------------
// `entering` is briefly true right after each line change; `y`/`opacity`
// animate from the "entering" pose (down + transparent) to rest. A plain
// int watch on the global active-index drives it (no geometry watched).
property <int> watched-index: LyricsState.active-index;
property <bool> entering: false;
changed watched-index => {
// Snap to the start pose, then release on the next tick so the
// `animate` runs forward (toggling within one evaluation would not
// produce a transition).
root.entering = true;
enter-timer.running = false;
enter-timer.running = true;
}
enter-timer := Timer {
interval: 16ms;
running: false;
triggered => {
root.entering = false;
self.running = false;
}
}
// The line block. Centered as a group in the viewport; padding mirrors the
// Svelte 80px 60px 140px (top / sides / bottom-player-clearance).
VerticalLayout {
x: 0px;
y: 0px;
width: root.viewport-width;
height: root.viewport-height;
padding-left: 60px;
padding-right: 60px;
padding-top: 80px;
padding-bottom: 140px;
// The line wrapper is a plain Rectangle (NOT a layout) so the inner
// Text's animated `y` offset is FREE — inside a layout `y` would be
// layout-managed and the translate-up would not take. It
// `vertical-stretch`-fills the padded region (real, non-circular
// height), and the inner Text self-centers within it. This avoids the
// "Rectangle has 0 preferred height under alignment:center" collapse.
Rectangle {
horizontal-stretch: 1;
vertical-stretch: 1;
// Current line — fadeInUp-lite (translate-up + opacity; blur
// skipped per recon §3 Slint note). Centered in the wrapper.
if root.has-current: Text {
text: root.current-line;
// The single highlighted line takes an ALBUM-ART accent that is
// the COMPLEMENT of the cover's dominant hue, so it contrasts
// with the atmosphere (built from the same cover) instead of
// blending into it — NOT the UI lyrics color, NOT plain white
// (owner request + owner-reported salmon-on-orange blend fix).
color: ImmersiveState.lyrics-accent;
font-size: root.line-size;
font-weight: 700;
letter-spacing: 0.2px;
horizontal-alignment: center;
vertical-alignment: center;
wrap: word-wrap;
width: Math.min(parent.width, root.viewport-width * 0.9);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2 + (root.entering ? 24px : 0px);
opacity: root.entering ? 0.0 : 1.0;
animate y {
duration: 500ms;
easing: cubic-bezier(0.22, 1, 0.36, 1);
}
animate opacity {
duration: 500ms;
easing: cubic-bezier(0.22, 1, 0.36, 1);
}
}
// Synced-but-before-first-line: the ♪ glyph (Svelte .waiting-text,
// 48px / opacity .3).
if !root.has-current && root.waiting: Text {
text: "♪";
color: #ffffff;
opacity: 0.3;
font-size: 48px;
horizontal-alignment: center;
vertical-alignment: center;
width: parent.width;
height: parent.height;
}
// No lyrics at all — localized empty state.
if !root.has-current && !root.waiting && root.no-lyrics: Text {
text: @tr("No lyrics");
color: #ffffff80;
font-size: 20px;
font-italic: true;
horizontal-alignment: center;
vertical-alignment: center;
width: parent.width;
height: parent.height;
}
}
}
}