@braccato/core 1.13.2 → 1.15.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/dist/engine.js CHANGED
@@ -18,6 +18,7 @@
18
18
  // that never meet. `themeSettings.ts` holds the third thing under that rule.
19
19
  import { ANIMATING_CLASS, CREDITS_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, WORD_STATE_ACTIVE, WORD_STATE_ATTR, WORD_STATE_PAST, WORD_STATE_UPCOMING, } from "./constants.js";
20
20
  import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
21
+ import { layoutStage, planStage } from "./stage.js";
21
22
  import { registerThemeSetting } from "./themeSettings.js";
22
23
  import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
23
24
  const NO_LYRICS_ELEMENT_LOG = "No lyrics element found on the page, skipping lyrics injection";
@@ -101,7 +102,7 @@ export function forEveryLiveView(runOperation) {
101
102
  runOperation(engine);
102
103
  }
103
104
  }
104
- export function createAnimationEngineInstance(engineDocument, engineWindow, host) {
105
+ export function createAnimationEngineInstance(engineDocument, engineWindow, host, layout = "scroll") {
105
106
  const reducedMotionQuery = engineWindow.matchMedia(REDUCED_MOTION_QUERY);
106
107
  const handleReducedMotionChange = () => clearStyleCaches(engine);
107
108
  const engine = {
@@ -153,6 +154,15 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
153
154
  pendingLyricsUpdateFrame: null,
154
155
  learnedAnimationTimingOffsetMs: 0,
155
156
  animationTimingVisibilityLogUntil: 0,
157
+ layout,
158
+ stageKey: "",
159
+ stageBox: null,
160
+ stageMetrics: new Map(),
161
+ stageY: new Map(),
162
+ stageMoves: new Map(),
163
+ stageFades: new Map(),
164
+ stageBlurs: new Map(),
165
+ stageFontSize: 16,
156
166
  destroy: () => {
157
167
  liveEngines.delete(engine);
158
168
  reducedMotionQuery.removeEventListener("change", handleReducedMotionChange);
@@ -242,6 +252,7 @@ export function clearOnScreenLyrics(engine) {
242
252
  if (!engine.lyricsContainer)
243
253
  return false;
244
254
  engine.lyricsContainer.replaceChildren();
255
+ resetStage(engine);
245
256
  return true;
246
257
  }
247
258
  export function hasRenderedLines(engine) {
@@ -289,6 +300,26 @@ export function clearLyrics(engine) {
289
300
  engine.creditsFocused = false;
290
301
  engine.lyricsContainer = null;
291
302
  engine.waveAnimationPool.length = 0;
303
+ resetStage(engine);
304
+ }
305
+ function resetStage(engine) {
306
+ for (const animation of [
307
+ ...engine.stageMoves.values(),
308
+ ...engine.stageFades.values(),
309
+ ...engine.stageBlurs.values(),
310
+ ]) {
311
+ animation.cancel();
312
+ }
313
+ engine.stageMoves.clear();
314
+ engine.stageFades.clear();
315
+ engine.stageBlurs.clear();
316
+ engine.stageMetrics.clear();
317
+ engine.stageY.clear();
318
+ engine.stageKey = "";
319
+ if (engine.stageBox !== null) {
320
+ engine.stageBox = null;
321
+ engine.host.onStageLayout?.(null);
322
+ }
292
323
  }
293
324
  function resetPartAnimations(part) {
294
325
  for (const animation of part.animations) {
@@ -319,6 +350,7 @@ function togglePartClass(part, className, force) {
319
350
  part.lyricElement.classList.toggle(className, force);
320
351
  if ("highlightElement" in part) {
321
352
  part.highlightElement.classList.toggle(className, force);
353
+ part.imageLayers?.highlight?.classList.toggle(className, force);
322
354
  }
323
355
  }
324
356
  function setAnimationsPlayState(lineData, isPlaying) {
@@ -359,6 +391,7 @@ export function updateWordStates(lineData, currentTime) {
359
391
  part.wordState = state;
360
392
  part.lyricElement.setAttribute(WORD_STATE_ATTR, state);
361
393
  part.highlightElement.setAttribute(WORD_STATE_ATTR, state);
394
+ part.imageLayers?.highlight?.setAttribute(WORD_STATE_ATTR, state);
362
395
  }
363
396
  }
364
397
  const LINE_SYNCED_WORD_CLASS = "blyrics-line-synced-word";
@@ -386,6 +419,25 @@ const LINE_SCROLL_INLINE_PROPERTIES = [
386
419
  ...LINE_SCROLL_STYLE_SETTINGS.map(([property]) => property),
387
420
  ];
388
421
  const animationTimingTracks = new WeakMap();
422
+ function mirrorImageHighlightAnimations(part, animations) {
423
+ const target = part.imageLayers?.highlight;
424
+ if (!target)
425
+ return [];
426
+ const mirrors = [];
427
+ for (const animation of animations) {
428
+ const effect = animation.effect;
429
+ if (!effect || effect.target !== part.highlightElement)
430
+ continue;
431
+ const mirror = target.animate(effect.getKeyframes(), effect.getTiming());
432
+ mirror.playbackRate = animation.playbackRate;
433
+ mirror.currentTime = animation.currentTime;
434
+ const timing = animationTimingTracks.get(animation);
435
+ if (timing)
436
+ animationTimingTracks.set(mirror, timing);
437
+ mirrors.push(mirror);
438
+ }
439
+ return mirrors;
440
+ }
389
441
  function trackLyricAnimationTiming(engine, animation, timing) {
390
442
  animationTimingTracks.set(animation, {
391
443
  ...timing,
@@ -652,7 +704,12 @@ function activeTextGradientKeyframes(config) {
652
704
  },
653
705
  ];
654
706
  }
655
- function activeTextGlowKeyframes(config) {
707
+ function activeTextGlowKeyframes(config, part) {
708
+ if (part.imageLayers)
709
+ return [
710
+ { filter: `blur(${config.highlight.imageGlowFrom})`, opacity: config.highlight.imageGlowOpacityFrom },
711
+ { filter: `blur(${config.highlight.imageGlowTo})`, opacity: config.highlight.imageGlowOpacityTo },
712
+ ];
656
713
  return [{ filter: config.highlight.glowFrom }, { filter: config.highlight.glowTo }];
657
714
  }
658
715
  function activeTextVisibleKeyframes() {
@@ -686,17 +743,17 @@ function lineSyncedTextKeyframes(config) {
686
743
  },
687
744
  ];
688
745
  }
689
- function fadeOutTextKeyframes(config) {
746
+ function fadeOutTextKeyframes(config, part) {
690
747
  return [
691
748
  {
692
749
  opacity: 1,
693
- filter: config.highlight.glowTo,
750
+ filter: part.imageLayers ? "none" : config.highlight.glowTo,
694
751
  "--lyric-transition-amount-start": config.highlight.swipeStartTo,
695
752
  "--lyric-transition-amount-end": config.highlight.swipeEndTo,
696
753
  },
697
754
  {
698
755
  opacity: 0,
699
- filter: config.highlight.glowTo,
756
+ filter: part.imageLayers ? "none" : config.highlight.glowTo,
700
757
  "--lyric-transition-amount-start": config.highlight.swipeStartTo,
701
758
  "--lyric-transition-amount-end": config.highlight.swipeEndTo,
702
759
  },
@@ -790,15 +847,18 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
790
847
  endTo: config.highlight.swipeEndTo,
791
848
  }, highlightLetters.length, swipeDurationMs, part.highlightElement.classList.contains(RTL_CLASS));
792
849
  sweeps.forEach((sweep, index) => {
793
- const animation = trackLyricAnimationTiming(engine, highlightLetters[index].animate(sweep.keyframes.map(frame => ({
794
- offset: frame.offset,
795
- maskPosition: frame.maskPosition,
796
- WebkitMaskPosition: frame.maskPosition,
797
- })), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }), swipeTiming);
798
- animation.currentTime = swipeCurrentTimeMs;
799
- if (index === 0)
800
- swipeAnimation = animation;
801
- animations.push(animation);
850
+ const targets = [highlightLetters[index], part.imageLayers?.glowLetters[index]].filter((target) => target !== undefined);
851
+ for (const target of targets) {
852
+ const animation = trackLyricAnimationTiming(engine, target.animate(sweep.keyframes.map(frame => ({
853
+ offset: frame.offset,
854
+ maskPosition: frame.maskPosition,
855
+ WebkitMaskPosition: frame.maskPosition,
856
+ })), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }), swipeTiming);
857
+ animation.currentTime = swipeCurrentTimeMs;
858
+ if (index === 0)
859
+ swipeAnimation = animation;
860
+ animations.push(animation);
861
+ }
802
862
  });
803
863
  }
804
864
  else {
@@ -820,10 +880,10 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
820
880
  animations.push(opacityAnimation);
821
881
  let glowAnimation;
822
882
  if (config.enabled.highlightGlow && !part.glowSuppressed) {
823
- glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
883
+ glowAnimation = trackLyricAnimationTiming(engine, (part.imageLayers?.glow ?? highlight).animate(activeTextGlowKeyframes(config, part), {
824
884
  duration: glowDurationMs,
825
885
  easing: config.highlight.glowEasing,
826
- fill: config.highlight.glowRestingInvisible ? "none" : "forwards",
886
+ fill: part.imageLayers ? "forwards" : config.highlight.glowRestingInvisible ? "none" : "forwards",
827
887
  }), { appliedTimingOffsetMs, offsetMs: 0 });
828
888
  glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
829
889
  animations.push(glowAnimation);
@@ -843,10 +903,10 @@ function startLineSyncedHighlightAnimations(engine, part, config, wordTimeMs, gl
843
903
  animations.push(opacityAnimation);
844
904
  let glowAnimation;
845
905
  if (config.enabled.highlightGlow && !part.glowSuppressed) {
846
- glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
906
+ glowAnimation = trackLyricAnimationTiming(engine, (part.imageLayers?.glow ?? highlight).animate(activeTextGlowKeyframes(config, part), {
847
907
  duration: glowDurationMs,
848
908
  easing: config.highlight.glowEasing,
849
- fill: config.highlight.glowRestingInvisible ? "none" : "forwards",
909
+ fill: part.imageLayers ? "forwards" : config.highlight.glowRestingInvisible ? "none" : "forwards",
850
910
  }), { appliedTimingOffsetMs, offsetMs: 0 });
851
911
  glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
852
912
  animations.push(glowAnimation);
@@ -947,7 +1007,7 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
947
1007
  const cascadeDurationMs = config.letterWave.durationMs + (letterCount - 1) * staggerMs;
948
1008
  const floatStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, cascadeDurationMs);
949
1009
  const floatKeyframeSignature = JSON.stringify(floatKeyframes);
950
- for (const set of [part.letterElements, part.highlightLetterElements]) {
1010
+ for (const set of [part.letterElements, part.highlightLetterElements, part.imageLayers?.glowLetters]) {
951
1011
  set?.forEach((letterElement, index) => {
952
1012
  const animation = trackLyricAnimationTiming(engine, acquireWaveAnimation(engine, letterElement, floatKeyframes, floatKeyframeSignature, {
953
1013
  duration: config.letterWave.durationMs,
@@ -960,7 +1020,11 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
960
1020
  }
961
1021
  }
962
1022
  }
963
- part.animations = [...highlightAnimations.animations, ...wobbleAnimations];
1023
+ part.animations = [
1024
+ ...highlightAnimations.animations,
1025
+ ...mirrorImageHighlightAnimations(part, highlightAnimations.animations),
1026
+ ...wobbleAnimations,
1027
+ ];
964
1028
  }
965
1029
  function startLineAnimations(engine, lineData, config, currentTime) {
966
1030
  const appliedTimingOffsetMs = engine.learnedAnimationTimingOffsetMs;
@@ -974,20 +1038,43 @@ function startLineAnimations(engine, lineData, config, currentTime) {
974
1038
  startWordAnimations(engine, part, config, currentTime, appliedTimingOffsetMs);
975
1039
  }
976
1040
  }
977
- function startWordExitAnimation(part, config) {
1041
+ function startWordExitAnimation(part, config, imagePaint = []) {
978
1042
  resetPartAnimations(part);
979
1043
  const fadeDuration = config.enabled.highlightFade ? config.highlight.fadeOutDurationMs : 1;
980
- const animation = part.highlightElement.animate(fadeOutTextKeyframes(config), {
1044
+ const animation = part.highlightElement.animate(fadeOutTextKeyframes(config, part), {
981
1045
  duration: fadeDuration,
982
1046
  easing: config.enabled.highlightFade ? config.highlight.fadeOutEasing : "linear",
983
1047
  fill: "none",
984
1048
  });
985
- part.animations = [animation];
1049
+ part.animations = [animation, ...mirrorImageHighlightAnimations(part, [animation])];
1050
+ // Keep the current blur, letter masks and motion visible until the parent fade ends.
1051
+ for (const { target, frame } of imagePaint) {
1052
+ part.animations.push(target.animate([frame, frame], { duration: fadeDuration, fill: "none" }));
1053
+ }
986
1054
  animation.addEventListener("finish", () => {
987
1055
  resetPartAnimations(part);
988
1056
  }, { once: true });
989
1057
  }
990
1058
  function startLineExitAnimations(engine, lineData, config, currentTime) {
1059
+ // Read the entire line before cancelling any effects, so this does not alternate
1060
+ // per-word style reads with writes. There are no extra reads during normal ticks.
1061
+ const imagePaint = new Map();
1062
+ for (const part of lineData.parts) {
1063
+ if (!part.imageLayers || currentTime < part.time)
1064
+ continue;
1065
+ const style = engine.window.getComputedStyle(part.imageLayers.glow);
1066
+ const paint = [
1067
+ { target: part.imageLayers.glow, frame: { opacity: style.opacity, filter: style.filter } },
1068
+ ];
1069
+ for (const target of part.letterElements ?? []) {
1070
+ paint.push({ target, frame: { transform: engine.window.getComputedStyle(target).transform } });
1071
+ }
1072
+ for (const target of [...(part.highlightLetterElements ?? []), ...part.imageLayers.glowLetters]) {
1073
+ const letterStyle = engine.window.getComputedStyle(target);
1074
+ paint.push({ target, frame: { maskPosition: letterStyle.maskPosition, transform: letterStyle.transform } });
1075
+ }
1076
+ imagePaint.set(part, paint);
1077
+ }
991
1078
  startLineExitAnimation(lineData, config);
992
1079
  if (lineData.lyricElement.dataset.instrumental === "true") {
993
1080
  startInstrumentalExitAnimations(engine, lineData, config, currentTime);
@@ -995,7 +1082,7 @@ function startLineExitAnimations(engine, lineData, config, currentTime) {
995
1082
  }
996
1083
  for (const part of lineData.parts) {
997
1084
  if (currentTime >= part.time) {
998
- startWordExitAnimation(part, config);
1085
+ startWordExitAnimation(part, config, imagePaint.get(part));
999
1086
  }
1000
1087
  else {
1001
1088
  resetPartAnimations(part);
@@ -1171,7 +1258,7 @@ export function parseColorAlpha(value) {
1171
1258
  // Per-word glow is resolved only when the container glow already renders nothing, so an empty word
1172
1259
  // drops its blur while a word a theme lit up keeps it.
1173
1260
  function resolveLineGlowSuppression(engine, lineData, config) {
1174
- if (config.highlight.glowContainerAlpha >= GLOW_INVISIBLE_ALPHA) {
1261
+ if (lineData.parts.some(part => part.imageLayers) || config.highlight.glowContainerAlpha >= GLOW_INVISIBLE_ALPHA) {
1175
1262
  for (const part of lineData.parts)
1176
1263
  part.glowSuppressed = false;
1177
1264
  return;
@@ -1229,6 +1316,10 @@ function readAnimationConfig(engine, lyricsElement) {
1229
1316
  swipeEndFrom: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-end-from", "-0.1"),
1230
1317
  swipeStartTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-start-to", "1.4"),
1231
1318
  swipeEndTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-end-to", "1.5"),
1319
+ imageGlowFrom: getCSSValue(engine, lyricsElement, "--blyrics-highlight-glow-radius-from", "0.8rem"),
1320
+ imageGlowTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-glow-radius-to", "0px"),
1321
+ imageGlowOpacityFrom: getCSSValue(engine, lyricsElement, "--blyrics-image-glow-opacity-from", "0.5"),
1322
+ imageGlowOpacityTo: getCSSValue(engine, lyricsElement, "--blyrics-image-glow-opacity-to", "0"),
1232
1323
  glowFrom: resolveGlowFilter(engine, lyricsElement, "from", "0.8rem"),
1233
1324
  glowTo: resolveGlowFilter(engine, lyricsElement, "to", "0"),
1234
1325
  glowDurationRatio: getCSSNumber(engine, lyricsElement, "--blyrics-highlight-glow-duration-ratio", 1.2),
@@ -1425,6 +1516,10 @@ function clearOffscreenLineCulling(engine) {
1425
1516
  export function setupLineCullObserver(engine) {
1426
1517
  engine.lineCullObserver?.disconnect();
1427
1518
  clearOffscreenLineCulling(engine);
1519
+ if (engine.layout === "stage") {
1520
+ engine.lineCullObserver = null;
1521
+ return;
1522
+ }
1428
1523
  const ObserverConstructor = engine.window.IntersectionObserver;
1429
1524
  if (typeof ObserverConstructor !== "function" || engine.lines.length === 0) {
1430
1525
  engine.lineCullObserver = null;
@@ -1841,6 +1936,181 @@ export function resolveTickOptions(options) {
1841
1936
  function resolvePlaybackRate(rate) {
1842
1937
  return rate !== undefined && Number.isFinite(rate) && rate > 0 ? rate : 1;
1843
1938
  }
1939
+ // -- Stage layout --------------------------------------------
1940
+ const STAGE_MOTION = { durationMs: 380, easing: "cubic-bezier(0.2, 0, 0, 1)" };
1941
+ // Read by an important rule in stage.css, so a theme's own important opacity cannot reveal a line
1942
+ // the stage is hiding or fading.
1943
+ const STAGE_OPACITY_PROPERTY = "--blyrics-stage-opacity";
1944
+ const stageOpacityRegistrations = new WeakSet();
1945
+ const STAGE_FADE_IN_MS = 300;
1946
+ const STAGE_FADE_IN_DELAY_MS = 70;
1947
+ const STAGE_FADE_OUT_MS = 220;
1948
+ // Lines blur out and in across a handoff, so one reads as turning into the next.
1949
+ const STAGE_BLUR = "blur(3px)";
1950
+ const STAGE_GAP_EM = 0.32;
1951
+ function stageElements(engine) {
1952
+ const elements = [];
1953
+ const items = [];
1954
+ let lastEnd = 0;
1955
+ for (const line of engine.lines) {
1956
+ const end = line.time + line.duration;
1957
+ lastEnd = Math.max(lastEnd, end);
1958
+ elements.push(line.lyricElement);
1959
+ items.push({
1960
+ kind: line.lyricElement.dataset.instrumental === "true" ? "instrumental" : "line",
1961
+ start: line.time,
1962
+ end,
1963
+ });
1964
+ }
1965
+ const credits = engine.lyricsContainer?.querySelector(`:scope > .${CREDITS_CLASS}`);
1966
+ if (credits) {
1967
+ elements.push(credits);
1968
+ items.push({ kind: "credits", start: lastEnd, end: Number.POSITIVE_INFINITY });
1969
+ }
1970
+ return { elements, items };
1971
+ }
1972
+ function originXOf(engine, element) {
1973
+ const align = engine.window.getComputedStyle(element).textAlign;
1974
+ if (align === "center")
1975
+ return 0.5;
1976
+ if (align === "right" || align === "end")
1977
+ return 1;
1978
+ return 0;
1979
+ }
1980
+ export function measureStage(engine) {
1981
+ engine.stageMetrics.clear();
1982
+ const container = engine.lyricsContainer;
1983
+ if (!container)
1984
+ return;
1985
+ engine.stageFontSize = Number.parseFloat(engine.window.getComputedStyle(container).fontSize) || 16;
1986
+ for (const element of stageElements(engine).elements) {
1987
+ engine.stageMetrics.set(element, {
1988
+ height: element.offsetHeight,
1989
+ left: element.offsetLeft,
1990
+ width: element.offsetWidth,
1991
+ originX: originXOf(engine, element),
1992
+ });
1993
+ }
1994
+ engine.stageKey = "";
1995
+ }
1996
+ function currentTranslateY(engine, element, fallback) {
1997
+ const parts = engine.window.getComputedStyle(element).translate.split(" ");
1998
+ const y = Number.parseFloat(parts[1] ?? "");
1999
+ return Number.isFinite(y) ? y : fallback;
2000
+ }
2001
+ function placeStageElement(engine, { element, placement, wasVisible, fromY, fromOpacity }, instant, reduced) {
2002
+ engine.stageMoves.get(element)?.cancel();
2003
+ engine.stageFades.get(element)?.cancel();
2004
+ const moveMs = instant || reduced ? 0 : STAGE_MOTION.durationMs;
2005
+ engine.stageMoves.set(element, element.animate([{ translate: `0 ${fromY}px` }, { translate: `0 ${placement.y}px` }], {
2006
+ duration: moveMs,
2007
+ easing: STAGE_MOTION.easing,
2008
+ fill: "forwards",
2009
+ }));
2010
+ const fadeMs = instant ? 0 : placement.visible ? STAGE_FADE_IN_MS : STAGE_FADE_OUT_MS;
2011
+ if (placement.visible)
2012
+ element.dataset.stageVisible = "";
2013
+ else if (fadeMs === 0)
2014
+ delete element.dataset.stageVisible;
2015
+ const fade = element.animate([{ [STAGE_OPACITY_PROPERTY]: fromOpacity }, { [STAGE_OPACITY_PROPERTY]: placement.visible ? 1 : 0 }], {
2016
+ duration: fadeMs,
2017
+ delay: placement.visible && !instant ? STAGE_FADE_IN_DELAY_MS : 0,
2018
+ easing: placement.visible ? "ease-out" : "ease",
2019
+ // Backwards too: the fade replaces one that was cancelled, and through its delay the line would
2020
+ // otherwise drop to the stylesheet's resting opacity of zero.
2021
+ fill: "both",
2022
+ });
2023
+ if (!placement.visible && fadeMs > 0) {
2024
+ fade.onfinish = () => {
2025
+ if (engine.stageFades.get(element) === fade)
2026
+ delete element.dataset.stageVisible;
2027
+ };
2028
+ }
2029
+ engine.stageFades.set(element, fade);
2030
+ engine.stageY.set(element, placement.y);
2031
+ engine.stageBlurs.get(element)?.cancel();
2032
+ engine.stageBlurs.delete(element);
2033
+ if (fadeMs === 0 || reduced || wasVisible === placement.visible)
2034
+ return;
2035
+ const blurs = ["none", STAGE_BLUR];
2036
+ engine.stageBlurs.set(element, element.animate((placement.visible ? blurs.reverse() : blurs).map(filter => ({ filter })), {
2037
+ duration: fadeMs,
2038
+ delay: placement.visible ? STAGE_FADE_IN_DELAY_MS : 0,
2039
+ easing: placement.visible ? "ease-out" : "ease",
2040
+ // Added to the theme's own filter rather than replacing it, and held only until the line is in focus.
2041
+ composite: "add",
2042
+ fill: placement.visible ? "backwards" : "forwards",
2043
+ }));
2044
+ }
2045
+ // Registered from script rather than with @property, which Firefox ignores in a stylesheet
2046
+ // cross-origin to the document. Unregistered, the fade would snap instead of interpolating.
2047
+ function registerStageOpacity(engine) {
2048
+ const css = engine.window.CSS;
2049
+ if (stageOpacityRegistrations.has(engine.window) || typeof css?.registerProperty !== "function")
2050
+ return;
2051
+ stageOpacityRegistrations.add(engine.window);
2052
+ try {
2053
+ css.registerProperty({ name: STAGE_OPACITY_PROPERTY, syntax: "<number>", inherits: false, initialValue: "0" });
2054
+ }
2055
+ catch (error) {
2056
+ engine.host.log("Stage opacity property was already registered", error);
2057
+ }
2058
+ }
2059
+ function isStageRoleVisible(role) {
2060
+ return role !== "queued" && role !== "gone";
2061
+ }
2062
+ function sameBox(a, b) {
2063
+ if (a === null || b === null)
2064
+ return a === b;
2065
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
2066
+ }
2067
+ function applyStage(engine, timeS, instant) {
2068
+ const container = engine.lyricsContainer;
2069
+ if (!container)
2070
+ return;
2071
+ registerStageOpacity(engine);
2072
+ const { elements, items } = stageElements(engine);
2073
+ const roles = planStage(items, timeS);
2074
+ const key = roles.join(",");
2075
+ if (key === engine.stageKey && !instant)
2076
+ return;
2077
+ engine.stageKey = key;
2078
+ const metrics = elements.map(element => engine.stageMetrics.get(element) ?? { height: 0, left: 0, width: 0, originX: 0 });
2079
+ const { placements, box } = layoutStage(roles, items, metrics, elements.map(element => engine.stageY.get(element) ?? null), {
2080
+ stageHeight: container.clientHeight,
2081
+ gap: engine.stageFontSize * STAGE_GAP_EM,
2082
+ activeScale: getCSSNumber(engine, container, "--blyrics-active-scale", 1),
2083
+ });
2084
+ // Only the elements whose place changed move. A line that stays where it is keeps its running
2085
+ // animations, and one already hidden stays hidden without being animated again.
2086
+ const moves = [];
2087
+ elements.forEach((element, index) => {
2088
+ const placement = placements[index];
2089
+ const previousY = engine.stageY.get(element);
2090
+ const wasVisible = element.dataset.stageRole !== undefined && isStageRoleVisible(element.dataset.stageRole);
2091
+ const unchanged = previousY !== undefined && wasVisible === placement.visible && (!placement.visible || previousY === placement.y);
2092
+ if (unchanged && !instant)
2093
+ return;
2094
+ moves.push({
2095
+ element,
2096
+ placement,
2097
+ wasVisible,
2098
+ fromY: currentTranslateY(engine, element, previousY ?? placement.y),
2099
+ fromOpacity: Number(engine.window.getComputedStyle(element).opacity) || 0,
2100
+ });
2101
+ });
2102
+ const reduced = engine.window.matchMedia(REDUCED_MOTION_QUERY).matches;
2103
+ elements.forEach((element, index) => {
2104
+ if (element.dataset.stageRole !== roles[index])
2105
+ element.dataset.stageRole = roles[index];
2106
+ });
2107
+ for (const move of moves)
2108
+ placeStageElement(engine, move, instant, reduced);
2109
+ if (!sameBox(box, engine.stageBox)) {
2110
+ engine.stageBox = box;
2111
+ engine.host.onStageLayout?.(box);
2112
+ }
2113
+ }
1844
2114
  /**
1845
2115
  * Renders one view against a tick with nothing left out.
1846
2116
  */
@@ -1860,6 +2130,9 @@ export function tickView(engine, currentTime, options) {
1860
2130
  return "ok";
1861
2131
  }
1862
2132
  if (hasUnsyncedLyrics(engine)) {
2133
+ // The stage shows lines by their time, and unsynced lines have none.
2134
+ if (engine.layout === "stage")
2135
+ return "ok";
1863
2136
  if (!playbackClock.lastPlayState && isPlaying) {
1864
2137
  engine.scrollResumeTime = 0;
1865
2138
  }
@@ -1909,19 +2182,22 @@ export function tickView(engine, currentTime, options) {
1909
2182
  getCSSDurationInMs(engine, lyricsElement, "--blyrics-scroll-timing-offset") / 1000;
1910
2183
  const { config: animationConfig, scrollTiming } = getAnimationSettings(engine, lyricsElement);
1911
2184
  // Read layout values before the loop writes class changes, to avoid forced reflow
1912
- const tabRenderer = engine.host.getScrollElement();
1913
- if (!tabRenderer) {
2185
+ const isStage = engine.layout === "stage";
2186
+ const tabRenderer = isStage ? null : engine.host.getScrollElement();
2187
+ if (!isStage && !tabRenderer) {
1914
2188
  clearVisibleLyricWillChange(engine);
1915
2189
  return "ok";
1916
2190
  }
1917
- if (tabRenderer !== engine.observedTabRenderer) {
2191
+ if (tabRenderer && tabRenderer !== engine.observedTabRenderer) {
1918
2192
  setupTabRendererObserver(engine, tabRenderer);
1919
2193
  }
1920
- const tabRendererHeight = engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height;
2194
+ const tabRendererHeight = tabRenderer
2195
+ ? (engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height)
2196
+ : 0;
1921
2197
  // Read before the loop's class writes so this layout read is batched and forces no mid-frame reflow.
1922
- let scrollTop = tabRenderer.scrollTop;
1923
- const maxScrollTop = Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight);
1924
- if (animationConfig.enabled.scroll) {
2198
+ let scrollTop = tabRenderer?.scrollTop ?? 0;
2199
+ const maxScrollTop = tabRenderer ? Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight) : 0;
2200
+ if (!isStage && animationConfig.enabled.scroll) {
1925
2201
  updateVisibleLyricWillChange(engine, lines, scrollTop, engine.pendingLineScroll?.toScrollTop ?? scrollTop, tabRendererHeight);
1926
2202
  }
1927
2203
  else {
@@ -2064,6 +2340,11 @@ export function tickView(engine, currentTime, options) {
2064
2340
  delete lyricsElement.dataset.creditsFocused;
2065
2341
  newLyricSelected = true;
2066
2342
  }
2343
+ if (isStage || !tabRenderer) {
2344
+ if (isStage)
2345
+ applyStage(engine, currentTime, timeJumped);
2346
+ return "ok";
2347
+ }
2067
2348
  if (engine.scrollResumeTime < Date.now() || engine.scrollPos === -1) {
2068
2349
  if (activeElems.length == 0) {
2069
2350
  activeElems.push(lines[0]);
@@ -2292,6 +2573,13 @@ function applyScrollPadding(engine) {
2292
2573
  * nothing to work from once rendering resumes.
2293
2574
  */
2294
2575
  export function relayout(engine, measureLines) {
2576
+ if (engine.layout === "stage") {
2577
+ if (!measureLines || !engine.lyricsContainer)
2578
+ return;
2579
+ engine.cachedCreditsItem = measureTrailingItem(engine.lyricsContainer, CREDITS_CLASS);
2580
+ measureStage(engine);
2581
+ return;
2582
+ }
2295
2583
  applyScrollPadding(engine);
2296
2584
  const scrollElement = engine.host.getScrollElement();
2297
2585
  if (scrollElement)
@@ -2348,7 +2636,7 @@ function lineDecorators(lineElement) {
2348
2636
  */
2349
2637
  function captureDecorationSlide(engine) {
2350
2638
  const container = engine.lyricsContainer;
2351
- if (!container || !decorationSlideAllowed(engine, container))
2639
+ if (!container || engine.layout === "stage" || !decorationSlideAllowed(engine, container))
2352
2640
  return null;
2353
2641
  const before = engine.lines.map(line => ({
2354
2642
  line,
@@ -0,0 +1,15 @@
1
+ import type { PartData } from "./inject.js";
2
+ export declare const imageHighlights: import("./themeSettings.js").Setting;
3
+ export interface ImageHighlightLayers {
4
+ glow: HTMLElement;
5
+ glowLetters: HTMLElement[];
6
+ /** A separate inline layout host, only for runs that can fragment. */
7
+ highlight?: HTMLElement;
8
+ }
9
+ export declare function wrapImageHighlight(highlight: HTMLElement): ImageHighlightLayers | undefined;
10
+ /** Keep bidi ordering and inline fragments in the browser's normal text layout. An
11
+ * absolutely positioned copy inside an inline word cannot reproduce those fragments. */
12
+ export declare function alignImageGlowRun(run: HTMLElement, parts: PartData[]): void;
13
+ /** A single CSS url(), not gradients or an arbitrary list of background layers. */
14
+ export declare function imageURL(value: string): string | null;
15
+ export declare function refreshInstrumentalImages(container: HTMLElement | null, view: Window): void;
@@ -0,0 +1,80 @@
1
+ // Optional image paint, independent of HDR: a theme supplies the image and its dynamic range.
2
+ // The extra layer exists only when requested. Blur surrounds the already-masked paint, so it
3
+ // cannot be cut off by the karaoke mask. Both copies are driven by the engine's own animations.
4
+ import { registerThemeSetting } from "./themeSettings.js";
5
+ export const imageHighlights = registerThemeSetting("blyrics-image-highlights", false, true);
6
+ export function wrapImageHighlight(highlight) {
7
+ if (!imageHighlights.getBooleanValue())
8
+ return;
9
+ const doc = highlight.ownerDocument;
10
+ const paint = doc.createElement("span");
11
+ paint.classList.add("blyrics-image-fill");
12
+ while (highlight.firstChild)
13
+ paint.appendChild(highlight.firstChild);
14
+ const glow = doc.createElement("span");
15
+ glow.classList.add("blyrics-image-glow");
16
+ glow.setAttribute("aria-hidden", "true");
17
+ glow.appendChild(paint.cloneNode(true));
18
+ highlight.classList.add("blyrics-image-highlight");
19
+ highlight.appendChild(glow);
20
+ highlight.appendChild(paint);
21
+ return { glow, glowLetters: Array.from(glow.querySelectorAll(".blyrics--letter")) };
22
+ }
23
+ /** Keep bidi ordering and inline fragments in the browser's normal text layout. An
24
+ * absolutely positioned copy inside an inline word cannot reproduce those fragments. */
25
+ export function alignImageGlowRun(run, parts) {
26
+ if (!imageHighlights.getBooleanValue())
27
+ return;
28
+ if (!run.parentElement?.classList.contains("blyrics-bidi-sensitive") &&
29
+ !run.querySelector(".blyrics-word-group-long"))
30
+ return;
31
+ const originals = Array.from(run.querySelectorAll(".blyrics-word-highlight"));
32
+ const byHighlight = new Map(parts.map(part => [part.highlightElement, part]));
33
+ const glowRun = run.cloneNode(true);
34
+ glowRun.classList.add("blyrics-image-glow-run");
35
+ const copies = glowRun.querySelectorAll(".blyrics-word-highlight");
36
+ originals.forEach((original, index) => {
37
+ const part = byHighlight.get(original);
38
+ if (!part?.imageLayers)
39
+ return;
40
+ const copy = copies[index];
41
+ copy.replaceChildren(part.imageLayers.glow);
42
+ part.imageLayers.highlight = copy;
43
+ // Non-fragmented groups in a mixed run can still wobble. Give their glow the
44
+ // identical group transform, rather than letting it drift from the sharp copy.
45
+ const group = original.parentElement;
46
+ if (group && part.wobbleElements.includes(group) && copy.parentElement)
47
+ part.wobbleElements.push(copy.parentElement);
48
+ });
49
+ run.parentElement.insertBefore(glowRun, run);
50
+ }
51
+ /** A single CSS url(), not gradients or an arbitrary list of background layers. */
52
+ export function imageURL(value) {
53
+ const match = /^url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s'"()]+))\s*\)$/i.exec(value.trim());
54
+ return match ? (match[1] ?? match[2] ?? match[3]) : null;
55
+ }
56
+ // SVG <image> follows the browser's normal image drawing path, including gain maps. feImage
57
+ // and SVG patterns can rasterize through an SDR intermediate. Keep the original color path
58
+ // available until loading succeeds, and whenever a theme's media query supplies no image.
59
+ export function refreshInstrumentalImages(container, view) {
60
+ if (!container || !imageHighlights.getBooleanValue())
61
+ return;
62
+ for (const svg of container.querySelectorAll(".blyrics--instrumental-icon")) {
63
+ const image = svg.querySelector(".blyrics-instrumental-image");
64
+ if (!image)
65
+ continue;
66
+ const source = imageURL(view.getComputedStyle(svg).getPropertyValue("--blyrics-highlight-image"));
67
+ if (source === image.getAttribute("href"))
68
+ continue;
69
+ svg.removeAttribute("data-image-ready");
70
+ image.onload = () => {
71
+ if (image.getAttribute("href") === source)
72
+ svg.setAttribute("data-image-ready", "");
73
+ };
74
+ image.onerror = () => svg.removeAttribute("data-image-ready");
75
+ if (source)
76
+ image.setAttribute("href", source);
77
+ else
78
+ image.removeAttribute("href");
79
+ }
80
+ }
package/dist/index.d.ts CHANGED
@@ -10,3 +10,7 @@ export type { PartData } from "./inject.js";
10
10
  export type { LyricPart, LyricsRendererDebugSink, LyricsRendererOptions, LyricSyncType } from "./types.js";
11
11
  /** @public */
12
12
  export type { SetLyricsOptions } from "./view.js";
13
+ /** @public */
14
+ export type { LyricsLayout } from "./types.js";
15
+ /** @public */
16
+ export type { StageBox } from "./stage.js";