@braccato/core 1.5.0 → 1.6.1

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