@braccato/core 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/engine.d.ts +52 -0
- package/dist/engine.js +358 -55
- package/dist/inject.d.ts +1 -0
- package/dist/inject.js +10 -5
- package/dist/styles/lyrics.css +18 -10
- package/dist/view.d.ts +1 -1
- package/dist/view.js +2 -0
- package/package.json +1 -1
package/dist/constants.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export declare const BACKGROUND_LINE_CLASS: "blyrics-background-line";
|
|
|
20
20
|
export declare const WORD_GROUP_CLASS: "blyrics-word-group";
|
|
21
21
|
export declare const LONG_WORD_GROUP_CLASS: "blyrics-word-group-long";
|
|
22
22
|
export declare const WORD_HIGHLIGHT_CLASS: "blyrics-word-highlight";
|
|
23
|
+
export declare const WORD_HIGHLIGHT_LETTERED_CLASS: "blyrics-word-highlight--lettered";
|
|
23
24
|
export declare const HIGHLIGHT_RUN_CLASS: "blyrics-highlight-run";
|
|
24
25
|
export declare const LINE_SYNCED_WORD_CLASS: "blyrics-line-synced-word";
|
|
25
26
|
export declare const BIDI_RUN_CLASS: "blyrics-bidi-run";
|
package/dist/constants.js
CHANGED
|
@@ -26,6 +26,7 @@ export const BACKGROUND_LINE_CLASS = "blyrics-background-line";
|
|
|
26
26
|
export const WORD_GROUP_CLASS = "blyrics-word-group";
|
|
27
27
|
export const LONG_WORD_GROUP_CLASS = "blyrics-word-group-long";
|
|
28
28
|
export const WORD_HIGHLIGHT_CLASS = "blyrics-word-highlight";
|
|
29
|
+
export const WORD_HIGHLIGHT_LETTERED_CLASS = "blyrics-word-highlight--lettered";
|
|
29
30
|
export const HIGHLIGHT_RUN_CLASS = "blyrics-highlight-run";
|
|
30
31
|
export const LINE_SYNCED_WORD_CLASS = "blyrics-line-synced-word";
|
|
31
32
|
export const BIDI_RUN_CLASS = "blyrics-bidi-run";
|
package/dist/engine.d.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface AnimationEngineInstance extends AnimEngineViewState {
|
|
|
41
41
|
window: EngineWindow;
|
|
42
42
|
host: LyricsRendererHost;
|
|
43
43
|
cachedTabRendererHeight: number | null;
|
|
44
|
+
cachedMaxScrollTop: number | null;
|
|
45
|
+
cachedScrollTop: number | null;
|
|
46
|
+
cachedFooterItem: LineScrollItem | null;
|
|
47
|
+
cachedLineScrollTiming: Map<string, LineScrollTiming>;
|
|
44
48
|
tabRendererResizeObserver: ResizeObserver | null;
|
|
45
49
|
observedTabRenderer: HTMLElement | null;
|
|
46
50
|
lineScrollAnimations: LineScrollAnimationRecord[];
|
|
@@ -48,6 +52,9 @@ export interface AnimationEngineInstance extends AnimEngineViewState {
|
|
|
48
52
|
pendingLineScroll: PendingLineScroll | null;
|
|
49
53
|
lineScrollElementTokens: WeakMap<HTMLElement, number>;
|
|
50
54
|
visibleWillChangeElements: Set<HTMLElement>;
|
|
55
|
+
culledLineElements: Set<HTMLElement>;
|
|
56
|
+
waveAnimationPool: Animation[];
|
|
57
|
+
lineCullObserver: IntersectionObserver | null;
|
|
51
58
|
cachedDurations: Map<string, number>;
|
|
52
59
|
cachedCSSValues: Map<string, string>;
|
|
53
60
|
cachedAnimationSettings: AnimationSettings | null;
|
|
@@ -123,6 +130,11 @@ export declare function getRenderedSyncType(engine: AnimationEngineInstance): Ly
|
|
|
123
130
|
*/
|
|
124
131
|
export declare function clearLyrics(engine: AnimationEngineInstance): void;
|
|
125
132
|
type LineScrollSide = "above" | "active" | "below";
|
|
133
|
+
interface LineScrollItem {
|
|
134
|
+
lyricElement: HTMLElement;
|
|
135
|
+
height: number;
|
|
136
|
+
position: number;
|
|
137
|
+
}
|
|
126
138
|
interface LineScrollAnimationRecord {
|
|
127
139
|
animation: Animation;
|
|
128
140
|
lineElement: HTMLElement;
|
|
@@ -162,6 +174,8 @@ interface AnimationConfig {
|
|
|
162
174
|
glowDurationRatio: number;
|
|
163
175
|
glowMinDurationMs: number;
|
|
164
176
|
glowEasing: string;
|
|
177
|
+
glowContainerAlpha: number;
|
|
178
|
+
glowRestingInvisible: boolean;
|
|
165
179
|
};
|
|
166
180
|
word: {
|
|
167
181
|
wobbleDurationMs: number;
|
|
@@ -215,7 +229,39 @@ interface AnimationSettings {
|
|
|
215
229
|
};
|
|
216
230
|
}
|
|
217
231
|
export declare function noteVisibilityChange(engine: AnimationEngineInstance): void;
|
|
232
|
+
export interface LetterSwipeWindow {
|
|
233
|
+
delayMs: number;
|
|
234
|
+
durationMs: number;
|
|
235
|
+
from: {
|
|
236
|
+
start: number;
|
|
237
|
+
end: number;
|
|
238
|
+
};
|
|
239
|
+
to: {
|
|
240
|
+
start: number;
|
|
241
|
+
end: number;
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
export interface SwipeRamp {
|
|
245
|
+
easing: string;
|
|
246
|
+
startFrom: string;
|
|
247
|
+
startTo: string;
|
|
248
|
+
endFrom: string;
|
|
249
|
+
endTo: string;
|
|
250
|
+
}
|
|
251
|
+
export declare function computeLetterSwipeWindows(swipe: SwipeRamp, letterCount: number, swipeDurationMs: number): LetterSwipeWindow[] | null;
|
|
252
|
+
export interface LetterMaskKeyframe {
|
|
253
|
+
offset: number;
|
|
254
|
+
maskPosition: string;
|
|
255
|
+
}
|
|
256
|
+
export interface LetterMaskSweep {
|
|
257
|
+
keyframes: LetterMaskKeyframe[];
|
|
258
|
+
delayMs: number;
|
|
259
|
+
durationMs: number;
|
|
260
|
+
easing: string;
|
|
261
|
+
}
|
|
262
|
+
export declare function planLetterMaskSweep(swipe: SwipeRamp, letterCount: number, swipeDurationMs: number, rtl: boolean): LetterMaskSweep[];
|
|
218
263
|
export declare function clearStyleCaches(engine: AnimationEngineInstance): void;
|
|
264
|
+
export declare function parseColorAlpha(value: string): number | null;
|
|
219
265
|
interface PreparedLineScroll {
|
|
220
266
|
lineElement: HTMLElement;
|
|
221
267
|
side: LineScrollSide;
|
|
@@ -228,6 +274,11 @@ interface ResolvedLineScroll extends PreparedLineScroll {
|
|
|
228
274
|
startTranslate: string;
|
|
229
275
|
endTranslate: string;
|
|
230
276
|
}
|
|
277
|
+
interface LineScrollTiming {
|
|
278
|
+
durationMs: number;
|
|
279
|
+
startEasing: string;
|
|
280
|
+
endEasing: string;
|
|
281
|
+
}
|
|
231
282
|
interface LineScrollPlan {
|
|
232
283
|
items: ResolvedLineScroll[];
|
|
233
284
|
}
|
|
@@ -237,6 +288,7 @@ interface PendingLineScroll {
|
|
|
237
288
|
fromScrollTop: number;
|
|
238
289
|
toScrollTop: number;
|
|
239
290
|
}
|
|
291
|
+
export declare function setupLineCullObserver(engine: AnimationEngineInstance): void;
|
|
240
292
|
/**
|
|
241
293
|
* Unsynced lyrics that this view still has on screen. `syncType` outlives the lyrics it was derived
|
|
242
294
|
* from, so the container is the term that says they are still there.
|
package/dist/engine.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// playback clock the last tick wrote. Their unit is a bundle rather than a document, and this
|
|
17
17
|
// module is bundled into the isolated world and the page world separately, so those are two clocks
|
|
18
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, ROMANIZED_LYRICS_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, } from "./constants.js";
|
|
19
|
+
import { ANIMATING_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, } from "./constants.js";
|
|
20
20
|
import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
|
|
21
21
|
import { registerThemeSetting } from "./themeSettings.js";
|
|
22
22
|
import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
|
|
@@ -78,6 +78,8 @@ const LINE_SCROLL_STYLE_SETTINGS = [
|
|
|
78
78
|
registerLineScrollStyleSetting("--blyrics-line-scroll-active-translate-y-end", ""),
|
|
79
79
|
registerLineScrollStyleSetting("--blyrics-line-scroll-below-translate-y-end", ""),
|
|
80
80
|
];
|
|
81
|
+
// Tracked so the tick can skip the per-line translate write-then-read unless a theme overrides it.
|
|
82
|
+
const LINE_SCROLL_TRANSLATE_SETTINGS = LINE_SCROLL_STYLE_SETTINGS.filter(([property]) => property.includes("translate-y")).map(([, setting]) => setting);
|
|
81
83
|
const animationTimingLastLogTimes = new WeakMap();
|
|
82
84
|
// 0.5 means the selected lyric will be in the middle of the screen, 0 means top, 1 means bottom
|
|
83
85
|
const SCROLL_POS_OFFSET_RATIO = registerThemeSetting("blyrics-target-scroll-pos-ratio", 0.37);
|
|
@@ -135,6 +137,10 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
|
|
|
135
137
|
passiveScrollAccumulatedTime: 0,
|
|
136
138
|
passiveLastWallTime: 0,
|
|
137
139
|
cachedTabRendererHeight: null,
|
|
140
|
+
cachedMaxScrollTop: null,
|
|
141
|
+
cachedScrollTop: null,
|
|
142
|
+
cachedFooterItem: null,
|
|
143
|
+
cachedLineScrollTiming: new Map(),
|
|
138
144
|
tabRendererResizeObserver: null,
|
|
139
145
|
observedTabRenderer: null,
|
|
140
146
|
lineScrollAnimations: [],
|
|
@@ -142,6 +148,9 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
|
|
|
142
148
|
pendingLineScroll: null,
|
|
143
149
|
lineScrollElementTokens: new WeakMap(),
|
|
144
150
|
visibleWillChangeElements: new Set(),
|
|
151
|
+
culledLineElements: new Set(),
|
|
152
|
+
waveAnimationPool: [],
|
|
153
|
+
lineCullObserver: null,
|
|
145
154
|
cachedDurations: new Map(),
|
|
146
155
|
cachedCSSValues: new Map(),
|
|
147
156
|
cachedAnimationSettings: null,
|
|
@@ -157,6 +166,8 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
|
|
|
157
166
|
engine.tabRendererResizeObserver?.disconnect();
|
|
158
167
|
engine.tabRendererResizeObserver = null;
|
|
159
168
|
engine.observedTabRenderer = null;
|
|
169
|
+
engine.lineCullObserver?.disconnect();
|
|
170
|
+
engine.lineCullObserver = null;
|
|
160
171
|
stopPassiveScrollLoop(engine);
|
|
161
172
|
cancelLyricPositionUpdate(engine);
|
|
162
173
|
},
|
|
@@ -265,6 +276,9 @@ export function clearLyrics(engine) {
|
|
|
265
276
|
dropPendingLineScroll(engine);
|
|
266
277
|
clearLineScrollAnimations(engine);
|
|
267
278
|
clearVisibleLyricWillChange(engine);
|
|
279
|
+
engine.lineCullObserver?.disconnect();
|
|
280
|
+
engine.lineCullObserver = null;
|
|
281
|
+
clearOffscreenLineCulling(engine);
|
|
268
282
|
for (const line of engine.lines) {
|
|
269
283
|
resetLineAnimationState(line);
|
|
270
284
|
line.isSelected = false;
|
|
@@ -278,11 +292,16 @@ export function clearLyrics(engine) {
|
|
|
278
292
|
engine.passiveLastWallTime = 0;
|
|
279
293
|
stopPassiveScrollLoop(engine);
|
|
280
294
|
engine.lines = [];
|
|
295
|
+
engine.cachedFooterItem = null;
|
|
281
296
|
engine.lyricsContainer = null;
|
|
297
|
+
engine.waveAnimationPool.length = 0;
|
|
282
298
|
}
|
|
283
299
|
function resetPartAnimations(part) {
|
|
284
300
|
for (const animation of part.animations) {
|
|
285
301
|
animation.cancel();
|
|
302
|
+
const pool = pooledWaveAnimations.get(animation);
|
|
303
|
+
if (pool)
|
|
304
|
+
pool.push(animation);
|
|
286
305
|
}
|
|
287
306
|
part.animations = [];
|
|
288
307
|
}
|
|
@@ -363,6 +382,30 @@ function trackLyricAnimationTiming(engine, animation, timing) {
|
|
|
363
382
|
animation.playbackRate = engine.playbackRate;
|
|
364
383
|
return animation;
|
|
365
384
|
}
|
|
385
|
+
// Finished per-letter wave animations are pooled and retargeted onto new letters, since recreating
|
|
386
|
+
// ~150 identical ones per line activation is pure churn.
|
|
387
|
+
const pooledWaveAnimations = new WeakMap();
|
|
388
|
+
const pooledWaveKeyframeSignatures = new WeakMap();
|
|
389
|
+
function acquireWaveAnimation(engine, letterElement, keyframes, keyframeSignature, timing) {
|
|
390
|
+
const pool = engine.waveAnimationPool;
|
|
391
|
+
const pooled = pool[pool.length - 1];
|
|
392
|
+
const effect = pooled?.effect;
|
|
393
|
+
if (pooled && effect && typeof effect.setKeyframes === "function") {
|
|
394
|
+
pool.pop();
|
|
395
|
+
effect.target = letterElement;
|
|
396
|
+
if (pooledWaveKeyframeSignatures.get(pooled) !== keyframeSignature) {
|
|
397
|
+
effect.setKeyframes(keyframes);
|
|
398
|
+
pooledWaveKeyframeSignatures.set(pooled, keyframeSignature);
|
|
399
|
+
}
|
|
400
|
+
effect.updateTiming(timing);
|
|
401
|
+
pooled.play();
|
|
402
|
+
return pooled;
|
|
403
|
+
}
|
|
404
|
+
const animation = letterElement.animate(keyframes, timing);
|
|
405
|
+
pooledWaveAnimations.set(animation, pool);
|
|
406
|
+
pooledWaveKeyframeSignatures.set(animation, keyframeSignature);
|
|
407
|
+
return animation;
|
|
408
|
+
}
|
|
366
409
|
/**
|
|
367
410
|
* Puts the animations already running onto a new rate. Setting `playbackRate` keeps `currentTime`,
|
|
368
411
|
* so each one carries on from where the song left it rather than restarting.
|
|
@@ -645,18 +688,114 @@ function fadeOutTextKeyframes(config) {
|
|
|
645
688
|
},
|
|
646
689
|
];
|
|
647
690
|
}
|
|
691
|
+
// null unless the ramp is linear and forward; planLetterMaskSweep sweeps the whole word instead there.
|
|
692
|
+
export function computeLetterSwipeWindows(swipe, letterCount, swipeDurationMs) {
|
|
693
|
+
if (swipe.easing !== "linear" || swipeDurationMs <= 0 || letterCount <= 0) {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
const startFrom = Number.parseFloat(swipe.startFrom);
|
|
697
|
+
const startTo = Number.parseFloat(swipe.startTo);
|
|
698
|
+
const endFrom = Number.parseFloat(swipe.endFrom);
|
|
699
|
+
const endTo = Number.parseFloat(swipe.endTo);
|
|
700
|
+
if (![startFrom, startTo, endFrom, endTo].every(Number.isFinite) || startTo <= startFrom || endTo <= endFrom) {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
const startAt = (timeMs) => startFrom + ((startTo - startFrom) * timeMs) / swipeDurationMs;
|
|
704
|
+
const endAt = (timeMs) => endFrom + ((endTo - endFrom) * timeMs) / swipeDurationMs;
|
|
705
|
+
const timeWhereEnd = (value) => (swipeDurationMs * (value - endFrom)) / (endTo - endFrom);
|
|
706
|
+
const timeWhereStart = (value) => (swipeDurationMs * (value - startFrom)) / (startTo - startFrom);
|
|
707
|
+
const windows = [];
|
|
708
|
+
for (let index = 0; index < letterCount; index++) {
|
|
709
|
+
const beginMs = clamp(timeWhereEnd(index / letterCount), 0, swipeDurationMs);
|
|
710
|
+
const finishMs = clamp(timeWhereStart((index + 1) / letterCount), 0, swipeDurationMs);
|
|
711
|
+
windows.push({
|
|
712
|
+
delayMs: beginMs,
|
|
713
|
+
durationMs: Math.max(finishMs - beginMs, 1),
|
|
714
|
+
from: { start: startAt(beginMs), end: endAt(beginMs) },
|
|
715
|
+
to: { start: startAt(finishMs), end: endAt(finishMs) },
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return windows;
|
|
719
|
+
}
|
|
720
|
+
// Per-letter mask reveal. A linear forward ramp keeps the short windowed animations so a settled letter
|
|
721
|
+
// holds a finished one; any other easing runs the same geometry over the whole duration, letting the
|
|
722
|
+
// theme easing warp when each letter reveals, so it ends revealed rather than swept past.
|
|
723
|
+
export function planLetterMaskSweep(swipe, letterCount, swipeDurationMs, rtl) {
|
|
724
|
+
if (letterCount <= 0)
|
|
725
|
+
return [];
|
|
726
|
+
const windows = computeLetterSwipeWindows({ ...swipe, easing: "linear" }, letterCount, swipeDurationMs);
|
|
727
|
+
if (!windows)
|
|
728
|
+
return [];
|
|
729
|
+
const maskSpan = letterCount + 2;
|
|
730
|
+
const maskPositionAt = (start, index) => {
|
|
731
|
+
const q = (0.5 * maskSpan - (start * letterCount - index)) / (maskSpan - 1);
|
|
732
|
+
return `${(rtl ? 1 - q : q) * 100}% 0%`;
|
|
733
|
+
};
|
|
734
|
+
const linear = swipe.easing === "linear";
|
|
735
|
+
const durationMs = swipeDurationMs > 0 ? swipeDurationMs : 1;
|
|
736
|
+
return windows.map((window, index) => {
|
|
737
|
+
const from = maskPositionAt(window.from.start, index);
|
|
738
|
+
const to = maskPositionAt(window.to.start, index);
|
|
739
|
+
if (linear) {
|
|
740
|
+
return {
|
|
741
|
+
keyframes: [
|
|
742
|
+
{ offset: 0, maskPosition: from },
|
|
743
|
+
{ offset: 1, maskPosition: to },
|
|
744
|
+
],
|
|
745
|
+
delayMs: window.delayMs,
|
|
746
|
+
durationMs: window.durationMs,
|
|
747
|
+
easing: "linear",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const revealStart = clamp(window.delayMs / durationMs, 0, 1);
|
|
751
|
+
const revealEnd = clamp((window.delayMs + window.durationMs) / durationMs, 0, 1);
|
|
752
|
+
const keyframes = [{ offset: 0, maskPosition: from }];
|
|
753
|
+
if (revealStart > 0)
|
|
754
|
+
keyframes.push({ offset: revealStart, maskPosition: from });
|
|
755
|
+
if (revealEnd > revealStart)
|
|
756
|
+
keyframes.push({ offset: revealEnd, maskPosition: to });
|
|
757
|
+
if (revealEnd < 1)
|
|
758
|
+
keyframes.push({ offset: 1, maskPosition: to });
|
|
759
|
+
return { keyframes, delayMs: 0, durationMs, easing: swipe.easing };
|
|
760
|
+
});
|
|
761
|
+
}
|
|
648
762
|
function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, wordTimeMs, swipeDurationMs, glowDurationMs, appliedTimingOffsetMs) {
|
|
649
763
|
const animations = [];
|
|
650
764
|
const highlight = part.highlightElement;
|
|
651
765
|
let swipeAnimation;
|
|
652
766
|
if (config.enabled.highlightSwipe) {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
767
|
+
const swipeTiming = { appliedTimingOffsetMs, offsetMs: swipeTimeMs - wordTimeMs };
|
|
768
|
+
const swipeCurrentTimeMs = correctedAnimationTimeMs(swipeTimeMs, appliedTimingOffsetMs, swipeDurationMs);
|
|
769
|
+
const highlightLetters = part.highlightLetterElements;
|
|
770
|
+
if (highlightLetters && highlightLetters.length > 0) {
|
|
771
|
+
const sweeps = planLetterMaskSweep({
|
|
772
|
+
easing: config.highlight.swipeEasing,
|
|
773
|
+
startFrom: config.highlight.swipeStartFrom,
|
|
774
|
+
startTo: config.highlight.swipeStartTo,
|
|
775
|
+
endFrom: config.highlight.swipeEndFrom,
|
|
776
|
+
endTo: config.highlight.swipeEndTo,
|
|
777
|
+
}, highlightLetters.length, swipeDurationMs, part.highlightElement.classList.contains(RTL_CLASS));
|
|
778
|
+
sweeps.forEach((sweep, index) => {
|
|
779
|
+
const animation = trackLyricAnimationTiming(engine, highlightLetters[index].animate(sweep.keyframes.map(frame => ({
|
|
780
|
+
offset: frame.offset,
|
|
781
|
+
maskPosition: frame.maskPosition,
|
|
782
|
+
WebkitMaskPosition: frame.maskPosition,
|
|
783
|
+
})), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }), swipeTiming);
|
|
784
|
+
animation.currentTime = swipeCurrentTimeMs;
|
|
785
|
+
if (index === 0)
|
|
786
|
+
swipeAnimation = animation;
|
|
787
|
+
animations.push(animation);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
swipeAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGradientKeyframes(config), {
|
|
792
|
+
duration: swipeDurationMs,
|
|
793
|
+
easing: config.highlight.swipeEasing,
|
|
794
|
+
fill: "forwards",
|
|
795
|
+
}), swipeTiming);
|
|
796
|
+
swipeAnimation.currentTime = swipeCurrentTimeMs;
|
|
797
|
+
animations.push(swipeAnimation);
|
|
798
|
+
}
|
|
660
799
|
}
|
|
661
800
|
const opacityAnimation = trackLyricAnimationTiming(engine, highlight.animate(config.enabled.highlightSwipe ? activeTextVisibleKeyframes() : activeTextInstantKeyframes(config), {
|
|
662
801
|
duration: 1,
|
|
@@ -666,11 +805,11 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
|
|
|
666
805
|
opacityAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, 1);
|
|
667
806
|
animations.push(opacityAnimation);
|
|
668
807
|
let glowAnimation;
|
|
669
|
-
if (config.enabled.highlightGlow) {
|
|
808
|
+
if (config.enabled.highlightGlow && !part.glowSuppressed) {
|
|
670
809
|
glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
|
|
671
810
|
duration: glowDurationMs,
|
|
672
811
|
easing: config.highlight.glowEasing,
|
|
673
|
-
fill: "forwards",
|
|
812
|
+
fill: config.highlight.glowRestingInvisible ? "none" : "forwards",
|
|
674
813
|
}), { appliedTimingOffsetMs, offsetMs: 0 });
|
|
675
814
|
glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
|
|
676
815
|
animations.push(glowAnimation);
|
|
@@ -689,11 +828,11 @@ function startLineSyncedHighlightAnimations(engine, part, config, wordTimeMs, gl
|
|
|
689
828
|
opacityAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, fadeInDuration);
|
|
690
829
|
animations.push(opacityAnimation);
|
|
691
830
|
let glowAnimation;
|
|
692
|
-
if (config.enabled.highlightGlow) {
|
|
831
|
+
if (config.enabled.highlightGlow && !part.glowSuppressed) {
|
|
693
832
|
glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
|
|
694
833
|
duration: glowDurationMs,
|
|
695
834
|
easing: config.highlight.glowEasing,
|
|
696
|
-
fill: "forwards",
|
|
835
|
+
fill: config.highlight.glowRestingInvisible ? "none" : "forwards",
|
|
697
836
|
}), { appliedTimingOffsetMs, offsetMs: 0 });
|
|
698
837
|
glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
|
|
699
838
|
animations.push(glowAnimation);
|
|
@@ -793,17 +932,14 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
793
932
|
const staggerMs = timedDurationMs > 0 ? timedDurationMs / 2.5 / letterCount : 0;
|
|
794
933
|
const cascadeDurationMs = config.letterWave.durationMs + (letterCount - 1) * staggerMs;
|
|
795
934
|
const floatStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, cascadeDurationMs);
|
|
935
|
+
const floatKeyframeSignature = JSON.stringify(floatKeyframes);
|
|
796
936
|
for (const set of [part.letterElements, part.highlightLetterElements]) {
|
|
797
937
|
set?.forEach((letterElement, index) => {
|
|
798
|
-
const
|
|
938
|
+
const animation = trackLyricAnimationTiming(engine, acquireWaveAnimation(engine, letterElement, floatKeyframes, floatKeyframeSignature, {
|
|
799
939
|
duration: config.letterWave.durationMs,
|
|
800
940
|
delay: index * staggerMs,
|
|
801
941
|
fill: "forwards",
|
|
802
|
-
};
|
|
803
|
-
const animation = trackLyricAnimationTiming(engine, letterElement.animate(floatKeyframes, options), {
|
|
804
|
-
appliedTimingOffsetMs,
|
|
805
|
-
offsetMs: 0,
|
|
806
|
-
});
|
|
942
|
+
}), { appliedTimingOffsetMs, offsetMs: 0 });
|
|
807
943
|
animation.currentTime = floatStartMs;
|
|
808
944
|
wobbleAnimations.push(animation);
|
|
809
945
|
});
|
|
@@ -819,6 +955,7 @@ function startLineAnimations(engine, lineData, config, currentTime) {
|
|
|
819
955
|
startInstrumentalAnimations(engine, lineData, config, currentTime, appliedTimingOffsetMs);
|
|
820
956
|
return;
|
|
821
957
|
}
|
|
958
|
+
resolveLineGlowSuppression(engine, lineData, config);
|
|
822
959
|
for (const part of lineData.parts) {
|
|
823
960
|
startWordAnimations(engine, part, config, currentTime, appliedTimingOffsetMs);
|
|
824
961
|
}
|
|
@@ -925,6 +1062,7 @@ export function clearStyleCaches(engine) {
|
|
|
925
1062
|
engine.cachedDurations.clear();
|
|
926
1063
|
engine.cachedCSSValues.clear();
|
|
927
1064
|
engine.cachedAnimationSettings = null;
|
|
1065
|
+
engine.cachedLineScrollTiming.clear();
|
|
928
1066
|
}
|
|
929
1067
|
function getCSSValue(engine, lyricsElement, property, fallback) {
|
|
930
1068
|
let value = engine.cachedCSSValues.get(property);
|
|
@@ -978,6 +1116,52 @@ function getCSSOffset(engine, lyricsElement, property, fallback) {
|
|
|
978
1116
|
// the color left as a literal var lets the Web Animations API resolve it against each animated
|
|
979
1117
|
// word instead. A theme that sets the full filter var still wins, but its color resolves once
|
|
980
1118
|
// at the container (globally), as before.
|
|
1119
|
+
// A glow whose resolved color falls below half a quantization step stays invisible even after the
|
|
1120
|
+
// blur spreads it, so the per-frame drop-shadow can be skipped with no pixel changing.
|
|
1121
|
+
const GLOW_INVISIBLE_ALPHA = 0.5 / 255;
|
|
1122
|
+
function alphaToken(token) {
|
|
1123
|
+
const value = token.endsWith("%") ? Number.parseFloat(token) / 100 : Number.parseFloat(token);
|
|
1124
|
+
return clamp(value, 0, 1);
|
|
1125
|
+
}
|
|
1126
|
+
// null for an unrecognized format, which the caller treats as opaque so a visible glow is never dropped.
|
|
1127
|
+
export function parseColorAlpha(value) {
|
|
1128
|
+
const color = value.trim().toLowerCase();
|
|
1129
|
+
if (color === "")
|
|
1130
|
+
return null;
|
|
1131
|
+
if (color === "transparent")
|
|
1132
|
+
return 0;
|
|
1133
|
+
const slashAlpha = color.match(/\/\s*([0-9]*\.?[0-9]+%?)\s*\)\s*$/);
|
|
1134
|
+
if (slashAlpha)
|
|
1135
|
+
return alphaToken(slashAlpha[1]);
|
|
1136
|
+
const commaAlpha = color.match(/^(?:rgba|hsla)\([^)]*,\s*([0-9]*\.?[0-9]+%?)\s*\)$/);
|
|
1137
|
+
if (commaAlpha)
|
|
1138
|
+
return alphaToken(commaAlpha[1]);
|
|
1139
|
+
const hex = color.match(/^#([0-9a-f]{4}|[0-9a-f]{8})$/);
|
|
1140
|
+
if (hex) {
|
|
1141
|
+
const digits = hex[1];
|
|
1142
|
+
const alphaHex = digits.length === 8 ? digits.slice(6) : digits.slice(3).repeat(2);
|
|
1143
|
+
return Number.parseInt(alphaHex, 16) / 255;
|
|
1144
|
+
}
|
|
1145
|
+
const opaqueFunction = /^(?:rgb|hsl|hwb|lab|lch|oklab|oklch|color)\(/.test(color);
|
|
1146
|
+
const opaqueHex = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/.test(color);
|
|
1147
|
+
const namedColor = /^[a-z]+$/.test(color);
|
|
1148
|
+
if (opaqueFunction || opaqueHex || namedColor)
|
|
1149
|
+
return 1;
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
// Per-word glow is resolved only when the container glow already renders nothing, so an empty word
|
|
1153
|
+
// drops its blur while a word a theme lit up keeps it.
|
|
1154
|
+
function resolveLineGlowSuppression(engine, lineData, config) {
|
|
1155
|
+
if (config.highlight.glowContainerAlpha >= GLOW_INVISIBLE_ALPHA) {
|
|
1156
|
+
for (const part of lineData.parts)
|
|
1157
|
+
part.glowSuppressed = false;
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
for (const part of lineData.parts) {
|
|
1161
|
+
const color = engine.window.getComputedStyle(part.highlightElement).getPropertyValue("--blyrics-glow-color");
|
|
1162
|
+
part.glowSuppressed = (parseColorAlpha(color) ?? 1) < GLOW_INVISIBLE_ALPHA;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
981
1165
|
function resolveGlowFilter(engine, lyricsElement, suffix, radiusDefault) {
|
|
982
1166
|
const override = getCSSValue(engine, lyricsElement, `--blyrics-highlight-glow-filter-${suffix}`, "");
|
|
983
1167
|
if (override)
|
|
@@ -985,6 +1169,15 @@ function resolveGlowFilter(engine, lyricsElement, suffix, radiusDefault) {
|
|
|
985
1169
|
const radius = getCSSValue(engine, lyricsElement, `--blyrics-highlight-glow-radius-${suffix}`, radiusDefault);
|
|
986
1170
|
return `drop-shadow(0 0 ${radius} var(--blyrics-glow-color))`;
|
|
987
1171
|
}
|
|
1172
|
+
// Only the engine's own drop-shadow(0 0 <radius> ...) resting shape counts, so a theme override or a
|
|
1173
|
+
// non-zero radius keeps its permanent glow on fill:forwards.
|
|
1174
|
+
function isGlowRestingInvisible(glowTo) {
|
|
1175
|
+
const value = glowTo.trim();
|
|
1176
|
+
if (value === "" || value === "none")
|
|
1177
|
+
return true;
|
|
1178
|
+
const match = value.match(/^drop-shadow\(\s*0\s+0\s+(\S+)\s+var\(--blyrics-glow-color\)\)$/);
|
|
1179
|
+
return match ? Number.parseFloat(match[1]) === 0 : false;
|
|
1180
|
+
}
|
|
988
1181
|
function readAnimationConfig(engine, lyricsElement) {
|
|
989
1182
|
const prefersReducedMotion = engine.window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
|
990
1183
|
const scrollDurationMs = getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-lyric-scroll-duration", "650ms");
|
|
@@ -1023,6 +1216,8 @@ function readAnimationConfig(engine, lyricsElement) {
|
|
|
1023
1216
|
glowDurationRatio: getCSSNumber(engine, lyricsElement, "--blyrics-highlight-glow-duration-ratio", 1.2),
|
|
1024
1217
|
glowMinDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-highlight-glow-min-duration", "1.2s"),
|
|
1025
1218
|
glowEasing: getCSSValue(engine, lyricsElement, "--blyrics-highlight-glow-easing", "ease"),
|
|
1219
|
+
glowContainerAlpha: parseColorAlpha(getCSSValue(engine, lyricsElement, "--blyrics-glow-color", "")) ?? 1,
|
|
1220
|
+
glowRestingInvisible: isGlowRestingInvisible(resolveGlowFilter(engine, lyricsElement, "to", "0")),
|
|
1026
1221
|
},
|
|
1027
1222
|
word: {
|
|
1028
1223
|
wobbleDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-wobble-duration", "1s"),
|
|
@@ -1224,6 +1419,55 @@ function clearVisibleLyricWillChange(engine) {
|
|
|
1224
1419
|
}
|
|
1225
1420
|
engine.visibleWillChangeElements = new Set();
|
|
1226
1421
|
}
|
|
1422
|
+
// Near-viewport lines come from the intersection observer with one viewport of overscan, not from
|
|
1423
|
+
// scrollTop, because a scaled/transformed scroll decouples layout coords from what is on screen.
|
|
1424
|
+
const LINE_CULL_ROOT_MARGIN = "100% 0px 100% 0px";
|
|
1425
|
+
function cullLine(element) {
|
|
1426
|
+
element.style.setProperty("content-visibility", "auto");
|
|
1427
|
+
}
|
|
1428
|
+
function uncullLine(element) {
|
|
1429
|
+
element.style.removeProperty("content-visibility");
|
|
1430
|
+
}
|
|
1431
|
+
function clearOffscreenLineCulling(engine) {
|
|
1432
|
+
for (const element of engine.culledLineElements) {
|
|
1433
|
+
uncullLine(element);
|
|
1434
|
+
}
|
|
1435
|
+
engine.culledLineElements = new Set();
|
|
1436
|
+
}
|
|
1437
|
+
// Rebuilt rather than updated on every line change or relayout, so a skipped line's placeholder height
|
|
1438
|
+
// is always freshly measured. Without IntersectionObserver every line stays rendered.
|
|
1439
|
+
export function setupLineCullObserver(engine) {
|
|
1440
|
+
engine.lineCullObserver?.disconnect();
|
|
1441
|
+
clearOffscreenLineCulling(engine);
|
|
1442
|
+
const ObserverConstructor = engine.window.IntersectionObserver;
|
|
1443
|
+
if (typeof ObserverConstructor !== "function" || engine.lines.length === 0) {
|
|
1444
|
+
engine.lineCullObserver = null;
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
// Pin each line's skipped placeholder to its last rendered size, so skipping a line never shifts the
|
|
1448
|
+
// container's scroll height, which the engine would misread as a user scroll.
|
|
1449
|
+
const restingHeights = engine.lines.map(line => line.lyricElement.offsetHeight);
|
|
1450
|
+
engine.lines.forEach((line, index) => {
|
|
1451
|
+
line.lyricElement.style.setProperty("contain-intrinsic-block-size", `auto ${restingHeights[index]}px`);
|
|
1452
|
+
});
|
|
1453
|
+
const observer = new ObserverConstructor(entries => {
|
|
1454
|
+
for (const entry of entries) {
|
|
1455
|
+
const element = entry.target;
|
|
1456
|
+
if (entry.isIntersecting) {
|
|
1457
|
+
if (engine.culledLineElements.delete(element))
|
|
1458
|
+
uncullLine(element);
|
|
1459
|
+
}
|
|
1460
|
+
else if (!engine.culledLineElements.has(element)) {
|
|
1461
|
+
cullLine(element);
|
|
1462
|
+
engine.culledLineElements.add(element);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}, { root: null, rootMargin: LINE_CULL_ROOT_MARGIN, threshold: 0 });
|
|
1466
|
+
for (const line of engine.lines) {
|
|
1467
|
+
observer.observe(line.lyricElement);
|
|
1468
|
+
}
|
|
1469
|
+
engine.lineCullObserver = observer;
|
|
1470
|
+
}
|
|
1227
1471
|
function updateVisibleLyricWillChange(engine, lines, fromScrollTop, toScrollTop, viewportHeight) {
|
|
1228
1472
|
const nextVisibleElements = new Set();
|
|
1229
1473
|
for (const line of lines) {
|
|
@@ -1239,28 +1483,30 @@ function updateVisibleLyricWillChange(engine, lines, fromScrollTop, toScrollTop,
|
|
|
1239
1483
|
}
|
|
1240
1484
|
engine.visibleWillChangeElements = nextVisibleElements;
|
|
1241
1485
|
}
|
|
1242
|
-
|
|
1486
|
+
// Footer bounds come from the relayout-time cache, not a getBoundingClientRect here, which would force
|
|
1487
|
+
// a synchronous recalc mid-tick after the scroll commit dirtied style.
|
|
1488
|
+
function getLineScrollItems(engine, lines) {
|
|
1489
|
+
return engine.cachedFooterItem ? [...lines, engine.cachedFooterItem] : lines;
|
|
1490
|
+
}
|
|
1491
|
+
function measureFooterItem(engine, lyricsElement) {
|
|
1243
1492
|
const footer = lyricsElement.querySelector(`:scope > .${FOOTER_CLASS}`);
|
|
1244
|
-
if (!footer)
|
|
1245
|
-
|
|
1493
|
+
if (!footer) {
|
|
1494
|
+
engine.cachedFooterItem = null;
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1246
1497
|
const footerBounds = getRelativeLayoutBounds(lyricsElement, footer);
|
|
1247
|
-
|
|
1248
|
-
...lines,
|
|
1249
|
-
{
|
|
1250
|
-
lyricElement: footer,
|
|
1251
|
-
position: footerBounds.y,
|
|
1252
|
-
height: footerBounds.height,
|
|
1253
|
-
},
|
|
1254
|
-
];
|
|
1498
|
+
engine.cachedFooterItem = { lyricElement: footer, position: footerBounds.y, height: footerBounds.height };
|
|
1255
1499
|
}
|
|
1256
1500
|
function prepareLineScrollOffsets(engine, lines, activeLineIndex, scrollDeltaPx, fromScrollTop, toScrollTop, viewportHeight, config) {
|
|
1257
1501
|
if (!config.enabled.scroll || activeLineIndex < 0) {
|
|
1258
1502
|
return null;
|
|
1259
1503
|
}
|
|
1260
1504
|
const scrollDistancePx = Math.abs(scrollDeltaPx);
|
|
1261
|
-
const
|
|
1505
|
+
const translateThemed = LINE_SCROLL_TRANSLATE_SETTINGS.some(setting => setting.isManuallySet());
|
|
1506
|
+
const defaultStartTranslate = `0px ${scrollDeltaPx}px`;
|
|
1262
1507
|
// Preserve the original windowing exactly: only lines intersecting the
|
|
1263
1508
|
// union of the old and new viewports receive scroll animations.
|
|
1509
|
+
const requests = [];
|
|
1264
1510
|
for (let index = 0; index < lines.length; index++) {
|
|
1265
1511
|
if (!isLineVisibleDuringScroll(lines[index], fromScrollTop, toScrollTop, viewportHeight)) {
|
|
1266
1512
|
continue;
|
|
@@ -1268,35 +1514,64 @@ function prepareLineScrollOffsets(engine, lines, activeLineIndex, scrollDeltaPx,
|
|
|
1268
1514
|
const lineElement = lines[index].lyricElement;
|
|
1269
1515
|
const relativeIndex = index - activeLineIndex;
|
|
1270
1516
|
const side = lineScrollSide(relativeIndex, scrollDeltaPx);
|
|
1517
|
+
const token = ++engine.lineScrollAnimationToken;
|
|
1518
|
+
engine.lineScrollElementTokens.set(lineElement, token);
|
|
1519
|
+
const item = { lineElement, side, token };
|
|
1520
|
+
const timingKey = `${side}|${Math.abs(relativeIndex)}`;
|
|
1521
|
+
requests.push({ item, index, relativeIndex, timingKey, timing: engine.cachedLineScrollTiming.get(timingKey) });
|
|
1522
|
+
}
|
|
1523
|
+
// The line-scroll vars reach the DOM only for an uncached line, or every line when a theme drives the
|
|
1524
|
+
// translate off them, so an otherwise-cached scroll leaves the visible lines untouched.
|
|
1525
|
+
const linesToWrite = requests.filter(request => translateThemed || !request.timing);
|
|
1526
|
+
for (const { item, index, relativeIndex } of linesToWrite) {
|
|
1527
|
+
const lineElement = item.lineElement;
|
|
1271
1528
|
lineElement.style.setProperty(LINE_SCROLL_INDEX_PROPERTY, String(index));
|
|
1272
1529
|
lineElement.style.setProperty(LINE_SCROLL_ACTIVE_INDEX_PROPERTY, String(activeLineIndex));
|
|
1273
1530
|
lineElement.style.setProperty(LINE_SCROLL_RELATIVE_INDEX_PROPERTY, String(relativeIndex));
|
|
1274
1531
|
lineElement.style.setProperty(LINE_SCROLL_ABS_RELATIVE_INDEX_PROPERTY, String(Math.abs(relativeIndex)));
|
|
1275
|
-
lineElement.style.setProperty(LINE_SCROLL_SIDE_PROPERTY, side);
|
|
1532
|
+
lineElement.style.setProperty(LINE_SCROLL_SIDE_PROPERTY, item.side);
|
|
1276
1533
|
lineElement.style.setProperty(LINE_SCROLL_DELTA_PROPERTY, `${scrollDeltaPx}px`);
|
|
1277
1534
|
lineElement.style.setProperty(LINE_SCROLL_DISTANCE_PROPERTY, `${scrollDistancePx}px`);
|
|
1278
1535
|
setLineScrollStyleProperties(lineElement);
|
|
1279
|
-
const token = ++engine.lineScrollAnimationToken;
|
|
1280
|
-
engine.lineScrollElementTokens.set(lineElement, token);
|
|
1281
|
-
prepared.push({ lineElement, side, token });
|
|
1282
1536
|
}
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1537
|
+
const misses = requests.filter(request => !request.timing);
|
|
1538
|
+
if (misses.length > 0) {
|
|
1539
|
+
const missItems = misses.map(request => request.item);
|
|
1540
|
+
const durations = batchResolveLineScrollProperty(engine, missItems, "transition-duration", item => lineScrollDurationProperty(item.side, config.lineScroll.durationMs, config.lineScroll.differentialEffects), style => {
|
|
1541
|
+
const durationMs = toMs(style.transitionDuration.split(",")[0].trim());
|
|
1542
|
+
return durationMs > 0 ? durationMs : config.lineScroll.durationMs;
|
|
1543
|
+
});
|
|
1544
|
+
const startEasings = batchResolveLineScrollProperty(engine, missItems, "transition-timing-function", item => lineScrollEasingProperty(item.side, "start", config.lineScroll.easing, config.lineScroll.differentialEffects), style => style.transitionTimingFunction.trim() || config.lineScroll.easing);
|
|
1545
|
+
const endEasings = batchResolveLineScrollProperty(engine, missItems, "transition-timing-function", item => lineScrollEasingProperty(item.side, "end", config.lineScroll.easing, config.lineScroll.differentialEffects), style => style.transitionTimingFunction.trim() || config.lineScroll.easing);
|
|
1546
|
+
misses.forEach((request, resolveIndex) => {
|
|
1547
|
+
const timing = {
|
|
1548
|
+
durationMs: durations[resolveIndex],
|
|
1549
|
+
startEasing: startEasings[resolveIndex],
|
|
1550
|
+
endEasing: endEasings[resolveIndex],
|
|
1551
|
+
};
|
|
1552
|
+
engine.cachedLineScrollTiming.set(request.timingKey, timing);
|
|
1553
|
+
request.timing = timing;
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
let startTranslates = null;
|
|
1557
|
+
let endTranslates = null;
|
|
1558
|
+
if (translateThemed) {
|
|
1559
|
+
const items = requests.map(request => request.item);
|
|
1560
|
+
startTranslates = batchResolveLineScrollProperty(engine, items, "translate", item => lineScrollTranslate(item.side, "start", config.lineScroll.differentialEffects), style => normalizedTranslate(style.translate));
|
|
1561
|
+
endTranslates = batchResolveLineScrollProperty(engine, items, "translate", item => lineScrollTranslate(item.side, "end", config.lineScroll.differentialEffects), style => normalizedTranslate(style.translate));
|
|
1562
|
+
}
|
|
1291
1563
|
return {
|
|
1292
|
-
items:
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1564
|
+
items: requests.map((request, requestIndex) => {
|
|
1565
|
+
const timing = request.timing;
|
|
1566
|
+
return {
|
|
1567
|
+
...request.item,
|
|
1568
|
+
durationMs: timing.durationMs,
|
|
1569
|
+
startEasing: timing.startEasing,
|
|
1570
|
+
endEasing: timing.endEasing,
|
|
1571
|
+
startTranslate: startTranslates ? startTranslates[requestIndex] : defaultStartTranslate,
|
|
1572
|
+
endTranslate: endTranslates ? endTranslates[requestIndex] : "0px 0px",
|
|
1573
|
+
};
|
|
1574
|
+
}),
|
|
1300
1575
|
};
|
|
1301
1576
|
}
|
|
1302
1577
|
function startPreparedLineScroll(engine, plan) {
|
|
@@ -1473,10 +1748,12 @@ function passiveScrollEngine(engine, isPlaying) {
|
|
|
1473
1748
|
}
|
|
1474
1749
|
const prevScrollTop = tabRenderer.scrollTop;
|
|
1475
1750
|
tabRenderer.scrollTop = targetScroll;
|
|
1751
|
+
const appliedScrollTop = tabRenderer.scrollTop;
|
|
1752
|
+
engine.cachedScrollTop = appliedScrollTop;
|
|
1476
1753
|
// Only skip the next scroll event if scrollTop actually changed.
|
|
1477
1754
|
// When it doesn't change (pause phases, sub-pixel rounding), no programmatic
|
|
1478
1755
|
// scroll event fires, so setting skipScrolls would eat user scroll events instead.
|
|
1479
|
-
if (
|
|
1756
|
+
if (appliedScrollTop !== prevScrollTop) {
|
|
1480
1757
|
engine.skipScrolls = 1;
|
|
1481
1758
|
}
|
|
1482
1759
|
}
|
|
@@ -1492,11 +1769,19 @@ function setupTabRendererObserver(engine, element) {
|
|
|
1492
1769
|
dropPendingLineScroll(engine);
|
|
1493
1770
|
if (element && element.isConnected) {
|
|
1494
1771
|
engine.cachedTabRendererHeight = element.getBoundingClientRect().height;
|
|
1772
|
+
refreshScrollMetrics(engine, element);
|
|
1495
1773
|
}
|
|
1496
1774
|
});
|
|
1497
1775
|
engine.tabRendererResizeObserver.observe(element);
|
|
1498
1776
|
engine.observedTabRenderer = element;
|
|
1499
1777
|
engine.cachedTabRendererHeight = element.getBoundingClientRect().height;
|
|
1778
|
+
refreshScrollMetrics(engine, element);
|
|
1779
|
+
}
|
|
1780
|
+
// Scroll position and bounds are cached here (and refreshed where layout is measured on purpose) so
|
|
1781
|
+
// the tick never reads them off the DOM, which would force a synchronous layout flush mid-frame.
|
|
1782
|
+
function refreshScrollMetrics(engine, tabRenderer) {
|
|
1783
|
+
engine.cachedMaxScrollTop = Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight);
|
|
1784
|
+
engine.cachedScrollTop = tabRenderer.scrollTop;
|
|
1500
1785
|
}
|
|
1501
1786
|
/**
|
|
1502
1787
|
* Fills in everything a caller left out of a tick. The tick reads each of these arithmetically, so
|
|
@@ -1598,8 +1883,17 @@ export function tickView(engine, currentTime, options) {
|
|
|
1598
1883
|
setupTabRendererObserver(engine, tabRenderer);
|
|
1599
1884
|
}
|
|
1600
1885
|
const tabRendererHeight = engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height;
|
|
1601
|
-
|
|
1602
|
-
|
|
1886
|
+
// Read the DOM position only while the user scrolls (they own it then); otherwise reuse the cached
|
|
1887
|
+
// value the engine last wrote, so the tick never forces a mid-frame layout flush.
|
|
1888
|
+
let scrollTop;
|
|
1889
|
+
if (engine.scrollResumeTime >= now || engine.cachedScrollTop === null) {
|
|
1890
|
+
scrollTop = tabRenderer.scrollTop;
|
|
1891
|
+
engine.cachedScrollTop = scrollTop;
|
|
1892
|
+
}
|
|
1893
|
+
else {
|
|
1894
|
+
scrollTop = engine.cachedScrollTop;
|
|
1895
|
+
}
|
|
1896
|
+
const maxScrollTop = engine.cachedMaxScrollTop ?? Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight);
|
|
1603
1897
|
if (animationConfig.enabled.scroll) {
|
|
1604
1898
|
updateVisibleLyricWillChange(engine, lines, scrollTop, engine.pendingLineScroll?.toScrollTop ?? scrollTop, tabRendererHeight);
|
|
1605
1899
|
}
|
|
@@ -1835,7 +2129,7 @@ export function tickView(engine, currentTime, options) {
|
|
|
1835
2129
|
Date.now() > engine.nextScrollAllowedTime &&
|
|
1836
2130
|
Math.abs(scrollTop - scrollPos) > 2) {
|
|
1837
2131
|
updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
|
|
1838
|
-
prepareUpcomingLineScroll(engine, getLineScrollItems(
|
|
2132
|
+
prepareUpcomingLineScroll(engine, getLineScrollItems(engine, lines), lastActiveLyric, scrollPos - scrollTop, scrollTop, scrollPos, tabRendererHeight, animationConfig);
|
|
1839
2133
|
}
|
|
1840
2134
|
if (engine.wasUserScrolling || newLyricSelected || engine.queuedScroll) {
|
|
1841
2135
|
if (Date.now() > engine.nextScrollAllowedTime) {
|
|
@@ -1847,7 +2141,7 @@ export function tickView(engine, currentTime, options) {
|
|
|
1847
2141
|
const scrollDeltaPx = scrollPos - scrollTop;
|
|
1848
2142
|
if (animationConfig.enabled.scroll) {
|
|
1849
2143
|
updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
|
|
1850
|
-
const lineScrollItems = getLineScrollItems(
|
|
2144
|
+
const lineScrollItems = getLineScrollItems(engine, lines);
|
|
1851
2145
|
commitOrPrepareLineScroll(engine, lineScrollItems, lastActiveLyric, scrollDeltaPx, scrollTop, scrollPos, tabRendererHeight, animationConfig);
|
|
1852
2146
|
engine.nextScrollAllowedTime = animationConfig.scroll.durationMs + Date.now() + 20;
|
|
1853
2147
|
}
|
|
@@ -1858,6 +2152,7 @@ export function tickView(engine, currentTime, options) {
|
|
|
1858
2152
|
scrollTop = scrollPos;
|
|
1859
2153
|
engine.scrollPos = scrollTop;
|
|
1860
2154
|
tabRenderer.scrollTop = scrollTop;
|
|
2155
|
+
engine.cachedScrollTop = scrollTop;
|
|
1861
2156
|
engine.skipScrolls += 1;
|
|
1862
2157
|
engine.skipScrollsDecayTimes.push(Date.now() + 2000);
|
|
1863
2158
|
}
|
|
@@ -1963,12 +2258,20 @@ export function relayout(engine, measureLines) {
|
|
|
1963
2258
|
// resize report looking like a new height, and each one measures again and forces a rescroll.
|
|
1964
2259
|
engine.lyricWidth = lyricsElement.clientWidth;
|
|
1965
2260
|
engine.lyricHeight = lyricsElement.clientHeight;
|
|
2261
|
+
// Skipped lines report intrinsic size, so un-cull before the walk reads offsetTop/offsetHeight.
|
|
2262
|
+
clearOffscreenLineCulling(engine);
|
|
1966
2263
|
for (const line of engine.lines) {
|
|
1967
2264
|
const bounds = getRelativeLayoutBounds(lyricsElement, line.lyricElement);
|
|
1968
2265
|
line.position = bounds.y;
|
|
1969
2266
|
line.height = bounds.height;
|
|
1970
2267
|
line.decorations = new Map(lineDecorators(line.lyricElement).map(element => [element, getRelativeLayoutBounds(lyricsElement, element).y]));
|
|
1971
2268
|
}
|
|
2269
|
+
measureFooterItem(engine, lyricsElement);
|
|
2270
|
+
// Re-arm from the fresh measurements, so a line skipped after this holds its new placeholder height.
|
|
2271
|
+
setupLineCullObserver(engine);
|
|
2272
|
+
const tabRenderer = engine.host.getScrollElement();
|
|
2273
|
+
if (tabRenderer)
|
|
2274
|
+
refreshScrollMetrics(engine, tabRenderer);
|
|
1972
2275
|
engine.wasUserScrolling = true; // trigger rescrolls
|
|
1973
2276
|
engine.host.debug?.resize();
|
|
1974
2277
|
}
|
package/dist/inject.d.ts
CHANGED
package/dist/inject.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// animates is one word, so every part is split on whitespace with its timing pro-rated across the
|
|
12
12
|
// split by character count. A line that arrives with no timed parts at all is rebuilt the same way
|
|
13
13
|
// into zero duration words, so line synced lyrics reach the DOM the sweep already knows.
|
|
14
|
-
import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LETTER_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
|
|
14
|
+
import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LETTER_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, WORD_HIGHLIGHT_LETTERED_CLASS, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
|
|
15
15
|
import { getSeekTimeFromClick } from "./seek.js";
|
|
16
16
|
import { testRtl } from "./text.js";
|
|
17
17
|
import { registerThemeSetting } from "./themeSettings.js";
|
|
@@ -201,10 +201,12 @@ function appendLongWordBreaks(doc, span, text, threshold) {
|
|
|
201
201
|
function appendLetters(doc, wordElement, text) {
|
|
202
202
|
const chars = [...text];
|
|
203
203
|
wordElement.style.setProperty("--letters", String(chars.length));
|
|
204
|
-
|
|
204
|
+
// Width of the reveal mask's soft edge as a percentage of its (n+2)-letter-wide box, so the fade
|
|
205
|
+
// spans the same 0.1*letters of a letter that the old gradient did. See the mask rule in lyrics.css.
|
|
206
|
+
wordElement.style.setProperty("--mask-fade", `${(10 * chars.length) / (chars.length + 2)}%`);
|
|
207
|
+
return chars.map(char => {
|
|
205
208
|
const letter = doc.createElement("span");
|
|
206
209
|
letter.classList.add(LETTER_CLASS);
|
|
207
|
-
letter.style.setProperty("--letter-index", String(index));
|
|
208
210
|
letter.textContent = char;
|
|
209
211
|
wordElement.appendChild(letter);
|
|
210
212
|
return letter;
|
|
@@ -233,10 +235,13 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
|
233
235
|
wordElement.classList.add(EXPLICIT_WORD_CLASS);
|
|
234
236
|
if (perLetter) {
|
|
235
237
|
const collected = appendLetters(doc, wordElement, part.words);
|
|
236
|
-
if (wordElement === span)
|
|
238
|
+
if (wordElement === span) {
|
|
237
239
|
letters = collected;
|
|
238
|
-
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
239
242
|
highlightLetters = collected;
|
|
243
|
+
wordElement.classList.add(WORD_HIGHLIGHT_LETTERED_CLASS);
|
|
244
|
+
}
|
|
240
245
|
}
|
|
241
246
|
else {
|
|
242
247
|
appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
|
package/dist/styles/lyrics.css
CHANGED
|
@@ -137,7 +137,6 @@
|
|
|
137
137
|
calc(100% * var(--lyric-transition-amount-end) + 1px)
|
|
138
138
|
);
|
|
139
139
|
background-clip: text;
|
|
140
|
-
filter: drop-shadow(0 0 0 var(--blyrics-glow-color));
|
|
141
140
|
opacity: 0;
|
|
142
141
|
pointer-events: none;
|
|
143
142
|
--lyric-transition-amount-start: var(
|
|
@@ -161,19 +160,28 @@
|
|
|
161
160
|
display: inline-block;
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
.blyrics-word-highlight
|
|
163
|
+
.blyrics-word-highlight.blyrics-word-highlight--lettered {
|
|
165
164
|
background-image: none;
|
|
166
165
|
}
|
|
167
166
|
|
|
168
167
|
.blyrics-word-highlight .blyrics--letter {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
)
|
|
176
|
-
|
|
168
|
+
color: var(--blyrics-lyric-active-color);
|
|
169
|
+
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 50%, #0000 calc(50% + var(--mask-fade, 3%)));
|
|
170
|
+
mask-image: linear-gradient(90deg, #000 0, #000 50%, #0000 calc(50% + var(--mask-fade, 3%)));
|
|
171
|
+
-webkit-mask-repeat: no-repeat;
|
|
172
|
+
mask-repeat: no-repeat;
|
|
173
|
+
-webkit-mask-size: calc((var(--letters, 1) + 2) * 100%) 100%;
|
|
174
|
+
mask-size: calc((var(--letters, 1) + 2) * 100%) 100%;
|
|
175
|
+
-webkit-mask-position: 0% 0%;
|
|
176
|
+
mask-position: 0% 0%;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.blyrics-word-highlight.blyrics-rtl .blyrics--letter,
|
|
180
|
+
.blyrics-rtl .blyrics-word-highlight .blyrics--letter {
|
|
181
|
+
-webkit-mask-image: linear-gradient(270deg, #000 0, #000 50%, #0000 calc(50% + var(--mask-fade, 3%)));
|
|
182
|
+
mask-image: linear-gradient(270deg, #000 0, #000 50%, #0000 calc(50% + var(--mask-fade, 3%)));
|
|
183
|
+
-webkit-mask-position: 100% 0%;
|
|
184
|
+
mask-position: 100% 0%;
|
|
177
185
|
}
|
|
178
186
|
|
|
179
187
|
[blyrics-alt-hover] .blyrics--word:hover {
|
package/dist/view.d.ts
CHANGED
package/dist/view.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// lines is where those values are first knowable: the container, the records, the sync type, the
|
|
11
11
|
// size they were measured at, and the scrolls to swallow before the view has settled.
|
|
12
12
|
import { LINE_CLASS, LYRICS_CLASS, RTL_CLASS } from "./constants.js";
|
|
13
|
+
import { setupLineCullObserver } from "./engine.js";
|
|
13
14
|
import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, isNearestLyricRtl, newLineData, } from "./inject.js";
|
|
14
15
|
import { createInstrumentalElement } from "./instrumental.js";
|
|
15
16
|
const INITIAL_SKIP_SCROLLS = 2;
|
|
@@ -90,4 +91,5 @@ export function setLyrics(engine, mount, lyrics, options) {
|
|
|
90
91
|
// sizes it by.
|
|
91
92
|
engine.lyricWidth = container.clientWidth;
|
|
92
93
|
engine.lyricHeight = container.clientHeight;
|
|
94
|
+
setupLineCullObserver(engine);
|
|
93
95
|
}
|