@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/README.md +66 -3
- package/dist/README.md +66 -3
- package/dist/engine.d.ts +17 -2
- package/dist/engine.js +321 -33
- package/dist/imageHighlights.d.ts +15 -0
- package/dist/imageHighlights.js +80 -0
- package/dist/index.d.ts +4 -0
- package/dist/inject.d.ts +2 -0
- package/dist/inject.js +16 -4
- package/dist/instrumental.d.ts +1 -2
- package/dist/instrumental.js +47 -5
- package/dist/renderer.js +23 -1
- package/dist/stage.d.ts +41 -0
- package/dist/stage.js +76 -0
- package/dist/styles/image-highlights.css +62 -0
- package/dist/styles/lyrics.css +8 -1
- package/dist/styles/stage.css +57 -0
- package/dist/styles/variables.css +5 -0
- package/dist/types.d.ts +10 -0
- package/dist/view.js +9 -1
- package/package.json +1 -1
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
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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 = [
|
|
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
|
|
1913
|
-
|
|
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 =
|
|
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
|
|
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";
|