@braccato/core 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -179,6 +179,13 @@ The ones a theme reaches for first. `variables.css` declares the rest.
179
179
  `line-height`. Every word is given the glow, so a theme that wants it to mean something selects on
180
180
  `data-long-word`, which the module sets on any part held past `blyrics-long-word-threshold`.
181
181
 
182
+ The instrumental ripple is the one place a property carries geometry rather than a value.
183
+ `--blyrics-instrumental-wave-path-high` and `--blyrics-instrumental-wave-path-low` are the two shapes
184
+ it morphs between, each a `path()`, and a theme redrawing them is how the wave changes amplitude or
185
+ frequency. Both must use the same commands in the same order with the same number of arguments: a
186
+ browser only interpolates two paths smoothly when their command sequences match, and a mismatched
187
+ pair snaps at the halfway point instead of flowing.
188
+
182
189
  ### Class names
183
190
 
184
191
  These are published API rather than implementation. Renaming one costs a migration rather than a
package/dist/README.md CHANGED
@@ -179,6 +179,13 @@ The ones a theme reaches for first. `variables.css` declares the rest.
179
179
  `line-height`. Every word is given the glow, so a theme that wants it to mean something selects on
180
180
  `data-long-word`, which the module sets on any part held past `blyrics-long-word-threshold`.
181
181
 
182
+ The instrumental ripple is the one place a property carries geometry rather than a value.
183
+ `--blyrics-instrumental-wave-path-high` and `--blyrics-instrumental-wave-path-low` are the two shapes
184
+ it morphs between, each a `path()`, and a theme redrawing them is how the wave changes amplitude or
185
+ frequency. Both must use the same commands in the same order with the same number of arguments: a
186
+ browser only interpolates two paths smoothly when their command sequences match, and a mismatched
187
+ pair snaps at the halfway point instead of flowing.
188
+
182
189
  ### Class names
183
190
 
184
191
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -19,6 +19,7 @@ export declare const BACKGROUND_LINE_CLASS: "blyrics-background-line";
19
19
  export declare const WORD_GROUP_CLASS: "blyrics-word-group";
20
20
  export declare const LONG_WORD_GROUP_CLASS: "blyrics-word-group-long";
21
21
  export declare const WORD_HIGHLIGHT_CLASS: "blyrics-word-highlight";
22
+ export declare const HIGHLIGHT_RUN_CLASS: "blyrics-highlight-run";
22
23
  export declare const LINE_SYNCED_WORD_CLASS: "blyrics-line-synced-word";
23
24
  export declare const BIDI_RUN_CLASS: "blyrics-bidi-run";
24
25
  export declare const BIDI_SENSITIVE_CLASS: "blyrics-bidi-sensitive";
package/dist/constants.js CHANGED
@@ -25,6 +25,7 @@ export const BACKGROUND_LINE_CLASS = "blyrics-background-line";
25
25
  export const WORD_GROUP_CLASS = "blyrics-word-group";
26
26
  export const LONG_WORD_GROUP_CLASS = "blyrics-word-group-long";
27
27
  export const WORD_HIGHLIGHT_CLASS = "blyrics-word-highlight";
28
+ export const HIGHLIGHT_RUN_CLASS = "blyrics-highlight-run";
28
29
  export const LINE_SYNCED_WORD_CLASS = "blyrics-line-synced-word";
29
30
  export const BIDI_RUN_CLASS = "blyrics-bidi-run";
30
31
  export const BIDI_SENSITIVE_CLASS = "blyrics-bidi-sensitive";
package/dist/engine.d.ts CHANGED
@@ -184,6 +184,8 @@ interface AnimationConfig {
184
184
  waveFrom: string;
185
185
  waveTo: string;
186
186
  waveEasing: string;
187
+ wavePathHigh: string;
188
+ wavePathLow: string;
187
189
  waveOscillationDurationMs: number;
188
190
  waveOscillationEasing: string;
189
191
  };
package/dist/engine.js CHANGED
@@ -17,6 +17,7 @@
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
19
  import { ANIMATING_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, USER_SCROLLING_CLASS, } from "./constants.js";
20
+ import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
20
21
  import { registerThemeSetting } from "./themeSettings.js";
21
22
  import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
22
23
  const NO_LYRICS_ELEMENT_LOG = "No lyrics element found on the page, skipping lyrics injection";
@@ -301,10 +302,14 @@ function resetLineAnimationState(lineData) {
301
302
  resetLineAnimations(lineData);
302
303
  markLineAnimationsStopped(lineData);
303
304
  }
305
+ function togglePartClass(part, className, force) {
306
+ part.lyricElement.classList.toggle(className, force);
307
+ part.highlightElement?.classList.toggle(className, force);
308
+ }
304
309
  function setAnimationsPlayState(lineData, isPlaying) {
305
310
  const children = [lineData, ...lineData.parts];
306
311
  for (const part of children) {
307
- part.lyricElement.classList.toggle(PAUSED_CLASS, !isPlaying);
312
+ togglePartClass(part, PAUSED_CLASS, !isPlaying);
308
313
  for (const animation of part.animations) {
309
314
  if (isPlaying) {
310
315
  animation.play();
@@ -318,16 +323,15 @@ function setAnimationsPlayState(lineData, isPlaying) {
318
323
  function clearLineStateClasses(lineData) {
319
324
  lineData.lyricElement.classList.remove(ANIMATING_CLASS);
320
325
  for (const part of [lineData, ...lineData.parts]) {
321
- part.lyricElement.classList.remove(PAUSED_CLASS);
326
+ togglePartClass(part, PAUSED_CLASS, false);
322
327
  }
323
328
  }
324
329
  const LINE_SYNCED_WORD_CLASS = "blyrics-line-synced-word";
325
- const WORD_HIGHLIGHT_SELECTOR = ".blyrics-word-highlight";
326
330
  const INSTRUMENTAL_FILL_SELECTOR = ".blyrics--instrumental-fill";
327
331
  const INSTRUMENTAL_WAVE_CLIP_SELECTOR = ".blyrics--wave-clip";
328
332
  const INSTRUMENTAL_WAVE_PATH_SELECTOR = ".blyrics--wave-path";
329
- const INSTRUMENTAL_WAVE_PATH_HIGH = 'path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z")';
330
- const INSTRUMENTAL_WAVE_PATH_LOW = 'path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z")';
333
+ const INSTRUMENTAL_WAVE_PATH_HIGH_FALLBACK = `path("${INSTRUMENTAL_WAVE_PATH_HIGH}")`;
334
+ const INSTRUMENTAL_WAVE_PATH_LOW_FALLBACK = `path("${INSTRUMENTAL_WAVE_PATH_LOW}")`;
331
335
  const LINE_SCROLL_INDEX_PROPERTY = "--blyrics-line-scroll-index";
332
336
  const LINE_SCROLL_ACTIVE_INDEX_PROPERTY = "--blyrics-line-scroll-active-index";
333
337
  const LINE_SCROLL_RELATIVE_INDEX_PROPERTY = "--blyrics-line-scroll-relative-index";
@@ -609,13 +613,6 @@ function activeTextInstantKeyframes(config) {
609
613
  },
610
614
  ];
611
615
  }
612
- function highlightTarget(part) {
613
- const highlight = part.lyricElement.querySelector(WORD_HIGHLIGHT_SELECTOR);
614
- if (highlight) {
615
- return { element: highlight, options: {} };
616
- }
617
- return { element: part.lyricElement, options: { pseudoElement: "::after" } };
618
- }
619
616
  function lineSyncedTextKeyframes(config) {
620
617
  return [
621
618
  {
@@ -648,33 +645,30 @@ function fadeOutTextKeyframes(config) {
648
645
  }
649
646
  function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, wordTimeMs, swipeDurationMs, glowDurationMs, appliedTimingOffsetMs) {
650
647
  const animations = [];
651
- const target = highlightTarget(part);
648
+ const highlight = part.highlightElement;
652
649
  let swipeAnimation;
653
650
  if (config.enabled.highlightSwipe) {
654
- swipeAnimation = trackLyricAnimationTiming(engine, target.element.animate(activeTextGradientKeyframes(config), {
651
+ swipeAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGradientKeyframes(config), {
655
652
  duration: swipeDurationMs,
656
653
  easing: config.highlight.swipeEasing,
657
654
  fill: "forwards",
658
- ...target.options,
659
655
  }), { appliedTimingOffsetMs, offsetMs: swipeTimeMs - wordTimeMs });
660
656
  swipeAnimation.currentTime = correctedAnimationTimeMs(swipeTimeMs, appliedTimingOffsetMs, swipeDurationMs);
661
657
  animations.push(swipeAnimation);
662
658
  }
663
- const opacityAnimation = trackLyricAnimationTiming(engine, target.element.animate(config.enabled.highlightSwipe ? activeTextVisibleKeyframes() : activeTextInstantKeyframes(config), {
659
+ const opacityAnimation = trackLyricAnimationTiming(engine, highlight.animate(config.enabled.highlightSwipe ? activeTextVisibleKeyframes() : activeTextInstantKeyframes(config), {
664
660
  duration: 1,
665
661
  easing: "linear",
666
662
  fill: "forwards",
667
- ...target.options,
668
663
  }), { appliedTimingOffsetMs, offsetMs: 0 });
669
664
  opacityAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, 1);
670
665
  animations.push(opacityAnimation);
671
666
  let glowAnimation;
672
667
  if (config.enabled.highlightGlow) {
673
- glowAnimation = trackLyricAnimationTiming(engine, target.element.animate(activeTextGlowKeyframes(config), {
668
+ glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
674
669
  duration: glowDurationMs,
675
670
  easing: config.highlight.glowEasing,
676
671
  fill: "forwards",
677
- ...target.options,
678
672
  }), { appliedTimingOffsetMs, offsetMs: 0 });
679
673
  glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
680
674
  animations.push(glowAnimation);
@@ -684,22 +678,20 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
684
678
  function startLineSyncedHighlightAnimations(engine, part, config, wordTimeMs, glowDurationMs, appliedTimingOffsetMs) {
685
679
  const animations = [];
686
680
  const fadeInDuration = config.enabled.highlightFade ? config.highlight.fadeInDurationMs : 1;
687
- const target = highlightTarget(part);
688
- const opacityAnimation = trackLyricAnimationTiming(engine, target.element.animate(lineSyncedTextKeyframes(config), {
681
+ const highlight = part.highlightElement;
682
+ const opacityAnimation = trackLyricAnimationTiming(engine, highlight.animate(lineSyncedTextKeyframes(config), {
689
683
  duration: fadeInDuration,
690
684
  easing: config.enabled.highlightFade ? config.highlight.fadeInEasing : "linear",
691
685
  fill: "forwards",
692
- ...target.options,
693
686
  }), { appliedTimingOffsetMs, offsetMs: 0 });
694
687
  opacityAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, fadeInDuration);
695
688
  animations.push(opacityAnimation);
696
689
  let glowAnimation;
697
690
  if (config.enabled.highlightGlow) {
698
- glowAnimation = trackLyricAnimationTiming(engine, target.element.animate(activeTextGlowKeyframes(config), {
691
+ glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
699
692
  duration: glowDurationMs,
700
693
  easing: config.highlight.glowEasing,
701
694
  fill: "forwards",
702
- ...target.options,
703
695
  }), { appliedTimingOffsetMs, offsetMs: 0 });
704
696
  glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
705
697
  animations.push(glowAnimation);
@@ -752,8 +744,9 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
752
744
  const highlightAnimations = isLineSyncedWord
753
745
  ? startLineSyncedHighlightAnimations(engine, part, config, wordTimeMs, config.highlight.glowMinDurationMs, appliedTimingOffsetMs)
754
746
  : startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, wordTimeMs, swipeDurationMs, glowDurationMs, appliedTimingOffsetMs);
755
- const wobbleAnimation = config.enabled.wordWobble
756
- ? trackLyricAnimationTiming(engine, part.lyricElement.animate([
747
+ const wobbleAnimations = [];
748
+ if (config.enabled.wordWobble) {
749
+ const wobbleKeyframes = [
757
750
  { transform: config.word.wobbleFrom },
758
751
  {
759
752
  transform: config.word.wobblePeak,
@@ -768,18 +761,25 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
768
761
  offset: Math.max(config.word.wobblePeakOffset, config.word.wobbleSettleOffset),
769
762
  },
770
763
  { transform: config.word.wobbleTo, easing: config.word.wobbleEndEasing },
771
- ], {
764
+ ];
765
+ const wobbleOptions = {
772
766
  duration: config.word.wobbleDurationMs,
773
767
  easing: config.word.wobbleEasing,
774
768
  fill: "forwards",
775
- }), { appliedTimingOffsetMs, offsetMs: 0 })
776
- : null;
777
- if (wobbleAnimation) {
778
- wobbleAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, config.word.wobbleDurationMs);
769
+ };
770
+ const wobbleStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, config.word.wobbleDurationMs);
771
+ // The wobble is a paint transform, so the highlight copy must carry it too or the active
772
+ // sweep drifts off the word.
773
+ for (const wordElement of [part.lyricElement, part.highlightElement]) {
774
+ const animation = trackLyricAnimationTiming(engine, wordElement.animate(wobbleKeyframes, wobbleOptions), {
775
+ appliedTimingOffsetMs,
776
+ offsetMs: 0,
777
+ });
778
+ animation.currentTime = wobbleStartMs;
779
+ wobbleAnimations.push(animation);
780
+ }
779
781
  }
780
- part.animations = wobbleAnimation
781
- ? [...highlightAnimations.animations, wobbleAnimation]
782
- : highlightAnimations.animations;
782
+ part.animations = [...highlightAnimations.animations, ...wobbleAnimations];
783
783
  }
784
784
  function startLineAnimations(engine, lineData, config, currentTime) {
785
785
  const appliedTimingOffsetMs = engine.learnedAnimationTimingOffsetMs;
@@ -795,12 +795,10 @@ function startLineAnimations(engine, lineData, config, currentTime) {
795
795
  function startWordExitAnimation(part, config) {
796
796
  resetPartAnimations(part);
797
797
  const fadeDuration = config.enabled.highlightFade ? config.highlight.fadeOutDurationMs : 1;
798
- const target = highlightTarget(part);
799
- const animation = target.element.animate(fadeOutTextKeyframes(config), {
798
+ const animation = part.highlightElement.animate(fadeOutTextKeyframes(config), {
800
799
  duration: fadeDuration,
801
800
  easing: config.enabled.highlightFade ? config.highlight.fadeOutEasing : "linear",
802
801
  fill: "none",
803
- ...target.options,
804
802
  });
805
803
  part.animations = [animation];
806
804
  animation.addEventListener("finish", () => {
@@ -856,9 +854,9 @@ function startInstrumentalAnimations(engine, lineData, config, currentTime, appl
856
854
  fill: "both",
857
855
  }, { appliedTimingOffsetMs, offsetMs: 0 });
858
856
  waveOscillationAnimation = animateInstrumentalChild(engine, lineData, INSTRUMENTAL_WAVE_PATH_SELECTOR, [
859
- { d: INSTRUMENTAL_WAVE_PATH_HIGH },
860
- { d: INSTRUMENTAL_WAVE_PATH_LOW, offset: 0.5 },
861
- { d: INSTRUMENTAL_WAVE_PATH_HIGH },
857
+ { d: config.instrumental.wavePathHigh },
858
+ { d: config.instrumental.wavePathLow, offset: 0.5 },
859
+ { d: config.instrumental.wavePathHigh },
862
860
  ], {
863
861
  duration: config.instrumental.waveOscillationDurationMs,
864
862
  easing: config.instrumental.waveOscillationEasing,
@@ -1016,6 +1014,8 @@ function readAnimationConfig(engine, lyricsElement) {
1016
1014
  waveFrom: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-transform-from", "scaleY(1.2)"),
1017
1015
  waveTo: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-transform-to", "scaleY(0.0001)"),
1018
1016
  waveEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-easing", "ease-in"),
1017
+ wavePathHigh: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-path-high", INSTRUMENTAL_WAVE_PATH_HIGH_FALLBACK),
1018
+ wavePathLow: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-path-low", INSTRUMENTAL_WAVE_PATH_LOW_FALLBACK),
1019
1019
  waveOscillationDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-instrumental-wave-oscillation-duration", "1.25s"),
1020
1020
  waveOscillationEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-oscillation-easing", "ease-in-out"),
1021
1021
  },
@@ -1560,6 +1560,7 @@ export function tickView(engine, currentTime, options) {
1560
1560
  }
1561
1561
  const tabRendererHeight = engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height;
1562
1562
  let scrollTop = tabRenderer.scrollTop;
1563
+ const maxScrollTop = Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight);
1563
1564
  if (animationConfig.enabled.scroll) {
1564
1565
  updateVisibleLyricWillChange(engine, lines, scrollTop, engine.pendingLineScroll?.toScrollTop ?? scrollTop, tabRendererHeight);
1565
1566
  }
@@ -1713,8 +1714,9 @@ export function tickView(engine, currentTime, options) {
1713
1714
  scrollPos = Math.max(scrollPos, lastActiveLyric.position - tabRendererHeight + lastActiveLyric.height);
1714
1715
  // Make sure top of last active lyric is visible.
1715
1716
  scrollPos = Math.min(scrollPos, lastActiveLyric.position);
1716
- // Make sure we're not trying to scroll to negative values
1717
- scrollPos = Math.max(0, scrollPos);
1717
+ // Past either end the browser clamps the write and reports nothing, leaving the view aiming
1718
+ // at a position it never reached and re-aiming once per remaining line.
1719
+ scrollPos = clamp(scrollPos, 0, maxScrollTop);
1718
1720
  if (ENABLE_DEBUG_RENDER.getBooleanValue()) {
1719
1721
  let transform = engine.window.getComputedStyle(lyricsElement).transform;
1720
1722
  const matrix = new engine.window.DOMMatrix(transform);
@@ -1899,6 +1901,9 @@ function applyScrollPadding(engine) {
1899
1901
  });
1900
1902
  engine.document.documentElement.style.setProperty("--blyrics-padding-top", top + "px");
1901
1903
  engine.document.documentElement.style.setProperty("--blyrics-padding-bottom", bottom + "px");
1904
+ // Inline, because a theme's own `.blyrics-container { padding }` is appended after the package's
1905
+ // stylesheet at the same weight and would otherwise take this room away.
1906
+ lyricsElement.style.setProperty("padding-bottom", bottom + "px");
1902
1907
  }
1903
1908
  /**
1904
1909
  * Re-reads the view's layout: the scroll padding first, then the line positions the padding moved.
package/dist/inject.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface PartData {
14
14
  duration: number;
15
15
  lyricElement: HTMLElement;
16
16
  animations: Animation[];
17
+ highlightElement?: HTMLElement;
17
18
  }
18
19
  export type LineData = {
19
20
  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, 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, 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";
@@ -60,11 +60,12 @@ export function deriveSyncType(lyrics) {
60
60
  return "richsync";
61
61
  return lyrics.every(item => item.startTimeMs === 0) ? "none" : "synced";
62
62
  }
63
- function newPartData(part, span) {
63
+ function newPartData(part, span, highlight) {
64
64
  return {
65
65
  time: part.startTimeMs / 1000,
66
66
  duration: part.durationMs / 1000,
67
67
  lyricElement: span,
68
+ highlightElement: highlight,
68
69
  animations: [],
69
70
  };
70
71
  }
@@ -193,67 +194,57 @@ function appendLongWordBreaks(doc, span, text, threshold) {
193
194
  }
194
195
  return true;
195
196
  }
196
- function cloneTextWithBreaks(doc, source) {
197
- const fragment = doc.createDocumentFragment();
198
- for (const node of source.childNodes) {
199
- fragment.appendChild(node.cloneNode(true));
200
- }
201
- return fragment;
202
- }
203
197
  function createTimedWordSpan(doc, part, wrapThreshold) {
204
198
  const span = doc.createElement("span");
205
- span.classList.add(WORD_CLASS);
206
- span.dir = "auto";
207
- if (part.durationMs === 0) {
208
- span.classList.add(ZERO_DURATION_ANIMATION_CLASS);
209
- span.classList.add(LINE_SYNCED_WORD_CLASS);
210
- }
211
- if (testRtl(part.words)) {
212
- span.classList.add(RTL_CLASS);
213
- }
214
- if (part.durationMs > longWordThreshold.getNumberValue()) {
215
- span.dataset.longWord = "true";
216
- }
217
- if (part.isBackground) {
218
- span.classList.add(BACKGROUND_LYRIC_CLASS);
219
- }
220
- if (part.explicit) {
221
- span.classList.add(EXPLICIT_WORD_CLASS);
222
- }
223
- const hasBreaks = appendLongWordBreaks(doc, span, part.words, wrapThreshold);
224
- if (hasBreaks) {
225
- const highlight = doc.createElement("span");
226
- highlight.classList.add(WORD_HIGHLIGHT_CLASS);
227
- highlight.setAttribute("aria-hidden", "true");
228
- highlight.appendChild(cloneTextWithBreaks(doc, span));
229
- span.appendChild(highlight);
199
+ const highlight = doc.createElement("span");
200
+ highlight.classList.add(WORD_HIGHLIGHT_CLASS);
201
+ for (const wordElement of [span, highlight]) {
202
+ wordElement.classList.add(WORD_CLASS);
203
+ wordElement.dir = "auto";
204
+ if (part.durationMs === 0) {
205
+ wordElement.classList.add(ZERO_DURATION_ANIMATION_CLASS);
206
+ wordElement.classList.add(LINE_SYNCED_WORD_CLASS);
207
+ }
208
+ if (testRtl(part.words))
209
+ wordElement.classList.add(RTL_CLASS);
210
+ if (part.durationMs > longWordThreshold.getNumberValue())
211
+ wordElement.dataset.longWord = "true";
212
+ if (part.isBackground)
213
+ wordElement.classList.add(BACKGROUND_LYRIC_CLASS);
214
+ if (part.explicit)
215
+ wordElement.classList.add(EXPLICIT_WORD_CLASS);
216
+ appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
217
+ wordElement.dataset.time = String(part.startTimeMs / 1000);
218
+ wordElement.dataset.duration = String(part.durationMs / 1000);
219
+ wordElement.dataset.content = part.words;
220
+ wordElement.style.setProperty("--blyrics-duration", part.durationMs + "ms");
230
221
  }
231
- span.dataset.time = String(part.startTimeMs / 1000);
232
- span.dataset.duration = String(part.durationMs / 1000);
233
- span.dataset.content = part.words;
234
- span.style.setProperty("--blyrics-duration", part.durationMs + "ms");
235
- return span;
222
+ return { span, highlight };
236
223
  }
237
224
  function createWordGroup(doc, group, lineData) {
238
225
  const wrapThreshold = Math.max(1, longWordWrapThreshold.getNumberValue());
239
- const groupElement = doc.createElement("span");
240
- groupElement.classList.add(WORD_GROUP_CLASS);
241
- groupElement.dir = "auto";
242
- groupElement.dataset.content = group.text;
243
- if (group.text.length > wrapThreshold * 2) {
244
- groupElement.classList.add(LONG_WORD_GROUP_CLASS);
245
- }
246
- if (group.isBackground) {
247
- groupElement.classList.add(BACKGROUND_LYRIC_CLASS);
226
+ const lyricGroup = doc.createElement("span");
227
+ const highlightGroup = doc.createElement("span");
228
+ for (const groupElement of [lyricGroup, highlightGroup]) {
229
+ groupElement.classList.add(WORD_GROUP_CLASS);
230
+ groupElement.dir = "auto";
231
+ groupElement.dataset.content = group.text;
232
+ if (group.text.length > wrapThreshold * 2) {
233
+ groupElement.classList.add(LONG_WORD_GROUP_CLASS);
234
+ }
235
+ if (group.isBackground) {
236
+ groupElement.classList.add(BACKGROUND_LYRIC_CLASS);
237
+ }
248
238
  }
249
239
  for (const token of group.tokens) {
250
240
  if (token.kind === "space")
251
241
  continue;
252
- const span = createTimedWordSpan(doc, token.part, wrapThreshold);
253
- lineData.parts.push(newPartData(token.part, span));
254
- groupElement.appendChild(span);
242
+ const { span, highlight } = createTimedWordSpan(doc, token.part, wrapThreshold);
243
+ lineData.parts.push(newPartData(token.part, span, highlight));
244
+ lyricGroup.appendChild(span);
245
+ highlightGroup.appendChild(highlight);
255
246
  }
256
- return groupElement;
247
+ return { lyricGroup, highlightGroup };
257
248
  }
258
249
  function createContentLine(doc, className, text) {
259
250
  const line = doc.createElement("div");
@@ -268,6 +259,12 @@ function createBidiRun(doc, text) {
268
259
  applyDirection(run, text);
269
260
  return run;
270
261
  }
262
+ function createHighlightRun(doc, text) {
263
+ const run = createBidiRun(doc, text);
264
+ run.classList.add(HIGHLIGHT_RUN_CLASS);
265
+ run.setAttribute("aria-hidden", "true");
266
+ return run;
267
+ }
271
268
  export function createLyricsLine(doc, parts, line, lyricElement, options = { splitBackgroundLine: true }) {
272
269
  const lineText = parts.map(part => part.words).join("");
273
270
  const mainText = options.splitBackgroundLine
@@ -282,13 +279,17 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
282
279
  .join("");
283
280
  const main = createContentLine(doc, LINE_MAIN_CLASS, mainText);
284
281
  const mainRun = createBidiRun(doc, mainText);
282
+ const mainHighlightRun = createHighlightRun(doc, mainText);
285
283
  const groupedTokens = groupTokensByWord(normalizeParts(parts));
286
284
  const backgroundLine = createContentLine(doc, BACKGROUND_LINE_CLASS, backgroundText);
287
285
  const backgroundRun = createBidiRun(doc, backgroundText);
286
+ const backgroundHighlightRun = createHighlightRun(doc, backgroundText);
288
287
  let hasBackground = false;
289
288
  let pendingForegroundSpace = "";
290
289
  let pendingBackgroundSpace = "";
290
+ main.appendChild(mainHighlightRun);
291
291
  main.appendChild(mainRun);
292
+ backgroundLine.appendChild(backgroundHighlightRun);
292
293
  backgroundLine.appendChild(backgroundRun);
293
294
  for (const item of groupedTokens) {
294
295
  if ("kind" in item) {
@@ -299,11 +300,15 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
299
300
  else {
300
301
  const shouldUseBackgroundLine = options.splitBackgroundLine && item.isBackground;
301
302
  const target = shouldUseBackgroundLine ? backgroundRun : mainRun;
303
+ const highlightRun = shouldUseBackgroundLine ? backgroundHighlightRun : mainHighlightRun;
302
304
  const pendingSpace = shouldUseBackgroundLine ? pendingBackgroundSpace : pendingForegroundSpace;
303
305
  if (target.childNodes.length > 0 && pendingSpace.length > 0) {
304
306
  target.appendChild(doc.createTextNode(pendingSpace));
307
+ highlightRun.appendChild(doc.createTextNode(pendingSpace));
305
308
  }
306
- target.appendChild(createWordGroup(doc, item, line));
309
+ const { lyricGroup, highlightGroup } = createWordGroup(doc, item, line);
310
+ target.appendChild(lyricGroup);
311
+ highlightRun.appendChild(highlightGroup);
307
312
  if (shouldUseBackgroundLine) {
308
313
  hasBackground = true;
309
314
  pendingBackgroundSpace = "";
@@ -1,3 +1,5 @@
1
+ export declare const INSTRUMENTAL_WAVE_PATH_HIGH = "M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z";
2
+ export declare const INSTRUMENTAL_WAVE_PATH_LOW = "M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z";
1
3
  /**
2
4
  * Creates an HTML element representing an instrumental break in the lyrics.
3
5
  *
@@ -1,3 +1,5 @@
1
+ export const INSTRUMENTAL_WAVE_PATH_HIGH = "M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z";
2
+ export const INSTRUMENTAL_WAVE_PATH_LOW = "M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z";
1
3
  /**
2
4
  * Creates an HTML element representing an instrumental break in the lyrics.
3
5
  *
@@ -56,8 +58,7 @@ export function createInstrumentalElement(doc, container, durationMs, lineIndex)
56
58
  // This only contains the surface water. It closes at y=4.
57
59
  const wavePath = doc.createElementNS(svgNS, "path");
58
60
  wavePath.classList.add("blyrics--wave-path");
59
- // Initial draw (matches the 0% keyframe below)
60
- wavePath.setAttribute("d", "M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z");
61
+ wavePath.setAttribute("d", INSTRUMENTAL_WAVE_PATH_HIGH);
61
62
  clipPath.appendChild(wavePath);
62
63
  defs.appendChild(clipPath);
63
64
  svg.appendChild(defs);
package/dist/renderer.js CHANGED
@@ -269,6 +269,10 @@ export function createLyricsRenderer(rendererOptions) {
269
269
  // Everything the engine resolved off the document was resolved against the theme that just
270
270
  // went away.
271
271
  clearEngineStyleCaches(engine);
272
+ // The target scroll position is one of the settings a theme carries, and the scroll padding is
273
+ // sized against it, so a theme that moves the target moves the layout even when no rule in it
274
+ // does.
275
+ measure();
272
276
  return needsLyricRebuild;
273
277
  },
274
278
  tick(currentTimeS, options) {
@@ -69,6 +69,8 @@
69
69
 
70
70
  /* Prepare the WAAPI flatten transition. */
71
71
  transform: scaleY(1.2);
72
+
73
+ d: var(--blyrics-instrumental-wave-path-high);
72
74
  }
73
75
 
74
76
  /* Trigger the Flattening */
@@ -90,14 +92,3 @@
90
92
  transition-timing-function: ease-out;
91
93
  transition-delay: 0s;
92
94
  }
93
-
94
- /* Update Keyframes for the "Split" shape */
95
- /* These paths must close at 'L 30 4 L -4 4' to match the static rect overlap */
96
- @keyframes blyrics-wave {
97
- 0%, 100% {
98
- d: path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z");
99
- }
100
- 50% {
101
- d: path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z");
102
- }
103
- }
@@ -38,6 +38,7 @@
38
38
  display: block;
39
39
  max-width: 100%;
40
40
  overflow-wrap: normal;
41
+ position: relative;
41
42
  text-align: inherit;
42
43
  unicode-bidi: plaintext;
43
44
  white-space: normal;
@@ -47,18 +48,28 @@
47
48
  display: block;
48
49
  margin-top: 0.1em;
49
50
  max-width: 100%;
51
+ position: relative;
50
52
  text-align: inherit;
51
53
  unicode-bidi: plaintext;
52
54
  white-space: normal;
53
55
  }
54
56
 
55
57
  .blyrics-bidi-run {
56
- display: inline;
58
+ display: block;
57
59
  max-width: 100%;
58
60
  unicode-bidi: normal;
59
61
  white-space: inherit;
60
62
  }
61
63
 
64
+ .blyrics-highlight-run {
65
+ inset: 0 0 auto;
66
+ pointer-events: none;
67
+ position: absolute;
68
+ user-select: none;
69
+ width: auto;
70
+ z-index: 1;
71
+ }
72
+
62
73
  .blyrics-word-group {
63
74
  display: inline-block;
64
75
  max-width: 100%;
@@ -116,68 +127,33 @@
116
127
  initial-value: -0.1;
117
128
  }
118
129
 
119
- .blyrics--word::after,
120
- .blyrics-word-highlight {
130
+ .blyrics--word.blyrics-word-highlight {
121
131
  color: transparent;
122
132
  background-image: linear-gradient(
123
133
  90deg,
124
134
  var(--blyrics-lyric-active-color)
125
- calc(
126
- 100% * var(--lyric-transition-amount-start) - 4rem *
127
- var(--lyric-transition-amount-start) + 2rem
128
- ),
135
+ calc(100% * var(--lyric-transition-amount-start)),
129
136
  #00000000
130
- calc(
131
- 100% * var(--lyric-transition-amount-end) - 4rem *
132
- var(--lyric-transition-amount-end) + 2rem + 1px
133
- )
137
+ calc(100% * var(--lyric-transition-amount-end) + 1px)
134
138
  );
135
139
  background-clip: text;
136
- box-sizing: content-box;
137
140
  filter: drop-shadow(0 0 0 var(--blyrics-glow-color));
138
- left: -2rem;
139
141
  opacity: 0;
140
- padding: 2rem;
141
142
  pointer-events: none;
142
- position: absolute;
143
- top: -2rem;
144
- white-space: inherit;
145
- width: 100%;
146
143
  --lyric-transition-amount-start: var(
147
144
  --blyrics-highlight-swipe-start-from,
148
145
  -0.2
149
146
  );
150
147
  --lyric-transition-amount-end: var(--blyrics-highlight-swipe-end-from, -0.1);
151
148
  }
152
-
153
- .blyrics--word::after {
154
- content: attr(data-content);
155
- }
156
-
157
- .blyrics-word-highlight {
158
- user-select: none;
159
- }
160
-
161
- .blyrics--word:has(.blyrics-word-highlight)::after {
162
- content: none;
163
- }
164
-
165
- .blyrics-rtl.blyrics--word::after,
166
- .blyrics--word.blyrics-rtl::after,
167
- .blyrics-rtl .blyrics-word-highlight,
168
- .blyrics--word.blyrics-rtl .blyrics-word-highlight {
149
+ .blyrics-word-highlight.blyrics-rtl,
150
+ .blyrics-rtl .blyrics-word-highlight {
169
151
  background-image: linear-gradient(
170
152
  270deg,
171
153
  var(--blyrics-lyric-active-color)
172
- calc(
173
- 100% * var(--lyric-transition-amount-start) - 4rem *
174
- var(--lyric-transition-amount-start) + 2rem
175
- ),
154
+ calc(100% * var(--lyric-transition-amount-start)),
176
155
  #00000000
177
- calc(
178
- 100% * var(--lyric-transition-amount-end) - 4rem *
179
- var(--lyric-transition-amount-end) + 2rem + 1px
180
- )
156
+ calc(100% * var(--lyric-transition-amount-end) + 1px)
181
157
  );
182
158
  }
183
159
 
@@ -117,6 +117,8 @@
117
117
  --blyrics-instrumental-wave-transform-from: scaleY(1.2);
118
118
  --blyrics-instrumental-wave-transform-to: scaleY(0.0001);
119
119
  --blyrics-instrumental-wave-easing: ease-in;
120
+ --blyrics-instrumental-wave-path-high: path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z");
121
+ --blyrics-instrumental-wave-path-low: path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z");
120
122
  --blyrics-instrumental-wave-oscillation-duration: 1.25s;
121
123
  --blyrics-instrumental-wave-oscillation-easing: ease-in-out;
122
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",