@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/LICENSE +1 -1
- package/README.md +267 -32
- package/dist/LICENSE +21 -0
- package/dist/README.md +298 -0
- package/dist/constants.d.ts +25 -0
- package/dist/constants.js +42 -0
- package/dist/element.d.ts +155 -0
- package/dist/element.js +655 -0
- package/dist/engine.d.ts +309 -0
- package/dist/engine.js +1940 -0
- package/dist/index.d.ts +12 -128
- package/dist/index.js +11 -948
- package/dist/inject.d.ts +37 -0
- package/dist/inject.js +376 -0
- package/dist/instrumental.d.ts +10 -0
- package/dist/instrumental.js +78 -0
- package/dist/renderer.d.ts +29 -0
- package/dist/renderer.js +349 -0
- package/dist/seek.d.ts +1 -0
- package/dist/seek.js +27 -0
- package/dist/styles/instrumental.css +103 -0
- package/dist/styles/lyrics.css +274 -0
- package/dist/styles/variables.css +181 -0
- package/dist/text.d.ts +11 -0
- package/dist/text.js +54 -0
- package/dist/themeSettings.d.ts +23 -0
- package/dist/themeSettings.js +126 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +21 -0
- package/dist/util.js +64 -0
- package/dist/view.d.ts +22 -0
- package/dist/view.js +93 -0
- package/package.json +34 -21
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
|
+
}
|