-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathuseKaraokeWordFill.ts
More file actions
146 lines (136 loc) Β· 6.81 KB
/
Copy pathuseKaraokeWordFill.ts
File metadata and controls
146 lines (136 loc) Β· 6.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import { useCallback, useEffect, useRef } from "react";
import { usePlayer } from "./usePlayer";
import { usePrefersReducedMotion } from "./usePrefersReducedMotion";
import type { LyricsWord } from "../lib/tauri/lyrics";
/** CSS custom property the fill layer's `clip-path` reads. */
const FILL_VAR = "--kw-fill";
/** Monotonic clock, guarded for non-browser hosts (tests). */
function now(): number {
return typeof performance !== "undefined" ? performance.now() : 0;
}
/**
* Drives the progressive sweep across the active karaoke word (issue
* #491) β a continuous fill rather than the per-word step the column
* used before.
*
* Two problems this solves, both invisible in a naive implementation:
*
* 1. **The position only arrives at 4 Hz.** The decoder throttles
* `player:position` to one event per 250 ms
* ([`POSITION_EMIT_INTERVAL`](../../src-tauri/crates/app/src/audio/decoder.rs)),
* so painting straight from `positionMs` would advance the fill in
* 250 ms steps β worse than the old discrete highlight, not better.
* Each event becomes an *anchor* (position + the `performance.now()`
* at which we saw it) and every frame extrapolates from it, scaled by
* `playbackSpeed` so the sweep still tracks at 0.5Γ or 2Γ.
* 2. **A frame-rate React update would re-render the world.**
* `useTrackLyrics` is shared by the immersive column and the side
* panel, and the column renders every line β so `setState` per frame
* would re-render both trees ~60 times a second. Nothing here touches
* React state: the loop writes a CSS variable straight onto the one
* element it owns, and the browser handles the rest.
*
* Returns a ref callback to attach to the active word's element **only**.
* Attaching it to a word means "this one is being sung"; React detaches
* it as the active word moves on, which restarts the loop against the
* new bounds.
*
* Falls back to no fill (the caller keeps its discrete styling) when
* motion is reduced, when the word has no usable duration, or when
* playback is paused mid-word.
*/
export function useKaraokeWordFill(word: LyricsWord | null | undefined) {
const { positionMs, isPlaying, playbackSpeed } = usePlayer();
const reduceMotion = usePrefersReducedMotion();
const elRef = useRef<HTMLElement | null>(null);
// The point the loop extrapolates from: a position, the moment we
// adopted it, and the speed in force from that moment on. `at` is
// seeded on the first render so the very first frame doesn't measure
// the whole page lifetime as elapsed playback.
const anchorRef = useRef({ positionMs, at: now(), speed: playbackSpeed });
// What the last position *event* carried, so we can tell "the backend
// told us something new" from "only the speed or play state changed".
const lastEventPositionRef = useRef(positionMs);
const wasPlayingRef = useRef(isPlaying);
useEffect(() => {
const at = now();
const previous = anchorRef.current;
const fromEvent = positionMs !== lastEventPositionRef.current;
lastEventPositionRef.current = positionMs;
// Where the fill had actually reached, advanced with the speed that
// was in force β not the one that just took effect.
const carried =
previous.positionMs +
(wasPlayingRef.current
? Math.max(0, at - previous.at) * previous.speed
: 0);
wasPlayingRef.current = isPlaying;
anchorRef.current = {
// A fresh position from the backend is authoritative. Otherwise the
// trigger was a speed change or a play/pause, and re-anchoring on
// `positionMs` would discard up to 250 ms of already-extrapolated
// progress β the fill would visibly jump backwards on every pause
// and every speed change. Carrying the extrapolated value over also
// means a resume excludes the paused time for free: `at` is reset
// here, so `elapsed` restarts at zero.
positionMs: fromEvent ? positionMs : carried,
at,
speed: playbackSpeed,
};
}, [positionMs, isPlaying, playbackSpeed]);
const start = word?.timeMs ?? -1;
// `fillEndMs` first: an estimated last word stays active until the line
// hands over but finishes its fill earlier (see `LyricsWord`).
const end = word?.fillEndMs ?? word?.endMs ?? -1;
// A word needs a real, forward-going, *finite* span to sweep across.
// `endMs` is normally filled in by `fillLineAndWordEnds`, but the last
// word of the last line can stay -1, a sloppy source can stamp two
// words at the same millisecond, and `LyricsWord`'s contract allows
// `+β` for a final word β which passes `end > start` while making every
// ratio 0, so the word would never light up at all.
const hasSpan =
Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end > start;
useEffect(() => {
const el = elRef.current;
if (!el) return;
if (reduceMotion || !hasSpan) {
// Leave the variable unset so the fill layer stays collapsed and
// the caller's discrete styling shows through unchanged.
el.style.removeProperty(FILL_VAR);
return;
}
let raf = 0;
const paint = () => {
const anchor = anchorRef.current;
// Only extrapolate while actually playing: paused, the fill must
// hold where it is instead of drifting to the end of the word.
const elapsed = isPlaying ? Math.max(0, now() - anchor.at) : 0;
// The anchor's own speed, not the live one β they differ for the
// frames between a speed change and the next position event.
const estimated = anchor.positionMs + elapsed * anchor.speed;
const ratio = (estimated - start) / (end - start);
const clamped = ratio <= 0 ? 0 : ratio >= 1 ? 1 : ratio;
el.style.setProperty(FILL_VAR, `${(clamped * 100).toFixed(2)}%`);
// Stop at a full word: there is nothing left to move, and a long
// word would otherwise hold a frame callback for its whole tail.
// Safe because `positionMs` is a dependency of this effect β a seek
// backwards inside the same word re-runs it and repaints from the
// new anchor, which is what stopping here used to break.
if (isPlaying && clamped < 1) raf = requestAnimationFrame(paint);
};
paint();
return () => {
if (raf) cancelAnimationFrame(raf);
// Hand the element back in a neutral state β it may be reused for
// a different word before React drops it.
el.style.removeProperty(FILL_VAR);
};
// `positionMs` is a dependency on purpose. It only changes at 4 Hz, so
// re-running this is cheap, and it's what repaints after a seek while
// *paused* β no loop is running then, and the anchor effect above
// updates a ref, which by itself repaints nothing.
}, [start, end, hasSpan, isPlaying, playbackSpeed, reduceMotion, positionMs]);
return useCallback((el: HTMLElement | null) => {
elRef.current = el;
}, []);
}