Files
qbz/crates/qbz-ui/ui/shell/LyricsLinesView.slint
T
vicrodh 499f652b71 feat(ui): carry lyrics translation state and render bilingual lines
LyricsLineItem gains translation-text (empty when the doc has none) and
LyricsState the translation props (translation-available, show-translation,
toggle-translation callback) per spec section C. LyricsLinesView renders
the translation under its original line while show-translation is on —
smaller (~72%) and dimmer, static text that follows the line; the karaoke
fill stays keyed on the original timings/text. Secondary surfaces share
the model, so the field flows through untouched.

km
2026-07-22 19:58:35 -06:00

437 lines
21 KiB
Plaintext

// Shared lyrics lines renderer — consumed by the sidebar now, the miniplayer
// surface later (spec §3.6: everything arrives via LyricsState + properties;
// nothing here references the sidebar, ShellState, or window geometry).
//
// 1:1 to Tauri's `LyricsLines.svelte` compact mode, ADAPTED to the OWNER's
// 280px typography spec (Q3): reduced sizes vs the 340px Tauri column
// (13/15px = Tauri's compact "small" tier instead of the 15/17 medium),
// friendly 0.2px letter-spacing, minimal 6px side padding (never removed),
// the text takes the full remaining width.
//
// S4 adds the sync visuals:
// - Karaoke fill (Q2 / deviation D5): the active line is TWO stacked Texts —
// the dim base plus a bright overlay clipped by a Rectangle whose width is
// `base.width * LyricsState.line-progress` (the rect-clip approximation of
// Tauri's background-clip gradient). The overlay Text gets the SAME width
// as the base so word-wrap is identical and glyphs coincide; on wrapped
// lines the fill is proportional to the whole block (owner-blessed). The
// engine pushes word-anchored fractions for Qobuz wsync docs Rust-side.
// `animate width` bridges the ~30Hz engine ticks; the engine zeroes
// `fill-anim-ms` across discontinuities (line change / backward seek) so
// the fill first-paints exactly at the reported progress.
// - Auto-center scroll (1:1 to LyricsLines.svelte:358-392): the ACTIVE row
// self-reports its LAYOUT-relative geometry (self.y within the body
// VerticalLayout — NEVER absolute-position, which double-counts inside a
// Flickable); the view centers it, smooth for moves of <= 2 lines and
// INSTANT for bigger jumps / initial sync. The smooth path animates a
// separate `scroll-target` property and copies it onto `flick.viewport-y`
// from its change handler — animating `viewport-y` itself would also
// animate user wheel input. Auto-follow only: no user-scroll pause, no
// click-to-seek (Tauri has neither — parity).
//
// FOOTGUN (crash fix, "Recursion detected" properties.rs:583): a `changed`
// callback's WATCHED expression must NEVER read layout-managed geometry
// (self.y / self.height / flick.height / viewport-*). Slint eagerly
// evaluates the watched expression at instance creation (ChangeTracker::init,
// not init_delayed), and repeated rows are created from INSIDE the parent
// layout's `layout-cache` binding evaluation (ensure_updated -> user_init
// runs while layout-cache is locked). A `changed y` handler then evaluates
// `self.y` -> reads the locked `layout-cache` -> re-entry panic on the main
// thread. Geometry may only be read inside notify BODIES of non-geometry
// watches (globals / plain properties) or Timer callbacks — those run from
// run_change_handlers / timer dispatch, outside any binding lock, and the
// pull-based re-evaluation there always yields settled post-relayout values.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LyricsState, LyricsSegment, ShellState } from "../state.slint";
// Q6 (SHIPPED): Tauri's four non-system lyric font families, embedded into
// the binary at compile time (slint-build default EmbedAllResources for Rust
// output -> RegisterCustomFontByMemory at window setup — the same mechanism
// as the app-wide Inter imports in app.slint). Family names inside the TTFs
// match Tauri's CSS stacks exactly: "LINE Seed JP" / "Montserrat" /
// "Noto Sans" / "Source Sans 3" (verified via fc-scan). The variable-font
// weight axis IS applied at raster time (femtovg's swash scaler receives the
// fontique normalized coords), so Montserrat's Thin-default fvar is harmless.
// "System" = empty font-family -> the window default (Inter), 1:1 with
// Tauri's 'system' stack.
import "../../../../static/fonts/LINESeedJP-Regular.ttf";
import "../../../../static/fonts/Montserrat-VariableFont_wght.ttf";
import "../../../../static/fonts/NotoSans-VariableFont_wdth,wght.ttf";
import "../../../../static/fonts/SourceSans3-VariableFont_wght.ttf";
// One lyric line. FOOTGUN (memory `reference_slint_root_opacity_absolute_position`):
// the dimming ladder is applied to the INNER Text (opacity + color), NEVER as
// an opacity binding on a row root — a root-opacity binding wraps the row in
// an opacity item that corrupts absolute-position / self.y anchoring inside
// the Flickable (one-row-pitch drift), exactly the geometry the scroll math
// relies on.
component LyricsLineRow inherits VerticalLayout {
in property <int> index;
in property <string> line-text;
// Translation of this line (Qobuz v10); empty when the doc has none.
// Rendered under the original line while LyricsState.show-translation.
in property <string> translation-text;
in property <length> size-inactive;
in property <length> size-active;
// 0 = off, 1 = soft, 2 = strong (Tauri default) — pref-driven (S5).
in property <int> dimming-mode;
// S5 display prefs: uppercase transform, font family ("" = window
// default), karaoke fill / active color (theme accent or D6 swatch).
in property <bool> uppercase;
in property <string> font-name;
in property <color> active-color;
// Re-center generation counter (plain int — safe to watch). The view
// bumps it once shortly after mount so a row that is born already-active
// (doc READY + sync kick BEFORE the rows exist — the panel-open-mid-song
// case) still reports: `changed is-active` never fires for it because the
// eager init already saw it active.
in property <int> recenter-epoch;
// Gap between the original line and its translation (no effect while
// the translation is hidden — a single child has no spacing).
spacing: 3px;
// Tauri renders uppercase via CSS text-transform; Slint transforms the
// string (display only — the engine still matches on the raw text).
property <string> display-text:
root.uppercase ? root.line-text.to-uppercase() : root.line-text;
// Active-row geometry for the view's auto-center scroll. y/height are
// LAYOUT-relative (self.y within the body VerticalLayout) so the
// viewport math is scroll-safe.
callback report-active-geometry(int /* index */, length /* y */, length /* height */);
property <bool> is-active:
LyricsState.synced && root.index == LyricsState.active-index;
property <bool> is-past:
LyricsState.synced
&& LyricsState.active-index >= 0
&& root.index < LyricsState.active-index;
property <int> distance: Math.abs(root.index - LyricsState.active-index);
// Report on activation and on the view's recenter bump. Reading y/height
// HERE (notify body) is safe and yields settled values: by notify time
// the is-active flip has already dirtied font-size -> layout-info ->
// layout-cache transitively, so the lazy pull re-computes the NEW layout.
// NO `changed y` / `changed height` handlers — watching geometry panics
// at row creation (see header FOOTGUN). Wrap/resize drift re-centers on
// the next line change, same as Tauri (which only scrolls on
// activeLineIndex changes, LyricsLines.svelte:358-392).
function report-if-active() {
if (root.is-active) {
root.report-active-geometry(root.index, root.y, root.height);
}
}
changed is-active => { report-if-active(); }
changed recenter-epoch => { report-if-active(); }
// Dimming ladder (Tauri getLineOpacity, LyricsLines.svelte:342-356):
// off -> 1.0 all; soft -> 0.6 inactive; strong (default) -> distance 1:
// 0.5, 2: 0.35, 3: 0.25, far: 0.15. Unsynced lyrics render the whole
// list at 0.85 (LyricsLines.svelte:515-523). With NO active line (-1)
// the ladder has no reference point — render full brightness.
pure function line-opacity() -> float {
if (!LyricsState.synced) {
return 0.85;
}
if (root.is-active) {
return 1.0;
}
if (root.dimming-mode == 0 || LyricsState.active-index < 0) {
return 1.0;
}
if (root.dimming-mode == 1) {
return 0.6;
}
// strong (default) — ASYMMETRIC: incoming (future) lines fade much
// harder than already-sung ones, nearly dissolving into the
// background and brightening as they approach the active line, for a
// stronger timing cue. Past lines keep a gentler fall-off.
if (root.is-past) {
if (root.distance == 1) {
return 0.5;
}
if (root.distance == 2) {
return 0.35;
}
if (root.distance == 3) {
return 0.25;
}
return 0.18;
}
if (root.distance == 1) {
return 0.4;
}
if (root.distance == 2) {
return 0.2;
}
if (root.distance == 3) {
return 0.08;
}
return 0.03;
}
// INACTIVE / unsynced rows: a single wrapping Text, exactly as before.
// (Active rows render the per-visual-line karaoke split below instead.)
if !root.is-active: Text {
text: root.display-text;
wrap: word-wrap;
opacity: root.line-opacity();
// 1:1 with Tauri's `transition: opacity 220ms ease-out`
// (LyricsLines.svelte:613-614): upcoming lines fade IN as they brighten
// toward active, passed lines fade OUT to their dim tier — instead of
// the opacity snapping between dimming steps.
animate opacity {
duration: 220ms;
easing: ease-out;
}
color: root.is-past ? Theme.text-muted : Theme.text-secondary;
font-family: root.font-name;
font-size: root.size-inactive;
font-weight: Typography.medium;
// Friendly letter-spacing (Q3): slightly open, never cramped.
letter-spacing: 0.2px;
}
// ACTIVE row: per-VISUAL-line karaoke highlight. The engine measured the
// active line's word-wrap (`lyrics_measure` / `lyrics_sync`) against this
// view's content-width + font + size and pushed one LyricsSegment per
// visual row, each carrying `start-weight` (cumulative width fraction
// before it) and `width-weight` (its share of the line). We render one
// dim-base + clipped-bright-overlay row PER segment with `wrap: no-wrap`
// (segments are already single visual lines, Tauri's `white-space:nowrap`),
// and re-map the single global `line-progress` to a per-segment local
// fraction so row 1 fills to 100% BEFORE row 2 starts — instead of the old
// single full-height clip rect that lit every wrapped row at once.
if root.is-active: VerticalLayout {
for seg[i] in LyricsState.active-segments: HorizontalLayout {
// Left-align each visual line; the segment's own width drives the
// clip, the HorizontalLayout just keeps it flush-left.
alignment: start;
segbase := Text {
text: seg.text;
wrap: no-wrap;
// Active line is fully bright (opacity 1.0, dimming ladder
// returns 1.0 for the active row) — keep parity with the old
// base which animated opacity; active stays solid.
color: Theme.text-secondary;
font-family: root.font-name;
font-size: root.size-active;
font-weight: Typography.bold;
letter-spacing: 0.2px;
// Bright (sung) overlay, clipped to this segment's LOCAL fill.
// local = clamp((line-progress - start-weight) / width-weight, 0, 1)
Rectangle {
// Lite fill (pref) or reduce-motion (kiosk / weak GPU): the
// active line is fully lit and STATIC — no per-frame clip
// animation, so the surface stops repainting the whole window
// ~60x/s just to sweep the karaoke fill.
property <bool> cheap: ShellState.reduce-motion || LyricsState.lite-fill;
x: 0;
y: 0;
width: self.cheap
? segbase.width
: segbase.width * Math.clamp(
seg.width-weight > 0
? (LyricsState.line-progress - seg.start-weight) / seg.width-weight
: 0,
0.0, 1.0);
height: segbase.height;
clip: true;
animate width {
duration: self.cheap ? 0ms : LyricsState.fill-anim-ms * 1ms;
easing: linear;
}
Text {
x: 0;
y: 0;
width: segbase.width;
height: segbase.height;
text: seg.text;
wrap: no-wrap;
color: root.active-color;
font-family: root.font-name;
font-size: root.size-active;
font-weight: Typography.bold;
letter-spacing: 0.2px;
}
}
}
}
}
// Translation (Qobuz v10): static text UNDER the original line — smaller
// (~72%, mobile reference) and dimmer. The karaoke fill above stays keyed
// on the ORIGINAL timings/text; this follows its line statically and
// joins the same dimming ladder (never uppercased — it's a translation,
// not the lyric). No-op while the toggle is off or the doc has none.
if LyricsState.show-translation && root.translation-text != "": Text {
text: root.translation-text;
wrap: word-wrap;
opacity: root.line-opacity();
color: root.is-past ? Theme.text-muted : Theme.text-secondary.with-alpha(0.7);
font-family: root.font-name;
font-size: (root.is-active ? root.size-active : root.size-inactive) * 0.72;
font-weight: Typography.regular;
letter-spacing: 0.2px;
animate opacity {
duration: 220ms;
easing: ease-out;
}
}
}
export component LyricsLinesView inherits Rectangle {
// Owner Q3 layout knobs — 4-6px per side, NEVER removed; the miniplayer
// surface later overrides padding/gap without touching display prefs.
in property <length> side-padding: 6px;
in property <length> line-gap: 12px; // Tauri compact gap parity
// 280px-adapted type scale (Q3). The sidebar maps the persisted S/M/L/XL
// pref onto these; the miniplayer surface later keeps the defaults
// (Tauri plumbs NO display prefs there, spec §3.6).
in property <length> size-inactive: 13px;
in property <length> size-active: 15px;
in property <int> dimming-mode: 2; // strong (Tauri default)
in property <bool> show-attribution: true;
// Auto-center the active line (persisted pref; Tauri default true,
// lyricsDisplayStore.ts:37-44).
in property <bool> auto-follow: true;
// S5 display prefs (defaults = Tauri's).
in property <bool> uppercase: false;
in property <string> font-name: ""; // "" = window default font
in property <color> active-color: Theme.accent;
// Publish the text content width (column width minus the side padding) to
// the engine so it can segment the active line's word-wrap at the right
// budget for the per-visual-line karaoke highlight. The ROOT is NOT a
// repeated row, so reading `self.width` here is SAFE (the header FOOTGUN
// forbids geometry reads only in `changed` handlers on the repeated rows).
// `init` seeds it (width is set once at construction; the view remounts on
// every doc commit), `changed width` keeps it live on resize.
function publish-content-width() {
LyricsState.content-width = Math.max(0px, self.width - 2 * root.side-padding);
}
init => { root.publish-content-width(); }
changed width => { root.publish-content-width(); }
// ---- Auto-center scroll state ----
// Last index the view centered on; -1 = nothing yet -> next center is
// INSTANT ("initial sync" rule). Reset whenever the engine drops the
// active index (track change / loading), via the mirrored property
// below (change callbacks can't attach to global properties directly).
property <int> last-centered-index: -1;
property <int> watched-active: LyricsState.active-index;
changed watched-active => {
if (root.watched-active < 0) {
root.last-centered-index = -1;
}
}
// Initial-centering bump (see row comment): one shot after the first
// frame, when the rows exist and the layout is settled. The view is
// recreated on every doc commit (status passes through loading ->
// remount, lyrics.rs), so each doc gets its bump. A plain int the rows
// watch — geometry stays OUT of watched expressions (header FOOTGUN).
property <int> recenter-epoch: 0;
recenter-once := Timer {
interval: 16ms;
running: true;
triggered => {
root.recenter-epoch += 1;
self.running = false;
}
}
// Smooth scrolling animates `scroll-target`, NEVER flick.viewport-y
// (that would animate user wheel input too); the change handler — which
// fires per animation frame — copies the interpolated value across.
property <bool> smooth-scroll: false;
property <length> scroll-target: 0px;
animate scroll-target {
duration: root.smooth-scroll ? 260ms : 0ms;
easing: ease-in-out;
}
changed scroll-target => {
flick.viewport-y = root.scroll-target;
}
// viewport-y that puts the row's center at the Flickable's center,
// clamped to the scrollable range [min(0, height - content), 0].
pure function center-target(row-y: length, row-h: length) -> length {
return Math.clamp(
-(row-y + row-h / 2 - flick.height / 2),
Math.min(0px, flick.height - flick.viewport-height),
0px);
}
function center-active(idx: int, row-y: length, row-h: length) {
if (!root.auto-follow || !LyricsState.synced) {
return;
}
// Smooth for adjacent moves (<= 2 lines), INSTANT for bigger jumps
// and the initial sync (Tauri parity, LyricsLines.svelte:386-388).
root.smooth-scroll = root.last-centered-index >= 0
&& Math.abs(idx - root.last-centered-index) <= 2;
root.last-centered-index = idx;
if (root.scroll-target == root.center-target(row-y, row-h)) {
// Equal target: the animated property emits no change event, but
// the user may have wheel-scrolled away — re-apply directly.
flick.viewport-y = root.center-target(row-y, row-h);
} else {
root.scroll-target = root.center-target(row-y, row-h);
}
}
// Top/bottom spacers so the first/last SYNCED lines can center
// (Tauri's 40vh spacers, LyricsLines.svelte:510-513). Fixed-height
// Rectangles derived from the Flickable's OWN height — Slint layouts
// ignore percentage tricks (landing review §7.4), and sibling geometry
// would be a binding loop.
property <length> spacer-height:
Math.max(0px, flick.height / 2 - root.size-active * 1.5);
flick := Flickable {
width: 100%;
height: 100%;
viewport-width: self.width;
viewport-height: body.preferred-height;
body := VerticalLayout {
padding-left: root.side-padding;
padding-right: root.side-padding;
// Unsynced/plain mode is a static top-aligned list (no spacers,
// no active line, no scroll-follow — LyricsLines.svelte:515-523).
padding-top: LyricsState.synced ? 0px : 10px;
padding-bottom: LyricsState.synced ? 0px : 10px;
spacing: root.line-gap;
if LyricsState.synced: Rectangle { height: root.spacer-height; }
for line[idx] in LyricsState.lines: LyricsLineRow {
index: idx;
line-text: line.text;
translation-text: line.translation-text;
size-inactive: root.size-inactive;
size-active: root.size-active;
dimming-mode: root.dimming-mode;
uppercase: root.uppercase;
font-name: root.font-name;
active-color: root.active-color;
recenter-epoch: root.recenter-epoch;
report-active-geometry(report-idx, row-y, row-h) => {
root.center-active(report-idx, row-y, row-h);
}
}
if LyricsState.synced: Rectangle { height: root.spacer-height; }
// Provider attribution moved to a fixed footer in LyricsSidebar
// ("Lyrics source: <provider>"), shown for ALL providers incl.
// Qobuz — so the scrolling attribution that used to live here is
// gone (it only covered the LRCLIB/ovh fallbacks).
}
}
}