@braccato/core 1.13.2 → 1.14.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 +39 -0
- package/dist/README.md +39 -0
- package/dist/engine.d.ts +4 -0
- package/dist/engine.js +84 -24
- package/dist/imageHighlights.d.ts +15 -0
- package/dist/imageHighlights.js +80 -0
- package/dist/inject.d.ts +2 -0
- package/dist/inject.js +16 -4
- package/dist/instrumental.js +40 -1
- package/dist/renderer.js +21 -0
- package/dist/styles/image-highlights.css +62 -0
- package/dist/styles/lyrics.css +8 -1
- package/dist/styles/variables.css +5 -0
- package/dist/view.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -479,3 +479,42 @@ loading regional CJK web fonts should set `--noto-sans-universal` on these eleme
|
|
|
479
479
|
`--blyrics-font-family` and `--blyrics-translated-font-family` remain explicit theme overrides.
|
|
480
480
|
They are unset by default; custom CSS that reads them directly should use
|
|
481
481
|
`var(--blyrics-font-family, var(--blyrics-default-font-family))` for the default stack.
|
|
482
|
+
|
|
483
|
+
### Theme-provided image fills
|
|
484
|
+
|
|
485
|
+
Image highlights are optional. Enable them in a theme comment:
|
|
486
|
+
|
|
487
|
+
```css
|
|
488
|
+
/* blyrics-image-highlights = true; */
|
|
489
|
+
.blyrics-container {
|
|
490
|
+
--blyrics-highlight-image: url("my-image.jpg");
|
|
491
|
+
--blyrics-image-glow-opacity-from: 0.5;
|
|
492
|
+
--blyrics-image-glow-opacity-to: 0;
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Changing this setting requests a lyric rebuild through the existing `setTheme` return value.
|
|
497
|
+
Without it, the renderer adds no image or glow layers and keeps its shadow animation. With it,
|
|
498
|
+
the sharp fill and a separate blurred copy share the word reveal, letter motion, opacity, pause,
|
|
499
|
+
seek and playback-rate handling. Existing glow radius, duration and easing properties still apply;
|
|
500
|
+
the two image-glow opacity properties replace the solid shadow color's alpha.
|
|
501
|
+
|
|
502
|
+
The image may be an SDR texture or an HDR gain-map asset. The theme owns HDR media queries
|
|
503
|
+
and `dynamic-range-limit`; the core never opts into extra display brightness. Ordinary active
|
|
504
|
+
text color backs up the image fill, while the note keeps its original color path until its
|
|
505
|
+
SVG image loads. The note image reuses the original animated wave clip and fade group.
|
|
506
|
+
|
|
507
|
+
The glow layer is outside the paint's mask, avoiding clipped halos at word edges. Bidi-sensitive
|
|
508
|
+
runs and fragmented long words use a parallel inline text run for the glow, so the browser
|
|
509
|
+
reproduces the same ordering and wrapped fragments. Its reveal and visibility use the same
|
|
510
|
+
animation clock as the sharp text. No generated text or per-frame geometry reads are needed.
|
|
511
|
+
Joining-script word groups retain contextual joining by using whole-word sweeps instead of
|
|
512
|
+
inline-block letter waves, including when image highlights are disabled.
|
|
513
|
+
Forced colors suppress the image and halo in favor of system text colors.
|
|
514
|
+
|
|
515
|
+
For real-browser regression checks, build the package, serve the repository root and open
|
|
516
|
+
`tooling/browser/image-highlights.html`. Import `image-highlights-checks.js` from that page and
|
|
517
|
+
call `runImageHighlightChecks(testView)`. The default fixture uses an SDR image; a `theme` query
|
|
518
|
+
parameter can point at a consumer's HDR stylesheet for physical-display testing.
|
|
519
|
+
|
|
520
|
+
Words that mix RTL characters with LTR letters or numbers retain native text shaping instead of per-letter motion. Image glow, letter masks and letter motion retain their current paint during the line exit fade.
|
package/dist/README.md
CHANGED
|
@@ -479,3 +479,42 @@ loading regional CJK web fonts should set `--noto-sans-universal` on these eleme
|
|
|
479
479
|
`--blyrics-font-family` and `--blyrics-translated-font-family` remain explicit theme overrides.
|
|
480
480
|
They are unset by default; custom CSS that reads them directly should use
|
|
481
481
|
`var(--blyrics-font-family, var(--blyrics-default-font-family))` for the default stack.
|
|
482
|
+
|
|
483
|
+
### Theme-provided image fills
|
|
484
|
+
|
|
485
|
+
Image highlights are optional. Enable them in a theme comment:
|
|
486
|
+
|
|
487
|
+
```css
|
|
488
|
+
/* blyrics-image-highlights = true; */
|
|
489
|
+
.blyrics-container {
|
|
490
|
+
--blyrics-highlight-image: url("my-image.jpg");
|
|
491
|
+
--blyrics-image-glow-opacity-from: 0.5;
|
|
492
|
+
--blyrics-image-glow-opacity-to: 0;
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Changing this setting requests a lyric rebuild through the existing `setTheme` return value.
|
|
497
|
+
Without it, the renderer adds no image or glow layers and keeps its shadow animation. With it,
|
|
498
|
+
the sharp fill and a separate blurred copy share the word reveal, letter motion, opacity, pause,
|
|
499
|
+
seek and playback-rate handling. Existing glow radius, duration and easing properties still apply;
|
|
500
|
+
the two image-glow opacity properties replace the solid shadow color's alpha.
|
|
501
|
+
|
|
502
|
+
The image may be an SDR texture or an HDR gain-map asset. The theme owns HDR media queries
|
|
503
|
+
and `dynamic-range-limit`; the core never opts into extra display brightness. Ordinary active
|
|
504
|
+
text color backs up the image fill, while the note keeps its original color path until its
|
|
505
|
+
SVG image loads. The note image reuses the original animated wave clip and fade group.
|
|
506
|
+
|
|
507
|
+
The glow layer is outside the paint's mask, avoiding clipped halos at word edges. Bidi-sensitive
|
|
508
|
+
runs and fragmented long words use a parallel inline text run for the glow, so the browser
|
|
509
|
+
reproduces the same ordering and wrapped fragments. Its reveal and visibility use the same
|
|
510
|
+
animation clock as the sharp text. No generated text or per-frame geometry reads are needed.
|
|
511
|
+
Joining-script word groups retain contextual joining by using whole-word sweeps instead of
|
|
512
|
+
inline-block letter waves, including when image highlights are disabled.
|
|
513
|
+
Forced colors suppress the image and halo in favor of system text colors.
|
|
514
|
+
|
|
515
|
+
For real-browser regression checks, build the package, serve the repository root and open
|
|
516
|
+
`tooling/browser/image-highlights.html`. Import `image-highlights-checks.js` from that page and
|
|
517
|
+
call `runImageHighlightChecks(testView)`. The default fixture uses an SDR image; a `theme` query
|
|
518
|
+
parameter can point at a consumer's HDR stylesheet for physical-display testing.
|
|
519
|
+
|
|
520
|
+
Words that mix RTL characters with LTR letters or numbers retain native text shaping instead of per-letter motion. Image glow, letter masks and letter motion retain their current paint during the line exit fade.
|
package/dist/engine.d.ts
CHANGED
|
@@ -171,6 +171,10 @@ interface AnimationConfig {
|
|
|
171
171
|
swipeEndFrom: string;
|
|
172
172
|
swipeStartTo: string;
|
|
173
173
|
swipeEndTo: string;
|
|
174
|
+
imageGlowFrom: string;
|
|
175
|
+
imageGlowTo: string;
|
|
176
|
+
imageGlowOpacityFrom: string;
|
|
177
|
+
imageGlowOpacityTo: string;
|
|
174
178
|
glowFrom: string;
|
|
175
179
|
glowTo: string;
|
|
176
180
|
glowDurationRatio: number;
|
package/dist/engine.js
CHANGED
|
@@ -319,6 +319,7 @@ function togglePartClass(part, className, force) {
|
|
|
319
319
|
part.lyricElement.classList.toggle(className, force);
|
|
320
320
|
if ("highlightElement" in part) {
|
|
321
321
|
part.highlightElement.classList.toggle(className, force);
|
|
322
|
+
part.imageLayers?.highlight?.classList.toggle(className, force);
|
|
322
323
|
}
|
|
323
324
|
}
|
|
324
325
|
function setAnimationsPlayState(lineData, isPlaying) {
|
|
@@ -359,6 +360,7 @@ export function updateWordStates(lineData, currentTime) {
|
|
|
359
360
|
part.wordState = state;
|
|
360
361
|
part.lyricElement.setAttribute(WORD_STATE_ATTR, state);
|
|
361
362
|
part.highlightElement.setAttribute(WORD_STATE_ATTR, state);
|
|
363
|
+
part.imageLayers?.highlight?.setAttribute(WORD_STATE_ATTR, state);
|
|
362
364
|
}
|
|
363
365
|
}
|
|
364
366
|
const LINE_SYNCED_WORD_CLASS = "blyrics-line-synced-word";
|
|
@@ -386,6 +388,25 @@ const LINE_SCROLL_INLINE_PROPERTIES = [
|
|
|
386
388
|
...LINE_SCROLL_STYLE_SETTINGS.map(([property]) => property),
|
|
387
389
|
];
|
|
388
390
|
const animationTimingTracks = new WeakMap();
|
|
391
|
+
function mirrorImageHighlightAnimations(part, animations) {
|
|
392
|
+
const target = part.imageLayers?.highlight;
|
|
393
|
+
if (!target)
|
|
394
|
+
return [];
|
|
395
|
+
const mirrors = [];
|
|
396
|
+
for (const animation of animations) {
|
|
397
|
+
const effect = animation.effect;
|
|
398
|
+
if (!effect || effect.target !== part.highlightElement)
|
|
399
|
+
continue;
|
|
400
|
+
const mirror = target.animate(effect.getKeyframes(), effect.getTiming());
|
|
401
|
+
mirror.playbackRate = animation.playbackRate;
|
|
402
|
+
mirror.currentTime = animation.currentTime;
|
|
403
|
+
const timing = animationTimingTracks.get(animation);
|
|
404
|
+
if (timing)
|
|
405
|
+
animationTimingTracks.set(mirror, timing);
|
|
406
|
+
mirrors.push(mirror);
|
|
407
|
+
}
|
|
408
|
+
return mirrors;
|
|
409
|
+
}
|
|
389
410
|
function trackLyricAnimationTiming(engine, animation, timing) {
|
|
390
411
|
animationTimingTracks.set(animation, {
|
|
391
412
|
...timing,
|
|
@@ -652,7 +673,12 @@ function activeTextGradientKeyframes(config) {
|
|
|
652
673
|
},
|
|
653
674
|
];
|
|
654
675
|
}
|
|
655
|
-
function activeTextGlowKeyframes(config) {
|
|
676
|
+
function activeTextGlowKeyframes(config, part) {
|
|
677
|
+
if (part.imageLayers)
|
|
678
|
+
return [
|
|
679
|
+
{ filter: `blur(${config.highlight.imageGlowFrom})`, opacity: config.highlight.imageGlowOpacityFrom },
|
|
680
|
+
{ filter: `blur(${config.highlight.imageGlowTo})`, opacity: config.highlight.imageGlowOpacityTo },
|
|
681
|
+
];
|
|
656
682
|
return [{ filter: config.highlight.glowFrom }, { filter: config.highlight.glowTo }];
|
|
657
683
|
}
|
|
658
684
|
function activeTextVisibleKeyframes() {
|
|
@@ -686,17 +712,17 @@ function lineSyncedTextKeyframes(config) {
|
|
|
686
712
|
},
|
|
687
713
|
];
|
|
688
714
|
}
|
|
689
|
-
function fadeOutTextKeyframes(config) {
|
|
715
|
+
function fadeOutTextKeyframes(config, part) {
|
|
690
716
|
return [
|
|
691
717
|
{
|
|
692
718
|
opacity: 1,
|
|
693
|
-
filter: config.highlight.glowTo,
|
|
719
|
+
filter: part.imageLayers ? "none" : config.highlight.glowTo,
|
|
694
720
|
"--lyric-transition-amount-start": config.highlight.swipeStartTo,
|
|
695
721
|
"--lyric-transition-amount-end": config.highlight.swipeEndTo,
|
|
696
722
|
},
|
|
697
723
|
{
|
|
698
724
|
opacity: 0,
|
|
699
|
-
filter: config.highlight.glowTo,
|
|
725
|
+
filter: part.imageLayers ? "none" : config.highlight.glowTo,
|
|
700
726
|
"--lyric-transition-amount-start": config.highlight.swipeStartTo,
|
|
701
727
|
"--lyric-transition-amount-end": config.highlight.swipeEndTo,
|
|
702
728
|
},
|
|
@@ -790,15 +816,18 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
|
|
|
790
816
|
endTo: config.highlight.swipeEndTo,
|
|
791
817
|
}, highlightLetters.length, swipeDurationMs, part.highlightElement.classList.contains(RTL_CLASS));
|
|
792
818
|
sweeps.forEach((sweep, index) => {
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
819
|
+
const targets = [highlightLetters[index], part.imageLayers?.glowLetters[index]].filter((target) => target !== undefined);
|
|
820
|
+
for (const target of targets) {
|
|
821
|
+
const animation = trackLyricAnimationTiming(engine, target.animate(sweep.keyframes.map(frame => ({
|
|
822
|
+
offset: frame.offset,
|
|
823
|
+
maskPosition: frame.maskPosition,
|
|
824
|
+
WebkitMaskPosition: frame.maskPosition,
|
|
825
|
+
})), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }), swipeTiming);
|
|
826
|
+
animation.currentTime = swipeCurrentTimeMs;
|
|
827
|
+
if (index === 0)
|
|
828
|
+
swipeAnimation = animation;
|
|
829
|
+
animations.push(animation);
|
|
830
|
+
}
|
|
802
831
|
});
|
|
803
832
|
}
|
|
804
833
|
else {
|
|
@@ -820,10 +849,10 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
|
|
|
820
849
|
animations.push(opacityAnimation);
|
|
821
850
|
let glowAnimation;
|
|
822
851
|
if (config.enabled.highlightGlow && !part.glowSuppressed) {
|
|
823
|
-
glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
|
|
852
|
+
glowAnimation = trackLyricAnimationTiming(engine, (part.imageLayers?.glow ?? highlight).animate(activeTextGlowKeyframes(config, part), {
|
|
824
853
|
duration: glowDurationMs,
|
|
825
854
|
easing: config.highlight.glowEasing,
|
|
826
|
-
fill: config.highlight.glowRestingInvisible ? "none" : "forwards",
|
|
855
|
+
fill: part.imageLayers ? "forwards" : config.highlight.glowRestingInvisible ? "none" : "forwards",
|
|
827
856
|
}), { appliedTimingOffsetMs, offsetMs: 0 });
|
|
828
857
|
glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
|
|
829
858
|
animations.push(glowAnimation);
|
|
@@ -843,10 +872,10 @@ function startLineSyncedHighlightAnimations(engine, part, config, wordTimeMs, gl
|
|
|
843
872
|
animations.push(opacityAnimation);
|
|
844
873
|
let glowAnimation;
|
|
845
874
|
if (config.enabled.highlightGlow && !part.glowSuppressed) {
|
|
846
|
-
glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
|
|
875
|
+
glowAnimation = trackLyricAnimationTiming(engine, (part.imageLayers?.glow ?? highlight).animate(activeTextGlowKeyframes(config, part), {
|
|
847
876
|
duration: glowDurationMs,
|
|
848
877
|
easing: config.highlight.glowEasing,
|
|
849
|
-
fill: config.highlight.glowRestingInvisible ? "none" : "forwards",
|
|
878
|
+
fill: part.imageLayers ? "forwards" : config.highlight.glowRestingInvisible ? "none" : "forwards",
|
|
850
879
|
}), { appliedTimingOffsetMs, offsetMs: 0 });
|
|
851
880
|
glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
|
|
852
881
|
animations.push(glowAnimation);
|
|
@@ -947,7 +976,7 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
947
976
|
const cascadeDurationMs = config.letterWave.durationMs + (letterCount - 1) * staggerMs;
|
|
948
977
|
const floatStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, cascadeDurationMs);
|
|
949
978
|
const floatKeyframeSignature = JSON.stringify(floatKeyframes);
|
|
950
|
-
for (const set of [part.letterElements, part.highlightLetterElements]) {
|
|
979
|
+
for (const set of [part.letterElements, part.highlightLetterElements, part.imageLayers?.glowLetters]) {
|
|
951
980
|
set?.forEach((letterElement, index) => {
|
|
952
981
|
const animation = trackLyricAnimationTiming(engine, acquireWaveAnimation(engine, letterElement, floatKeyframes, floatKeyframeSignature, {
|
|
953
982
|
duration: config.letterWave.durationMs,
|
|
@@ -960,7 +989,11 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
960
989
|
}
|
|
961
990
|
}
|
|
962
991
|
}
|
|
963
|
-
part.animations = [
|
|
992
|
+
part.animations = [
|
|
993
|
+
...highlightAnimations.animations,
|
|
994
|
+
...mirrorImageHighlightAnimations(part, highlightAnimations.animations),
|
|
995
|
+
...wobbleAnimations,
|
|
996
|
+
];
|
|
964
997
|
}
|
|
965
998
|
function startLineAnimations(engine, lineData, config, currentTime) {
|
|
966
999
|
const appliedTimingOffsetMs = engine.learnedAnimationTimingOffsetMs;
|
|
@@ -974,20 +1007,43 @@ function startLineAnimations(engine, lineData, config, currentTime) {
|
|
|
974
1007
|
startWordAnimations(engine, part, config, currentTime, appliedTimingOffsetMs);
|
|
975
1008
|
}
|
|
976
1009
|
}
|
|
977
|
-
function startWordExitAnimation(part, config) {
|
|
1010
|
+
function startWordExitAnimation(part, config, imagePaint = []) {
|
|
978
1011
|
resetPartAnimations(part);
|
|
979
1012
|
const fadeDuration = config.enabled.highlightFade ? config.highlight.fadeOutDurationMs : 1;
|
|
980
|
-
const animation = part.highlightElement.animate(fadeOutTextKeyframes(config), {
|
|
1013
|
+
const animation = part.highlightElement.animate(fadeOutTextKeyframes(config, part), {
|
|
981
1014
|
duration: fadeDuration,
|
|
982
1015
|
easing: config.enabled.highlightFade ? config.highlight.fadeOutEasing : "linear",
|
|
983
1016
|
fill: "none",
|
|
984
1017
|
});
|
|
985
|
-
part.animations = [animation];
|
|
1018
|
+
part.animations = [animation, ...mirrorImageHighlightAnimations(part, [animation])];
|
|
1019
|
+
// Keep the current blur, letter masks and motion visible until the parent fade ends.
|
|
1020
|
+
for (const { target, frame } of imagePaint) {
|
|
1021
|
+
part.animations.push(target.animate([frame, frame], { duration: fadeDuration, fill: "none" }));
|
|
1022
|
+
}
|
|
986
1023
|
animation.addEventListener("finish", () => {
|
|
987
1024
|
resetPartAnimations(part);
|
|
988
1025
|
}, { once: true });
|
|
989
1026
|
}
|
|
990
1027
|
function startLineExitAnimations(engine, lineData, config, currentTime) {
|
|
1028
|
+
// Read the entire line before cancelling any effects, so this does not alternate
|
|
1029
|
+
// per-word style reads with writes. There are no extra reads during normal ticks.
|
|
1030
|
+
const imagePaint = new Map();
|
|
1031
|
+
for (const part of lineData.parts) {
|
|
1032
|
+
if (!part.imageLayers || currentTime < part.time)
|
|
1033
|
+
continue;
|
|
1034
|
+
const style = engine.window.getComputedStyle(part.imageLayers.glow);
|
|
1035
|
+
const paint = [
|
|
1036
|
+
{ target: part.imageLayers.glow, frame: { opacity: style.opacity, filter: style.filter } },
|
|
1037
|
+
];
|
|
1038
|
+
for (const target of part.letterElements ?? []) {
|
|
1039
|
+
paint.push({ target, frame: { transform: engine.window.getComputedStyle(target).transform } });
|
|
1040
|
+
}
|
|
1041
|
+
for (const target of [...(part.highlightLetterElements ?? []), ...part.imageLayers.glowLetters]) {
|
|
1042
|
+
const letterStyle = engine.window.getComputedStyle(target);
|
|
1043
|
+
paint.push({ target, frame: { maskPosition: letterStyle.maskPosition, transform: letterStyle.transform } });
|
|
1044
|
+
}
|
|
1045
|
+
imagePaint.set(part, paint);
|
|
1046
|
+
}
|
|
991
1047
|
startLineExitAnimation(lineData, config);
|
|
992
1048
|
if (lineData.lyricElement.dataset.instrumental === "true") {
|
|
993
1049
|
startInstrumentalExitAnimations(engine, lineData, config, currentTime);
|
|
@@ -995,7 +1051,7 @@ function startLineExitAnimations(engine, lineData, config, currentTime) {
|
|
|
995
1051
|
}
|
|
996
1052
|
for (const part of lineData.parts) {
|
|
997
1053
|
if (currentTime >= part.time) {
|
|
998
|
-
startWordExitAnimation(part, config);
|
|
1054
|
+
startWordExitAnimation(part, config, imagePaint.get(part));
|
|
999
1055
|
}
|
|
1000
1056
|
else {
|
|
1001
1057
|
resetPartAnimations(part);
|
|
@@ -1171,7 +1227,7 @@ export function parseColorAlpha(value) {
|
|
|
1171
1227
|
// Per-word glow is resolved only when the container glow already renders nothing, so an empty word
|
|
1172
1228
|
// drops its blur while a word a theme lit up keeps it.
|
|
1173
1229
|
function resolveLineGlowSuppression(engine, lineData, config) {
|
|
1174
|
-
if (config.highlight.glowContainerAlpha >= GLOW_INVISIBLE_ALPHA) {
|
|
1230
|
+
if (lineData.parts.some(part => part.imageLayers) || config.highlight.glowContainerAlpha >= GLOW_INVISIBLE_ALPHA) {
|
|
1175
1231
|
for (const part of lineData.parts)
|
|
1176
1232
|
part.glowSuppressed = false;
|
|
1177
1233
|
return;
|
|
@@ -1229,6 +1285,10 @@ function readAnimationConfig(engine, lyricsElement) {
|
|
|
1229
1285
|
swipeEndFrom: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-end-from", "-0.1"),
|
|
1230
1286
|
swipeStartTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-start-to", "1.4"),
|
|
1231
1287
|
swipeEndTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-end-to", "1.5"),
|
|
1288
|
+
imageGlowFrom: getCSSValue(engine, lyricsElement, "--blyrics-highlight-glow-radius-from", "0.8rem"),
|
|
1289
|
+
imageGlowTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-glow-radius-to", "0px"),
|
|
1290
|
+
imageGlowOpacityFrom: getCSSValue(engine, lyricsElement, "--blyrics-image-glow-opacity-from", "0.5"),
|
|
1291
|
+
imageGlowOpacityTo: getCSSValue(engine, lyricsElement, "--blyrics-image-glow-opacity-to", "0"),
|
|
1232
1292
|
glowFrom: resolveGlowFilter(engine, lyricsElement, "from", "0.8rem"),
|
|
1233
1293
|
glowTo: resolveGlowFilter(engine, lyricsElement, "to", "0"),
|
|
1234
1294
|
glowDurationRatio: getCSSNumber(engine, lyricsElement, "--blyrics-highlight-glow-duration-ratio", 1.2),
|
|
@@ -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/inject.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ImageHighlightLayers } from "./imageHighlights.js";
|
|
1
2
|
import { type WordState } from "./constants.js";
|
|
2
3
|
import type { Lyric, LyricPart, LyricSyncType } from "./types.js";
|
|
3
4
|
export declare let disableRichsync: import("./themeSettings.js").Setting;
|
|
@@ -19,6 +20,7 @@ export interface AnimationData {
|
|
|
19
20
|
}
|
|
20
21
|
export interface PartData extends AnimationData {
|
|
21
22
|
highlightElement: HTMLElement;
|
|
23
|
+
imageLayers?: ImageHighlightLayers;
|
|
22
24
|
letterElements?: HTMLElement[];
|
|
23
25
|
highlightLetterElements?: HTMLElement[];
|
|
24
26
|
wobbleElements: HTMLElement[];
|
package/dist/inject.js
CHANGED
|
@@ -11,6 +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 { alignImageGlowRun, wrapImageHighlight } from "./imageHighlights.js";
|
|
14
15
|
import { normalizeLanguage } from "./language.js";
|
|
15
16
|
import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LETTER_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, WORD_HIGHLIGHT_LETTERED_CLASS, WORD_STATE_ATTR, WORD_STATE_UPCOMING, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
|
|
16
17
|
import { getSeekTimeFromClick } from "./seek.js";
|
|
@@ -69,6 +70,7 @@ function newPartData(part, span, highlight, wobbleElements, letterElements, high
|
|
|
69
70
|
duration: part.durationMs / 1000,
|
|
70
71
|
lyricElement: span,
|
|
71
72
|
highlightElement: highlight,
|
|
73
|
+
imageLayers: wrapImageHighlight(highlight),
|
|
72
74
|
letterElements,
|
|
73
75
|
highlightLetterElements,
|
|
74
76
|
wobbleElements,
|
|
@@ -216,7 +218,7 @@ function appendLetters(doc, wordElement, text) {
|
|
|
216
218
|
return letter;
|
|
217
219
|
});
|
|
218
220
|
}
|
|
219
|
-
function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
221
|
+
function createTimedWordSpan(doc, part, wrapThreshold, perLetter, preserveJoining) {
|
|
220
222
|
const span = doc.createElement("span");
|
|
221
223
|
const highlight = doc.createElement("span");
|
|
222
224
|
highlight.classList.add(WORD_HIGHLIGHT_CLASS);
|
|
@@ -237,7 +239,8 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
|
237
239
|
wordElement.classList.add(BACKGROUND_LYRIC_CLASS);
|
|
238
240
|
if (part.explicit)
|
|
239
241
|
wordElement.classList.add(EXPLICIT_WORD_CLASS);
|
|
240
|
-
if (perLetter
|
|
242
|
+
if (perLetter) {
|
|
243
|
+
wordElement.classList.add("blyrics-word--lettered");
|
|
241
244
|
const collected = appendLetters(doc, wordElement, part.words);
|
|
242
245
|
if (wordElement === span) {
|
|
243
246
|
letters = collected;
|
|
@@ -247,6 +250,9 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
|
247
250
|
wordElement.classList.add(WORD_HIGHLIGHT_LETTERED_CLASS);
|
|
248
251
|
}
|
|
249
252
|
}
|
|
253
|
+
else if (preserveJoining) {
|
|
254
|
+
wordElement.textContent = part.words;
|
|
255
|
+
}
|
|
250
256
|
else {
|
|
251
257
|
appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
|
|
252
258
|
}
|
|
@@ -260,7 +266,10 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
|
260
266
|
}
|
|
261
267
|
function createWordGroup(doc, group, lineData) {
|
|
262
268
|
const wrapThreshold = Math.max(1, longWordWrapThreshold.getNumberValue());
|
|
263
|
-
|
|
269
|
+
// Atomic grapheme spans disrupt the browser's bidi ordering inside a mixed word.
|
|
270
|
+
const mixedDirection = testRtl(group.text) && [...group.text].some(char => /[\p{Letter}\p{Number}]/u.test(char) && !testRtl(char));
|
|
271
|
+
const preserveJoining = testJoiningScript(group.text);
|
|
272
|
+
const perLetter = letterWave.getBooleanValue() && !preserveJoining && !mixedDirection;
|
|
264
273
|
const lyricGroup = doc.createElement("span");
|
|
265
274
|
const highlightGroup = doc.createElement("span");
|
|
266
275
|
for (const groupElement of [lyricGroup, highlightGroup]) {
|
|
@@ -277,7 +286,7 @@ function createWordGroup(doc, group, lineData) {
|
|
|
277
286
|
for (const token of group.tokens) {
|
|
278
287
|
if (token.kind === "space")
|
|
279
288
|
continue;
|
|
280
|
-
const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter);
|
|
289
|
+
const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter, preserveJoining);
|
|
281
290
|
const wobbleElements = lyricGroup.childNodes.length === 0 ? [lyricGroup, highlightGroup] : [];
|
|
282
291
|
lineData.parts.push(newPartData(token.part, span, highlight, wobbleElements, letters, highlightLetters));
|
|
283
292
|
lyricGroup.appendChild(span);
|
|
@@ -366,6 +375,9 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
|
|
|
366
375
|
}
|
|
367
376
|
}
|
|
368
377
|
}
|
|
378
|
+
alignImageGlowRun(mainHighlightRun, line.parts);
|
|
379
|
+
if (hasBackground)
|
|
380
|
+
alignImageGlowRun(backgroundHighlightRun, line.parts);
|
|
369
381
|
lyricElement.appendChild(main);
|
|
370
382
|
if (hasBackground) {
|
|
371
383
|
lyricElement.appendChild(backgroundLine);
|
package/dist/instrumental.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { imageHighlights } from "./imageHighlights.js";
|
|
1
2
|
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
3
|
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";
|
|
3
4
|
/**
|
|
@@ -72,7 +73,45 @@ export function createInstrumentalElement(doc, container, durationMs, lineIndex)
|
|
|
72
73
|
fillPath.classList.add("blyrics--instrumental-fill");
|
|
73
74
|
fillPath.setAttribute("clip-path", `url(#${clipId})`);
|
|
74
75
|
fillPath.setAttribute("d", "M10 21q-1.65 0-2.825-1.175T6 17t1.175-2.825T10 13q.575 0 1.063.138t.937.412V4q0-.425.288-.712T13 3h4q.425 0 .713.288T18 4v2q0 .425-.288.713T17 7h-3v10q0 1.65-1.175 2.825T10 21");
|
|
75
|
-
|
|
76
|
+
if (imageHighlights.getBooleanValue()) {
|
|
77
|
+
// Fade the common group so both the image and the SDR fallback follow the original
|
|
78
|
+
// instrumental animation. The wave and silhouette clips are inside the glow filter.
|
|
79
|
+
fillPath.classList.remove("blyrics--instrumental-fill");
|
|
80
|
+
fillPath.classList.add("blyrics-instrumental-color");
|
|
81
|
+
const fill = doc.createElementNS(svgNS, "g");
|
|
82
|
+
fill.classList.add("blyrics--instrumental-fill");
|
|
83
|
+
fill.appendChild(fillPath);
|
|
84
|
+
const shapeId = `${clipId}-image-shape`;
|
|
85
|
+
const shape = doc.createElementNS(svgNS, "clipPath");
|
|
86
|
+
shape.setAttribute("id", shapeId);
|
|
87
|
+
const shapePath = doc.createElementNS(svgNS, "path");
|
|
88
|
+
shapePath.setAttribute("d", fillPath.getAttribute("d"));
|
|
89
|
+
shape.appendChild(shapePath);
|
|
90
|
+
defs.appendChild(shape);
|
|
91
|
+
const silhouette = doc.createElementNS(svgNS, "g");
|
|
92
|
+
silhouette.setAttribute("clip-path", `url(#${shapeId})`);
|
|
93
|
+
const wave = doc.createElementNS(svgNS, "g");
|
|
94
|
+
wave.setAttribute("clip-path", `url(#${clipId})`);
|
|
95
|
+
const image = doc.createElementNS(svgNS, "image");
|
|
96
|
+
image.classList.add("blyrics-instrumental-image");
|
|
97
|
+
image.setAttribute("x", "0");
|
|
98
|
+
image.setAttribute("y", "0");
|
|
99
|
+
image.setAttribute("width", "24");
|
|
100
|
+
image.setAttribute("height", "24");
|
|
101
|
+
image.setAttribute("preserveAspectRatio", "none");
|
|
102
|
+
wave.appendChild(image);
|
|
103
|
+
silhouette.appendChild(wave);
|
|
104
|
+
fill.appendChild(silhouette);
|
|
105
|
+
g.appendChild(fill);
|
|
106
|
+
// Blur RGB directly instead of recoloring the halo with an SDR flood/shadow.
|
|
107
|
+
filter.setAttribute("color-interpolation-filters", "sRGB");
|
|
108
|
+
feGaussianBlur.setAttribute("stdDeviation", "1");
|
|
109
|
+
filter.removeChild(feColorMatrix);
|
|
110
|
+
feMergeNode1.setAttribute("in", "blur");
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
g.appendChild(fillPath);
|
|
114
|
+
}
|
|
76
115
|
svg.appendChild(g);
|
|
77
116
|
container.appendChild(svg);
|
|
78
117
|
return container;
|
package/dist/renderer.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// this view on screen, is a loader up) and performs the actions it must not own (seek the player).
|
|
10
10
|
// `debug` is the exception because there is nothing to default it to. A consumer that wants the
|
|
11
11
|
// diagnostic overlay supplies the sink, and one that says nothing draws nothing.
|
|
12
|
+
import { imageHighlights, refreshInstrumentalImages } from "./imageHighlights.js";
|
|
12
13
|
import { CUSTOM_THEME_STYLE_ID } from "./constants.js";
|
|
13
14
|
import { clearLyrics, clearOnScreenLyrics as clearEngineOnScreenLyrics, clearStyleCaches as clearEngineStyleCaches, createAnimationEngineInstance, getRenderedLines, getRenderedSyncType, hasUnsyncedLyrics, noteContainerResize, noteUserScroll as noteEngineUserScroll, noteVisibilityChange as noteEngineVisibilityChange, relayout, resetScrollResume, resolveTickOptions, retickFromPlaybackClock as retickEngineFromPlaybackClock, scheduleLyricPositionUpdate as scheduleEngineLyricPositionUpdate, tickView, } from "./engine.js";
|
|
14
15
|
import { parseThemeConfig, setThemeSettings } from "./themeSettings.js";
|
|
@@ -139,6 +140,21 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
139
140
|
let currentLanguage = "";
|
|
140
141
|
const { host, forgetScrollElement } = withHostDefaults(rendererOptions.host, rendererWindow, () => mount);
|
|
141
142
|
const engine = createAnimationEngineInstance(rendererDocument, rendererWindow, host);
|
|
143
|
+
let imageMediaQueries = [];
|
|
144
|
+
const refreshImagePaint = () => refreshInstrumentalImages(engine.lyricsContainer, rendererWindow);
|
|
145
|
+
function syncImagePaint() {
|
|
146
|
+
if (imageHighlights.getBooleanValue() && imageMediaQueries.length === 0) {
|
|
147
|
+
imageMediaQueries = ["(dynamic-range: high)", "(forced-colors: active)"].map(query => rendererWindow.matchMedia(query));
|
|
148
|
+
for (const query of imageMediaQueries)
|
|
149
|
+
query.addEventListener("change", refreshImagePaint);
|
|
150
|
+
}
|
|
151
|
+
else if (!imageHighlights.getBooleanValue()) {
|
|
152
|
+
for (const query of imageMediaQueries)
|
|
153
|
+
query.removeEventListener("change", refreshImagePaint);
|
|
154
|
+
imageMediaQueries = [];
|
|
155
|
+
}
|
|
156
|
+
refreshImagePaint();
|
|
157
|
+
}
|
|
142
158
|
/**
|
|
143
159
|
* Every re-measurement runs through here, which makes it the one place that knows the layout may
|
|
144
160
|
* have moved under the view. The default scroll element is walked once and remembered, so this is
|
|
@@ -262,6 +278,7 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
262
278
|
noLyrics: options?.noLyrics ?? false,
|
|
263
279
|
songwriters: options?.songwriters,
|
|
264
280
|
});
|
|
281
|
+
syncImagePaint();
|
|
265
282
|
currentLyrics = lyrics;
|
|
266
283
|
currentLanguage = normalizeLanguage(options?.language);
|
|
267
284
|
measure();
|
|
@@ -284,6 +301,7 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
284
301
|
// the theme it is answering about.
|
|
285
302
|
const needsLyricRebuild = setThemeSettings(parseThemeConfig(css));
|
|
286
303
|
adoptThemeStyleSheet(css);
|
|
304
|
+
syncImagePaint();
|
|
287
305
|
// Everything the engine resolved off the document was resolved against the theme that just
|
|
288
306
|
// went away.
|
|
289
307
|
clearEngineStyleCaches(engine);
|
|
@@ -320,6 +338,9 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
320
338
|
createdThemeStyleElement?.remove();
|
|
321
339
|
createdThemeStyleElement = null;
|
|
322
340
|
rendererWindow.removeEventListener("resize", remeasureForViewport);
|
|
341
|
+
for (const query of imageMediaQueries)
|
|
342
|
+
query.removeEventListener("change", refreshImagePaint);
|
|
343
|
+
imageMediaQueries = [];
|
|
323
344
|
engine.destroy();
|
|
324
345
|
},
|
|
325
346
|
noteUserScroll() {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* The renderer adds these classes only for blyrics-image-highlights = true.
|
|
2
|
+
An SDR image is equally valid. HDR assets and dynamic-range-limit belong to the theme. */
|
|
3
|
+
.blyrics--word.blyrics-word-highlight.blyrics-image-highlight {
|
|
4
|
+
background: none;
|
|
5
|
+
mask: none;
|
|
6
|
+
-webkit-mask: none;
|
|
7
|
+
color: transparent;
|
|
8
|
+
}
|
|
9
|
+
.blyrics-image-fill,
|
|
10
|
+
.blyrics-word-highlight.blyrics-image-highlight .blyrics--letter {
|
|
11
|
+
background-image: var(--blyrics-highlight-image, none);
|
|
12
|
+
background-color: var(--blyrics-lyric-active-color, white);
|
|
13
|
+
background-size: 100% 100%;
|
|
14
|
+
background-repeat: no-repeat;
|
|
15
|
+
background-clip: text;
|
|
16
|
+
-webkit-background-clip: text;
|
|
17
|
+
color: transparent;
|
|
18
|
+
-webkit-text-fill-color: transparent;
|
|
19
|
+
}
|
|
20
|
+
.blyrics-image-highlight:not(.blyrics-word-highlight--lettered) .blyrics-image-fill {
|
|
21
|
+
--blyrics-image-sweep-angle: 90deg;
|
|
22
|
+
mask-image: linear-gradient(var(--blyrics-image-sweep-angle),
|
|
23
|
+
#000 calc(100% * var(--lyric-transition-amount-start)),
|
|
24
|
+
transparent calc(100% * var(--lyric-transition-amount-end) + 1px));
|
|
25
|
+
mask-repeat: no-repeat;
|
|
26
|
+
}
|
|
27
|
+
.blyrics-image-highlight.blyrics-rtl:not(.blyrics-word-highlight--lettered) .blyrics-image-fill {
|
|
28
|
+
--blyrics-image-sweep-angle: 270deg;
|
|
29
|
+
}
|
|
30
|
+
.blyrics-image-highlight.blyrics-word-highlight--lettered .blyrics-image-fill {
|
|
31
|
+
background: none;
|
|
32
|
+
}
|
|
33
|
+
.blyrics-image-glow {
|
|
34
|
+
position: absolute;
|
|
35
|
+
inset: 0;
|
|
36
|
+
pointer-events: none;
|
|
37
|
+
user-select: none;
|
|
38
|
+
opacity: 0;
|
|
39
|
+
overflow: visible;
|
|
40
|
+
}
|
|
41
|
+
/* A parallel run reproduces the full inline formatting context, including bidi
|
|
42
|
+
reordering and every wrapped fragment. No generated text or per-frame measuring. */
|
|
43
|
+
.blyrics-image-glow-run .blyrics-image-glow {
|
|
44
|
+
position: static;
|
|
45
|
+
inset: auto;
|
|
46
|
+
display: inline;
|
|
47
|
+
}
|
|
48
|
+
.blyrics-instrumental-image { display: none; }
|
|
49
|
+
.blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-image { display: inline; }
|
|
50
|
+
.blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-color { display: none; }
|
|
51
|
+
@media (forced-colors: active) {
|
|
52
|
+
.blyrics-image-glow,
|
|
53
|
+
.blyrics-image-glow-run .blyrics-image-glow { display: none; }
|
|
54
|
+
.blyrics-image-fill,
|
|
55
|
+
.blyrics-word-highlight.blyrics-image-highlight .blyrics--letter {
|
|
56
|
+
background: none;
|
|
57
|
+
color: CanvasText;
|
|
58
|
+
-webkit-text-fill-color: CanvasText;
|
|
59
|
+
}
|
|
60
|
+
.blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-image { display: none; }
|
|
61
|
+
.blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-color { display: inline; }
|
|
62
|
+
}
|
package/dist/styles/lyrics.css
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import url("image-highlights.css");
|
|
2
|
+
|
|
1
3
|
/* Resolve the default stack at each language boundary. A root-level var() would freeze
|
|
2
4
|
the host's language-specific fallback before the lyric/translation language is known.
|
|
3
5
|
Theme overrides remain inherited through --blyrics-font-family. */
|
|
@@ -122,7 +124,6 @@
|
|
|
122
124
|
.blyrics-bidi-sensitive .blyrics--word,
|
|
123
125
|
.blyrics-bidi-sensitive .blyrics-word-group:not(.blyrics-word-group-long) .blyrics--word {
|
|
124
126
|
display: inline;
|
|
125
|
-
unicode-bidi: normal;
|
|
126
127
|
white-space: inherit;
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -170,6 +171,12 @@
|
|
|
170
171
|
display: inline-block;
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
/* Atomic letter boxes otherwise take their visual ordering from the surrounding
|
|
175
|
+
paragraph. Isolate each word's own dir=auto while keeping it inline/wrappable. */
|
|
176
|
+
.blyrics--word.blyrics-word--lettered {
|
|
177
|
+
unicode-bidi: isolate;
|
|
178
|
+
}
|
|
179
|
+
|
|
173
180
|
.blyrics-word-highlight.blyrics-word-highlight--lettered {
|
|
174
181
|
background-image: none;
|
|
175
182
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
:root {
|
|
2
|
+
/* Optional image paint; themes own the asset and any HDR brightness policy. */
|
|
3
|
+
--blyrics-highlight-image: none;
|
|
4
|
+
--blyrics-image-glow-opacity-from: 0.5;
|
|
5
|
+
--blyrics-image-glow-opacity-to: 0;
|
|
6
|
+
|
|
2
7
|
/* Colors */
|
|
3
8
|
--blyrics-ui-text-color: var(
|
|
4
9
|
--blyrics-text-color,
|
package/dist/view.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { CREDITS_CLASS, CREDITS_NAMES_CLASS, CREDITS_TEXT_CLASS, LINE_CLASS, LYRICS_CLASS, RTL_CLASS, } from "./constants.js";
|
|
13
13
|
import { setupLineCullObserver } from "./engine.js";
|
|
14
14
|
import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, hideCredits, isNearestLyricRtl, newLineData, } from "./inject.js";
|
|
15
|
+
import { refreshInstrumentalImages } from "./imageHighlights.js";
|
|
15
16
|
import { createInstrumentalElement } from "./instrumental.js";
|
|
16
17
|
import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
|
|
17
18
|
const INITIAL_SKIP_SCROLLS = 2;
|
|
@@ -112,6 +113,7 @@ export function setLyrics(engine, mount, lyrics, options) {
|
|
|
112
113
|
engine.lines = lines;
|
|
113
114
|
engine.lyricsContainer = container;
|
|
114
115
|
engine.syncType = syncType;
|
|
116
|
+
refreshInstrumentalImages(container, engine.window);
|
|
115
117
|
// Measured last: the container is in the document, filled, and carrying the attributes CSS
|
|
116
118
|
// sizes it by.
|
|
117
119
|
engine.lyricWidth = container.clientWidth;
|