@braccato/core 1.4.0 → 1.6.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/README.md +27 -0
- package/dist/README.md +27 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/engine.d.ts +60 -0
- package/dist/engine.js +394 -54
- package/dist/inject.d.ts +3 -0
- package/dist/inject.js +36 -7
- package/dist/styles/lyrics.css +30 -3
- package/dist/styles/variables.css +7 -0
- package/dist/view.d.ts +1 -1
- package/dist/view.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -186,6 +186,33 @@ frequency. Both must use the same commands in the same order with the same numbe
|
|
|
186
186
|
browser only interpolates two paths smoothly when their command sequences match, and a mismatched
|
|
187
187
|
pair snaps at the halfway point instead of flowing.
|
|
188
188
|
|
|
189
|
+
### Letter wave (experimental)
|
|
190
|
+
|
|
191
|
+
On by default; a theme opts out with `/* blyrics-letter-wave = false; */`. It splits every word into
|
|
192
|
+
per-letter spans and, as the word is sung, floats each letter up and eases it most of the way back on
|
|
193
|
+
a small stagger, so a wave travels through the word. It layers on top of the word wobble rather than
|
|
194
|
+
replacing it: the word keeps whatever `--blyrics-word-wobble-*` does and the letters ride on top, so
|
|
195
|
+
it composes with the default `scaleX` pop and reduces to just the letters when a theme sets its wobble
|
|
196
|
+
to identity. It follows `--blyrics-animate-word-wobble`, so reduced motion turns it off with the rest.
|
|
197
|
+
|
|
198
|
+
A word held past `blyrics-long-word-threshold` (the same `data-long-word` the glow keys off) also
|
|
199
|
+
swells each letter with a transient scale at the crest.
|
|
200
|
+
|
|
201
|
+
```css
|
|
202
|
+
.blyrics-container {
|
|
203
|
+
--blyrics-letter-wave-transform: translateY(-0.05em); /* crest lift */
|
|
204
|
+
--blyrics-letter-wave-settle: translateY(-0.02em); /* rest the crest eases back to */
|
|
205
|
+
--blyrics-letter-wave-emphasis-scale: 1.11; /* long-word letter swell, 1 turns it off */
|
|
206
|
+
--blyrics-letter-wave-duration: 0.9s;
|
|
207
|
+
--blyrics-letter-wave-rise-easing: ease-in-out;
|
|
208
|
+
--blyrics-letter-wave-fall-easing: ease-out;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The split multiplies the DOM per character and reruns the karaoke sweep per letter, so a theme that
|
|
213
|
+
does not want the cost turns it off with `/* blyrics-letter-wave = false; */`. `blyrics-letter-wave`
|
|
214
|
+
reloads the lines when it changes, the way every build-time setting does.
|
|
215
|
+
|
|
189
216
|
### Class names
|
|
190
217
|
|
|
191
218
|
These are published API rather than implementation. Renaming one costs a migration rather than a
|
package/dist/README.md
CHANGED
|
@@ -186,6 +186,33 @@ frequency. Both must use the same commands in the same order with the same numbe
|
|
|
186
186
|
browser only interpolates two paths smoothly when their command sequences match, and a mismatched
|
|
187
187
|
pair snaps at the halfway point instead of flowing.
|
|
188
188
|
|
|
189
|
+
### Letter wave (experimental)
|
|
190
|
+
|
|
191
|
+
On by default; a theme opts out with `/* blyrics-letter-wave = false; */`. It splits every word into
|
|
192
|
+
per-letter spans and, as the word is sung, floats each letter up and eases it most of the way back on
|
|
193
|
+
a small stagger, so a wave travels through the word. It layers on top of the word wobble rather than
|
|
194
|
+
replacing it: the word keeps whatever `--blyrics-word-wobble-*` does and the letters ride on top, so
|
|
195
|
+
it composes with the default `scaleX` pop and reduces to just the letters when a theme sets its wobble
|
|
196
|
+
to identity. It follows `--blyrics-animate-word-wobble`, so reduced motion turns it off with the rest.
|
|
197
|
+
|
|
198
|
+
A word held past `blyrics-long-word-threshold` (the same `data-long-word` the glow keys off) also
|
|
199
|
+
swells each letter with a transient scale at the crest.
|
|
200
|
+
|
|
201
|
+
```css
|
|
202
|
+
.blyrics-container {
|
|
203
|
+
--blyrics-letter-wave-transform: translateY(-0.05em); /* crest lift */
|
|
204
|
+
--blyrics-letter-wave-settle: translateY(-0.02em); /* rest the crest eases back to */
|
|
205
|
+
--blyrics-letter-wave-emphasis-scale: 1.11; /* long-word letter swell, 1 turns it off */
|
|
206
|
+
--blyrics-letter-wave-duration: 0.9s;
|
|
207
|
+
--blyrics-letter-wave-rise-easing: ease-in-out;
|
|
208
|
+
--blyrics-letter-wave-fall-easing: ease-out;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The split multiplies the DOM per character and reruns the karaoke sweep per letter, so a theme that
|
|
213
|
+
does not want the cost turns it off with `/* blyrics-letter-wave = false; */`. `blyrics-letter-wave`
|
|
214
|
+
reloads the lines when it changes, the way every build-time setting does.
|
|
215
|
+
|
|
189
216
|
### Class names
|
|
190
217
|
|
|
191
218
|
These are published API rather than implementation. Renaming one costs a migration rather than a
|
package/dist/constants.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const LYRICS_WRAPPER_ID: "blyrics-wrapper";
|
|
|
2
2
|
export declare const LYRICS_CLASS: "blyrics-container";
|
|
3
3
|
export declare const LINE_CLASS: "blyrics--line";
|
|
4
4
|
export declare const WORD_CLASS: "blyrics--word";
|
|
5
|
+
export declare const LETTER_CLASS: "blyrics--letter";
|
|
5
6
|
export declare const FOOTER_CLASS: "blyrics-footer";
|
|
6
7
|
export declare const CURRENT_LYRICS_CLASS: "blyrics--active";
|
|
7
8
|
export declare const ANIMATING_CLASS: "blyrics--animating";
|
package/dist/constants.js
CHANGED
|
@@ -5,6 +5,7 @@ export const LYRICS_WRAPPER_ID = "blyrics-wrapper";
|
|
|
5
5
|
export const LYRICS_CLASS = "blyrics-container";
|
|
6
6
|
export const LINE_CLASS = "blyrics--line";
|
|
7
7
|
export const WORD_CLASS = "blyrics--word";
|
|
8
|
+
export const LETTER_CLASS = "blyrics--letter";
|
|
8
9
|
export const FOOTER_CLASS = "blyrics-footer";
|
|
9
10
|
// -- Playback state --------------------------------------------
|
|
10
11
|
export const CURRENT_LYRICS_CLASS = "blyrics--active";
|
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;
|
|
@@ -175,6 +189,14 @@ interface AnimationConfig {
|
|
|
175
189
|
wobblePeakOffset: number;
|
|
176
190
|
wobbleSettleOffset: number;
|
|
177
191
|
};
|
|
192
|
+
letterWave: {
|
|
193
|
+
transform: string;
|
|
194
|
+
settle: string;
|
|
195
|
+
emphasisScale: string;
|
|
196
|
+
durationMs: number;
|
|
197
|
+
riseEasing: string;
|
|
198
|
+
fallEasing: string;
|
|
199
|
+
};
|
|
178
200
|
instrumental: {
|
|
179
201
|
fillFadeDurationMs: number;
|
|
180
202
|
fillFadeEasing: string;
|
|
@@ -207,7 +229,39 @@ interface AnimationSettings {
|
|
|
207
229
|
};
|
|
208
230
|
}
|
|
209
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[];
|
|
210
263
|
export declare function clearStyleCaches(engine: AnimationEngineInstance): void;
|
|
264
|
+
export declare function parseColorAlpha(value: string): number | null;
|
|
211
265
|
interface PreparedLineScroll {
|
|
212
266
|
lineElement: HTMLElement;
|
|
213
267
|
side: LineScrollSide;
|
|
@@ -220,6 +274,11 @@ interface ResolvedLineScroll extends PreparedLineScroll {
|
|
|
220
274
|
startTranslate: string;
|
|
221
275
|
endTranslate: string;
|
|
222
276
|
}
|
|
277
|
+
interface LineScrollTiming {
|
|
278
|
+
durationMs: number;
|
|
279
|
+
startEasing: string;
|
|
280
|
+
endEasing: string;
|
|
281
|
+
}
|
|
223
282
|
interface LineScrollPlan {
|
|
224
283
|
items: ResolvedLineScroll[];
|
|
225
284
|
}
|
|
@@ -229,6 +288,7 @@ interface PendingLineScroll {
|
|
|
229
288
|
fromScrollTop: number;
|
|
230
289
|
toScrollTop: number;
|
|
231
290
|
}
|
|
291
|
+
export declare function setupLineCullObserver(engine: AnimationEngineInstance): void;
|
|
232
292
|
/**
|
|
233
293
|
* Unsynced lyrics that this view still has on screen. `syncType` outlives the lyrics it was derived
|
|
234
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);
|
|
@@ -755,9 +894,6 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
755
894
|
offset: config.word.wobblePeakOffset,
|
|
756
895
|
easing: config.word.wobblePeakEasing,
|
|
757
896
|
},
|
|
758
|
-
// The two offsets are read and clamped independently, so a theme is free to settle
|
|
759
|
-
// before it peaks. animate() rejects offsets that go backwards, and that throw would
|
|
760
|
-
// orphan the highlight animations above, which are not tracked until the end.
|
|
761
897
|
{
|
|
762
898
|
transform: config.word.wobbleSettle,
|
|
763
899
|
offset: Math.max(config.word.wobblePeakOffset, config.word.wobbleSettleOffset),
|
|
@@ -770,8 +906,6 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
770
906
|
fill: "forwards",
|
|
771
907
|
};
|
|
772
908
|
const wobbleStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, config.word.wobbleDurationMs);
|
|
773
|
-
// The wobble is a paint transform, so the highlight copy must carry it too or the active
|
|
774
|
-
// sweep drifts off the word.
|
|
775
909
|
for (const wordElement of [part.lyricElement, part.highlightElement]) {
|
|
776
910
|
const animation = trackLyricAnimationTiming(engine, wordElement.animate(wobbleKeyframes, wobbleOptions), {
|
|
777
911
|
appliedTimingOffsetMs,
|
|
@@ -780,6 +914,37 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
780
914
|
animation.currentTime = wobbleStartMs;
|
|
781
915
|
wobbleAnimations.push(animation);
|
|
782
916
|
}
|
|
917
|
+
const letters = part.letterElements;
|
|
918
|
+
if (letters && letters.length > 0) {
|
|
919
|
+
const emphasise = part.lyricElement?.dataset.longWord === "true";
|
|
920
|
+
const emphasisPeak = emphasise ? ` scale(${config.letterWave.emphasisScale})` : "";
|
|
921
|
+
const emphasisRest = emphasise ? " scale(1)" : "";
|
|
922
|
+
const floatKeyframes = [
|
|
923
|
+
{ transform: `translateY(0)${emphasisRest}`, easing: config.letterWave.riseEasing },
|
|
924
|
+
{
|
|
925
|
+
transform: `${config.letterWave.transform}${emphasisPeak}`,
|
|
926
|
+
offset: 0.4,
|
|
927
|
+
easing: config.letterWave.fallEasing,
|
|
928
|
+
},
|
|
929
|
+
{ transform: `${config.letterWave.settle}${emphasisRest}` },
|
|
930
|
+
];
|
|
931
|
+
const letterCount = letters.length;
|
|
932
|
+
const staggerMs = timedDurationMs > 0 ? timedDurationMs / 2.5 / letterCount : 0;
|
|
933
|
+
const cascadeDurationMs = config.letterWave.durationMs + (letterCount - 1) * staggerMs;
|
|
934
|
+
const floatStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, cascadeDurationMs);
|
|
935
|
+
const floatKeyframeSignature = JSON.stringify(floatKeyframes);
|
|
936
|
+
for (const set of [part.letterElements, part.highlightLetterElements]) {
|
|
937
|
+
set?.forEach((letterElement, index) => {
|
|
938
|
+
const animation = trackLyricAnimationTiming(engine, acquireWaveAnimation(engine, letterElement, floatKeyframes, floatKeyframeSignature, {
|
|
939
|
+
duration: config.letterWave.durationMs,
|
|
940
|
+
delay: index * staggerMs,
|
|
941
|
+
fill: "forwards",
|
|
942
|
+
}), { appliedTimingOffsetMs, offsetMs: 0 });
|
|
943
|
+
animation.currentTime = floatStartMs;
|
|
944
|
+
wobbleAnimations.push(animation);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
783
948
|
}
|
|
784
949
|
part.animations = [...highlightAnimations.animations, ...wobbleAnimations];
|
|
785
950
|
}
|
|
@@ -790,6 +955,7 @@ function startLineAnimations(engine, lineData, config, currentTime) {
|
|
|
790
955
|
startInstrumentalAnimations(engine, lineData, config, currentTime, appliedTimingOffsetMs);
|
|
791
956
|
return;
|
|
792
957
|
}
|
|
958
|
+
resolveLineGlowSuppression(engine, lineData, config);
|
|
793
959
|
for (const part of lineData.parts) {
|
|
794
960
|
startWordAnimations(engine, part, config, currentTime, appliedTimingOffsetMs);
|
|
795
961
|
}
|
|
@@ -896,6 +1062,7 @@ export function clearStyleCaches(engine) {
|
|
|
896
1062
|
engine.cachedDurations.clear();
|
|
897
1063
|
engine.cachedCSSValues.clear();
|
|
898
1064
|
engine.cachedAnimationSettings = null;
|
|
1065
|
+
engine.cachedLineScrollTiming.clear();
|
|
899
1066
|
}
|
|
900
1067
|
function getCSSValue(engine, lyricsElement, property, fallback) {
|
|
901
1068
|
let value = engine.cachedCSSValues.get(property);
|
|
@@ -949,6 +1116,52 @@ function getCSSOffset(engine, lyricsElement, property, fallback) {
|
|
|
949
1116
|
// the color left as a literal var lets the Web Animations API resolve it against each animated
|
|
950
1117
|
// word instead. A theme that sets the full filter var still wins, but its color resolves once
|
|
951
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
|
+
}
|
|
952
1165
|
function resolveGlowFilter(engine, lyricsElement, suffix, radiusDefault) {
|
|
953
1166
|
const override = getCSSValue(engine, lyricsElement, `--blyrics-highlight-glow-filter-${suffix}`, "");
|
|
954
1167
|
if (override)
|
|
@@ -956,6 +1169,15 @@ function resolveGlowFilter(engine, lyricsElement, suffix, radiusDefault) {
|
|
|
956
1169
|
const radius = getCSSValue(engine, lyricsElement, `--blyrics-highlight-glow-radius-${suffix}`, radiusDefault);
|
|
957
1170
|
return `drop-shadow(0 0 ${radius} var(--blyrics-glow-color))`;
|
|
958
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
|
+
}
|
|
959
1181
|
function readAnimationConfig(engine, lyricsElement) {
|
|
960
1182
|
const prefersReducedMotion = engine.window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
|
961
1183
|
const scrollDurationMs = getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-lyric-scroll-duration", "650ms");
|
|
@@ -994,6 +1216,8 @@ function readAnimationConfig(engine, lyricsElement) {
|
|
|
994
1216
|
glowDurationRatio: getCSSNumber(engine, lyricsElement, "--blyrics-highlight-glow-duration-ratio", 1.2),
|
|
995
1217
|
glowMinDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-highlight-glow-min-duration", "1.2s"),
|
|
996
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")),
|
|
997
1221
|
},
|
|
998
1222
|
word: {
|
|
999
1223
|
wobbleDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-wobble-duration", "1s"),
|
|
@@ -1007,6 +1231,14 @@ function readAnimationConfig(engine, lyricsElement) {
|
|
|
1007
1231
|
wobblePeakOffset: getCSSOffset(engine, lyricsElement, "--blyrics-word-wobble-peak-offset", 0.125),
|
|
1008
1232
|
wobbleSettleOffset: getCSSOffset(engine, lyricsElement, "--blyrics-word-wobble-settle-offset", 0.75),
|
|
1009
1233
|
},
|
|
1234
|
+
letterWave: {
|
|
1235
|
+
transform: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-transform", "translateY(-0.06em)"),
|
|
1236
|
+
settle: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-settle", "translateY(-0.05em)"),
|
|
1237
|
+
emphasisScale: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-emphasis-scale", "1.08"),
|
|
1238
|
+
durationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-letter-wave-duration", "0.9s"),
|
|
1239
|
+
riseEasing: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-rise-easing", "ease-in-out"),
|
|
1240
|
+
fallEasing: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-fall-easing", "ease-in-out"),
|
|
1241
|
+
},
|
|
1010
1242
|
instrumental: {
|
|
1011
1243
|
fillFadeDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-instrumental-fill-fade-duration", "150ms"),
|
|
1012
1244
|
fillFadeEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-fill-fade-easing", "ease"),
|
|
@@ -1187,6 +1419,55 @@ function clearVisibleLyricWillChange(engine) {
|
|
|
1187
1419
|
}
|
|
1188
1420
|
engine.visibleWillChangeElements = new Set();
|
|
1189
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
|
+
}
|
|
1190
1471
|
function updateVisibleLyricWillChange(engine, lines, fromScrollTop, toScrollTop, viewportHeight) {
|
|
1191
1472
|
const nextVisibleElements = new Set();
|
|
1192
1473
|
for (const line of lines) {
|
|
@@ -1202,28 +1483,30 @@ function updateVisibleLyricWillChange(engine, lines, fromScrollTop, toScrollTop,
|
|
|
1202
1483
|
}
|
|
1203
1484
|
engine.visibleWillChangeElements = nextVisibleElements;
|
|
1204
1485
|
}
|
|
1205
|
-
|
|
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) {
|
|
1206
1492
|
const footer = lyricsElement.querySelector(`:scope > .${FOOTER_CLASS}`);
|
|
1207
|
-
if (!footer)
|
|
1208
|
-
|
|
1493
|
+
if (!footer) {
|
|
1494
|
+
engine.cachedFooterItem = null;
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1209
1497
|
const footerBounds = getRelativeLayoutBounds(lyricsElement, footer);
|
|
1210
|
-
|
|
1211
|
-
...lines,
|
|
1212
|
-
{
|
|
1213
|
-
lyricElement: footer,
|
|
1214
|
-
position: footerBounds.y,
|
|
1215
|
-
height: footerBounds.height,
|
|
1216
|
-
},
|
|
1217
|
-
];
|
|
1498
|
+
engine.cachedFooterItem = { lyricElement: footer, position: footerBounds.y, height: footerBounds.height };
|
|
1218
1499
|
}
|
|
1219
1500
|
function prepareLineScrollOffsets(engine, lines, activeLineIndex, scrollDeltaPx, fromScrollTop, toScrollTop, viewportHeight, config) {
|
|
1220
1501
|
if (!config.enabled.scroll || activeLineIndex < 0) {
|
|
1221
1502
|
return null;
|
|
1222
1503
|
}
|
|
1223
1504
|
const scrollDistancePx = Math.abs(scrollDeltaPx);
|
|
1224
|
-
const
|
|
1505
|
+
const translateThemed = LINE_SCROLL_TRANSLATE_SETTINGS.some(setting => setting.isManuallySet());
|
|
1506
|
+
const defaultStartTranslate = `0px ${scrollDeltaPx}px`;
|
|
1225
1507
|
// Preserve the original windowing exactly: only lines intersecting the
|
|
1226
1508
|
// union of the old and new viewports receive scroll animations.
|
|
1509
|
+
const requests = [];
|
|
1227
1510
|
for (let index = 0; index < lines.length; index++) {
|
|
1228
1511
|
if (!isLineVisibleDuringScroll(lines[index], fromScrollTop, toScrollTop, viewportHeight)) {
|
|
1229
1512
|
continue;
|
|
@@ -1231,35 +1514,64 @@ function prepareLineScrollOffsets(engine, lines, activeLineIndex, scrollDeltaPx,
|
|
|
1231
1514
|
const lineElement = lines[index].lyricElement;
|
|
1232
1515
|
const relativeIndex = index - activeLineIndex;
|
|
1233
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;
|
|
1234
1528
|
lineElement.style.setProperty(LINE_SCROLL_INDEX_PROPERTY, String(index));
|
|
1235
1529
|
lineElement.style.setProperty(LINE_SCROLL_ACTIVE_INDEX_PROPERTY, String(activeLineIndex));
|
|
1236
1530
|
lineElement.style.setProperty(LINE_SCROLL_RELATIVE_INDEX_PROPERTY, String(relativeIndex));
|
|
1237
1531
|
lineElement.style.setProperty(LINE_SCROLL_ABS_RELATIVE_INDEX_PROPERTY, String(Math.abs(relativeIndex)));
|
|
1238
|
-
lineElement.style.setProperty(LINE_SCROLL_SIDE_PROPERTY, side);
|
|
1532
|
+
lineElement.style.setProperty(LINE_SCROLL_SIDE_PROPERTY, item.side);
|
|
1239
1533
|
lineElement.style.setProperty(LINE_SCROLL_DELTA_PROPERTY, `${scrollDeltaPx}px`);
|
|
1240
1534
|
lineElement.style.setProperty(LINE_SCROLL_DISTANCE_PROPERTY, `${scrollDistancePx}px`);
|
|
1241
1535
|
setLineScrollStyleProperties(lineElement);
|
|
1242
|
-
const token = ++engine.lineScrollAnimationToken;
|
|
1243
|
-
engine.lineScrollElementTokens.set(lineElement, token);
|
|
1244
|
-
prepared.push({ lineElement, side, token });
|
|
1245
1536
|
}
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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
|
+
}
|
|
1254
1563
|
return {
|
|
1255
|
-
items:
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
+
}),
|
|
1263
1575
|
};
|
|
1264
1576
|
}
|
|
1265
1577
|
function startPreparedLineScroll(engine, plan) {
|
|
@@ -1436,10 +1748,12 @@ function passiveScrollEngine(engine, isPlaying) {
|
|
|
1436
1748
|
}
|
|
1437
1749
|
const prevScrollTop = tabRenderer.scrollTop;
|
|
1438
1750
|
tabRenderer.scrollTop = targetScroll;
|
|
1751
|
+
const appliedScrollTop = tabRenderer.scrollTop;
|
|
1752
|
+
engine.cachedScrollTop = appliedScrollTop;
|
|
1439
1753
|
// Only skip the next scroll event if scrollTop actually changed.
|
|
1440
1754
|
// When it doesn't change (pause phases, sub-pixel rounding), no programmatic
|
|
1441
1755
|
// scroll event fires, so setting skipScrolls would eat user scroll events instead.
|
|
1442
|
-
if (
|
|
1756
|
+
if (appliedScrollTop !== prevScrollTop) {
|
|
1443
1757
|
engine.skipScrolls = 1;
|
|
1444
1758
|
}
|
|
1445
1759
|
}
|
|
@@ -1455,11 +1769,19 @@ function setupTabRendererObserver(engine, element) {
|
|
|
1455
1769
|
dropPendingLineScroll(engine);
|
|
1456
1770
|
if (element && element.isConnected) {
|
|
1457
1771
|
engine.cachedTabRendererHeight = element.getBoundingClientRect().height;
|
|
1772
|
+
refreshScrollMetrics(engine, element);
|
|
1458
1773
|
}
|
|
1459
1774
|
});
|
|
1460
1775
|
engine.tabRendererResizeObserver.observe(element);
|
|
1461
1776
|
engine.observedTabRenderer = element;
|
|
1462
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;
|
|
1463
1785
|
}
|
|
1464
1786
|
/**
|
|
1465
1787
|
* Fills in everything a caller left out of a tick. The tick reads each of these arithmetically, so
|
|
@@ -1561,8 +1883,17 @@ export function tickView(engine, currentTime, options) {
|
|
|
1561
1883
|
setupTabRendererObserver(engine, tabRenderer);
|
|
1562
1884
|
}
|
|
1563
1885
|
const tabRendererHeight = engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height;
|
|
1564
|
-
|
|
1565
|
-
|
|
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);
|
|
1566
1897
|
if (animationConfig.enabled.scroll) {
|
|
1567
1898
|
updateVisibleLyricWillChange(engine, lines, scrollTop, engine.pendingLineScroll?.toScrollTop ?? scrollTop, tabRendererHeight);
|
|
1568
1899
|
}
|
|
@@ -1798,7 +2129,7 @@ export function tickView(engine, currentTime, options) {
|
|
|
1798
2129
|
Date.now() > engine.nextScrollAllowedTime &&
|
|
1799
2130
|
Math.abs(scrollTop - scrollPos) > 2) {
|
|
1800
2131
|
updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
|
|
1801
|
-
prepareUpcomingLineScroll(engine, getLineScrollItems(
|
|
2132
|
+
prepareUpcomingLineScroll(engine, getLineScrollItems(engine, lines), lastActiveLyric, scrollPos - scrollTop, scrollTop, scrollPos, tabRendererHeight, animationConfig);
|
|
1802
2133
|
}
|
|
1803
2134
|
if (engine.wasUserScrolling || newLyricSelected || engine.queuedScroll) {
|
|
1804
2135
|
if (Date.now() > engine.nextScrollAllowedTime) {
|
|
@@ -1810,7 +2141,7 @@ export function tickView(engine, currentTime, options) {
|
|
|
1810
2141
|
const scrollDeltaPx = scrollPos - scrollTop;
|
|
1811
2142
|
if (animationConfig.enabled.scroll) {
|
|
1812
2143
|
updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
|
|
1813
|
-
const lineScrollItems = getLineScrollItems(
|
|
2144
|
+
const lineScrollItems = getLineScrollItems(engine, lines);
|
|
1814
2145
|
commitOrPrepareLineScroll(engine, lineScrollItems, lastActiveLyric, scrollDeltaPx, scrollTop, scrollPos, tabRendererHeight, animationConfig);
|
|
1815
2146
|
engine.nextScrollAllowedTime = animationConfig.scroll.durationMs + Date.now() + 20;
|
|
1816
2147
|
}
|
|
@@ -1821,6 +2152,7 @@ export function tickView(engine, currentTime, options) {
|
|
|
1821
2152
|
scrollTop = scrollPos;
|
|
1822
2153
|
engine.scrollPos = scrollTop;
|
|
1823
2154
|
tabRenderer.scrollTop = scrollTop;
|
|
2155
|
+
engine.cachedScrollTop = scrollTop;
|
|
1824
2156
|
engine.skipScrolls += 1;
|
|
1825
2157
|
engine.skipScrollsDecayTimes.push(Date.now() + 2000);
|
|
1826
2158
|
}
|
|
@@ -1926,12 +2258,20 @@ export function relayout(engine, measureLines) {
|
|
|
1926
2258
|
// resize report looking like a new height, and each one measures again and forces a rescroll.
|
|
1927
2259
|
engine.lyricWidth = lyricsElement.clientWidth;
|
|
1928
2260
|
engine.lyricHeight = lyricsElement.clientHeight;
|
|
2261
|
+
// Skipped lines report intrinsic size, so un-cull before the walk reads offsetTop/offsetHeight.
|
|
2262
|
+
clearOffscreenLineCulling(engine);
|
|
1929
2263
|
for (const line of engine.lines) {
|
|
1930
2264
|
const bounds = getRelativeLayoutBounds(lyricsElement, line.lyricElement);
|
|
1931
2265
|
line.position = bounds.y;
|
|
1932
2266
|
line.height = bounds.height;
|
|
1933
2267
|
line.decorations = new Map(lineDecorators(line.lyricElement).map(element => [element, getRelativeLayoutBounds(lyricsElement, element).y]));
|
|
1934
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);
|
|
1935
2275
|
engine.wasUserScrolling = true; // trigger rescrolls
|
|
1936
2276
|
engine.host.debug?.resize();
|
|
1937
2277
|
}
|
package/dist/inject.d.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface AnimationData {
|
|
|
17
17
|
}
|
|
18
18
|
export interface PartData extends AnimationData {
|
|
19
19
|
highlightElement: HTMLElement;
|
|
20
|
+
letterElements?: HTMLElement[];
|
|
21
|
+
highlightLetterElements?: HTMLElement[];
|
|
22
|
+
glowSuppressed?: boolean;
|
|
20
23
|
}
|
|
21
24
|
export type LineData = {
|
|
22
25
|
parts: PartData[];
|
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, 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, 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";
|
|
@@ -19,6 +19,7 @@ export let disableRichsync = registerThemeSetting("blyrics-disable-richsync", fa
|
|
|
19
19
|
let lineSyncedAnimationDelay = registerThemeSetting("blyrics-line-synced-animation-delay", 50, true);
|
|
20
20
|
let longWordThreshold = registerThemeSetting("blyrics-long-word-threshold", 1500, true);
|
|
21
21
|
let longWordWrapThreshold = registerThemeSetting("blyrics-long-word-wrap-threshold", 10, true);
|
|
22
|
+
let letterWave = registerThemeSetting("blyrics-letter-wave", true, true);
|
|
22
23
|
const RTL_SCRIPT_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u;
|
|
23
24
|
const LTR_SCRIPT_REGEX = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u;
|
|
24
25
|
const SPACE_REGEX = /^\s+$/u;
|
|
@@ -60,12 +61,14 @@ export function deriveSyncType(lyrics) {
|
|
|
60
61
|
return "richsync";
|
|
61
62
|
return lyrics.every(item => item.startTimeMs === 0) ? "none" : "synced";
|
|
62
63
|
}
|
|
63
|
-
function newPartData(part, span, highlight) {
|
|
64
|
+
function newPartData(part, span, highlight, letterElements, highlightLetterElements) {
|
|
64
65
|
return {
|
|
65
66
|
time: part.startTimeMs / 1000,
|
|
66
67
|
duration: part.durationMs / 1000,
|
|
67
68
|
lyricElement: span,
|
|
68
69
|
highlightElement: highlight,
|
|
70
|
+
letterElements,
|
|
71
|
+
highlightLetterElements,
|
|
69
72
|
animations: [],
|
|
70
73
|
};
|
|
71
74
|
}
|
|
@@ -195,10 +198,26 @@ function appendLongWordBreaks(doc, span, text, threshold) {
|
|
|
195
198
|
}
|
|
196
199
|
return true;
|
|
197
200
|
}
|
|
198
|
-
function
|
|
201
|
+
function appendLetters(doc, wordElement, text) {
|
|
202
|
+
const chars = [...text];
|
|
203
|
+
wordElement.style.setProperty("--letters", String(chars.length));
|
|
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 => {
|
|
208
|
+
const letter = doc.createElement("span");
|
|
209
|
+
letter.classList.add(LETTER_CLASS);
|
|
210
|
+
letter.textContent = char;
|
|
211
|
+
wordElement.appendChild(letter);
|
|
212
|
+
return letter;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
199
216
|
const span = doc.createElement("span");
|
|
200
217
|
const highlight = doc.createElement("span");
|
|
201
218
|
highlight.classList.add(WORD_HIGHLIGHT_CLASS);
|
|
219
|
+
let letters;
|
|
220
|
+
let highlightLetters;
|
|
202
221
|
for (const wordElement of [span, highlight]) {
|
|
203
222
|
wordElement.classList.add(WORD_CLASS);
|
|
204
223
|
wordElement.dir = "auto";
|
|
@@ -214,16 +233,26 @@ function createTimedWordSpan(doc, part, wrapThreshold) {
|
|
|
214
233
|
wordElement.classList.add(BACKGROUND_LYRIC_CLASS);
|
|
215
234
|
if (part.explicit)
|
|
216
235
|
wordElement.classList.add(EXPLICIT_WORD_CLASS);
|
|
217
|
-
|
|
236
|
+
if (perLetter) {
|
|
237
|
+
const collected = appendLetters(doc, wordElement, part.words);
|
|
238
|
+
if (wordElement === span)
|
|
239
|
+
letters = collected;
|
|
240
|
+
else
|
|
241
|
+
highlightLetters = collected;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
|
|
245
|
+
}
|
|
218
246
|
wordElement.dataset.time = String(part.startTimeMs / 1000);
|
|
219
247
|
wordElement.dataset.duration = String(part.durationMs / 1000);
|
|
220
248
|
wordElement.dataset.content = part.words;
|
|
221
249
|
wordElement.style.setProperty("--blyrics-duration", part.durationMs + "ms");
|
|
222
250
|
}
|
|
223
|
-
return { span, highlight };
|
|
251
|
+
return { span, highlight, letters, highlightLetters };
|
|
224
252
|
}
|
|
225
253
|
function createWordGroup(doc, group, lineData) {
|
|
226
254
|
const wrapThreshold = Math.max(1, longWordWrapThreshold.getNumberValue());
|
|
255
|
+
const perLetter = letterWave.getBooleanValue();
|
|
227
256
|
const lyricGroup = doc.createElement("span");
|
|
228
257
|
const highlightGroup = doc.createElement("span");
|
|
229
258
|
for (const groupElement of [lyricGroup, highlightGroup]) {
|
|
@@ -240,8 +269,8 @@ function createWordGroup(doc, group, lineData) {
|
|
|
240
269
|
for (const token of group.tokens) {
|
|
241
270
|
if (token.kind === "space")
|
|
242
271
|
continue;
|
|
243
|
-
const { span, highlight } = createTimedWordSpan(doc, token.part, wrapThreshold);
|
|
244
|
-
lineData.parts.push(newPartData(token.part, span, highlight));
|
|
272
|
+
const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter);
|
|
273
|
+
lineData.parts.push(newPartData(token.part, span, highlight, letters, highlightLetters));
|
|
245
274
|
lyricGroup.appendChild(span);
|
|
246
275
|
highlightGroup.appendChild(highlight);
|
|
247
276
|
}
|
package/dist/styles/lyrics.css
CHANGED
|
@@ -117,13 +117,13 @@
|
|
|
117
117
|
|
|
118
118
|
@property --lyric-transition-amount-start {
|
|
119
119
|
syntax: "<number>";
|
|
120
|
-
inherits:
|
|
120
|
+
inherits: true;
|
|
121
121
|
initial-value: -0.2;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
@property --lyric-transition-amount-end {
|
|
125
125
|
syntax: "<number>";
|
|
126
|
-
inherits:
|
|
126
|
+
inherits: true;
|
|
127
127
|
initial-value: -0.1;
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -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(
|
|
@@ -157,6 +156,34 @@
|
|
|
157
156
|
);
|
|
158
157
|
}
|
|
159
158
|
|
|
159
|
+
.blyrics--letter {
|
|
160
|
+
display: inline-block;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.blyrics-word-highlight:has(.blyrics--letter) {
|
|
164
|
+
background-image: none;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.blyrics-word-highlight .blyrics--letter {
|
|
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%;
|
|
185
|
+
}
|
|
186
|
+
|
|
160
187
|
[blyrics-alt-hover] .blyrics--word:hover {
|
|
161
188
|
text-decoration: underline;
|
|
162
189
|
text-underline-offset: 0.15em;
|
|
@@ -110,6 +110,13 @@
|
|
|
110
110
|
--blyrics-word-wobble-peak-easing: ease-in-out;
|
|
111
111
|
--blyrics-word-wobble-end-easing: ease-out;
|
|
112
112
|
|
|
113
|
+
--blyrics-letter-wave-transform: translateY(-0.05em);
|
|
114
|
+
--blyrics-letter-wave-settle: translateY(-0.02em);
|
|
115
|
+
--blyrics-letter-wave-emphasis-scale: 1.11;
|
|
116
|
+
--blyrics-letter-wave-duration: 0.9s;
|
|
117
|
+
--blyrics-letter-wave-rise-easing: ease-in-out;
|
|
118
|
+
--blyrics-letter-wave-fall-easing: ease-out;
|
|
119
|
+
|
|
113
120
|
--blyrics-instrumental-fill-fade-duration: 150ms;
|
|
114
121
|
--blyrics-instrumental-fill-fade-easing: ease;
|
|
115
122
|
--blyrics-instrumental-fill-transform-from: translateY(78%);
|
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
|
}
|