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
437 lines
21 KiB
Plaintext
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).
|
|
}
|
|
}
|
|
}
|