// 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 } 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 index; in property line-text; in property size-inactive; in property size-active; // 0 = off, 1 = soft, 2 = strong (Tauri default) — pref-driven (S5). in property dimming-mode; // S5 display prefs: uppercase transform, font family ("" = window // default), karaoke fill / active color (theme accent or D6 swatch). in property uppercase; in property font-name; in property 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 recenter-epoch; // Tauri renders uppercase via CSS text-transform; Slint transforms the // string (display only — the engine still matches on the raw text). property 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 is-active: LyricsState.synced && root.index == LyricsState.active-index; property is-past: LyricsState.synced && LyricsState.active-index >= 0 && root.index < LyricsState.active-index; property 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; } if (root.distance == 1) { return 0.5; } if (root.distance == 2) { return 0.35; } if (root.distance == 3) { return 0.25; } return 0.15; } base := Text { text: root.display-text; wrap: word-wrap; opacity: root.line-opacity(); // Active base = the DIM (unsung) layer under the karaoke overlay // (Tauri gradient parity: unsung stays dim, sung sweeps in accent). color: root.is-active ? Theme.text-secondary : (root.is-past ? Theme.text-muted : Theme.text-secondary); font-family: root.font-name; font-size: root.is-active ? root.size-active : root.size-inactive; font-weight: root.is-active ? Typography.bold : Typography.medium; // Friendly letter-spacing (Q3): slightly open, never cramped. letter-spacing: 0.2px; // Karaoke fill overlay (active synced line only): bright copy of the // SAME text, clipped to the sung fraction. Identical width/font so // wrapping and glyph positions match the base exactly. if root.is-active: Rectangle { x: 0; y: 0; width: base.width * Math.clamp(LyricsState.line-progress, 0.0, 1.0); height: base.height; clip: true; animate width { duration: LyricsState.fill-anim-ms * 1ms; easing: linear; } Text { x: 0; y: 0; width: base.width; height: base.height; text: root.display-text; wrap: word-wrap; color: root.active-color; font-family: root.font-name; font-size: root.size-active; font-weight: Typography.bold; letter-spacing: 0.2px; } } } } 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 side-padding: 6px; in property 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 size-inactive: 13px; in property size-active: 15px; in property dimming-mode: 2; // strong (Tauri default) in property show-attribution: true; // Auto-center the active line (persisted pref; Tauri default true, // lyricsDisplayStore.ts:37-44). in property auto-follow: true; // S5 display prefs (defaults = Tauri's). in property uppercase: false; in property font-name: ""; // "" = window default font in property active-color: Theme.accent; // ---- 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 last-centered-index: -1; property 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 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 smooth-scroll: false; property 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 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; 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; } // Fallback-provider attribution (LRCLIB / lyrics.ovh). Tauri keeps // these in the About modal (AboutModal.svelte:141,144); Slint has // no About surface yet, so the line lives at the end of the scroll. // Qobuz is first-party: provider-label is empty, nothing renders. if root.show-attribution && LyricsState.provider-label != "": Text { text: @tr("Lyrics provided by {}", LyricsState.provider-label); color: Theme.text-muted; font-size: 10px; horizontal-alignment: center; } } } }