feat(slint): lyrics sync engine with karaoke fill and auto-center scroll

S4 of the lyrics epic: ms-precision position source, 30Hz sync engine,
word-anchored karaoke fill, auto-centered ladder scroll, QConnect peer
following.

- qbz-player: read-only SharedState::current_position_ms() derived from
  the existing playback_start_millis/position_at_start anchors (Q1; no
  stream/seek/format path touched) + unit test
- qbz-lyrics: new sync module porting the Tauri active-line math (binary
  search active index, per-line progress with the 0.99 snap) plus the
  word-anchored clip fraction for Qobuz wsync lines (char-proportional
  word width approximation, Q2); unit tests
- qbz-slint: lyrics_sync engine - adaptive slint::Timer (33ms while
  panel open + synced + playing, 250ms gate polling otherwise),
  equality-guarded pushes, fill animation zeroed across discontinuities
  (line change / backward seek); kick() on doc commit and panel open
- QConnect (Q7): the poll loop controller branch publishes the raw
  renderer anchor; the engine extrapolates it per tick so lyrics follow
  the peer (Tauri freezes); local fallthrough clears the anchor
- LyricsLinesView: karaoke fill via clipped bright overlay text over the
  dim base (rect-clip approximation, D5); auto-center scroll from
  layout-relative active-row geometry, smooth for <=2-line moves and
  instant for jumps/initial sync, animated via a separate scroll-target
  property so user wheel input is never animated
- LyricsState: line-progress + fill-anim-ms properties

cc
This commit is contained in:
vicrodh committed 2026-06-10 17:14:37 -06:00
1 parent 5a4dbd87f6
commit 5f9fd5bdd6
9 files changed
+761 -18

No files matched your search

+138 -17
View File
@@ -8,9 +8,26 @@
// friendly 0.2px letter-spacing, minimal 6px side padding (never removed),
// the text takes the full remaining width.
//
// Static rendering only (S3): the S4 sync engine drives
// `LyricsState.active-index` and adds auto-center scroll + the karaoke fill;
// until then scrolling is a plain Flickable and active-index stays -1.
// 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).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
@@ -20,8 +37,8 @@ import { LyricsState } from "../state.slint";
// 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 S4 scroll
// math will rely on.
// 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;
@@ -30,6 +47,11 @@ component LyricsLineRow inherits VerticalLayout {
// 0 = off, 1 = soft, 2 = strong (Tauri default) — S5 wires the pref.
in property <int> dimming-mode;
// 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:
@@ -38,12 +60,23 @@ component LyricsLineRow inherits VerticalLayout {
&& root.index < LyricsState.active-index;
property <int> distance: Math.abs(root.index - LyricsState.active-index);
// Re-report whenever the active row's geometry settles: on activation
// (y/height may still be pre-relayout — the y/height change handlers
// correct the target right after) and on wrap/resize-driven shifts.
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 y => { report-if-active(); }
changed height => { report-if-active(); }
// Dimming ladder (Tauri getLineOpacity, LyricsLines.svelte:342-356):
// off -> 1.0 all; soft -> 0.6 inactive; strong -> 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 (the S4 engine drives
// the index whenever a synced track plays).
// 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;
@@ -69,17 +102,46 @@ component LyricsLineRow inherits VerticalLayout {
return 0.15;
}
Text {
base := Text {
text: root.line-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-primary
? Theme.text-secondary
: (root.is-past ? Theme.text-muted : Theme.text-secondary);
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.line-text;
wrap: word-wrap;
color: Theme.accent;
font-size: root.size-active;
font-weight: Typography.bold;
letter-spacing: 0.2px;
}
}
}
}
@@ -93,12 +155,68 @@ export component LyricsLinesView inherits Rectangle {
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 (S5 wires the persisted pref; Tauri
// default true, lyricsDisplayStore.ts:37-44).
in property <bool> auto-follow: true;
// Top/bottom spacers so the first/last SYNCED lines can center once the
// S4 auto-scroll lands (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.
// ---- 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;
}
}
// 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);
@@ -125,6 +243,9 @@ export component LyricsLinesView inherits Rectangle {
size-inactive: root.size-inactive;
size-active: root.size-active;
dimming-mode: root.dimming-mode;
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; }