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

+23 -1
View File
@@ -46,9 +46,10 @@ use qbz_qobuz::{QobuzClient, QobuzLyricsDocument};
use crate::{AppWindow, LyricsLineItem, LyricsState};
// `LyricsState.status` values (keep in sync with `ui/state.slint`).
// `READY` is pub(crate): the sync engine (`lyrics_sync`) gates on it.
const STATUS_IDLE: i32 = 0;
const STATUS_LOADING: i32 = 1;
const STATUS_READY: i32 = 2;
pub(crate) const STATUS_READY: i32 = 2;
const STATUS_NOT_FOUND: i32 = 3;
const STATUS_ERROR: i32 = 4;
const STATUS_OFFLINE: i32 = 5;
@@ -75,6 +76,16 @@ static CURRENT: Mutex<CurrentLyrics> = Mutex::new(CurrentLyrics {
/// carries text + line bounds + a has-words flag).
static CURRENT_DOC: Mutex<Option<LyricsDoc>> = Mutex::new(None);
/// Read access to the current parsed document for the sync engine: the
/// closure runs under the lock (keep it short — it executes on the UI
/// thread at the engine's tick rate). A poisoned lock degrades to `None`.
pub(crate) fn with_current_doc<R>(f: impl FnOnce(Option<&LyricsDoc>) -> R) -> R {
match CURRENT_DOC.lock() {
Ok(guard) => f(guard.as_ref()),
Err(_) => f(None),
}
}
/// Production providers over the shared core client lock. The lock is read
/// at CALL time (never cached), so re-inits of the Qobuz client are always
/// picked up; a missing client (pre-login, offline boot) errors out, which
@@ -253,6 +264,8 @@ pub fn on_track_changed(weak: slint::Weak<AppWindow>, track: &QueueTrack) {
state.set_lines(ModelRc::new(VecModel::default()));
state.set_synced(false);
state.set_active_index(-1);
state.set_line_progress(0.0);
state.set_fill_anim_ms(0);
state.set_provider("".into());
state.set_provider_label("".into());
state.set_error("".into());
@@ -301,6 +314,8 @@ pub fn on_track_cleared(weak: slint::Weak<AppWindow>) {
state.set_lines(ModelRc::new(VecModel::default()));
state.set_synced(false);
state.set_active_index(-1);
state.set_line_progress(0.0);
state.set_fill_anim_ms(0);
state.set_track_title("".into());
state.set_track_artist("".into());
state.set_provider("".into());
@@ -370,6 +385,13 @@ fn apply_result(weak: slint::Weak<AppWindow>, result: Result<LyricsResponse, Str
state.set_provider_label(label.into());
state.set_error(error.into());
state.set_active_index(-1);
state.set_line_progress(0.0);
state.set_fill_anim_ms(0);
state.set_status(status);
// One immediate engine pass so a freshly committed doc lands on the
// correct line right away — even while PAUSED (Tauri computes once on
// load, lyricsStore.ts:386-389); continuous ticking stays gated on
// playing.
crate::lyrics_sync::kick();
});
}
+231
View File
@@ -0,0 +1,231 @@
//! S4 lyrics sync engine — drives `LyricsState.active-index` /
//! `line-progress` from the playback position (spec §4).
//!
//! # Tick / push design
//!
//! ONE `slint::Timer` on the UI thread, started once for the app lifetime,
//! with an ADAPTIVE cadence:
//!
//! - **active ~30Hz (33ms)** while the gate is open: `lyrics panel open &&
//! doc synced+ready && effectively playing` (1:1 to Tauri's rAF gate,
//! `+page.svelte:6145-6157`);
//! - **idle 250ms** otherwise — the tick then only re-checks the gate (a few
//! property reads; something must observe the gate since Slint global
//! properties have no Rust-side reactivity. Tauri leans on Svelte
//! subscriptions for the same job).
//!
//! Every active tick recomputes the active index + karaoke fraction from the
//! ABSOLUTE position (binary search over `CURRENT_DOC` — `qbz_lyrics::sync`),
//! which makes seeks inherently safe: no smoothing ever crosses a
//! discontinuity because the engine zeroes `fill-anim-ms` whenever the index
//! changes or the fraction moves backward. Pause closes the gate → the last
//! pushed state freezes in place (parity). Pushes are equality-guarded so an
//! unchanged tick writes nothing.
//!
//! The 450ms playback poll loop is NOT touched (load-bearing for QConnect
//! report cadence + gapless edges); this timer is a parallel ms-precision
//! read used only by lyrics.
//!
//! # Position resolver (Q7 — lyrics FOLLOW the QConnect peer)
//!
//! - **Local playback**: `SharedState::current_position_ms()` — the
//! read-only ms getter on `qbz-player` (Q1; pure derivation from the
//! existing `playback_start_millis`/`position_at_start` anchors).
//! - **Peer renderer active (controller mode)**: the poll loop's controller
//! branch publishes the RAW renderer snapshot anchor here every ~450ms
//! ([`publish_remote_anchor`]); each engine tick extrapolates
//! `position_ms + (now - updated_at_ms)` while playing — the exact
//! controller-branch trick (`playback.rs` peer-position push). Tauri's
//! lyrics freeze under a peer; this is the documented D7 improvement.
//!
//! [`kick`] runs one immediate pass that ignores the playing gate: called on
//! doc commit and panel open so the ladder lands on the correct line
//! instantly, even while paused (Tauri computes once on load).
use std::cell::{Cell, RefCell};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use slint::{ComponentHandle, Timer, TimerMode};
use qbz_app::shell::AppRuntime;
use crate::adapter::SlintAdapter;
use crate::{AppWindow, LyricsState, ShellState};
type Runtime = Arc<AppRuntime<SlintAdapter>>;
/// Active cadence ~30Hz — within Tauri's effective granularity class; the
/// clip-width animation interpolates between ticks (spec §4.1).
const ACTIVE_TICK: Duration = Duration::from_millis(33);
/// Idle cadence: gate polling only.
const IDLE_TICK: Duration = Duration::from_millis(250);
/// Clip-width animation duration while progress is continuous — slightly
/// above the tick interval so a late tick doesn't stutter the fill.
const FILL_ANIM_MS: i32 = 45;
// ---- QConnect remote position anchor (Q7) ----------------------------------
// Raw renderer snapshot published by the poll loop's controller branch
// (~450ms); the engine extrapolates between publishes. Plain atomics — both
// sides touch them at low rates and a torn read across fields self-corrects
// on the next tick.
static REMOTE_ACTIVE: AtomicBool = AtomicBool::new(false);
static REMOTE_POSITION_MS: AtomicU64 = AtomicU64::new(0);
static REMOTE_UPDATED_AT_MS: AtomicU64 = AtomicU64::new(0);
static REMOTE_PLAYING: AtomicBool = AtomicBool::new(false);
/// Publish the RAW peer-renderer anchor (NOT the already-extrapolated
/// position — the engine extrapolates itself at tick time). Called from the
/// poll loop's controller branch on every tick while a peer owns playback.
pub fn publish_remote_anchor(position_ms: u64, updated_at_ms: u64, playing: bool) {
REMOTE_POSITION_MS.store(position_ms, Ordering::Relaxed);
REMOTE_UPDATED_AT_MS.store(updated_at_ms, Ordering::Relaxed);
REMOTE_PLAYING.store(playing, Ordering::Relaxed);
REMOTE_ACTIVE.store(true, Ordering::Release);
}
/// Drop back to the local position source. Called from the poll loop's
/// local fallthrough (every tick while no peer is active — cheap).
pub fn clear_remote_anchor() {
REMOTE_ACTIVE.store(false, Ordering::Release);
}
thread_local! {
static TIMER: Timer = Timer::default();
static FAST: Cell<bool> = const { Cell::new(false) };
static CTX: RefCell<Option<(Runtime, slint::Weak<AppWindow>)>> =
const { RefCell::new(None) };
}
/// Start the engine timer. UI-thread only (slint::Timer requirement);
/// idempotent — called once from shell setup in `main`.
pub fn start(runtime: Runtime, weak: slint::Weak<AppWindow>) {
CTX.with(|ctx| {
*ctx.borrow_mut() = Some((runtime, weak));
});
TIMER.with(|timer| {
if timer.running() {
return;
}
timer.start(TimerMode::Repeated, IDLE_TICK, || tick(true));
});
}
/// One immediate engine pass that bypasses the playing gate (panel-open /
/// doc-commit recompute — the view lands on the correct line instantly even
/// while paused). No-op before [`start`] or off the UI thread's context.
pub fn kick() {
tick(false);
}
fn tick(require_playing: bool) {
let Some((runtime, weak)) = CTX.with(|ctx| ctx.borrow().clone()) else {
return;
};
let Some(window) = weak.upgrade() else {
return;
};
let fast = compute_and_push(&window, &runtime, require_playing);
set_cadence(fast);
}
fn set_cadence(fast: bool) {
FAST.with(|flag| {
if flag.get() == fast {
return;
}
flag.set(fast);
TIMER.with(|timer| {
timer.set_interval(if fast { ACTIVE_TICK } else { IDLE_TICK });
});
});
}
/// Effective playback position in ms + the effective playing flag (the Q7
/// resolver — see module docs).
fn resolve_position_ms(runtime: &Runtime) -> (u64, bool) {
if REMOTE_ACTIVE.load(Ordering::Acquire) {
let playing = REMOTE_PLAYING.load(Ordering::Relaxed);
let mut position_ms = REMOTE_POSITION_MS.load(Ordering::Relaxed);
let updated_at_ms = REMOTE_UPDATED_AT_MS.load(Ordering::Relaxed);
if playing && updated_at_ms > 0 {
position_ms = position_ms.saturating_add(now_ms().saturating_sub(updated_at_ms));
}
(position_ms, playing)
} else {
let player = runtime.core().player();
(
player.state.current_position_ms(),
player.state.is_playing(),
)
}
}
/// Compute the active index + karaoke fraction and push them onto
/// `LyricsState` — equality-guarded, animation zeroed across
/// discontinuities. Returns whether the gate is fully open (open + synced +
/// playing) so the caller can pick the cadence.
fn compute_and_push(window: &AppWindow, runtime: &Runtime, require_playing: bool) -> bool {
if !window.global::<ShellState>().get_lyrics_open() {
return false;
}
let lyrics = window.global::<LyricsState>();
if !lyrics.get_synced() || lyrics.get_status() != crate::lyrics::STATUS_READY {
return false;
}
let (position_ms, playing) = resolve_position_ms(runtime);
if require_playing && !playing {
// Pause freezes the ladder + fill in place (Tauri parity: the rAF
// gate stops, getCurrentTime holds — review §8 "Pause").
return false;
}
let now = position_ms.min(i64::MAX as u64) as i64;
let (index, fraction) = crate::lyrics::with_current_doc(|doc| match doc {
Some(doc) if doc.synced && !doc.lines.is_empty() => {
let index = qbz_lyrics::sync::find_active_line_index(&doc.lines, now);
let fraction = if index >= 0 {
qbz_lyrics::sync::line_fill_fraction(&doc.lines, index as usize, now)
} else {
0.0
};
(index, fraction)
}
_ => (-1, 0.0),
});
// Push only on change. The properties themselves are the previous-tick
// state (the commit path resets them with the doc), so the engine stays
// stateless across track changes / re-opens.
let ui_index = lyrics.get_active_index();
let ui_progress = lyrics.get_line_progress();
let index_changed = index != ui_index;
let backward = fraction < ui_progress - 0.0005;
// Discontinuity (line change / backward seek): first paint EXACTLY at
// the reported progress — no smoothing across it (parity with
// LyricsLines.svelte:114-138).
let anim_ms = if index_changed || backward { 0 } else { FILL_ANIM_MS };
if lyrics.get_fill_anim_ms() != anim_ms {
lyrics.set_fill_anim_ms(anim_ms);
}
if index_changed {
lyrics.set_active_index(index);
}
if index_changed || (fraction - ui_progress).abs() >= 0.001 {
lyrics.set_line_progress(fraction);
}
playing
}
/// Wall-clock now in epoch ms — same convention as the poll loop's
/// extrapolation (`playback.rs::now_ms`) and the renderer's
/// `updated_at_ms`.
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
+12
View File
@@ -63,6 +63,7 @@ mod local_library;
mod local_playlist;
mod local_library_settings;
mod lyrics;
mod lyrics_sync;
mod media_controls;
mod locallibrary_prefs;
mod tag_editor;
@@ -6410,6 +6411,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<LyricsState>().on_panel_opened(move || {
// Immediate sync pass for an already-loaded doc, so opening
// mid-song lands on the correct line instantly (even paused) —
// the duplicate-fetch guard below skips the re-fetch then.
lyrics_sync::kick();
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
@@ -6422,6 +6427,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// S4 lyrics sync engine — a UI-thread `slint::Timer` driving
// `LyricsState.active-index` / `line-progress` at ~30Hz while the panel
// is open + the doc is synced + playback is live (idle gate polling
// otherwise). Position: local ms getter, or the published QConnect peer
// anchor while controlling a remote renderer (Q7).
lyrics_sync::start(app_runtime.clone(), window.as_weak());
// Album track search — client-side filter, no backend round-trip.
{
let weak = window.as_weak();
+10
View File
@@ -3064,6 +3064,14 @@ pub fn start_poll_loop(
refresh_sidebar(true);
last_peer_track_id = remote.track_id;
}
// Lyrics follow the peer (Q7): publish the RAW renderer
// anchor; the 30Hz sync engine extrapolates between poll
// ticks exactly like the position extrapolation below.
crate::lyrics_sync::publish_remote_anchor(
remote.position_ms,
remote.updated_at_ms,
remote.playing,
);
// Duration from the core queue's current track (aligned to the
// peer's track by the sink). Zero when unknown — clamp is skipped.
let duration_secs = runtime
@@ -3137,6 +3145,8 @@ pub fn start_poll_loop(
// Not in controller mode (no peer / returned to local): reset the
// peer-track edge var so re-entering the peer state refreshes meta.
last_peer_track_id = 0;
// Lyrics position source back to the local player (Q7 resolver).
crate::lyrics_sync::clear_remote_anchor();
let event = runtime.core().player().get_playback_event();
+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; }
+11
View File
@@ -2887,6 +2887,17 @@ export global LyricsState {
in property <string> error;
// Active line index; -1 = none. The S4 sync engine drives it.
in property <int> active-index: -1;
// Karaoke clip fraction 0..1 of the ACTIVE line's rendered width — the
// S4 engine pushes it at ~30Hz while playing: word-anchored when the
// current doc has native Qobuz wsync words, line-proportional for LRC
// sources (Q2). Drives the clip-rect width in LyricsLinesView.
in property <float> line-progress: 0.0;
// Clip-width animation duration (ms). Slightly above the engine tick
// while progress is continuous so the fill sweeps smoothly between
// pushes; the engine sets 0 across discontinuities (line change /
// backward seek) so the fill first-paints exactly at the reported
// progress (Tauri parity, LyricsLines.svelte:114-138).
in property <int> fill-anim-ms: 0;
// Fired by the sidebar's `init` (conditional mount, ADR-010): re-requests
// lyrics for the current track (cache-served when already fetched).
callback panel-opened();