@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 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
@@ -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
- swipeAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGradientKeyframes(config), {
654
- duration: swipeDurationMs,
655
- easing: config.highlight.swipeEasing,
656
- fill: "forwards",
657
- }), { appliedTimingOffsetMs, offsetMs: swipeTimeMs - wordTimeMs });
658
- swipeAnimation.currentTime = correctedAnimationTimeMs(swipeTimeMs, appliedTimingOffsetMs, swipeDurationMs);
659
- animations.push(swipeAnimation);
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
- function getLineScrollItems(lines, lyricsElement) {
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
- return lines;
1493
+ if (!footer) {
1494
+ engine.cachedFooterItem = null;
1495
+ return;
1496
+ }
1209
1497
  const footerBounds = getRelativeLayoutBounds(lyricsElement, footer);
1210
- return [
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 prepared = [];
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 durations = batchResolveLineScrollProperty(engine, prepared, "transition-duration", item => lineScrollDurationProperty(item.side, config.lineScroll.durationMs, config.lineScroll.differentialEffects), style => {
1247
- const durationMs = toMs(style.transitionDuration.split(",")[0].trim());
1248
- return durationMs > 0 ? durationMs : config.lineScroll.durationMs;
1249
- });
1250
- const startEasings = batchResolveLineScrollProperty(engine, prepared, "transition-timing-function", item => lineScrollEasingProperty(item.side, "start", config.lineScroll.easing, config.lineScroll.differentialEffects), style => style.transitionTimingFunction.trim() || config.lineScroll.easing);
1251
- const endEasings = batchResolveLineScrollProperty(engine, prepared, "transition-timing-function", item => lineScrollEasingProperty(item.side, "end", config.lineScroll.easing, config.lineScroll.differentialEffects), style => style.transitionTimingFunction.trim() || config.lineScroll.easing);
1252
- const startTranslates = batchResolveLineScrollProperty(engine, prepared, "translate", item => lineScrollTranslate(item.side, "start", config.lineScroll.differentialEffects), style => normalizedTranslate(style.translate));
1253
- const endTranslates = batchResolveLineScrollProperty(engine, prepared, "translate", item => lineScrollTranslate(item.side, "end", config.lineScroll.differentialEffects), style => normalizedTranslate(style.translate));
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: prepared.map((item, index) => ({
1256
- ...item,
1257
- durationMs: durations[index],
1258
- startEasing: startEasings[index],
1259
- endEasing: endEasings[index],
1260
- startTranslate: startTranslates[index],
1261
- endTranslate: endTranslates[index],
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 (tabRenderer.scrollTop !== prevScrollTop) {
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
- let scrollTop = tabRenderer.scrollTop;
1565
- const maxScrollTop = Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight);
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(lines, lyricsElement), lastActiveLyric, scrollPos - scrollTop, scrollTop, scrollPos, tabRendererHeight, animationConfig);
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(lines, lyricsElement);
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 createTimedWordSpan(doc, part, wrapThreshold) {
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
- appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
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
  }
@@ -117,13 +117,13 @@
117
117
 
118
118
  @property --lyric-transition-amount-start {
119
119
  syntax: "<number>";
120
- inherits: false;
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: false;
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
@@ -1,4 +1,4 @@
1
- import type { AnimationEngineInstance } from "./engine.js";
1
+ import { type AnimationEngineInstance } from "./engine.js";
2
2
  import type { Lyric } from "./types.js";
3
3
  export interface SetLyricsOptions {
4
4
  /**
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",