@braccato/core 0.1.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js ADDED
@@ -0,0 +1,1940 @@
1
+ // The animation engine: one instance per rendered view, holding that view's lines, its selection,
2
+ // its scroll state and the per frame work that keeps the three in step. `renderer.ts` is what a
3
+ // consumer holds; this is what it is holding.
4
+ //
5
+ // An instance per rendered surface rather than a singleton, because this extension runs two: the
6
+ // YouTube Music side panel and the floating window. The two share parsed lyric data and a playback
7
+ // clock and nothing else, so anything one view can disagree with another about lives on the
8
+ // instance.
9
+ //
10
+ // The module owns no clock. A tick arrives from outside with the time already on it, which here is
11
+ // the interpolated player snapshot behind `blyrics-send-player-time`, and in the floating window a
12
+ // second interpolation of that same snapshot. Neither is a media element, which is why the custom
13
+ // element can own an animation frame loop over one while nothing under here owns a loop at all.
14
+ //
15
+ // Two things are module scope rather than instance scope: the set of live instances, and the
16
+ // playback clock the last tick wrote. Their unit is a bundle rather than a document, and this
17
+ // module is bundled into the isolated world and the page world separately, so those are two clocks
18
+ // that never meet. `themeSettings.ts` holds the third thing under that rule.
19
+ import { ANIMATING_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, USER_SCROLLING_CLASS, } from "./constants.js";
20
+ import { registerThemeSetting } from "./themeSettings.js";
21
+ import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
22
+ const NO_LYRICS_ELEMENT_LOG = "No lyrics element found on the page, skipping lyrics injection";
23
+ const LYRICS_CHECK_INTERVAL_ERROR = "Error in lyrics check interval:";
24
+ const PAUSING_LYRICS_SCROLL_LOG = "Pausing Lyrics Autoscroll Due to User Scroll";
25
+ const USER_SCROLL_RESUME_DELAY_MS = 25000;
26
+ const PASSIVE_USER_SCROLL_RESUME_DELAY_MS = 5000;
27
+ const LYRIC_ENDING_THRESHOLD_S = registerThemeSetting("blyrics-lyric-ending-threshold-s", 0.5);
28
+ const EARLY_SCROLL_CONSIDER = registerThemeSetting("blyrics-early-scroll-consider-s", 0.62);
29
+ const QUEUE_SCROLL_THRESHOLD = registerThemeSetting("blyrics-queue-scroll-ms", 150);
30
+ const TIME_JUMP_THRESHOLD = 0.5;
31
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
32
+ const SCROLL_TIMING_RATIO_BASE_DURATION_MS = 750;
33
+ const SCROLL_TIMING_RATIO_BASE_EARLY_SCROLL_CONSIDER_S = 0.62;
34
+ const SCROLL_TIMING_RATIO_BASE_QUEUE_SCROLL_THRESHOLD_MS = 150;
35
+ const MAX_AUTO_QUEUE_SCROLL_THRESHOLD_MS = 200;
36
+ const SCROLL_PREPARE_LEAD_MS = 120;
37
+ const SCROLL_TIMING_RATIO_BASE_TOTAL_MS = SCROLL_TIMING_RATIO_BASE_EARLY_SCROLL_CONSIDER_S * 1000 + SCROLL_TIMING_RATIO_BASE_QUEUE_SCROLL_THRESHOLD_MS;
38
+ const SCROLL_TIMING_BUFFER_MS = SCROLL_TIMING_RATIO_BASE_TOTAL_MS - SCROLL_TIMING_RATIO_BASE_DURATION_MS;
39
+ const AUTO_QUEUE_SCROLL_RATIO = SCROLL_TIMING_RATIO_BASE_QUEUE_SCROLL_THRESHOLD_MS / SCROLL_TIMING_RATIO_BASE_TOTAL_MS;
40
+ const SWIPE_LEAD_RATIO = registerThemeSetting("blyrics-swipe-lead-ratio", 0.1);
41
+ const SWIPE_DURATION_RATIO = registerThemeSetting("blyrics-swipe-duration-ratio", 1.6);
42
+ const ENABLE_DEBUG_RENDER = registerThemeSetting("blyrics-debug-renderer", false);
43
+ const ENABLE_ANIMATION_TIMING_LOGS = registerThemeSetting("blyrics-debug-animation-timing", false);
44
+ const ANIMATION_TIMING_LOG_WINDOW_MS = 3000;
45
+ const ANIMATION_TIMING_LOG_INTERVAL_MS = 750;
46
+ const ANIMATION_TIMING_LOG_THRESHOLD_MS = 30;
47
+ const ANIMATION_TIMING_RESET_THRESHOLD_MS = 100;
48
+ const ANIMATION_TIMING_ACCUMULATION_DECAY = 1.08;
49
+ const ANIMATION_TIMING_ACCUMULATION_WEIGHT = 0.4;
50
+ const ANIMATION_TIMING_LEARN_RATE = 0.08;
51
+ const ANIMATION_TIMING_LEARN_SAMPLE_LIMIT_MS = 80;
52
+ const ANIMATION_TIMING_MAX_LEARNED_OFFSET_MS = 80;
53
+ function registerLineScrollStyleSetting(property, defaultValue) {
54
+ return [property, registerThemeSetting(property.slice(2), defaultValue)];
55
+ }
56
+ const LINE_SCROLL_AFTER_FUNCTION = "calc(750ms + log(var(--blyrics-line-scroll-abs-relative-index) + 2, 2.71828) * 80ms + (var(--blyrics-line-scroll-abs-relative-index) + 1) * 20ms)";
57
+ const LINE_SCROLL_STYLE_SETTINGS = [
58
+ registerLineScrollStyleSetting("--blyrics-line-scroll-duration", LINE_SCROLL_AFTER_FUNCTION),
59
+ registerLineScrollStyleSetting("--blyrics-line-scroll-above-duration", "calc(750ms + min(var(--blyrics-line-scroll-abs-relative-index), 6) * 20ms)"),
60
+ registerLineScrollStyleSetting("--blyrics-line-scroll-active-duration", ""),
61
+ registerLineScrollStyleSetting("--blyrics-line-scroll-below-duration", LINE_SCROLL_AFTER_FUNCTION),
62
+ registerLineScrollStyleSetting("--blyrics-line-scroll-timing-function", "var(--blyrics-lyric-scroll-timing-function)"),
63
+ registerLineScrollStyleSetting("--blyrics-line-scroll-start-easing", "var(--blyrics-line-scroll-timing-function)"),
64
+ registerLineScrollStyleSetting("--blyrics-line-scroll-end-easing", "linear"),
65
+ registerLineScrollStyleSetting("--blyrics-line-scroll-above-start-easing", ""),
66
+ registerLineScrollStyleSetting("--blyrics-line-scroll-active-start-easing", ""),
67
+ registerLineScrollStyleSetting("--blyrics-line-scroll-below-start-easing", ""),
68
+ registerLineScrollStyleSetting("--blyrics-line-scroll-above-end-easing", ""),
69
+ registerLineScrollStyleSetting("--blyrics-line-scroll-active-end-easing", ""),
70
+ registerLineScrollStyleSetting("--blyrics-line-scroll-below-end-easing", ""),
71
+ registerLineScrollStyleSetting("--blyrics-line-scroll-translate-y-start", "var(--blyrics-line-scroll-delta-px)"),
72
+ registerLineScrollStyleSetting("--blyrics-line-scroll-translate-y-end", "0px"),
73
+ registerLineScrollStyleSetting("--blyrics-line-scroll-above-translate-y-start", ""),
74
+ registerLineScrollStyleSetting("--blyrics-line-scroll-active-translate-y-start", ""),
75
+ registerLineScrollStyleSetting("--blyrics-line-scroll-below-translate-y-start", ""),
76
+ registerLineScrollStyleSetting("--blyrics-line-scroll-above-translate-y-end", ""),
77
+ registerLineScrollStyleSetting("--blyrics-line-scroll-active-translate-y-end", ""),
78
+ registerLineScrollStyleSetting("--blyrics-line-scroll-below-translate-y-end", ""),
79
+ ];
80
+ const animationTimingLastLogTimes = new WeakMap();
81
+ // 0.5 means the selected lyric will be in the middle of the screen, 0 means top, 1 means bottom
82
+ const SCROLL_POS_OFFSET_RATIO = registerThemeSetting("blyrics-target-scroll-pos-ratio", 0.37);
83
+ const PASSIVE_SCROLL_ENABLED = registerThemeSetting("blyrics-passive-scroll-enabled", true);
84
+ const PASSIVE_SECONDS_PER_LINE = registerThemeSetting("blyrics-passive-scroll-seconds-per-line", 3.5);
85
+ const PASSIVE_BOTTOM_PAUSE_S = registerThemeSetting("blyrics-passive-scroll-bottom-pause-s", 1.5);
86
+ const PASSIVE_RESET_DURATION_S = registerThemeSetting("blyrics-passive-scroll-reset-duration-s", 0.6);
87
+ const PASSIVE_TOP_PAUSE_S = registerThemeSetting("blyrics-passive-scroll-top-pause-s", 0.8);
88
+ /**
89
+ * Stands where a snapshot's wall clock timestamp would be when the time did not come from a live
90
+ * player, and so says nothing about how long ago it was sampled.
91
+ */
92
+ const NO_PLAYER_SNAPSHOT = -1;
93
+ /**
94
+ * Every instance that has been created and not yet destroyed. Operations that describe the song
95
+ * rather than one view are addressed to all of them: nothing outside this module gets to name a
96
+ * particular view, so nothing outside it can leave one showing lyrics the others have dropped.
97
+ */
98
+ const liveEngines = new Set();
99
+ /**
100
+ * Runs an operation against every live instance. Cheap enough for a scroll or a song change, and
101
+ * deliberately not used inside the tick, which runs per frame and per line.
102
+ */
103
+ export function forEveryLiveView(runOperation) {
104
+ for (const engine of liveEngines) {
105
+ runOperation(engine);
106
+ }
107
+ }
108
+ export function createAnimationEngineInstance(engineDocument, engineWindow, host) {
109
+ const reducedMotionQuery = engineWindow.matchMedia(REDUCED_MOTION_QUERY);
110
+ const handleReducedMotionChange = () => clearStyleCaches(engine);
111
+ const engine = {
112
+ document: engineDocument,
113
+ window: engineWindow,
114
+ host,
115
+ lines: [],
116
+ lyricsContainer: null,
117
+ syncType: "none",
118
+ lyricWidth: 0,
119
+ lyricHeight: 0,
120
+ skipScrolls: 0,
121
+ skipScrollsDecayTimes: [],
122
+ scrollResumeTime: 0,
123
+ scrollPos: 0,
124
+ selectedElementIndex: 0,
125
+ nextScrollAllowedTime: 0,
126
+ wasUserScrolling: false,
127
+ lastActiveElements: [],
128
+ queuedScroll: false,
129
+ lastScrollDebugContext: {
130
+ activeElms: [],
131
+ centers: [],
132
+ lyricScrollTime: 0,
133
+ },
134
+ passiveScrollAccumulatedTime: 0,
135
+ passiveLastWallTime: 0,
136
+ cachedTabRendererHeight: null,
137
+ tabRendererResizeObserver: null,
138
+ observedTabRenderer: null,
139
+ lineScrollAnimations: [],
140
+ lineScrollAnimationToken: 0,
141
+ pendingLineScroll: null,
142
+ lineScrollElementTokens: new WeakMap(),
143
+ visibleWillChangeElements: new Set(),
144
+ cachedDurations: new Map(),
145
+ cachedCSSValues: new Map(),
146
+ cachedAnimationSettings: null,
147
+ passiveScrollEnabled: false,
148
+ passiveRAFId: null,
149
+ pendingLyricsUpdateFrame: null,
150
+ learnedAnimationTimingOffsetMs: 0,
151
+ animationTimingVisibilityLogUntil: 0,
152
+ destroy: () => {
153
+ liveEngines.delete(engine);
154
+ reducedMotionQuery.removeEventListener("change", handleReducedMotionChange);
155
+ engine.tabRendererResizeObserver?.disconnect();
156
+ engine.tabRendererResizeObserver = null;
157
+ engine.observedTabRenderer = null;
158
+ stopPassiveScrollLoop(engine);
159
+ cancelLyricPositionUpdate(engine);
160
+ },
161
+ };
162
+ reducedMotionQuery.addEventListener("change", handleReducedMotionChange);
163
+ liveEngines.add(engine);
164
+ return engine;
165
+ }
166
+ const playbackClock = {
167
+ lastTime: 0,
168
+ lastPlayState: false,
169
+ lastEventCreationTime: NO_PLAYER_SNAPSHOT,
170
+ };
171
+ /**
172
+ * Forgets the last player snapshot, so the next tick is treated as the first one of a new song
173
+ * rather than as a jump away from the previous one.
174
+ */
175
+ export function resetPlaybackClock() {
176
+ playbackClock.lastTime = 0;
177
+ playbackClock.lastPlayState = false;
178
+ playbackClock.lastEventCreationTime = NO_PLAYER_SNAPSHOT;
179
+ }
180
+ // -- View operations --------------------------
181
+ /**
182
+ * The user asked for autoscroll back, now.
183
+ */
184
+ export function resetScrollResume(engine) {
185
+ engine.scrollResumeTime = 0;
186
+ }
187
+ /**
188
+ * The user asked for autoscroll back, now. Resuming is a property of playback rather than of one
189
+ * view, so every live instance resumes. Published in this shape rather than as the registry walk
190
+ * and the per view operation it is built from, so that nothing outside the module gets to name a
191
+ * particular view.
192
+ */
193
+ export function resumeAllAutoscroll() {
194
+ forEveryLiveView(resetScrollResume);
195
+ }
196
+ /**
197
+ * The user scrolled this view. Scrolls the engine itself performed are swallowed one at a time;
198
+ * a real one pauses autoscroll long enough to read where it landed, and offers the way back.
199
+ *
200
+ * @param isPassive - Whether the lyrics on screen are unsynced, and so are drifting on their own
201
+ * rather than following the song. Those resume sooner.
202
+ */
203
+ export function noteUserScroll(engine, isPassive) {
204
+ if (engine.skipScrolls > 0) {
205
+ engine.skipScrolls--;
206
+ engine.skipScrollsDecayTimes.shift();
207
+ return;
208
+ }
209
+ dropPendingLineScroll(engine);
210
+ if (engine.host.isLoaderActive())
211
+ return;
212
+ if (engine.scrollResumeTime < Date.now()) {
213
+ engine.host.log(PAUSING_LYRICS_SCROLL_LOG);
214
+ }
215
+ engine.scrollResumeTime =
216
+ Date.now() + (isPassive ? PASSIVE_USER_SCROLL_RESUME_DELAY_MS : USER_SCROLL_RESUME_DELAY_MS);
217
+ engine.wasUserScrolling = true;
218
+ engine.host.setResumeAffordanceVisible(true);
219
+ engine.lyricsContainer?.classList.add(USER_SCROLLING_CLASS);
220
+ }
221
+ /**
222
+ * Reports whether the container is a different size than the lines were last measured against, and
223
+ * clears the scroll cooldown when it is so the caller's re-measurement can scroll immediately. The
224
+ * new size is recorded by that re-measurement, not here.
225
+ */
226
+ export function noteContainerResize(engine, width, height) {
227
+ if (width === engine.lyricWidth && height === engine.lyricHeight)
228
+ return false;
229
+ engine.nextScrollAllowedTime = 0;
230
+ return true;
231
+ }
232
+ /**
233
+ * Takes the lines this view is showing off the screen, keeping the container they were in, and
234
+ * reports whether there was anything there to take.
235
+ */
236
+ export function clearOnScreenLyrics(engine) {
237
+ if (!engine.lyricsContainer)
238
+ return false;
239
+ engine.lyricsContainer.replaceChildren();
240
+ return true;
241
+ }
242
+ export function hasRenderedLines(engine) {
243
+ return engine.lines.length > 0;
244
+ }
245
+ /**
246
+ * The render records this view built. Handing them out is a leak: they carry this view's elements
247
+ * and its `Animation` objects, so a caller that rewrites line times or hangs translations off them
248
+ * can only ever reach one view. Phase 5 revisits it, when both of those have to reach two.
249
+ */
250
+ export function getRenderedLines(engine) {
251
+ return engine.lines;
252
+ }
253
+ export function getRenderedSyncType(engine) {
254
+ return engine.syncType;
255
+ }
256
+ /**
257
+ * Drops the song this view was rendering: its selection, its pending scroll work, its animations
258
+ * and its render records. The records go before the caller clears the DOM, so the elements they
259
+ * hold are released along with it.
260
+ */
261
+ export function clearLyrics(engine) {
262
+ engine.scrollPos = -1;
263
+ dropPendingLineScroll(engine);
264
+ clearLineScrollAnimations(engine);
265
+ clearVisibleLyricWillChange(engine);
266
+ for (const line of engine.lines) {
267
+ resetLineAnimationState(line);
268
+ line.isSelected = false;
269
+ }
270
+ engine.skipScrollsDecayTimes = [];
271
+ engine.lastActiveElements = [];
272
+ engine.lastScrollDebugContext.activeElms = [];
273
+ engine.lastScrollDebugContext.centers = [];
274
+ engine.queuedScroll = false;
275
+ engine.passiveScrollAccumulatedTime = 0;
276
+ engine.passiveLastWallTime = 0;
277
+ stopPassiveScrollLoop(engine);
278
+ engine.lines = [];
279
+ engine.lyricsContainer = null;
280
+ }
281
+ function resetPartAnimations(part) {
282
+ for (const animation of part.animations) {
283
+ animation.cancel();
284
+ }
285
+ part.animations = [];
286
+ }
287
+ function resetLineAnimations(lineData) {
288
+ const children = [lineData, ...lineData.parts];
289
+ children.forEach(resetPartAnimations);
290
+ }
291
+ function hasLineAnimations(lineData) {
292
+ return [lineData, ...lineData.parts].some(part => part.animations.length > 0);
293
+ }
294
+ function markLineAnimationsStopped(lineData) {
295
+ lineData.isAnimating = false;
296
+ lineData.isAnimationPlayStatePlaying = false;
297
+ lineData.accumulatedOffsetMs = 0;
298
+ }
299
+ function resetLineAnimationState(lineData) {
300
+ resetLineAnimations(lineData);
301
+ markLineAnimationsStopped(lineData);
302
+ }
303
+ function setAnimationsPlayState(lineData, isPlaying) {
304
+ const children = [lineData, ...lineData.parts];
305
+ for (const part of children) {
306
+ part.lyricElement.classList.toggle(PAUSED_CLASS, !isPlaying);
307
+ for (const animation of part.animations) {
308
+ if (isPlaying) {
309
+ animation.play();
310
+ }
311
+ else {
312
+ animation.pause();
313
+ }
314
+ }
315
+ }
316
+ }
317
+ function clearLineStateClasses(lineData) {
318
+ lineData.lyricElement.classList.remove(ANIMATING_CLASS);
319
+ for (const part of [lineData, ...lineData.parts]) {
320
+ part.lyricElement.classList.remove(PAUSED_CLASS);
321
+ }
322
+ }
323
+ const LINE_SYNCED_WORD_CLASS = "blyrics-line-synced-word";
324
+ const WORD_HIGHLIGHT_SELECTOR = ".blyrics-word-highlight";
325
+ const INSTRUMENTAL_FILL_SELECTOR = ".blyrics--instrumental-fill";
326
+ const INSTRUMENTAL_WAVE_CLIP_SELECTOR = ".blyrics--wave-clip";
327
+ const INSTRUMENTAL_WAVE_PATH_SELECTOR = ".blyrics--wave-path";
328
+ const INSTRUMENTAL_WAVE_PATH_HIGH = 'path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z")';
329
+ const INSTRUMENTAL_WAVE_PATH_LOW = 'path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z")';
330
+ const LINE_SCROLL_INDEX_PROPERTY = "--blyrics-line-scroll-index";
331
+ const LINE_SCROLL_ACTIVE_INDEX_PROPERTY = "--blyrics-line-scroll-active-index";
332
+ const LINE_SCROLL_RELATIVE_INDEX_PROPERTY = "--blyrics-line-scroll-relative-index";
333
+ const LINE_SCROLL_ABS_RELATIVE_INDEX_PROPERTY = "--blyrics-line-scroll-abs-relative-index";
334
+ const LINE_SCROLL_SIDE_PROPERTY = "--blyrics-line-scroll-side";
335
+ const LINE_SCROLL_DELTA_PROPERTY = "--blyrics-line-scroll-delta-px";
336
+ const LINE_SCROLL_DISTANCE_PROPERTY = "--blyrics-line-scroll-distance-px";
337
+ const LINE_SCROLL_WILL_CHANGE_VALUE = "transform, translate";
338
+ const LINE_SCROLL_INLINE_PROPERTIES = [
339
+ LINE_SCROLL_INDEX_PROPERTY,
340
+ LINE_SCROLL_ACTIVE_INDEX_PROPERTY,
341
+ LINE_SCROLL_RELATIVE_INDEX_PROPERTY,
342
+ LINE_SCROLL_ABS_RELATIVE_INDEX_PROPERTY,
343
+ LINE_SCROLL_SIDE_PROPERTY,
344
+ LINE_SCROLL_DELTA_PROPERTY,
345
+ LINE_SCROLL_DISTANCE_PROPERTY,
346
+ ...LINE_SCROLL_STYLE_SETTINGS.map(([property]) => property),
347
+ ];
348
+ const animationTimingTracks = new WeakMap();
349
+ function trackLyricAnimationTiming(engine, animation, timing) {
350
+ animationTimingTracks.set(animation, {
351
+ ...timing,
352
+ appliedTimingOffsetMs: timing.appliedTimingOffsetMs ?? engine.learnedAnimationTimingOffsetMs,
353
+ });
354
+ return animation;
355
+ }
356
+ function correctedAnimationTimeMs(targetTimeMs, appliedTimingOffsetMs, maxTimeMs) {
357
+ const scheduledTimeMs = targetTimeMs - appliedTimingOffsetMs;
358
+ return maxTimeMs === undefined ? scheduledTimeMs : Math.min(scheduledTimeMs, maxTimeMs);
359
+ }
360
+ function correctedWrappedAnimationTimeMs(targetTimeMs, appliedTimingOffsetMs, wrapDurationMs) {
361
+ return positiveModulo(targetTimeMs - appliedTimingOffsetMs, wrapDurationMs);
362
+ }
363
+ function correctedScrollTimeS(engine, currentTime) {
364
+ return currentTime - engine.learnedAnimationTimingOffsetMs / 1000;
365
+ }
366
+ function timingValueToMs(value) {
367
+ if (typeof value === "number") {
368
+ return Number.isFinite(value) || value === Number.POSITIVE_INFINITY ? value : null;
369
+ }
370
+ if (typeof value === "string") {
371
+ const durationMs = toMs(value);
372
+ return durationMs > 0 ? durationMs : null;
373
+ }
374
+ if (!value || typeof value !== "object") {
375
+ return null;
376
+ }
377
+ const numericValue = value;
378
+ if (typeof numericValue.to !== "function") {
379
+ return null;
380
+ }
381
+ try {
382
+ const msValue = numericValue.to("ms").value;
383
+ return typeof msValue === "number" && Number.isFinite(msValue) ? msValue : null;
384
+ }
385
+ catch (_err) {
386
+ return null;
387
+ }
388
+ }
389
+ function animationCurrentTimeMs(animation) {
390
+ return timingValueToMs(animation.currentTime);
391
+ }
392
+ function animationActiveDurationMs(animation) {
393
+ return timingValueToMs(animation.effect?.getComputedTiming().activeDuration);
394
+ }
395
+ function wrappedTimingOffsetMs(actualTimeMs, expectedTimeMs, wrapDurationMs) {
396
+ return positiveModulo(actualTimeMs - expectedTimeMs + wrapDurationMs / 2, wrapDurationMs) - wrapDurationMs / 2;
397
+ }
398
+ function normalizeAnimationTimeMs(animation, timeMs, timing) {
399
+ if (!Number.isFinite(timeMs) || timeMs < 0) {
400
+ return null;
401
+ }
402
+ if (timing.wrapDurationMs && timing.wrapDurationMs > 0) {
403
+ return positiveModulo(timeMs, timing.wrapDurationMs);
404
+ }
405
+ const activeDurationMs = animationActiveDurationMs(animation);
406
+ if (activeDurationMs !== null && Number.isFinite(activeDurationMs)) {
407
+ return Math.min(timeMs, activeDurationMs);
408
+ }
409
+ return timeMs;
410
+ }
411
+ function animationTimingSample(part, animation, currentTime) {
412
+ if (animation.playState === "idle") {
413
+ return null;
414
+ }
415
+ const timing = animationTimingTracks.get(animation);
416
+ if (!timing) {
417
+ return null;
418
+ }
419
+ const actualTimeMs = animationCurrentTimeMs(animation);
420
+ if (actualTimeMs === null) {
421
+ return null;
422
+ }
423
+ const rawExpectedTimeMs = (currentTime - part.time) * 1000 + timing.offsetMs;
424
+ const expectedTimeMs = normalizeAnimationTimeMs(animation, rawExpectedTimeMs, timing);
425
+ const normalizedActualTimeMs = normalizeAnimationTimeMs(animation, actualTimeMs, timing);
426
+ if (expectedTimeMs === null || normalizedActualTimeMs === null) {
427
+ return null;
428
+ }
429
+ const offsetMs = timing.wrapDurationMs && timing.wrapDurationMs > 0
430
+ ? wrappedTimingOffsetMs(normalizedActualTimeMs, expectedTimeMs, timing.wrapDurationMs)
431
+ : normalizedActualTimeMs - expectedTimeMs;
432
+ const biasOffsetMs = offsetMs + timing.appliedTimingOffsetMs;
433
+ return {
434
+ actualTimeMs: normalizedActualTimeMs,
435
+ appliedTimingOffsetMs: timing.appliedTimingOffsetMs,
436
+ biasOffsetMs,
437
+ expectedTimeMs,
438
+ offsetMs,
439
+ playState: animation.playState,
440
+ };
441
+ }
442
+ function largestNativeTimingSample(part, currentTime) {
443
+ let largestSample = null;
444
+ for (const animation of part.animations) {
445
+ const sample = animationTimingSample(part, animation, currentTime);
446
+ if (sample === null) {
447
+ continue;
448
+ }
449
+ if (largestSample === null || Math.abs(sample.offsetMs) > Math.abs(largestSample.offsetMs)) {
450
+ largestSample = sample;
451
+ }
452
+ }
453
+ return largestSample;
454
+ }
455
+ function lineNativeTimingSample(lineData, currentTime) {
456
+ let largestSample = null;
457
+ for (const part of [lineData, ...lineData.parts]) {
458
+ const sample = largestNativeTimingSample(part, currentTime);
459
+ if (sample === null) {
460
+ continue;
461
+ }
462
+ if (largestSample === null || Math.abs(sample.offsetMs) > Math.abs(largestSample.offsetMs)) {
463
+ largestSample = sample;
464
+ }
465
+ }
466
+ return largestSample;
467
+ }
468
+ function linePreview(lineData) {
469
+ return lineData.lyricElement.textContent?.trim().replace(/\s+/g, " ").slice(0, 80) ?? "";
470
+ }
471
+ function canUseTimingSampleForDrift(sample, isPlaying) {
472
+ if (!isPlaying) {
473
+ return false;
474
+ }
475
+ return sample.playState === "running" || sample.playState === "finished";
476
+ }
477
+ function learnAnimationTimingOffset(engine, sample) {
478
+ if (Math.abs(sample.biasOffsetMs) > ANIMATION_TIMING_LEARN_SAMPLE_LIMIT_MS) {
479
+ return engine.learnedAnimationTimingOffsetMs;
480
+ }
481
+ engine.learnedAnimationTimingOffsetMs = clamp(engine.learnedAnimationTimingOffsetMs +
482
+ (sample.biasOffsetMs - engine.learnedAnimationTimingOffsetMs) * ANIMATION_TIMING_LEARN_RATE, -ANIMATION_TIMING_MAX_LEARNED_OFFSET_MS, ANIMATION_TIMING_MAX_LEARNED_OFFSET_MS);
483
+ return engine.learnedAnimationTimingOffsetMs;
484
+ }
485
+ function shouldLogAnimationTiming(engine, lineData, sample, now) {
486
+ if (!ENABLE_ANIMATION_TIMING_LOGS.getBooleanValue()) {
487
+ return false;
488
+ }
489
+ const isVisibilityLogWindow = now < engine.animationTimingVisibilityLogUntil;
490
+ if (!isVisibilityLogWindow && Math.abs(sample.offsetMs) < ANIMATION_TIMING_LOG_THRESHOLD_MS) {
491
+ return false;
492
+ }
493
+ const lastLogTime = animationTimingLastLogTimes.get(lineData) ?? 0;
494
+ if (now - lastLogTime < ANIMATION_TIMING_LOG_INTERVAL_MS) {
495
+ return false;
496
+ }
497
+ animationTimingLastLogTimes.set(lineData, now);
498
+ return true;
499
+ }
500
+ function logAnimationTiming(engine, reason, lineData, lineIndex, sample, currentTime, accumulatedOffsetMs, learnedOffsetMs = engine.learnedAnimationTimingOffsetMs, residualOffsetMs = sample.offsetMs) {
501
+ if (!ENABLE_ANIMATION_TIMING_LOGS.getBooleanValue()) {
502
+ return;
503
+ }
504
+ engine.host.log("WAAPI timing", {
505
+ reason,
506
+ lineIndex,
507
+ lineTimeS: roundedMs(lineData.time * 1000) / 1000,
508
+ mediaTimeS: roundedMs(currentTime * 1000) / 1000,
509
+ actualTimeMs: roundedMs(sample.actualTimeMs),
510
+ expectedTimeMs: roundedMs(sample.expectedTimeMs),
511
+ offsetMs: roundedMs(sample.offsetMs),
512
+ learnedOffsetMs: roundedMs(learnedOffsetMs),
513
+ appliedTimingOffsetMs: roundedMs(sample.appliedTimingOffsetMs),
514
+ biasOffsetMs: roundedMs(sample.biasOffsetMs),
515
+ residualOffsetMs: roundedMs(residualOffsetMs),
516
+ accumulatedOffsetMs: roundedMs(accumulatedOffsetMs),
517
+ playState: sample.playState,
518
+ text: linePreview(lineData),
519
+ });
520
+ }
521
+ function logAnimationCleanup(engine, reason, lineData, lineIndex, currentTime, staleAnimationEndTime) {
522
+ if (!ENABLE_ANIMATION_TIMING_LOGS.getBooleanValue()) {
523
+ return;
524
+ }
525
+ engine.host.log("Animation cleanup", {
526
+ reason,
527
+ lineIndex,
528
+ lineTimeS: roundedMs(lineData.time * 1000) / 1000,
529
+ mediaTimeS: roundedMs(currentTime * 1000) / 1000,
530
+ staleAnimationEndTimeS: roundedMs(staleAnimationEndTime * 1000) / 1000,
531
+ runningAnimationCount: [lineData, ...lineData.parts].reduce((count, part) => count + part.animations.length, 0),
532
+ text: linePreview(lineData),
533
+ });
534
+ }
535
+ export function noteVisibilityChange(engine) {
536
+ if (!ENABLE_ANIMATION_TIMING_LOGS.getBooleanValue()) {
537
+ return;
538
+ }
539
+ if (!engine.lyricsContainer)
540
+ return;
541
+ const runningAnimationCount = engine.lines.reduce((count, line) => count + [line, ...line.parts].reduce((lineCount, part) => lineCount + part.animations.length, 0), 0);
542
+ if (engine.document.visibilityState === "visible") {
543
+ engine.animationTimingVisibilityLogUntil = Date.now() + ANIMATION_TIMING_LOG_WINDOW_MS;
544
+ engine.host.log("Visibility changed; keeping WAAPI animations for timing verification", {
545
+ visibilityState: engine.document.visibilityState,
546
+ runningAnimationCount,
547
+ resetSkipped: true,
548
+ timingLogWindowMs: ANIMATION_TIMING_LOG_WINDOW_MS,
549
+ });
550
+ return;
551
+ }
552
+ engine.host.log("Visibility changed; WAAPI animations left intact", {
553
+ visibilityState: engine.document.visibilityState,
554
+ runningAnimationCount,
555
+ resetSkipped: true,
556
+ });
557
+ }
558
+ function activeTextGradientKeyframes(config) {
559
+ return [
560
+ {
561
+ "--lyric-transition-amount-start": config.highlight.swipeStartFrom,
562
+ "--lyric-transition-amount-end": config.highlight.swipeEndFrom,
563
+ },
564
+ {
565
+ "--lyric-transition-amount-start": config.highlight.swipeStartTo,
566
+ "--lyric-transition-amount-end": config.highlight.swipeEndTo,
567
+ },
568
+ ];
569
+ }
570
+ function activeTextGlowKeyframes(config) {
571
+ return [{ filter: config.highlight.glowFrom }, { filter: config.highlight.glowTo }];
572
+ }
573
+ function activeTextVisibleKeyframes() {
574
+ return [{ opacity: 1 }, { opacity: 1 }];
575
+ }
576
+ function activeTextInstantKeyframes(config) {
577
+ return [
578
+ {
579
+ opacity: 1,
580
+ "--lyric-transition-amount-start": config.highlight.swipeStartTo,
581
+ "--lyric-transition-amount-end": config.highlight.swipeEndTo,
582
+ },
583
+ {
584
+ opacity: 1,
585
+ "--lyric-transition-amount-start": config.highlight.swipeStartTo,
586
+ "--lyric-transition-amount-end": config.highlight.swipeEndTo,
587
+ },
588
+ ];
589
+ }
590
+ function highlightTarget(part) {
591
+ const highlight = part.lyricElement.querySelector(WORD_HIGHLIGHT_SELECTOR);
592
+ if (highlight) {
593
+ return { element: highlight, options: {} };
594
+ }
595
+ return { element: part.lyricElement, options: { pseudoElement: "::after" } };
596
+ }
597
+ function lineSyncedTextKeyframes(config) {
598
+ return [
599
+ {
600
+ opacity: 0,
601
+ "--lyric-transition-amount-start": config.highlight.swipeStartTo,
602
+ "--lyric-transition-amount-end": config.highlight.swipeEndTo,
603
+ },
604
+ {
605
+ opacity: 1,
606
+ "--lyric-transition-amount-start": config.highlight.swipeStartTo,
607
+ "--lyric-transition-amount-end": config.highlight.swipeEndTo,
608
+ },
609
+ ];
610
+ }
611
+ function fadeOutTextKeyframes(config) {
612
+ return [
613
+ {
614
+ opacity: 1,
615
+ filter: config.highlight.glowTo,
616
+ "--lyric-transition-amount-start": config.highlight.swipeStartTo,
617
+ "--lyric-transition-amount-end": config.highlight.swipeEndTo,
618
+ },
619
+ {
620
+ opacity: 0,
621
+ filter: config.highlight.glowTo,
622
+ "--lyric-transition-amount-start": config.highlight.swipeStartTo,
623
+ "--lyric-transition-amount-end": config.highlight.swipeEndTo,
624
+ },
625
+ ];
626
+ }
627
+ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, wordTimeMs, swipeDurationMs, glowDurationMs, appliedTimingOffsetMs) {
628
+ const animations = [];
629
+ const target = highlightTarget(part);
630
+ let swipeAnimation;
631
+ if (config.enabled.highlightSwipe) {
632
+ swipeAnimation = trackLyricAnimationTiming(engine, target.element.animate(activeTextGradientKeyframes(config), {
633
+ duration: swipeDurationMs,
634
+ easing: config.highlight.swipeEasing,
635
+ fill: "forwards",
636
+ ...target.options,
637
+ }), { appliedTimingOffsetMs, offsetMs: swipeTimeMs - wordTimeMs });
638
+ swipeAnimation.currentTime = correctedAnimationTimeMs(swipeTimeMs, appliedTimingOffsetMs, swipeDurationMs);
639
+ animations.push(swipeAnimation);
640
+ }
641
+ const opacityAnimation = trackLyricAnimationTiming(engine, target.element.animate(config.enabled.highlightSwipe ? activeTextVisibleKeyframes() : activeTextInstantKeyframes(config), {
642
+ duration: 1,
643
+ easing: "linear",
644
+ fill: "forwards",
645
+ ...target.options,
646
+ }), { appliedTimingOffsetMs, offsetMs: 0 });
647
+ opacityAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, 1);
648
+ animations.push(opacityAnimation);
649
+ let glowAnimation;
650
+ if (config.enabled.highlightGlow) {
651
+ glowAnimation = trackLyricAnimationTiming(engine, target.element.animate(activeTextGlowKeyframes(config), {
652
+ duration: glowDurationMs,
653
+ easing: config.highlight.glowEasing,
654
+ fill: "forwards",
655
+ ...target.options,
656
+ }), { appliedTimingOffsetMs, offsetMs: 0 });
657
+ glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
658
+ animations.push(glowAnimation);
659
+ }
660
+ return { animations, swipe: swipeAnimation, fade: opacityAnimation, glow: glowAnimation };
661
+ }
662
+ function startLineSyncedHighlightAnimations(engine, part, config, wordTimeMs, glowDurationMs, appliedTimingOffsetMs) {
663
+ const animations = [];
664
+ const fadeInDuration = config.enabled.highlightFade ? config.highlight.fadeInDurationMs : 1;
665
+ const target = highlightTarget(part);
666
+ const opacityAnimation = trackLyricAnimationTiming(engine, target.element.animate(lineSyncedTextKeyframes(config), {
667
+ duration: fadeInDuration,
668
+ easing: config.enabled.highlightFade ? config.highlight.fadeInEasing : "linear",
669
+ fill: "forwards",
670
+ ...target.options,
671
+ }), { appliedTimingOffsetMs, offsetMs: 0 });
672
+ opacityAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, fadeInDuration);
673
+ animations.push(opacityAnimation);
674
+ let glowAnimation;
675
+ if (config.enabled.highlightGlow) {
676
+ glowAnimation = trackLyricAnimationTiming(engine, target.element.animate(activeTextGlowKeyframes(config), {
677
+ duration: glowDurationMs,
678
+ easing: config.highlight.glowEasing,
679
+ fill: "forwards",
680
+ ...target.options,
681
+ }), { appliedTimingOffsetMs, offsetMs: 0 });
682
+ glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
683
+ animations.push(glowAnimation);
684
+ }
685
+ return { animations, fade: opacityAnimation, glow: glowAnimation };
686
+ }
687
+ function startLineAnimation(engine, lineData, config, currentTime, appliedTimingOffsetMs) {
688
+ resetPartAnimations(lineData);
689
+ const rawElapsedMs = (currentTime - lineData.time) * 1000;
690
+ if (!config.enabled.lineScale) {
691
+ lineData.animations = [];
692
+ return;
693
+ }
694
+ const animation = trackLyricAnimationTiming(engine, lineData.lyricElement.animate([{ transform: config.line.enterFrom }, { transform: config.line.enterTo }], {
695
+ duration: config.line.durationMs,
696
+ easing: config.line.enterEasing,
697
+ fill: "forwards",
698
+ }), { appliedTimingOffsetMs, offsetMs: 0 });
699
+ animation.currentTime = correctedAnimationTimeMs(rawElapsedMs, appliedTimingOffsetMs, config.line.durationMs);
700
+ lineData.animations = [animation];
701
+ }
702
+ function startLineExitAnimation(lineData, config) {
703
+ resetPartAnimations(lineData);
704
+ if (!config.enabled.lineScale) {
705
+ return;
706
+ }
707
+ const animation = lineData.lyricElement.animate([{ transform: config.line.exitFrom }, { transform: config.line.exitTo }], {
708
+ duration: config.line.durationMs,
709
+ easing: config.line.exitEasing,
710
+ fill: "none",
711
+ });
712
+ lineData.animations = [animation];
713
+ animation.addEventListener("finish", () => {
714
+ resetPartAnimations(lineData);
715
+ }, { once: true });
716
+ }
717
+ function startWordAnimations(engine, part, config, currentTime, appliedTimingOffsetMs) {
718
+ resetPartAnimations(part);
719
+ const rawElapsedMs = (currentTime - part.time) * 1000;
720
+ // Providers do ship words that end before they start: one -0.01s word in a Musixmatch richsync
721
+ // was enough to make animate() throw here, and the throw took the rest of the tick with it, so
722
+ // the line never finished setting up and the engine tried it again on every frame.
723
+ const timedDurationMs = Math.max(0, part.duration * 1000);
724
+ const isLineSyncedWord = part.lyricElement.classList.contains(LINE_SYNCED_WORD_CLASS);
725
+ const swipeLeadMs = timedDurationMs * SWIPE_LEAD_RATIO.getNumberValue();
726
+ const swipeTimeMs = rawElapsedMs + swipeLeadMs;
727
+ const wordTimeMs = rawElapsedMs;
728
+ const swipeDurationMs = timedDurationMs * SWIPE_DURATION_RATIO.getNumberValue();
729
+ const glowDurationMs = Math.max(timedDurationMs * config.highlight.glowDurationRatio, config.highlight.glowMinDurationMs);
730
+ const highlightAnimations = isLineSyncedWord
731
+ ? startLineSyncedHighlightAnimations(engine, part, config, wordTimeMs, config.highlight.glowMinDurationMs, appliedTimingOffsetMs)
732
+ : startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, wordTimeMs, swipeDurationMs, glowDurationMs, appliedTimingOffsetMs);
733
+ const wobbleAnimation = config.enabled.wordWobble
734
+ ? trackLyricAnimationTiming(engine, part.lyricElement.animate([
735
+ { transform: config.word.wobbleFrom },
736
+ {
737
+ transform: config.word.wobblePeak,
738
+ offset: config.word.wobblePeakOffset,
739
+ easing: config.word.wobblePeakEasing,
740
+ },
741
+ // The two offsets are read and clamped independently, so a theme is free to settle
742
+ // before it peaks. animate() rejects offsets that go backwards, and that throw would
743
+ // orphan the highlight animations above, which are not tracked until the end.
744
+ {
745
+ transform: config.word.wobbleSettle,
746
+ offset: Math.max(config.word.wobblePeakOffset, config.word.wobbleSettleOffset),
747
+ },
748
+ { transform: config.word.wobbleTo, easing: config.word.wobbleEndEasing },
749
+ ], {
750
+ duration: config.word.wobbleDurationMs,
751
+ easing: config.word.wobbleEasing,
752
+ fill: "forwards",
753
+ }), { appliedTimingOffsetMs, offsetMs: 0 })
754
+ : null;
755
+ if (wobbleAnimation) {
756
+ wobbleAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, config.word.wobbleDurationMs);
757
+ }
758
+ part.animations = wobbleAnimation
759
+ ? [...highlightAnimations.animations, wobbleAnimation]
760
+ : highlightAnimations.animations;
761
+ }
762
+ function startLineAnimations(engine, lineData, config, currentTime) {
763
+ const appliedTimingOffsetMs = engine.learnedAnimationTimingOffsetMs;
764
+ startLineAnimation(engine, lineData, config, currentTime, appliedTimingOffsetMs);
765
+ if (lineData.lyricElement.dataset.instrumental === "true") {
766
+ startInstrumentalAnimations(engine, lineData, config, currentTime, appliedTimingOffsetMs);
767
+ return;
768
+ }
769
+ for (const part of lineData.parts) {
770
+ startWordAnimations(engine, part, config, currentTime, appliedTimingOffsetMs);
771
+ }
772
+ }
773
+ function startWordExitAnimation(part, config) {
774
+ resetPartAnimations(part);
775
+ const fadeDuration = config.enabled.highlightFade ? config.highlight.fadeOutDurationMs : 1;
776
+ const target = highlightTarget(part);
777
+ const animation = target.element.animate(fadeOutTextKeyframes(config), {
778
+ duration: fadeDuration,
779
+ easing: config.enabled.highlightFade ? config.highlight.fadeOutEasing : "linear",
780
+ fill: "none",
781
+ ...target.options,
782
+ });
783
+ part.animations = [animation];
784
+ animation.addEventListener("finish", () => {
785
+ resetPartAnimations(part);
786
+ }, { once: true });
787
+ }
788
+ function startLineExitAnimations(engine, lineData, config, currentTime) {
789
+ startLineExitAnimation(lineData, config);
790
+ if (lineData.lyricElement.dataset.instrumental === "true") {
791
+ startInstrumentalExitAnimations(engine, lineData, config, currentTime);
792
+ return;
793
+ }
794
+ for (const part of lineData.parts) {
795
+ if (currentTime >= part.time) {
796
+ startWordExitAnimation(part, config);
797
+ }
798
+ else {
799
+ resetPartAnimations(part);
800
+ }
801
+ }
802
+ }
803
+ function animateInstrumentalChild(engine, lineData, selector, keyframes, options, timing) {
804
+ const element = lineData.lyricElement.querySelector(selector);
805
+ if (!element)
806
+ return null;
807
+ const animation = timing
808
+ ? trackLyricAnimationTiming(engine, element.animate(keyframes, options), timing)
809
+ : element.animate(keyframes, options);
810
+ lineData.animations.push(animation);
811
+ return animation;
812
+ }
813
+ function startInstrumentalAnimations(engine, lineData, config, currentTime, appliedTimingOffsetMs) {
814
+ const rawElapsedMs = (currentTime - lineData.time) * 1000;
815
+ const durationMs = Math.max(lineData.duration * 1000, 1);
816
+ const fillFadeDuration = config.enabled.instrumental ? config.instrumental.fillFadeDurationMs : 1;
817
+ const fillAnimation = animateInstrumentalChild(engine, lineData, INSTRUMENTAL_FILL_SELECTOR, [{ opacity: 0 }, { opacity: 1 }], {
818
+ duration: fillFadeDuration,
819
+ easing: config.enabled.instrumental ? config.instrumental.fillFadeEasing : "linear",
820
+ fill: "forwards",
821
+ }, { appliedTimingOffsetMs, offsetMs: 0 });
822
+ let fillTravelAnimation = null;
823
+ let waveFlattenAnimation = null;
824
+ let waveOscillationAnimation = null;
825
+ if (config.enabled.instrumental) {
826
+ fillTravelAnimation = animateInstrumentalChild(engine, lineData, INSTRUMENTAL_WAVE_CLIP_SELECTOR, [{ transform: config.instrumental.fillFrom }, { transform: config.instrumental.fillTo }], {
827
+ duration: durationMs,
828
+ easing: config.instrumental.fillEasing,
829
+ fill: "both",
830
+ }, { appliedTimingOffsetMs, offsetMs: 0 });
831
+ waveFlattenAnimation = animateInstrumentalChild(engine, lineData, INSTRUMENTAL_WAVE_PATH_SELECTOR, [{ transform: config.instrumental.waveFrom }, { transform: config.instrumental.waveTo }], {
832
+ duration: durationMs,
833
+ easing: config.instrumental.waveEasing,
834
+ fill: "both",
835
+ }, { appliedTimingOffsetMs, offsetMs: 0 });
836
+ waveOscillationAnimation = animateInstrumentalChild(engine, lineData, INSTRUMENTAL_WAVE_PATH_SELECTOR, [
837
+ { d: INSTRUMENTAL_WAVE_PATH_HIGH },
838
+ { d: INSTRUMENTAL_WAVE_PATH_LOW, offset: 0.5 },
839
+ { d: INSTRUMENTAL_WAVE_PATH_HIGH },
840
+ ], {
841
+ duration: config.instrumental.waveOscillationDurationMs,
842
+ easing: config.instrumental.waveOscillationEasing,
843
+ iterations: Infinity,
844
+ }, {
845
+ appliedTimingOffsetMs,
846
+ offsetMs: 0,
847
+ wrapDurationMs: config.instrumental.waveOscillationDurationMs,
848
+ });
849
+ }
850
+ if (fillAnimation) {
851
+ fillAnimation.currentTime = correctedAnimationTimeMs(rawElapsedMs, appliedTimingOffsetMs, fillFadeDuration);
852
+ }
853
+ for (const animation of [fillTravelAnimation, waveFlattenAnimation]) {
854
+ if (animation) {
855
+ animation.currentTime = correctedAnimationTimeMs(rawElapsedMs, appliedTimingOffsetMs, durationMs);
856
+ }
857
+ }
858
+ if (waveOscillationAnimation) {
859
+ waveOscillationAnimation.currentTime = correctedWrappedAnimationTimeMs(rawElapsedMs, appliedTimingOffsetMs, Math.max(config.instrumental.waveOscillationDurationMs, 1));
860
+ }
861
+ }
862
+ function startInstrumentalExitAnimations(engine, lineData, config, currentTime) {
863
+ if (currentTime < lineData.time)
864
+ return;
865
+ const fadeDuration = config.enabled.instrumental && config.enabled.highlightFade ? config.highlight.fadeOutDurationMs : 1;
866
+ animateInstrumentalChild(engine, lineData, INSTRUMENTAL_FILL_SELECTOR, [{ opacity: 1 }, { opacity: 0 }], {
867
+ duration: fadeDuration,
868
+ easing: config.enabled.instrumental ? config.highlight.fadeOutEasing : "linear",
869
+ fill: "none",
870
+ });
871
+ }
872
+ export function clearStyleCaches(engine) {
873
+ dropPendingLineScroll(engine);
874
+ engine.cachedDurations.clear();
875
+ engine.cachedCSSValues.clear();
876
+ engine.cachedAnimationSettings = null;
877
+ }
878
+ function getCSSValue(engine, lyricsElement, property, fallback) {
879
+ let value = engine.cachedCSSValues.get(property);
880
+ if (value === undefined) {
881
+ value = engine.window.getComputedStyle(lyricsElement).getPropertyValue(property).trim() || fallback;
882
+ engine.cachedCSSValues.set(property, value);
883
+ }
884
+ return value;
885
+ }
886
+ /**
887
+ * Gets and caches a css duration.
888
+ * The cache belongs to one engine instance, which resolves every lookup against its own lyrics
889
+ * container, so this function does not key its cache on the element provided -- it assumes that
890
+ * it isn't relevant to the calling code
891
+ *
892
+ * @param lyricsElement - the element to look up against
893
+ * @param property - the css property to look up
894
+ * @return - in ms
895
+ */
896
+ function getCSSDurationInMs(engine, lyricsElement, property) {
897
+ let duration = engine.cachedDurations.get(property);
898
+ if (duration === undefined) {
899
+ duration = toMs(getCSSValue(engine, lyricsElement, property, "0ms"));
900
+ engine.cachedDurations.set(property, duration);
901
+ }
902
+ return duration;
903
+ }
904
+ function getCSSDurationWithFallback(engine, lyricsElement, property, fallback) {
905
+ return Math.max(toMs(getCSSValue(engine, lyricsElement, property, fallback)), 1);
906
+ }
907
+ function getCSSNumber(engine, lyricsElement, property, fallback) {
908
+ const value = Number.parseFloat(getCSSValue(engine, lyricsElement, property, `${fallback}`));
909
+ return Number.isFinite(value) ? value : fallback;
910
+ }
911
+ function getCSSBoolean(engine, lyricsElement, property, fallback) {
912
+ const value = getCSSValue(engine, lyricsElement, property, fallback ? "1" : "0").toLowerCase();
913
+ if (value === "false" || value === "off" || value === "none")
914
+ return false;
915
+ const numericValue = Number.parseFloat(value);
916
+ if (Number.isFinite(numericValue))
917
+ return numericValue > 0;
918
+ return fallback;
919
+ }
920
+ function getCSSOffset(engine, lyricsElement, property, fallback) {
921
+ return Math.max(0, Math.min(1, getCSSNumber(engine, lyricsElement, property, fallback)));
922
+ }
923
+ // Compose the glow filter so the color stays an unresolved var(--blyrics-glow-color).
924
+ // Reading a fully composed --blyrics-highlight-glow-filter-* off the container resolves the
925
+ // nested color there, which would defeat per-word overrides like
926
+ // .blyrics--word[data-long-word] { --blyrics-glow-color: ... }. Building the filter here with
927
+ // the color left as a literal var lets the Web Animations API resolve it against each animated
928
+ // word instead. A theme that sets the full filter var still wins, but its color resolves once
929
+ // at the container (globally), as before.
930
+ function resolveGlowFilter(engine, lyricsElement, suffix, radiusDefault) {
931
+ const override = getCSSValue(engine, lyricsElement, `--blyrics-highlight-glow-filter-${suffix}`, "");
932
+ if (override)
933
+ return override;
934
+ const radius = getCSSValue(engine, lyricsElement, `--blyrics-highlight-glow-radius-${suffix}`, radiusDefault);
935
+ return `drop-shadow(0 0 ${radius} var(--blyrics-glow-color))`;
936
+ }
937
+ function readAnimationConfig(engine, lyricsElement) {
938
+ const prefersReducedMotion = engine.window.matchMedia(REDUCED_MOTION_QUERY).matches;
939
+ const scrollDurationMs = getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-lyric-scroll-duration", "650ms");
940
+ const scrollEasing = getCSSValue(engine, lyricsElement, "--blyrics-lyric-scroll-timing-function", "cubic-bezier(0.86, 0, 0.2, 1)");
941
+ return {
942
+ enabled: {
943
+ lineScale: getCSSBoolean(engine, lyricsElement, "--blyrics-animate-line-scale", true),
944
+ wordWobble: getCSSBoolean(engine, lyricsElement, "--blyrics-animate-word-wobble", true),
945
+ highlightSwipe: getCSSBoolean(engine, lyricsElement, "--blyrics-animate-highlight-swipe", true),
946
+ highlightGlow: getCSSBoolean(engine, lyricsElement, "--blyrics-animate-highlight-glow", true),
947
+ highlightFade: getCSSBoolean(engine, lyricsElement, "--blyrics-animate-highlight-fade", true),
948
+ scroll: getCSSBoolean(engine, lyricsElement, "--blyrics-animate-scroll", true),
949
+ instrumental: getCSSBoolean(engine, lyricsElement, "--blyrics-animate-instrumental", true),
950
+ },
951
+ line: {
952
+ durationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-scale-transition-duration", "0.166s"),
953
+ enterEasing: getCSSValue(engine, lyricsElement, "--blyrics-line-enter-easing", "ease"),
954
+ exitEasing: getCSSValue(engine, lyricsElement, "--blyrics-line-exit-easing", "ease"),
955
+ enterFrom: getCSSValue(engine, lyricsElement, "--blyrics-line-enter-transform-from", "scale(var(--blyrics-scale))"),
956
+ enterTo: getCSSValue(engine, lyricsElement, "--blyrics-line-enter-transform-to", "scale(var(--blyrics-active-scale))"),
957
+ exitFrom: getCSSValue(engine, lyricsElement, "--blyrics-line-exit-transform-from", "scale(var(--blyrics-active-scale))"),
958
+ exitTo: getCSSValue(engine, lyricsElement, "--blyrics-line-exit-transform-to", "scale(var(--blyrics-scale))"),
959
+ },
960
+ highlight: {
961
+ fadeInDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-lyric-highlight-fade-in-duration", "0.33s"),
962
+ fadeOutDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-lyric-highlight-fade-out-duration", "0.5s"),
963
+ fadeInEasing: getCSSValue(engine, lyricsElement, "--blyrics-lyric-highlight-fade-in-easing", "ease"),
964
+ fadeOutEasing: getCSSValue(engine, lyricsElement, "--blyrics-lyric-highlight-fade-out-easing", "ease"),
965
+ swipeEasing: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-easing", "linear"),
966
+ swipeStartFrom: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-start-from", "-0.2"),
967
+ swipeEndFrom: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-end-from", "-0.1"),
968
+ swipeStartTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-start-to", "1.4"),
969
+ swipeEndTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-end-to", "1.5"),
970
+ glowFrom: resolveGlowFilter(engine, lyricsElement, "from", "0.8rem"),
971
+ glowTo: resolveGlowFilter(engine, lyricsElement, "to", "0"),
972
+ glowDurationRatio: getCSSNumber(engine, lyricsElement, "--blyrics-highlight-glow-duration-ratio", 1.2),
973
+ glowMinDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-highlight-glow-min-duration", "1.2s"),
974
+ glowEasing: getCSSValue(engine, lyricsElement, "--blyrics-highlight-glow-easing", "ease"),
975
+ },
976
+ word: {
977
+ wobbleDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-wobble-duration", "1s"),
978
+ wobbleEasing: getCSSValue(engine, lyricsElement, "--blyrics-word-wobble-easing", "ease"),
979
+ wobblePeakEasing: getCSSValue(engine, lyricsElement, "--blyrics-word-wobble-peak-easing", "ease-in-out"),
980
+ wobbleEndEasing: getCSSValue(engine, lyricsElement, "--blyrics-word-wobble-end-easing", "ease-out"),
981
+ wobbleFrom: getCSSValue(engine, lyricsElement, "--blyrics-word-wobble-transform-from", "scaleX(1)"),
982
+ wobblePeak: getCSSValue(engine, lyricsElement, "--blyrics-word-wobble-transform-peak", "translateX(0.05em) scaleX(1.025)"),
983
+ wobbleSettle: getCSSValue(engine, lyricsElement, "--blyrics-word-wobble-transform-settle", "translateX(0) scaleX(1)"),
984
+ wobbleTo: getCSSValue(engine, lyricsElement, "--blyrics-word-wobble-transform-to", "scaleX(1)"),
985
+ wobblePeakOffset: getCSSOffset(engine, lyricsElement, "--blyrics-word-wobble-peak-offset", 0.125),
986
+ wobbleSettleOffset: getCSSOffset(engine, lyricsElement, "--blyrics-word-wobble-settle-offset", 0.75),
987
+ },
988
+ instrumental: {
989
+ fillFadeDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-instrumental-fill-fade-duration", "150ms"),
990
+ fillFadeEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-fill-fade-easing", "ease"),
991
+ fillFrom: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-fill-transform-from", "translateY(78%)"),
992
+ fillTo: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-fill-transform-to", "translateY(-4%)"),
993
+ fillEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-fill-easing", "linear"),
994
+ waveFrom: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-transform-from", "scaleY(1.2)"),
995
+ waveTo: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-transform-to", "scaleY(0.0001)"),
996
+ waveEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-easing", "ease-in"),
997
+ waveOscillationDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-instrumental-wave-oscillation-duration", "1.25s"),
998
+ waveOscillationEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-oscillation-easing", "ease-in-out"),
999
+ },
1000
+ scroll: {
1001
+ durationMs: scrollDurationMs,
1002
+ easing: scrollEasing,
1003
+ },
1004
+ lineScroll: {
1005
+ durationMs: scrollDurationMs,
1006
+ easing: scrollEasing,
1007
+ differentialEffects: !prefersReducedMotion,
1008
+ },
1009
+ };
1010
+ }
1011
+ function readScrollTiming(scrollDurationMs) {
1012
+ const totalMs = Math.max(0, scrollDurationMs + SCROLL_TIMING_BUFFER_MS);
1013
+ const earlyScrollConsiderWasSet = EARLY_SCROLL_CONSIDER.isManuallySet();
1014
+ const queueScrollWasSet = QUEUE_SCROLL_THRESHOLD.isManuallySet();
1015
+ if (earlyScrollConsiderWasSet && queueScrollWasSet) {
1016
+ return {
1017
+ earlyScrollConsiderS: Math.max(0, EARLY_SCROLL_CONSIDER.getNumberValue()),
1018
+ queueScrollMs: Math.max(0, QUEUE_SCROLL_THRESHOLD.getNumberValue()),
1019
+ };
1020
+ }
1021
+ if (earlyScrollConsiderWasSet) {
1022
+ const earlyScrollConsiderS = Math.max(0, EARLY_SCROLL_CONSIDER.getNumberValue());
1023
+ return {
1024
+ earlyScrollConsiderS,
1025
+ queueScrollMs: Math.min(Math.max(0, totalMs - earlyScrollConsiderS * 1000), MAX_AUTO_QUEUE_SCROLL_THRESHOLD_MS),
1026
+ };
1027
+ }
1028
+ if (queueScrollWasSet) {
1029
+ const queueScrollMs = Math.max(0, QUEUE_SCROLL_THRESHOLD.getNumberValue());
1030
+ return {
1031
+ earlyScrollConsiderS: Math.max(0, (totalMs - queueScrollMs) / 1000),
1032
+ queueScrollMs,
1033
+ };
1034
+ }
1035
+ const queueScrollMs = Math.min(totalMs * AUTO_QUEUE_SCROLL_RATIO, MAX_AUTO_QUEUE_SCROLL_THRESHOLD_MS);
1036
+ return {
1037
+ earlyScrollConsiderS: Math.max(0, (totalMs - queueScrollMs) / 1000),
1038
+ queueScrollMs,
1039
+ };
1040
+ }
1041
+ function getAnimationSettings(engine, lyricsElement) {
1042
+ if (!engine.cachedAnimationSettings) {
1043
+ const config = readAnimationConfig(engine, lyricsElement);
1044
+ engine.cachedAnimationSettings = {
1045
+ config,
1046
+ scrollTiming: readScrollTiming(config.scroll.durationMs),
1047
+ };
1048
+ }
1049
+ return engine.cachedAnimationSettings;
1050
+ }
1051
+ function clearLineScrollAnimations(engine) {
1052
+ const records = engine.lineScrollAnimations;
1053
+ engine.lineScrollAnimations = [];
1054
+ for (const record of records) {
1055
+ record.animation.cancel();
1056
+ clearLineScrollInlineProperties(engine, record.lineElement, record.token);
1057
+ }
1058
+ }
1059
+ function removeLineScrollAnimation(engine, record) {
1060
+ const index = engine.lineScrollAnimations.indexOf(record);
1061
+ if (index !== -1) {
1062
+ engine.lineScrollAnimations.splice(index, 1);
1063
+ }
1064
+ clearLineScrollInlineProperties(engine, record.lineElement, record.token);
1065
+ }
1066
+ function trackLineScrollAnimation(engine, animation, lineElement, token) {
1067
+ const record = { animation, lineElement, token };
1068
+ engine.lineScrollAnimations.push(record);
1069
+ animation.addEventListener("finish", () => removeLineScrollAnimation(engine, record), { once: true });
1070
+ animation.addEventListener("cancel", () => removeLineScrollAnimation(engine, record), { once: true });
1071
+ }
1072
+ function lineScrollSide(relativeIndex, scrollDeltaPx) {
1073
+ const isScrollingUp = scrollDeltaPx < 0;
1074
+ if (relativeIndex < 0)
1075
+ return isScrollingUp ? "below" : "above";
1076
+ if (relativeIndex > 0)
1077
+ return isScrollingUp ? "above" : "below";
1078
+ return "active";
1079
+ }
1080
+ function setLineScrollSettingProperty(lineElement, property, setting) {
1081
+ const value = setting.getStringValue().trim();
1082
+ if (value) {
1083
+ lineElement.style.setProperty(property, value);
1084
+ }
1085
+ else {
1086
+ lineElement.style.removeProperty(property);
1087
+ }
1088
+ }
1089
+ function setLineScrollStyleProperties(lineElement) {
1090
+ for (const [property, setting] of LINE_SCROLL_STYLE_SETTINGS) {
1091
+ setLineScrollSettingProperty(lineElement, property, setting);
1092
+ }
1093
+ }
1094
+ function lineScrollTranslate(side, state, useDifferentialEffects) {
1095
+ const sideProperty = `--blyrics-line-scroll-${side}-translate-y-${state}`;
1096
+ const sharedProperty = `--blyrics-line-scroll-translate-y-${state}`;
1097
+ const fallback = state === "start" ? "var(--blyrics-line-scroll-delta-px, 0px)" : "0px";
1098
+ if (useDifferentialEffects) {
1099
+ return `0 var(${sideProperty}, var(${sharedProperty}, ${fallback}))`;
1100
+ }
1101
+ return `0 var(${sharedProperty}, ${fallback})`;
1102
+ }
1103
+ function normalizedTranslate(translateValue) {
1104
+ const translate = translateValue.trim();
1105
+ return translate && translate !== "none" ? translate : "0px 0px";
1106
+ }
1107
+ function restoreInlineStyleProperty(lineElement, property, previousValue, previousPriority) {
1108
+ if (previousValue) {
1109
+ lineElement.style.setProperty(property, previousValue, previousPriority);
1110
+ }
1111
+ else {
1112
+ lineElement.style.removeProperty(property);
1113
+ }
1114
+ }
1115
+ function lineScrollDurationProperty(side, fallbackMs, useDifferentialEffects) {
1116
+ return useDifferentialEffects
1117
+ ? `var(--blyrics-line-scroll-${side}-duration, var(--blyrics-line-scroll-duration, ${fallbackMs}ms))`
1118
+ : `var(--blyrics-line-scroll-duration, ${fallbackMs}ms)`;
1119
+ }
1120
+ function clearLineScrollInlineProperties(engine, lineElement, token) {
1121
+ if (token !== undefined && engine.lineScrollElementTokens.get(lineElement) !== token) {
1122
+ return;
1123
+ }
1124
+ for (const property of LINE_SCROLL_INLINE_PROPERTIES) {
1125
+ lineElement.style.removeProperty(property);
1126
+ }
1127
+ engine.lineScrollElementTokens.delete(lineElement);
1128
+ }
1129
+ function lineScrollEasingProperty(side, keyframe, fallback, useDifferentialEffects) {
1130
+ return useDifferentialEffects
1131
+ ? `var(--blyrics-line-scroll-${side}-${keyframe}-easing, var(--blyrics-line-scroll-${keyframe}-easing, var(--blyrics-line-scroll-timing-function, ${fallback})))`
1132
+ : `var(--blyrics-line-scroll-${keyframe}-easing, var(--blyrics-line-scroll-timing-function, ${fallback}))`;
1133
+ }
1134
+ /**
1135
+ * Resolves one temporary computed-style probe for every visible line. Keeping
1136
+ * each property in its own write/read/restore phase preserves the original
1137
+ * resolver semantics while reducing N style flushes to one flush per probe.
1138
+ */
1139
+ function batchResolveLineScrollProperty(engine, items, property, probeValue, readValue) {
1140
+ const previous = items.map(item => ({
1141
+ value: item.lineElement.style.getPropertyValue(property),
1142
+ priority: item.lineElement.style.getPropertyPriority(property),
1143
+ }));
1144
+ for (const item of items) {
1145
+ item.lineElement.style.setProperty(property, probeValue(item), "important");
1146
+ }
1147
+ const values = items.map(item => readValue(engine.window.getComputedStyle(item.lineElement)));
1148
+ for (let index = 0; index < items.length; index++) {
1149
+ restoreInlineStyleProperty(items[index].lineElement, property, previous[index].value, previous[index].priority);
1150
+ }
1151
+ return values;
1152
+ }
1153
+ function isLineVisibleDuringScroll(lineData, fromScrollTop, toScrollTop, viewportHeight) {
1154
+ const visibleTop = Math.min(fromScrollTop, toScrollTop);
1155
+ const visibleBottom = Math.max(fromScrollTop + viewportHeight, toScrollTop + viewportHeight);
1156
+ const lineTop = lineData.position;
1157
+ const lineBottom = lineData.position + lineData.height;
1158
+ return lineBottom >= visibleTop && lineTop <= visibleBottom;
1159
+ }
1160
+ function clearVisibleLyricWillChange(engine) {
1161
+ for (const element of engine.visibleWillChangeElements) {
1162
+ element.style.removeProperty("will-change");
1163
+ }
1164
+ engine.visibleWillChangeElements = new Set();
1165
+ }
1166
+ function updateVisibleLyricWillChange(engine, lines, fromScrollTop, toScrollTop, viewportHeight) {
1167
+ const nextVisibleElements = new Set();
1168
+ for (const line of lines) {
1169
+ if (isLineVisibleDuringScroll(line, fromScrollTop, toScrollTop, viewportHeight)) {
1170
+ line.lyricElement.style.setProperty("will-change", LINE_SCROLL_WILL_CHANGE_VALUE);
1171
+ nextVisibleElements.add(line.lyricElement);
1172
+ }
1173
+ }
1174
+ for (const element of engine.visibleWillChangeElements) {
1175
+ if (!nextVisibleElements.has(element)) {
1176
+ element.style.removeProperty("will-change");
1177
+ }
1178
+ }
1179
+ engine.visibleWillChangeElements = nextVisibleElements;
1180
+ }
1181
+ function getLineScrollItems(lines, lyricsElement) {
1182
+ const footer = lyricsElement.querySelector(`:scope > .${FOOTER_CLASS}`);
1183
+ if (!footer)
1184
+ return lines;
1185
+ const footerBounds = getRelativeLayoutBounds(lyricsElement, footer);
1186
+ return [
1187
+ ...lines,
1188
+ {
1189
+ lyricElement: footer,
1190
+ position: footerBounds.y,
1191
+ height: footerBounds.height,
1192
+ },
1193
+ ];
1194
+ }
1195
+ function prepareLineScrollOffsets(engine, lines, activeLineIndex, scrollDeltaPx, fromScrollTop, toScrollTop, viewportHeight, config) {
1196
+ if (!config.enabled.scroll || activeLineIndex < 0) {
1197
+ return null;
1198
+ }
1199
+ const scrollDistancePx = Math.abs(scrollDeltaPx);
1200
+ const prepared = [];
1201
+ // Preserve the original windowing exactly: only lines intersecting the
1202
+ // union of the old and new viewports receive scroll animations.
1203
+ for (let index = 0; index < lines.length; index++) {
1204
+ if (!isLineVisibleDuringScroll(lines[index], fromScrollTop, toScrollTop, viewportHeight)) {
1205
+ continue;
1206
+ }
1207
+ const lineElement = lines[index].lyricElement;
1208
+ const relativeIndex = index - activeLineIndex;
1209
+ const side = lineScrollSide(relativeIndex, scrollDeltaPx);
1210
+ lineElement.style.setProperty(LINE_SCROLL_INDEX_PROPERTY, String(index));
1211
+ lineElement.style.setProperty(LINE_SCROLL_ACTIVE_INDEX_PROPERTY, String(activeLineIndex));
1212
+ lineElement.style.setProperty(LINE_SCROLL_RELATIVE_INDEX_PROPERTY, String(relativeIndex));
1213
+ lineElement.style.setProperty(LINE_SCROLL_ABS_RELATIVE_INDEX_PROPERTY, String(Math.abs(relativeIndex)));
1214
+ lineElement.style.setProperty(LINE_SCROLL_SIDE_PROPERTY, side);
1215
+ lineElement.style.setProperty(LINE_SCROLL_DELTA_PROPERTY, `${scrollDeltaPx}px`);
1216
+ lineElement.style.setProperty(LINE_SCROLL_DISTANCE_PROPERTY, `${scrollDistancePx}px`);
1217
+ setLineScrollStyleProperties(lineElement);
1218
+ const token = ++engine.lineScrollAnimationToken;
1219
+ engine.lineScrollElementTokens.set(lineElement, token);
1220
+ prepared.push({ lineElement, side, token });
1221
+ }
1222
+ const durations = batchResolveLineScrollProperty(engine, prepared, "transition-duration", item => lineScrollDurationProperty(item.side, config.lineScroll.durationMs, config.lineScroll.differentialEffects), style => {
1223
+ const durationMs = toMs(style.transitionDuration.split(",")[0].trim());
1224
+ return durationMs > 0 ? durationMs : config.lineScroll.durationMs;
1225
+ });
1226
+ const startEasings = batchResolveLineScrollProperty(engine, prepared, "transition-timing-function", item => lineScrollEasingProperty(item.side, "start", config.lineScroll.easing, config.lineScroll.differentialEffects), style => style.transitionTimingFunction.trim() || config.lineScroll.easing);
1227
+ const endEasings = batchResolveLineScrollProperty(engine, prepared, "transition-timing-function", item => lineScrollEasingProperty(item.side, "end", config.lineScroll.easing, config.lineScroll.differentialEffects), style => style.transitionTimingFunction.trim() || config.lineScroll.easing);
1228
+ const startTranslates = batchResolveLineScrollProperty(engine, prepared, "translate", item => lineScrollTranslate(item.side, "start", config.lineScroll.differentialEffects), style => normalizedTranslate(style.translate));
1229
+ const endTranslates = batchResolveLineScrollProperty(engine, prepared, "translate", item => lineScrollTranslate(item.side, "end", config.lineScroll.differentialEffects), style => normalizedTranslate(style.translate));
1230
+ return {
1231
+ items: prepared.map((item, index) => ({
1232
+ ...item,
1233
+ durationMs: durations[index],
1234
+ startEasing: startEasings[index],
1235
+ endEasing: endEasings[index],
1236
+ startTranslate: startTranslates[index],
1237
+ endTranslate: endTranslates[index],
1238
+ })),
1239
+ };
1240
+ }
1241
+ function startPreparedLineScroll(engine, plan) {
1242
+ for (const item of plan.items) {
1243
+ if (!item.lineElement.isConnected || engine.lineScrollElementTokens.get(item.lineElement) !== item.token)
1244
+ continue;
1245
+ const animation = item.lineElement.animate([
1246
+ { translate: item.startTranslate, easing: item.startEasing },
1247
+ { translate: item.endTranslate, easing: item.endEasing },
1248
+ ], {
1249
+ composite: "add",
1250
+ duration: item.durationMs,
1251
+ easing: "linear",
1252
+ fill: "none",
1253
+ });
1254
+ trackLineScrollAnimation(engine, animation, item.lineElement, item.token);
1255
+ }
1256
+ }
1257
+ function discardLineScrollPlan(engine, plan) {
1258
+ for (const item of plan.items) {
1259
+ clearLineScrollInlineProperties(engine, item.lineElement, item.token);
1260
+ }
1261
+ }
1262
+ function dropPendingLineScroll(engine) {
1263
+ if (!engine.pendingLineScroll)
1264
+ return;
1265
+ discardLineScrollPlan(engine, engine.pendingLineScroll.plan);
1266
+ engine.pendingLineScroll = null;
1267
+ }
1268
+ function pendingLineScrollMatches(engine, activeLine, fromScrollTop, toScrollTop) {
1269
+ return !!(engine.pendingLineScroll &&
1270
+ engine.pendingLineScroll.activeLineElement === activeLine.lyricElement &&
1271
+ Math.abs(engine.pendingLineScroll.fromScrollTop - fromScrollTop) <= 2 &&
1272
+ Math.abs(engine.pendingLineScroll.toScrollTop - toScrollTop) <= 2);
1273
+ }
1274
+ function commitOrPrepareLineScroll(engine, lines, activeLine, scrollDeltaPx, fromScrollTop, toScrollTop, viewportHeight, config) {
1275
+ if (pendingLineScrollMatches(engine, activeLine, fromScrollTop, toScrollTop)) {
1276
+ const pending = engine.pendingLineScroll;
1277
+ engine.pendingLineScroll = null;
1278
+ startPreparedLineScroll(engine, pending.plan);
1279
+ return;
1280
+ }
1281
+ dropPendingLineScroll(engine);
1282
+ const plan = prepareLineScrollOffsets(engine, lines, lines.findIndex(line => line.lyricElement === activeLine.lyricElement), scrollDeltaPx, fromScrollTop, toScrollTop, viewportHeight, config);
1283
+ if (plan)
1284
+ startPreparedLineScroll(engine, plan);
1285
+ }
1286
+ function prepareUpcomingLineScroll(engine, lines, activeLine, scrollDeltaPx, fromScrollTop, toScrollTop, viewportHeight, config) {
1287
+ if (pendingLineScrollMatches(engine, activeLine, fromScrollTop, toScrollTop))
1288
+ return;
1289
+ dropPendingLineScroll(engine);
1290
+ const activeLineIndex = lines.findIndex(line => line.lyricElement === activeLine.lyricElement);
1291
+ const plan = prepareLineScrollOffsets(engine, lines, activeLineIndex, scrollDeltaPx, fromScrollTop, toScrollTop, viewportHeight, config);
1292
+ if (plan) {
1293
+ engine.pendingLineScroll = {
1294
+ plan,
1295
+ activeLineElement: activeLine.lyricElement,
1296
+ fromScrollTop,
1297
+ toScrollTop,
1298
+ };
1299
+ }
1300
+ }
1301
+ // -- Skip Scrolls Decay --------------------------
1302
+ function decaySkipScrolls(engine, now) {
1303
+ let j = 0;
1304
+ for (; j < engine.skipScrollsDecayTimes.length; j++) {
1305
+ if (engine.skipScrollsDecayTimes[j] > now) {
1306
+ break;
1307
+ }
1308
+ }
1309
+ engine.skipScrollsDecayTimes = engine.skipScrollsDecayTimes.slice(j);
1310
+ engine.skipScrolls -= j;
1311
+ if (engine.skipScrolls < 1) {
1312
+ engine.skipScrolls = 1;
1313
+ }
1314
+ }
1315
+ // -- Passive Scroll Engine --------------------------
1316
+ /**
1317
+ * The "no lyrics" message is one line at time zero, which is shaped exactly like unsynced lyrics.
1318
+ * Nothing here applies to it: passive scroll would drift the message up and down for the length of
1319
+ * the song, and there is no line to sync.
1320
+ */
1321
+ function hasNoLyricsPlaceholder(engine) {
1322
+ return engine.lyricsContainer?.dataset.noLyrics === "true";
1323
+ }
1324
+ /**
1325
+ * Unsynced lyrics that this view still has on screen. `syncType` outlives the lyrics it was derived
1326
+ * from, so the container is the term that says they are still there.
1327
+ */
1328
+ export function hasUnsyncedLyrics(engine) {
1329
+ return engine.lyricsContainer !== null && engine.syncType === "none" && !hasNoLyricsPlaceholder(engine);
1330
+ }
1331
+ function stopPassiveScrollLoop(engine) {
1332
+ if (engine.passiveRAFId !== null) {
1333
+ engine.window.cancelAnimationFrame(engine.passiveRAFId);
1334
+ engine.passiveRAFId = null;
1335
+ }
1336
+ }
1337
+ function startPassiveScrollLoop(engine) {
1338
+ if (engine.passiveRAFId !== null)
1339
+ return;
1340
+ engine.passiveRAFId = engine.window.requestAnimationFrame(() => passiveScrollRAFLoop(engine));
1341
+ }
1342
+ function passiveScrollRAFLoop(engine) {
1343
+ engine.passiveRAFId = null;
1344
+ if (!engine.passiveScrollEnabled || !PASSIVE_SCROLL_ENABLED.getBooleanValue() || !hasUnsyncedLyrics(engine))
1345
+ return;
1346
+ passiveScrollEngine(engine, playbackClock.lastPlayState);
1347
+ engine.passiveRAFId = engine.window.requestAnimationFrame(() => passiveScrollRAFLoop(engine));
1348
+ }
1349
+ function passiveScrollEngine(engine, isPlaying) {
1350
+ if (!engine.host.isViewVisible())
1351
+ return;
1352
+ if (engine.host.isLoaderActive())
1353
+ return;
1354
+ const tabRenderer = engine.host.getScrollElement();
1355
+ if (!tabRenderer)
1356
+ return;
1357
+ const now = Date.now();
1358
+ // -- Accumulate play time --------------------------
1359
+ if (engine.passiveLastWallTime > 0 && isPlaying) {
1360
+ const wallDelta = (now - engine.passiveLastWallTime) / 1000;
1361
+ engine.passiveScrollAccumulatedTime += Math.min(wallDelta, 0.5);
1362
+ }
1363
+ engine.passiveLastWallTime = now;
1364
+ // -- User scroll interruption --------------------------
1365
+ if (engine.scrollResumeTime > now) {
1366
+ return;
1367
+ }
1368
+ if (engine.wasUserScrolling) {
1369
+ engine.host.setResumeAffordanceVisible(false);
1370
+ engine.lyricsContainer?.classList.remove(USER_SCROLLING_CLASS);
1371
+ engine.wasUserScrolling = false;
1372
+ // Re-sync accumulated time to current scroll position so scroll continues from where user left off
1373
+ const maxScroll = tabRenderer.scrollHeight - tabRenderer.clientHeight;
1374
+ if (maxScroll > 0) {
1375
+ const ratio = tabRenderer.scrollTop / maxScroll;
1376
+ const numLines = engine.lines.length;
1377
+ const scrollDuration = numLines * PASSIVE_SECONDS_PER_LINE.getNumberValue();
1378
+ engine.passiveScrollAccumulatedTime = ratio * scrollDuration;
1379
+ }
1380
+ }
1381
+ // -- Cycle calculation --------------------------
1382
+ const numLines = engine.lines.length;
1383
+ if (numLines === 0)
1384
+ return;
1385
+ const scrollDuration = numLines * PASSIVE_SECONDS_PER_LINE.getNumberValue();
1386
+ const bottomPause = PASSIVE_BOTTOM_PAUSE_S.getNumberValue();
1387
+ const resetDuration = PASSIVE_RESET_DURATION_S.getNumberValue();
1388
+ const topPause = PASSIVE_TOP_PAUSE_S.getNumberValue();
1389
+ const cycleLength = scrollDuration + bottomPause + resetDuration + topPause;
1390
+ const maxScroll = tabRenderer.scrollHeight - tabRenderer.clientHeight;
1391
+ if (maxScroll <= 0)
1392
+ return;
1393
+ const cycleTime = engine.passiveScrollAccumulatedTime % cycleLength;
1394
+ let targetScroll;
1395
+ if (cycleTime < scrollDuration) {
1396
+ // Phase 1: linear scroll down
1397
+ targetScroll = (cycleTime / scrollDuration) * maxScroll;
1398
+ }
1399
+ else if (cycleTime < scrollDuration + bottomPause) {
1400
+ // Phase 2: hold at bottom
1401
+ targetScroll = maxScroll;
1402
+ }
1403
+ else if (cycleTime < scrollDuration + bottomPause + resetDuration) {
1404
+ // Phase 3: ease-out scroll back to top
1405
+ const resetProgress = (cycleTime - scrollDuration - bottomPause) / resetDuration;
1406
+ const eased = 1 - (1 - resetProgress) * (1 - resetProgress);
1407
+ targetScroll = maxScroll * (1 - eased);
1408
+ }
1409
+ else {
1410
+ // Phase 4: hold at top
1411
+ targetScroll = 0;
1412
+ }
1413
+ const prevScrollTop = tabRenderer.scrollTop;
1414
+ tabRenderer.scrollTop = targetScroll;
1415
+ // Only skip the next scroll event if scrollTop actually changed.
1416
+ // When it doesn't change (pause phases, sub-pixel rounding), no programmatic
1417
+ // scroll event fires, so setting skipScrolls would eat user scroll events instead.
1418
+ if (tabRenderer.scrollTop !== prevScrollTop) {
1419
+ engine.skipScrolls = 1;
1420
+ }
1421
+ }
1422
+ /**
1423
+ * Sets up a ResizeObserver on the tab renderer to cache its height.
1424
+ * Avoids calling getBoundingClientRect() every tick which causes layout thrashing.
1425
+ */
1426
+ function setupTabRendererObserver(engine, element) {
1427
+ if (engine.tabRendererResizeObserver) {
1428
+ engine.tabRendererResizeObserver.disconnect();
1429
+ }
1430
+ engine.tabRendererResizeObserver = new engine.window.ResizeObserver(() => {
1431
+ dropPendingLineScroll(engine);
1432
+ if (element && element.isConnected) {
1433
+ engine.cachedTabRendererHeight = element.getBoundingClientRect().height;
1434
+ }
1435
+ });
1436
+ engine.tabRendererResizeObserver.observe(element);
1437
+ engine.observedTabRenderer = element;
1438
+ engine.cachedTabRendererHeight = element.getBoundingClientRect().height;
1439
+ }
1440
+ /**
1441
+ * Fills in everything a caller left out of a tick. The tick reads each of these arithmetically, so
1442
+ * a missing one would not fail: it would quietly turn the playback time into NaN and leave the view
1443
+ * matching no line at all.
1444
+ */
1445
+ export function resolveTickOptions(options) {
1446
+ return {
1447
+ isPlaying: options.isPlaying,
1448
+ eventCreationTime: options.eventCreationTime ?? NO_PLAYER_SNAPSHOT,
1449
+ smoothScroll: options.smoothScroll ?? true,
1450
+ globalLyricOffset: options.globalLyricOffset ?? 0,
1451
+ lyricOffset: options.lyricOffset ?? 0,
1452
+ richsyncOffsetTrim: options.richsyncOffsetTrim ?? 0,
1453
+ lineOffsetTrim: options.lineOffsetTrim ?? 0,
1454
+ passiveScrollEnabled: options.passiveScrollEnabled ?? false,
1455
+ };
1456
+ }
1457
+ /**
1458
+ * Renders one view against a tick with nothing left out.
1459
+ */
1460
+ export function tickView(engine, currentTime, options) {
1461
+ const { eventCreationTime, isPlaying, smoothScroll } = options;
1462
+ engine.passiveScrollEnabled = options.passiveScrollEnabled;
1463
+ const now = Date.now();
1464
+ if (currentTime === 0 && !isPlaying) {
1465
+ return "ok";
1466
+ }
1467
+ if (hasNoLyricsPlaceholder(engine)) {
1468
+ stopPassiveScrollLoop(engine);
1469
+ return "ok";
1470
+ }
1471
+ if (hasUnsyncedLyrics(engine)) {
1472
+ if (!playbackClock.lastPlayState && isPlaying) {
1473
+ engine.scrollResumeTime = 0;
1474
+ }
1475
+ playbackClock.lastPlayState = isPlaying;
1476
+ if (!options.passiveScrollEnabled)
1477
+ return "ok";
1478
+ startPassiveScrollLoop(engine);
1479
+ return "ok";
1480
+ }
1481
+ const timeJumped = Math.abs(currentTime - playbackClock.lastTime - (eventCreationTime - playbackClock.lastEventCreationTime) / 1000) >
1482
+ TIME_JUMP_THRESHOLD;
1483
+ if (timeJumped)
1484
+ dropPendingLineScroll(engine);
1485
+ playbackClock.lastTime = currentTime;
1486
+ playbackClock.lastPlayState = isPlaying;
1487
+ playbackClock.lastEventCreationTime = eventCreationTime;
1488
+ let timeOffset = now - eventCreationTime;
1489
+ if (!isPlaying || eventCreationTime === NO_PLAYER_SNAPSHOT) {
1490
+ timeOffset = 0;
1491
+ }
1492
+ currentTime += timeOffset / 1000;
1493
+ if (!engine.host.isViewVisible()) {
1494
+ clearVisibleLyricWillChange(engine);
1495
+ return "ok";
1496
+ }
1497
+ if (engine.host.syncAdState()) {
1498
+ return "ok";
1499
+ }
1500
+ try {
1501
+ const lyricsElement = engine.lyricsContainer;
1502
+ // If lyrics element doesn't exist, clear the interval and return silently
1503
+ if (!lyricsElement) {
1504
+ engine.host.log(NO_LYRICS_ELEMENT_LOG);
1505
+ return "lyrics-missing";
1506
+ }
1507
+ const lines = engine.lines;
1508
+ if (engine.syncType === "richsync") {
1509
+ currentTime += getCSSDurationInMs(engine, lyricsElement, "--blyrics-richsync-timing-offset") / 1000;
1510
+ currentTime -= options.richsyncOffsetTrim;
1511
+ }
1512
+ else {
1513
+ currentTime += getCSSDurationInMs(engine, lyricsElement, "--blyrics-timing-offset") / 1000;
1514
+ currentTime -= options.lineOffsetTrim;
1515
+ }
1516
+ currentTime -= options.globalLyricOffset + options.lyricOffset;
1517
+ const lyricScrollTime = correctedScrollTimeS(engine, currentTime) +
1518
+ getCSSDurationInMs(engine, lyricsElement, "--blyrics-scroll-timing-offset") / 1000;
1519
+ const { config: animationConfig, scrollTiming } = getAnimationSettings(engine, lyricsElement);
1520
+ // Read layout values before the loop writes class changes, to avoid forced reflow
1521
+ const tabRenderer = engine.host.getScrollElement();
1522
+ if (!tabRenderer) {
1523
+ clearVisibleLyricWillChange(engine);
1524
+ return "ok";
1525
+ }
1526
+ if (tabRenderer !== engine.observedTabRenderer) {
1527
+ setupTabRendererObserver(engine, tabRenderer);
1528
+ }
1529
+ const tabRendererHeight = engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height;
1530
+ let scrollTop = tabRenderer.scrollTop;
1531
+ if (animationConfig.enabled.scroll) {
1532
+ updateVisibleLyricWillChange(engine, lines, scrollTop, engine.pendingLineScroll?.toScrollTop ?? scrollTop, tabRendererHeight);
1533
+ }
1534
+ else {
1535
+ dropPendingLineScroll(engine);
1536
+ clearVisibleLyricWillChange(engine);
1537
+ clearLineScrollAnimations(engine);
1538
+ }
1539
+ let activeElems = [];
1540
+ const linesToAnimate = [];
1541
+ let newLyricSelected = timeJumped;
1542
+ lines.every((lineData, index) => {
1543
+ const time = lineData.time;
1544
+ let nextTime = Infinity;
1545
+ if (index + 1 < lines.length) {
1546
+ const nextLyric = lines[index + 1];
1547
+ nextTime = nextLyric.time;
1548
+ }
1549
+ if (lyricScrollTime >= time - scrollTiming.earlyScrollConsiderS &&
1550
+ (lyricScrollTime < nextTime || lyricScrollTime < time + lineData.duration)) {
1551
+ activeElems.push(lineData);
1552
+ if (!engine.lastActiveElements.includes(lineData) && lyricScrollTime >= time) {
1553
+ newLyricSelected = true;
1554
+ }
1555
+ // const timeDelta = lyricScrollTime - time;
1556
+ // if (engine.selectedElementIndex !== index && timeDelta > 0.05 && index > 0) {
1557
+ // Utils.log(`[BetterLyrics] Scrolling to new lyric was late, dt: ${timeDelta.toFixed(5)}s`);
1558
+ // }
1559
+ engine.selectedElementIndex = index;
1560
+ if (!lineData.isScrolled) {
1561
+ lineData.lyricElement.classList.add(CURRENT_LYRICS_CLASS);
1562
+ lineData.isScrolled = true;
1563
+ }
1564
+ }
1565
+ else {
1566
+ if (lineData.isScrolled) {
1567
+ lineData.lyricElement.classList.remove(CURRENT_LYRICS_CLASS);
1568
+ lineData.isScrolled = false;
1569
+ }
1570
+ }
1571
+ /**
1572
+ * Time in seconds to set up animations. This shouldn't affect any visible effects, just help when the browser stutters
1573
+ */
1574
+ let setUpAnimationEarlyTime = 2;
1575
+ if (!isPlaying) {
1576
+ setUpAnimationEarlyTime = 0;
1577
+ }
1578
+ const effectiveEndTime = Math.max(nextTime, time + lineData.duration + 0.05);
1579
+ if (currentTime + setUpAnimationEarlyTime >= time && currentTime < effectiveEndTime) {
1580
+ if (!lineData.isSelected) {
1581
+ lineData.isSelected = true;
1582
+ lineData.lyricElement.classList.add(ANIMATING_CLASS);
1583
+ }
1584
+ if (isPlaying !== lineData.isAnimationPlayStatePlaying) {
1585
+ lineData.isAnimationPlayStatePlaying = isPlaying;
1586
+ setAnimationsPlayState(lineData, isPlaying);
1587
+ if (isPlaying)
1588
+ lineData.isAnimating = false; // reset the animation against current media time
1589
+ }
1590
+ const nativeTimingSample = lineNativeTimingSample(lineData, currentTime);
1591
+ let usedNativeTimingSampleForDrift = false;
1592
+ lineData.accumulatedOffsetMs = lineData.accumulatedOffsetMs / ANIMATION_TIMING_ACCUMULATION_DECAY;
1593
+ if (nativeTimingSample !== null && canUseTimingSampleForDrift(nativeTimingSample, isPlaying)) {
1594
+ usedNativeTimingSampleForDrift = true;
1595
+ const learnedOffsetMs = learnAnimationTimingOffset(engine, nativeTimingSample);
1596
+ const residualOffsetMs = nativeTimingSample.offsetMs;
1597
+ lineData.accumulatedOffsetMs += residualOffsetMs * ANIMATION_TIMING_ACCUMULATION_WEIGHT;
1598
+ if (shouldLogAnimationTiming(engine, lineData, nativeTimingSample, now)) {
1599
+ logAnimationTiming(engine, "sample", lineData, index, nativeTimingSample, currentTime, lineData.accumulatedOffsetMs, learnedOffsetMs, residualOffsetMs);
1600
+ }
1601
+ }
1602
+ else if (nativeTimingSample !== null && shouldLogAnimationTiming(engine, lineData, nativeTimingSample, now)) {
1603
+ logAnimationTiming(engine, "ignored-sample", lineData, index, nativeTimingSample, currentTime, lineData.accumulatedOffsetMs);
1604
+ }
1605
+ if (lineData.isAnimating &&
1606
+ usedNativeTimingSampleForDrift &&
1607
+ Math.abs(lineData.accumulatedOffsetMs) > ANIMATION_TIMING_RESET_THRESHOLD_MS &&
1608
+ isPlaying) {
1609
+ if (nativeTimingSample !== null) {
1610
+ logAnimationTiming(engine, "drift-reset", lineData, index, nativeTimingSample, currentTime, lineData.accumulatedOffsetMs);
1611
+ }
1612
+ resetLineAnimationState(lineData);
1613
+ }
1614
+ if (!lineData.isAnimating) {
1615
+ // We'll take care of the animation setup in a batch later
1616
+ linesToAnimate.push(lineData);
1617
+ }
1618
+ }
1619
+ else {
1620
+ const staleAnimationEndTime = effectiveEndTime + animationConfig.highlight.fadeOutDurationMs / 1000 + 0.05;
1621
+ if (lineData.isSelected) {
1622
+ if (isPlaying || timeJumped) {
1623
+ if (currentTime > staleAnimationEndTime) {
1624
+ logAnimationCleanup(engine, "selected-stale-reset", lineData, index, currentTime, staleAnimationEndTime);
1625
+ resetLineAnimationState(lineData);
1626
+ }
1627
+ else {
1628
+ startLineExitAnimations(engine, lineData, animationConfig, currentTime);
1629
+ markLineAnimationsStopped(lineData);
1630
+ }
1631
+ }
1632
+ else {
1633
+ setAnimationsPlayState(lineData, false);
1634
+ lineData.isAnimationPlayStatePlaying = false;
1635
+ }
1636
+ lineData.isSelected = false;
1637
+ clearLineStateClasses(lineData);
1638
+ }
1639
+ else if (hasLineAnimations(lineData) && (timeJumped || currentTime > staleAnimationEndTime)) {
1640
+ logAnimationCleanup(engine, timeJumped ? "time-jump-reset" : "stale-reset", lineData, index, currentTime, staleAnimationEndTime);
1641
+ resetLineAnimationState(lineData);
1642
+ }
1643
+ }
1644
+ return true;
1645
+ });
1646
+ if (linesToAnimate.length > 0) {
1647
+ for (const lineData of linesToAnimate) {
1648
+ startLineAnimations(engine, lineData, animationConfig, currentTime);
1649
+ lineData.isAnimating = true;
1650
+ lineData.lastAnimSetupAt = now;
1651
+ lineData.isAnimationPlayStatePlaying = isPlaying;
1652
+ lineData.accumulatedOffsetMs = 0;
1653
+ if (!isPlaying)
1654
+ setAnimationsPlayState(lineData, false);
1655
+ }
1656
+ }
1657
+ if (engine.scrollResumeTime < Date.now() || engine.scrollPos === -1) {
1658
+ if (activeElems.length == 0) {
1659
+ activeElems.push(lines[0]);
1660
+ }
1661
+ engine.lastActiveElements = activeElems.filter(elm => lyricScrollTime >= elm.time // remove elements that haven't reached their scroll time yet.
1662
+ );
1663
+ // Offset so lyrics appear towards the center of the screen.
1664
+ const scrollPosOffset = tabRendererHeight * SCROLL_POS_OFFSET_RATIO.getNumberValue();
1665
+ let lastActiveLyric = activeElems[activeElems.length - 1];
1666
+ let lyricPositions = activeElems
1667
+ .filter((lineData, index) => {
1668
+ // Ignore lyrics close to finishing unless it last active lyric
1669
+ return (lyricScrollTime < lineData.time + lineData.duration - LYRIC_ENDING_THRESHOLD_S.getNumberValue() ||
1670
+ index == activeElems.length - 1);
1671
+ })
1672
+ // We subtract selectedLyricHeight / 2 to center the selected lyric line vertically within the offset region,
1673
+ // so the lyric is not aligned at the very top of the offset but is visually centered.
1674
+ .map(lineData => lineData.position + lineData.height / 2);
1675
+ let avgPos = lyricPositions.reduce((accumulator, currentValue) => accumulator + currentValue, 0) / lyricPositions.length;
1676
+ // Base position
1677
+ let scrollPos = avgPos - scrollPosOffset;
1678
+ // Make sure the first selected line is stays visible
1679
+ scrollPos = Math.min(scrollPos, lyricPositions[0]);
1680
+ // Make sure bottom of last active lyric is visible
1681
+ scrollPos = Math.max(scrollPos, lastActiveLyric.position - tabRendererHeight + lastActiveLyric.height);
1682
+ // Make sure top of last active lyric is visible.
1683
+ scrollPos = Math.min(scrollPos, lastActiveLyric.position);
1684
+ // Make sure we're not trying to scroll to negative values
1685
+ scrollPos = Math.max(0, scrollPos);
1686
+ if (ENABLE_DEBUG_RENDER.getBooleanValue()) {
1687
+ let transform = engine.window.getComputedStyle(lyricsElement).transform;
1688
+ const matrix = new engine.window.DOMMatrix(transform);
1689
+ let yTransform = matrix.f;
1690
+ let yTop = scrollTop - yTransform;
1691
+ const ctx = engine.host.debug?.beginFrame(yTop);
1692
+ if (ctx) {
1693
+ ctx.strokeStyle = "green";
1694
+ ctx.fillStyle = "green";
1695
+ ctx?.fillText("visible top", 0, scrollTop);
1696
+ ctx?.beginPath();
1697
+ ctx?.moveTo(40, scrollTop);
1698
+ ctx?.lineTo(1000, scrollTop);
1699
+ ctx.stroke();
1700
+ ctx.strokeStyle = "blue";
1701
+ ctx.fillStyle = "blue";
1702
+ ctx?.fillText("visible bottom", 0, scrollTop + tabRendererHeight);
1703
+ ctx?.beginPath();
1704
+ ctx?.moveTo(40, scrollTop + tabRendererHeight);
1705
+ ctx?.lineTo(1000, scrollTop + tabRendererHeight);
1706
+ ctx.stroke();
1707
+ ctx.strokeStyle = "yellow";
1708
+ ctx.fillStyle = "yellow";
1709
+ ctx?.fillText("target", 0, scrollTop + scrollPosOffset);
1710
+ ctx?.beginPath();
1711
+ ctx?.moveTo(40, scrollTop + scrollPosOffset);
1712
+ ctx?.lineTo(1000, scrollTop + scrollPosOffset);
1713
+ ctx.stroke();
1714
+ function debugLyrics(xOffset, name, activeElems, lyricPositions, lyricScrollTime) {
1715
+ ctx.strokeStyle = "red";
1716
+ ctx.fillStyle = "red";
1717
+ ctx.fillText(name, xOffset + 2, yTop + 45);
1718
+ ctx.fillText("scroll time: " + lyricScrollTime.toFixed(3), xOffset + 2, yTop + 60);
1719
+ activeElems.forEach(elm => {
1720
+ let timeTillActive = elm.time - lyricScrollTime;
1721
+ let endTime = elm.time + elm.duration;
1722
+ let timeTillEnd = endTime - lyricScrollTime;
1723
+ if (timeTillEnd < LYRIC_ENDING_THRESHOLD_S.getNumberValue()) {
1724
+ ctx.strokeStyle = "gray";
1725
+ ctx.fillStyle = "gray";
1726
+ }
1727
+ else if (timeTillActive > 0) {
1728
+ ctx.strokeStyle = "magenta";
1729
+ ctx.fillStyle = "magenta";
1730
+ }
1731
+ else {
1732
+ ctx.strokeStyle = "orange";
1733
+ ctx.fillStyle = "orange";
1734
+ }
1735
+ ctx?.beginPath();
1736
+ ctx?.moveTo(xOffset + 5, elm.position);
1737
+ ctx?.lineTo(xOffset + 5, elm.position + elm.height);
1738
+ ctx?.stroke();
1739
+ ctx?.fillText("time: start=" + elm.time.toFixed(2) + " end=" + endTime.toFixed(2), xOffset + 15, elm.position);
1740
+ ctx?.fillText("till active: " + timeTillActive.toFixed(2), xOffset + 15, elm.position + 15);
1741
+ ctx?.fillText("till end: " + timeTillEnd.toFixed(2), xOffset + 15, elm.position + 30);
1742
+ });
1743
+ ctx.strokeStyle = "pink";
1744
+ ctx.fillStyle = "pink";
1745
+ lyricPositions.forEach(lyricPosition => {
1746
+ ctx?.beginPath();
1747
+ ctx?.arc(xOffset + 5, lyricPosition, 5, 0, 2 * Math.PI, false);
1748
+ ctx?.fill();
1749
+ });
1750
+ }
1751
+ debugLyrics(0, "realtime", activeElems, lyricPositions, lyricScrollTime);
1752
+ debugLyrics(160, "last scroll", engine.lastScrollDebugContext.activeElms, engine.lastScrollDebugContext.centers, engine.lastScrollDebugContext.lyricScrollTime);
1753
+ }
1754
+ }
1755
+ const timeUntilUpcomingScrollMs = (lastActiveLyric.time - lyricScrollTime) * 1000;
1756
+ if (smoothScroll &&
1757
+ animationConfig.enabled.scroll &&
1758
+ !newLyricSelected &&
1759
+ !engine.wasUserScrolling &&
1760
+ timeUntilUpcomingScrollMs > 0 &&
1761
+ timeUntilUpcomingScrollMs <= SCROLL_PREPARE_LEAD_MS &&
1762
+ Date.now() > engine.nextScrollAllowedTime &&
1763
+ Math.abs(scrollTop - scrollPos) > 2) {
1764
+ updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
1765
+ prepareUpcomingLineScroll(engine, getLineScrollItems(lines, lyricsElement), lastActiveLyric, scrollPos - scrollTop, scrollTop, scrollPos, tabRendererHeight, animationConfig);
1766
+ }
1767
+ if (engine.wasUserScrolling || newLyricSelected || engine.queuedScroll) {
1768
+ if (Date.now() > engine.nextScrollAllowedTime) {
1769
+ engine.queuedScroll = false;
1770
+ engine.lastScrollDebugContext.lyricScrollTime = lyricScrollTime;
1771
+ engine.lastScrollDebugContext.centers = lyricPositions;
1772
+ engine.lastScrollDebugContext.activeElms = activeElems;
1773
+ if (smoothScroll && Math.abs(scrollTop - scrollPos) > 2) {
1774
+ const scrollDeltaPx = scrollPos - scrollTop;
1775
+ if (animationConfig.enabled.scroll) {
1776
+ updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
1777
+ const lineScrollItems = getLineScrollItems(lines, lyricsElement);
1778
+ commitOrPrepareLineScroll(engine, lineScrollItems, lastActiveLyric, scrollDeltaPx, scrollTop, scrollPos, tabRendererHeight, animationConfig);
1779
+ engine.nextScrollAllowedTime = animationConfig.scroll.durationMs + Date.now() + 20;
1780
+ }
1781
+ }
1782
+ else {
1783
+ dropPendingLineScroll(engine);
1784
+ }
1785
+ scrollTop = scrollPos;
1786
+ engine.scrollPos = scrollTop;
1787
+ tabRenderer.scrollTop = scrollTop;
1788
+ engine.skipScrolls += 1;
1789
+ engine.skipScrollsDecayTimes.push(Date.now() + 2000);
1790
+ }
1791
+ else if (engine.nextScrollAllowedTime - Date.now() < scrollTiming.queueScrollMs || timeJumped) {
1792
+ // just missed out on being able to scroll, queue this once we finish our current lyric
1793
+ engine.queuedScroll = true;
1794
+ }
1795
+ }
1796
+ }
1797
+ if (engine.wasUserScrolling && engine.scrollResumeTime < Date.now()) {
1798
+ engine.host.setResumeAffordanceVisible(false);
1799
+ lyricsElement.classList.remove(USER_SCROLLING_CLASS);
1800
+ engine.wasUserScrolling = false;
1801
+ }
1802
+ decaySkipScrolls(engine, now);
1803
+ }
1804
+ catch (err) {
1805
+ if (!err.message?.includes("undefined")) {
1806
+ engine.host.log(LYRICS_CHECK_INTERVAL_ERROR, err);
1807
+ }
1808
+ }
1809
+ return "ok";
1810
+ }
1811
+ /**
1812
+ * Sizes the padding above the first line and below the last one so either can sit at the view's
1813
+ * target scroll position.
1814
+ *
1815
+ * The first two candidates are exact, and both are measured from the container. They are worth
1816
+ * nothing while it is not rendering: every line reports zero, which reads as content that already
1817
+ * runs past the last line and asks for no padding at all, and the last lines of the song then have
1818
+ * nowhere to scroll to. The viewport keeps its height whether the lyrics render or not, so the
1819
+ * space it alone demands below the last line is always knowable, and it is the floor. Over-padding
1820
+ * costs nothing visible; under-padding strands the end of every song.
1821
+ */
1822
+ export function computeScrollPadding(measurements) {
1823
+ const { viewportHeight, targetScrollRatio, contentHeight, lastLineCentre } = measurements;
1824
+ const top = Math.max(0, viewportHeight * targetScrollRatio - measurements.firstLineHeight / 2);
1825
+ const lastLineTargetContentHeight = lastLineCentre === null ? viewportHeight : lastLineCentre + viewportHeight * (1 - targetScrollRatio);
1826
+ const trailingContentHeight = measurements.footerHeight + measurements.lastLineHeight / 2;
1827
+ const viewportTailSpace = viewportHeight * (1 - targetScrollRatio) - trailingContentHeight;
1828
+ const bottom = Math.max(lastLineTargetContentHeight - contentHeight, viewportHeight - contentHeight, viewportTailSpace, 0);
1829
+ return { top, bottom: Math.ceil(bottom) };
1830
+ }
1831
+ /**
1832
+ * Sizes the padding this view needs and writes it where the stylesheet reads it, which is the
1833
+ * document's root element. That makes it the second thing a view writes per document rather than
1834
+ * per view, alongside the theme's `<style>`, so it is the same one renderer per document constraint
1835
+ * the README states under Theme settings and not a new one: a second renderer in this document
1836
+ * overwrites these two properties with the padding its own viewport needs, and the first view is
1837
+ * then padded for a viewport it is not in.
1838
+ *
1839
+ * The root rather than the container because these are published names. Both readers in this repo
1840
+ * select `.blyrics-container`, but the extension's own `mobile.css` is one of them, from outside
1841
+ * the module, and a theme is free to read them anywhere: narrowing where they resolve would break
1842
+ * such a theme silently, the way any custom property that stops resolving does. That is a real cost
1843
+ * against a corruption the module already forbids.
1844
+ */
1845
+ function applyScrollPadding(engine) {
1846
+ const lyricsElement = engine.lyricsContainer;
1847
+ const tabRenderer = engine.host.getScrollElement();
1848
+ if (!lyricsElement || !tabRenderer)
1849
+ return;
1850
+ const tabRendererHeight = tabRenderer.getBoundingClientRect().height;
1851
+ const scrollPosOffsetRatio = SCROLL_POS_OFFSET_RATIO.getNumberValue();
1852
+ const currentPaddingBottom = Number.parseFloat(engine.window.getComputedStyle(lyricsElement).paddingBottom) || 0;
1853
+ const lyricsHeightWithoutBottomPadding = Math.max(0, lyricsElement.scrollHeight - currentPaddingBottom);
1854
+ const lyricLines = lyricsElement.querySelectorAll(`:scope > .${LINE_CLASS}`);
1855
+ const firstLyric = lyricLines[0] ?? null;
1856
+ const lastLyric = lyricLines[lyricLines.length - 1] ?? null;
1857
+ const lastLyricBounds = lastLyric ? getRelativeLayoutBounds(lyricsElement, lastLyric) : null;
1858
+ const footer = lyricsElement.querySelector(`:scope > .${FOOTER_CLASS}`);
1859
+ const { top, bottom } = computeScrollPadding({
1860
+ viewportHeight: tabRendererHeight,
1861
+ targetScrollRatio: scrollPosOffsetRatio,
1862
+ contentHeight: lyricsHeightWithoutBottomPadding,
1863
+ firstLineHeight: firstLyric ? getRelativeLayoutBounds(lyricsElement, firstLyric).height : 0,
1864
+ lastLineCentre: lastLyricBounds ? lastLyricBounds.y + lastLyricBounds.height / 2 : null,
1865
+ lastLineHeight: lastLyricBounds?.height ?? 0,
1866
+ footerHeight: footer ? getRelativeLayoutBounds(lyricsElement, footer).height : 0,
1867
+ });
1868
+ engine.document.documentElement.style.setProperty("--blyrics-padding-top", top + "px");
1869
+ engine.document.documentElement.style.setProperty("--blyrics-padding-bottom", bottom + "px");
1870
+ }
1871
+ /**
1872
+ * Re-reads the view's layout: the scroll padding first, then the line positions the padding moved.
1873
+ *
1874
+ * @param measureLines - Pass false while the lines are not being rendered. An unrendered container
1875
+ * measures every line as zero height at zero offset, which would leave the scroll maths with
1876
+ * nothing to work from once rendering resumes.
1877
+ */
1878
+ export function relayout(engine, measureLines) {
1879
+ applyScrollPadding(engine);
1880
+ if (!measureLines)
1881
+ return;
1882
+ const lyricsElement = engine.lyricsContainer;
1883
+ if (!lyricsElement)
1884
+ return;
1885
+ // Both dimensions, because both are what a resize is compared against. The scroll padding written
1886
+ // above lands on this container, so a measurement that records only the width leaves every later
1887
+ // resize report looking like a new height, and each one measures again and forces a rescroll.
1888
+ engine.lyricWidth = lyricsElement.clientWidth;
1889
+ engine.lyricHeight = lyricsElement.clientHeight;
1890
+ for (const line of engine.lines) {
1891
+ const bounds = getRelativeLayoutBounds(lyricsElement, line.lyricElement);
1892
+ line.position = bounds.y;
1893
+ line.height = bounds.height;
1894
+ }
1895
+ engine.wasUserScrolling = true; // trigger rescrolls
1896
+ engine.host.debug?.resize();
1897
+ }
1898
+ // -- Debounced Lyrics Update --------------------------
1899
+ function cancelLyricPositionUpdate(engine) {
1900
+ if (engine.pendingLyricsUpdateFrame === null)
1901
+ return;
1902
+ engine.window.cancelAnimationFrame(engine.pendingLyricsUpdateFrame);
1903
+ engine.pendingLyricsUpdateFrame = null;
1904
+ }
1905
+ /**
1906
+ * Renders this view again against the last player snapshot, without moving the clock on. The
1907
+ * options are built now rather than handed in, so a caller that reads settings at tick time still
1908
+ * reads them at tick time.
1909
+ *
1910
+ * @param buildTickOptions - Given the snapshot the tick will run against, returns what to render it
1911
+ * with.
1912
+ */
1913
+ export function retickFromPlaybackClock(engine, buildTickOptions) {
1914
+ return tickView(engine, playbackClock.lastTime, resolveTickOptions(buildTickOptions(playbackClock.lastEventCreationTime, playbackClock.lastPlayState)));
1915
+ }
1916
+ /**
1917
+ * Called when a new lyrics element is added to trigger re-sync.
1918
+ * Debounced via requestAnimationFrame to avoid O(n²) layout thrashing
1919
+ * when translations/romanizations load (each addition would otherwise
1920
+ * re-measure ALL lines).
1921
+ *
1922
+ * @param isViewRendering - Asked on the frame rather than now. A driver that has stopped ticking is
1923
+ * one whose lines may no longer be rendered, and an unrendered line measures as nothing, so a
1924
+ * false answer declines both the re-measurement and the re-tick.
1925
+ * @param retick - Runs after the re-measurement, on the frame.
1926
+ */
1927
+ export function scheduleLyricPositionUpdate(engine, isViewRendering, retick) {
1928
+ if (engine.pendingLyricsUpdateFrame !== null) {
1929
+ return;
1930
+ }
1931
+ dropPendingLineScroll(engine);
1932
+ engine.pendingLyricsUpdateFrame = engine.window.requestAnimationFrame(() => {
1933
+ engine.pendingLyricsUpdateFrame = null;
1934
+ const isRendering = isViewRendering();
1935
+ relayout(engine, isRendering);
1936
+ if (!isRendering)
1937
+ return;
1938
+ retick();
1939
+ });
1940
+ }