@braccato/core 1.3.0 → 1.5.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 +32 -1
- package/dist/README.md +32 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/engine.d.ts +8 -0
- package/dist/engine.js +96 -6
- package/dist/inject.d.ts +3 -0
- package/dist/inject.js +35 -7
- package/dist/styles/lyrics.css +30 -2
- package/dist/styles/variables.css +9 -0
- package/dist/types.d.ts +8 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -186,6 +186,33 @@ frequency. Both must use the same commands in the same order with the same numbe
|
|
|
186
186
|
browser only interpolates two paths smoothly when their command sequences match, and a mismatched
|
|
187
187
|
pair snaps at the halfway point instead of flowing.
|
|
188
188
|
|
|
189
|
+
### Letter wave (experimental)
|
|
190
|
+
|
|
191
|
+
On by default; a theme opts out with `/* blyrics-letter-wave = false; */`. It splits every word into
|
|
192
|
+
per-letter spans and, as the word is sung, floats each letter up and eases it most of the way back on
|
|
193
|
+
a small stagger, so a wave travels through the word. It layers on top of the word wobble rather than
|
|
194
|
+
replacing it: the word keeps whatever `--blyrics-word-wobble-*` does and the letters ride on top, so
|
|
195
|
+
it composes with the default `scaleX` pop and reduces to just the letters when a theme sets its wobble
|
|
196
|
+
to identity. It follows `--blyrics-animate-word-wobble`, so reduced motion turns it off with the rest.
|
|
197
|
+
|
|
198
|
+
A word held past `blyrics-long-word-threshold` (the same `data-long-word` the glow keys off) also
|
|
199
|
+
swells each letter with a transient scale at the crest.
|
|
200
|
+
|
|
201
|
+
```css
|
|
202
|
+
.blyrics-container {
|
|
203
|
+
--blyrics-letter-wave-transform: translateY(-0.05em); /* crest lift */
|
|
204
|
+
--blyrics-letter-wave-settle: translateY(-0.02em); /* rest the crest eases back to */
|
|
205
|
+
--blyrics-letter-wave-emphasis-scale: 1.11; /* long-word letter swell, 1 turns it off */
|
|
206
|
+
--blyrics-letter-wave-duration: 0.9s;
|
|
207
|
+
--blyrics-letter-wave-rise-easing: ease-in-out;
|
|
208
|
+
--blyrics-letter-wave-fall-easing: ease-out;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The split multiplies the DOM per character and reruns the karaoke sweep per letter, so a theme that
|
|
213
|
+
does not want the cost turns it off with `/* blyrics-letter-wave = false; */`. `blyrics-letter-wave`
|
|
214
|
+
reloads the lines when it changes, the way every build-time setting does.
|
|
215
|
+
|
|
189
216
|
### Class names
|
|
190
217
|
|
|
191
218
|
These are published API rather than implementation. Renaming one costs a migration rather than a
|
|
@@ -235,7 +262,11 @@ package's stylesheets are yours to load for the same reason.
|
|
|
235
262
|
`@braccato/core` is the facade and registers nothing. `createLyricsRenderer(options)` returns one
|
|
236
263
|
`LyricsRenderer`: give it lyrics, tick it, and it owns the DOM it builds and every re-measurement
|
|
237
264
|
that DOM needs. `resetPlaybackClock`, `resumeAllAutoscroll`, `injectRomanization` and
|
|
238
|
-
`injectTranslation` are published beside it, for what one instance cannot answer for on its own.
|
|
265
|
+
`injectTranslation` are published beside it, for what one instance cannot answer for on its own. Hang
|
|
266
|
+
one onto a built line and call `renderer.scheduleLyricPositionUpdate` the way you already would to
|
|
267
|
+
catch the layout up: the hung line floats into place while the lines around it slide to make room,
|
|
268
|
+
rather than the rest of the lyric jumping down. `--blyrics-animate-decoration-entry` set to `0` drops
|
|
269
|
+
both for an instant insert, and reduced-motion does the same.
|
|
239
270
|
|
|
240
271
|
`@braccato/core/element` registers `<braccato-lyrics>`, and `<better-lyrics>` beside it, on import.
|
|
241
272
|
Registration is a side effect, which is why it is entered separately.
|
package/dist/README.md
CHANGED
|
@@ -186,6 +186,33 @@ frequency. Both must use the same commands in the same order with the same numbe
|
|
|
186
186
|
browser only interpolates two paths smoothly when their command sequences match, and a mismatched
|
|
187
187
|
pair snaps at the halfway point instead of flowing.
|
|
188
188
|
|
|
189
|
+
### Letter wave (experimental)
|
|
190
|
+
|
|
191
|
+
On by default; a theme opts out with `/* blyrics-letter-wave = false; */`. It splits every word into
|
|
192
|
+
per-letter spans and, as the word is sung, floats each letter up and eases it most of the way back on
|
|
193
|
+
a small stagger, so a wave travels through the word. It layers on top of the word wobble rather than
|
|
194
|
+
replacing it: the word keeps whatever `--blyrics-word-wobble-*` does and the letters ride on top, so
|
|
195
|
+
it composes with the default `scaleX` pop and reduces to just the letters when a theme sets its wobble
|
|
196
|
+
to identity. It follows `--blyrics-animate-word-wobble`, so reduced motion turns it off with the rest.
|
|
197
|
+
|
|
198
|
+
A word held past `blyrics-long-word-threshold` (the same `data-long-word` the glow keys off) also
|
|
199
|
+
swells each letter with a transient scale at the crest.
|
|
200
|
+
|
|
201
|
+
```css
|
|
202
|
+
.blyrics-container {
|
|
203
|
+
--blyrics-letter-wave-transform: translateY(-0.05em); /* crest lift */
|
|
204
|
+
--blyrics-letter-wave-settle: translateY(-0.02em); /* rest the crest eases back to */
|
|
205
|
+
--blyrics-letter-wave-emphasis-scale: 1.11; /* long-word letter swell, 1 turns it off */
|
|
206
|
+
--blyrics-letter-wave-duration: 0.9s;
|
|
207
|
+
--blyrics-letter-wave-rise-easing: ease-in-out;
|
|
208
|
+
--blyrics-letter-wave-fall-easing: ease-out;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The split multiplies the DOM per character and reruns the karaoke sweep per letter, so a theme that
|
|
213
|
+
does not want the cost turns it off with `/* blyrics-letter-wave = false; */`. `blyrics-letter-wave`
|
|
214
|
+
reloads the lines when it changes, the way every build-time setting does.
|
|
215
|
+
|
|
189
216
|
### Class names
|
|
190
217
|
|
|
191
218
|
These are published API rather than implementation. Renaming one costs a migration rather than a
|
|
@@ -235,7 +262,11 @@ package's stylesheets are yours to load for the same reason.
|
|
|
235
262
|
`@braccato/core` is the facade and registers nothing. `createLyricsRenderer(options)` returns one
|
|
236
263
|
`LyricsRenderer`: give it lyrics, tick it, and it owns the DOM it builds and every re-measurement
|
|
237
264
|
that DOM needs. `resetPlaybackClock`, `resumeAllAutoscroll`, `injectRomanization` and
|
|
238
|
-
`injectTranslation` are published beside it, for what one instance cannot answer for on its own.
|
|
265
|
+
`injectTranslation` are published beside it, for what one instance cannot answer for on its own. Hang
|
|
266
|
+
one onto a built line and call `renderer.scheduleLyricPositionUpdate` the way you already would to
|
|
267
|
+
catch the layout up: the hung line floats into place while the lines around it slide to make room,
|
|
268
|
+
rather than the rest of the lyric jumping down. `--blyrics-animate-decoration-entry` set to `0` drops
|
|
269
|
+
both for an instant insert, and reduced-motion does the same.
|
|
239
270
|
|
|
240
271
|
`@braccato/core/element` registers `<braccato-lyrics>`, and `<better-lyrics>` beside it, on import.
|
|
241
272
|
Registration is a side effect, which is why it is entered separately.
|
package/dist/constants.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const LYRICS_WRAPPER_ID: "blyrics-wrapper";
|
|
|
2
2
|
export declare const LYRICS_CLASS: "blyrics-container";
|
|
3
3
|
export declare const LINE_CLASS: "blyrics--line";
|
|
4
4
|
export declare const WORD_CLASS: "blyrics--word";
|
|
5
|
+
export declare const LETTER_CLASS: "blyrics--letter";
|
|
5
6
|
export declare const FOOTER_CLASS: "blyrics-footer";
|
|
6
7
|
export declare const CURRENT_LYRICS_CLASS: "blyrics--active";
|
|
7
8
|
export declare const ANIMATING_CLASS: "blyrics--animating";
|
package/dist/constants.js
CHANGED
|
@@ -5,6 +5,7 @@ export const LYRICS_WRAPPER_ID = "blyrics-wrapper";
|
|
|
5
5
|
export const LYRICS_CLASS = "blyrics-container";
|
|
6
6
|
export const LINE_CLASS = "blyrics--line";
|
|
7
7
|
export const WORD_CLASS = "blyrics--word";
|
|
8
|
+
export const LETTER_CLASS = "blyrics--letter";
|
|
8
9
|
export const FOOTER_CLASS = "blyrics-footer";
|
|
9
10
|
// -- Playback state --------------------------------------------
|
|
10
11
|
export const CURRENT_LYRICS_CLASS = "blyrics--active";
|
package/dist/engine.d.ts
CHANGED
|
@@ -175,6 +175,14 @@ interface AnimationConfig {
|
|
|
175
175
|
wobblePeakOffset: number;
|
|
176
176
|
wobbleSettleOffset: number;
|
|
177
177
|
};
|
|
178
|
+
letterWave: {
|
|
179
|
+
transform: string;
|
|
180
|
+
settle: string;
|
|
181
|
+
emphasisScale: string;
|
|
182
|
+
durationMs: number;
|
|
183
|
+
riseEasing: string;
|
|
184
|
+
fallEasing: string;
|
|
185
|
+
};
|
|
178
186
|
instrumental: {
|
|
179
187
|
fillFadeDurationMs: number;
|
|
180
188
|
fillFadeEasing: string;
|
package/dist/engine.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// playback clock the last tick wrote. Their unit is a bundle rather than a document, and this
|
|
17
17
|
// module is bundled into the isolated world and the page world separately, so those are two clocks
|
|
18
18
|
// that never meet. `themeSettings.ts` holds the third thing under that rule.
|
|
19
|
-
import { ANIMATING_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, USER_SCROLLING_CLASS, } from "./constants.js";
|
|
19
|
+
import { ANIMATING_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, ROMANIZED_LYRICS_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, } from "./constants.js";
|
|
20
20
|
import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
|
|
21
21
|
import { registerThemeSetting } from "./themeSettings.js";
|
|
22
22
|
import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
|
|
@@ -755,9 +755,6 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
755
755
|
offset: config.word.wobblePeakOffset,
|
|
756
756
|
easing: config.word.wobblePeakEasing,
|
|
757
757
|
},
|
|
758
|
-
// The two offsets are read and clamped independently, so a theme is free to settle
|
|
759
|
-
// before it peaks. animate() rejects offsets that go backwards, and that throw would
|
|
760
|
-
// orphan the highlight animations above, which are not tracked until the end.
|
|
761
758
|
{
|
|
762
759
|
transform: config.word.wobbleSettle,
|
|
763
760
|
offset: Math.max(config.word.wobblePeakOffset, config.word.wobbleSettleOffset),
|
|
@@ -770,8 +767,6 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
770
767
|
fill: "forwards",
|
|
771
768
|
};
|
|
772
769
|
const wobbleStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, config.word.wobbleDurationMs);
|
|
773
|
-
// The wobble is a paint transform, so the highlight copy must carry it too or the active
|
|
774
|
-
// sweep drifts off the word.
|
|
775
770
|
for (const wordElement of [part.lyricElement, part.highlightElement]) {
|
|
776
771
|
const animation = trackLyricAnimationTiming(engine, wordElement.animate(wobbleKeyframes, wobbleOptions), {
|
|
777
772
|
appliedTimingOffsetMs,
|
|
@@ -780,6 +775,40 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
|
|
|
780
775
|
animation.currentTime = wobbleStartMs;
|
|
781
776
|
wobbleAnimations.push(animation);
|
|
782
777
|
}
|
|
778
|
+
const letters = part.letterElements;
|
|
779
|
+
if (letters && letters.length > 0) {
|
|
780
|
+
const emphasise = part.lyricElement?.dataset.longWord === "true";
|
|
781
|
+
const emphasisPeak = emphasise ? ` scale(${config.letterWave.emphasisScale})` : "";
|
|
782
|
+
const emphasisRest = emphasise ? " scale(1)" : "";
|
|
783
|
+
const floatKeyframes = [
|
|
784
|
+
{ transform: `translateY(0)${emphasisRest}`, easing: config.letterWave.riseEasing },
|
|
785
|
+
{
|
|
786
|
+
transform: `${config.letterWave.transform}${emphasisPeak}`,
|
|
787
|
+
offset: 0.4,
|
|
788
|
+
easing: config.letterWave.fallEasing,
|
|
789
|
+
},
|
|
790
|
+
{ transform: `${config.letterWave.settle}${emphasisRest}` },
|
|
791
|
+
];
|
|
792
|
+
const letterCount = letters.length;
|
|
793
|
+
const staggerMs = timedDurationMs > 0 ? timedDurationMs / 2.5 / letterCount : 0;
|
|
794
|
+
const cascadeDurationMs = config.letterWave.durationMs + (letterCount - 1) * staggerMs;
|
|
795
|
+
const floatStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, cascadeDurationMs);
|
|
796
|
+
for (const set of [part.letterElements, part.highlightLetterElements]) {
|
|
797
|
+
set?.forEach((letterElement, index) => {
|
|
798
|
+
const options = {
|
|
799
|
+
duration: config.letterWave.durationMs,
|
|
800
|
+
delay: index * staggerMs,
|
|
801
|
+
fill: "forwards",
|
|
802
|
+
};
|
|
803
|
+
const animation = trackLyricAnimationTiming(engine, letterElement.animate(floatKeyframes, options), {
|
|
804
|
+
appliedTimingOffsetMs,
|
|
805
|
+
offsetMs: 0,
|
|
806
|
+
});
|
|
807
|
+
animation.currentTime = floatStartMs;
|
|
808
|
+
wobbleAnimations.push(animation);
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
783
812
|
}
|
|
784
813
|
part.animations = [...highlightAnimations.animations, ...wobbleAnimations];
|
|
785
814
|
}
|
|
@@ -1007,6 +1036,14 @@ function readAnimationConfig(engine, lyricsElement) {
|
|
|
1007
1036
|
wobblePeakOffset: getCSSOffset(engine, lyricsElement, "--blyrics-word-wobble-peak-offset", 0.125),
|
|
1008
1037
|
wobbleSettleOffset: getCSSOffset(engine, lyricsElement, "--blyrics-word-wobble-settle-offset", 0.75),
|
|
1009
1038
|
},
|
|
1039
|
+
letterWave: {
|
|
1040
|
+
transform: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-transform", "translateY(-0.06em)"),
|
|
1041
|
+
settle: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-settle", "translateY(-0.05em)"),
|
|
1042
|
+
emphasisScale: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-emphasis-scale", "1.08"),
|
|
1043
|
+
durationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-letter-wave-duration", "0.9s"),
|
|
1044
|
+
riseEasing: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-rise-easing", "ease-in-out"),
|
|
1045
|
+
fallEasing: getCSSValue(engine, lyricsElement, "--blyrics-letter-wave-fall-easing", "ease-in-out"),
|
|
1046
|
+
},
|
|
1010
1047
|
instrumental: {
|
|
1011
1048
|
fillFadeDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-instrumental-fill-fade-duration", "150ms"),
|
|
1012
1049
|
fillFadeEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-fill-fade-easing", "ease"),
|
|
@@ -1930,10 +1967,61 @@ export function relayout(engine, measureLines) {
|
|
|
1930
1967
|
const bounds = getRelativeLayoutBounds(lyricsElement, line.lyricElement);
|
|
1931
1968
|
line.position = bounds.y;
|
|
1932
1969
|
line.height = bounds.height;
|
|
1970
|
+
line.decorations = new Map(lineDecorators(line.lyricElement).map(element => [element, getRelativeLayoutBounds(lyricsElement, element).y]));
|
|
1933
1971
|
}
|
|
1934
1972
|
engine.wasUserScrolling = true; // trigger rescrolls
|
|
1935
1973
|
engine.host.debug?.resize();
|
|
1936
1974
|
}
|
|
1975
|
+
// -- Decoration slide --------------------------
|
|
1976
|
+
const DECORATION_SLIDE_MS = 350;
|
|
1977
|
+
const DECORATION_SLIDE_EASING = "cubic-bezier(0.25, 1, 0.5, 1)";
|
|
1978
|
+
const DECORATION_MIN_SHIFT_PX = 0.5;
|
|
1979
|
+
function slideElementBy(element, dy) {
|
|
1980
|
+
element.animate({ translate: [`0 ${dy}px`, "0 0"] }, { duration: DECORATION_SLIDE_MS, easing: DECORATION_SLIDE_EASING, composite: "add" });
|
|
1981
|
+
}
|
|
1982
|
+
function decorationSlideAllowed(engine, container) {
|
|
1983
|
+
if (engine.window.matchMedia(REDUCED_MOTION_QUERY).matches)
|
|
1984
|
+
return false;
|
|
1985
|
+
return (engine.window.getComputedStyle(container).getPropertyValue("--blyrics-animate-decoration-entry").trim() !== "0");
|
|
1986
|
+
}
|
|
1987
|
+
function lineDecorators(lineElement) {
|
|
1988
|
+
return [
|
|
1989
|
+
...lineElement.querySelectorAll(`.${ROMANIZED_LYRICS_CLASS}`),
|
|
1990
|
+
...lineElement.querySelectorAll(`.${TRANSLATED_LYRICS_CLASS}`),
|
|
1991
|
+
];
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Snapshots where the lines and their decorators sit now, so the remeasure that follows can slide
|
|
1995
|
+
* each one from there to wherever a streamed decoration just pushed it. Returns null when nothing
|
|
1996
|
+
* should animate, and otherwise a function to call once the new layout is measured: the line rides
|
|
1997
|
+
* its own shift and a decorator rides only the part of its shift its line did not already carry, so
|
|
1998
|
+
* a survivor whose line held still still slides into the gap a removed sibling left.
|
|
1999
|
+
*/
|
|
2000
|
+
function captureDecorationSlide(engine) {
|
|
2001
|
+
const container = engine.lyricsContainer;
|
|
2002
|
+
if (!container || !decorationSlideAllowed(engine, container))
|
|
2003
|
+
return null;
|
|
2004
|
+
const before = engine.lines.map(line => ({
|
|
2005
|
+
line,
|
|
2006
|
+
position: line.position,
|
|
2007
|
+
decorations: new Map(line.decorations),
|
|
2008
|
+
}));
|
|
2009
|
+
return () => {
|
|
2010
|
+
for (const { line, position, decorations } of before) {
|
|
2011
|
+
const lineShift = position - line.position;
|
|
2012
|
+
if (Math.abs(lineShift) >= DECORATION_MIN_SHIFT_PX)
|
|
2013
|
+
slideElementBy(line.lyricElement, lineShift);
|
|
2014
|
+
for (const [element, was] of decorations) {
|
|
2015
|
+
const now = line.decorations.get(element);
|
|
2016
|
+
if (now === undefined)
|
|
2017
|
+
continue;
|
|
2018
|
+
const shift = was - now - lineShift;
|
|
2019
|
+
if (Math.abs(shift) >= DECORATION_MIN_SHIFT_PX)
|
|
2020
|
+
slideElementBy(element, shift);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
1937
2025
|
// -- Debounced Lyrics Update --------------------------
|
|
1938
2026
|
function cancelLyricPositionUpdate(engine) {
|
|
1939
2027
|
if (engine.pendingLyricsUpdateFrame === null)
|
|
@@ -1971,7 +2059,9 @@ export function scheduleLyricPositionUpdate(engine, isViewRendering, retick) {
|
|
|
1971
2059
|
engine.pendingLyricsUpdateFrame = engine.window.requestAnimationFrame(() => {
|
|
1972
2060
|
engine.pendingLyricsUpdateFrame = null;
|
|
1973
2061
|
const isRendering = isViewRendering();
|
|
2062
|
+
const playSlide = isRendering ? captureDecorationSlide(engine) : null;
|
|
1974
2063
|
relayout(engine, isRendering);
|
|
2064
|
+
playSlide?.();
|
|
1975
2065
|
if (!isRendering)
|
|
1976
2066
|
return;
|
|
1977
2067
|
retick();
|
package/dist/inject.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface AnimationData {
|
|
|
17
17
|
}
|
|
18
18
|
export interface PartData extends AnimationData {
|
|
19
19
|
highlightElement: HTMLElement;
|
|
20
|
+
letterElements?: HTMLElement[];
|
|
21
|
+
highlightLetterElements?: HTMLElement[];
|
|
20
22
|
}
|
|
21
23
|
export type LineData = {
|
|
22
24
|
parts: PartData[];
|
|
@@ -28,6 +30,7 @@ export type LineData = {
|
|
|
28
30
|
isSelected: boolean;
|
|
29
31
|
height: number;
|
|
30
32
|
position: number;
|
|
33
|
+
decorations: Map<HTMLElement, number>;
|
|
31
34
|
} & AnimationData;
|
|
32
35
|
export declare function newLineData(lyricElement: HTMLElement, startTimeMs: number, durationMs: number): LineData;
|
|
33
36
|
export declare function applyDirection(element: HTMLElement, text: string): void;
|
package/dist/inject.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// animates is one word, so every part is split on whitespace with its timing pro-rated across the
|
|
12
12
|
// split by character count. A line that arrives with no timed parts at all is rebuilt the same way
|
|
13
13
|
// into zero duration words, so line synced lyrics reach the DOM the sweep already knows.
|
|
14
|
-
import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
|
|
14
|
+
import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LETTER_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
|
|
15
15
|
import { getSeekTimeFromClick } from "./seek.js";
|
|
16
16
|
import { testRtl } from "./text.js";
|
|
17
17
|
import { registerThemeSetting } from "./themeSettings.js";
|
|
@@ -19,6 +19,7 @@ export let disableRichsync = registerThemeSetting("blyrics-disable-richsync", fa
|
|
|
19
19
|
let lineSyncedAnimationDelay = registerThemeSetting("blyrics-line-synced-animation-delay", 50, true);
|
|
20
20
|
let longWordThreshold = registerThemeSetting("blyrics-long-word-threshold", 1500, true);
|
|
21
21
|
let longWordWrapThreshold = registerThemeSetting("blyrics-long-word-wrap-threshold", 10, true);
|
|
22
|
+
let letterWave = registerThemeSetting("blyrics-letter-wave", true, true);
|
|
22
23
|
const RTL_SCRIPT_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u;
|
|
23
24
|
const LTR_SCRIPT_REGEX = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u;
|
|
24
25
|
const SPACE_REGEX = /^\s+$/u;
|
|
@@ -60,12 +61,14 @@ export function deriveSyncType(lyrics) {
|
|
|
60
61
|
return "richsync";
|
|
61
62
|
return lyrics.every(item => item.startTimeMs === 0) ? "none" : "synced";
|
|
62
63
|
}
|
|
63
|
-
function newPartData(part, span, highlight) {
|
|
64
|
+
function newPartData(part, span, highlight, letterElements, highlightLetterElements) {
|
|
64
65
|
return {
|
|
65
66
|
time: part.startTimeMs / 1000,
|
|
66
67
|
duration: part.durationMs / 1000,
|
|
67
68
|
lyricElement: span,
|
|
68
69
|
highlightElement: highlight,
|
|
70
|
+
letterElements,
|
|
71
|
+
highlightLetterElements,
|
|
69
72
|
animations: [],
|
|
70
73
|
};
|
|
71
74
|
}
|
|
@@ -83,6 +86,7 @@ export function newLineData(lyricElement, startTimeMs, durationMs) {
|
|
|
83
86
|
isSelected: false,
|
|
84
87
|
height: -1,
|
|
85
88
|
position: -1,
|
|
89
|
+
decorations: new Map(),
|
|
86
90
|
animations: [],
|
|
87
91
|
};
|
|
88
92
|
}
|
|
@@ -194,10 +198,24 @@ function appendLongWordBreaks(doc, span, text, threshold) {
|
|
|
194
198
|
}
|
|
195
199
|
return true;
|
|
196
200
|
}
|
|
197
|
-
function
|
|
201
|
+
function appendLetters(doc, wordElement, text) {
|
|
202
|
+
const chars = [...text];
|
|
203
|
+
wordElement.style.setProperty("--letters", String(chars.length));
|
|
204
|
+
return chars.map((char, index) => {
|
|
205
|
+
const letter = doc.createElement("span");
|
|
206
|
+
letter.classList.add(LETTER_CLASS);
|
|
207
|
+
letter.style.setProperty("--letter-index", String(index));
|
|
208
|
+
letter.textContent = char;
|
|
209
|
+
wordElement.appendChild(letter);
|
|
210
|
+
return letter;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
198
214
|
const span = doc.createElement("span");
|
|
199
215
|
const highlight = doc.createElement("span");
|
|
200
216
|
highlight.classList.add(WORD_HIGHLIGHT_CLASS);
|
|
217
|
+
let letters;
|
|
218
|
+
let highlightLetters;
|
|
201
219
|
for (const wordElement of [span, highlight]) {
|
|
202
220
|
wordElement.classList.add(WORD_CLASS);
|
|
203
221
|
wordElement.dir = "auto";
|
|
@@ -213,16 +231,26 @@ function createTimedWordSpan(doc, part, wrapThreshold) {
|
|
|
213
231
|
wordElement.classList.add(BACKGROUND_LYRIC_CLASS);
|
|
214
232
|
if (part.explicit)
|
|
215
233
|
wordElement.classList.add(EXPLICIT_WORD_CLASS);
|
|
216
|
-
|
|
234
|
+
if (perLetter) {
|
|
235
|
+
const collected = appendLetters(doc, wordElement, part.words);
|
|
236
|
+
if (wordElement === span)
|
|
237
|
+
letters = collected;
|
|
238
|
+
else
|
|
239
|
+
highlightLetters = collected;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
|
|
243
|
+
}
|
|
217
244
|
wordElement.dataset.time = String(part.startTimeMs / 1000);
|
|
218
245
|
wordElement.dataset.duration = String(part.durationMs / 1000);
|
|
219
246
|
wordElement.dataset.content = part.words;
|
|
220
247
|
wordElement.style.setProperty("--blyrics-duration", part.durationMs + "ms");
|
|
221
248
|
}
|
|
222
|
-
return { span, highlight };
|
|
249
|
+
return { span, highlight, letters, highlightLetters };
|
|
223
250
|
}
|
|
224
251
|
function createWordGroup(doc, group, lineData) {
|
|
225
252
|
const wrapThreshold = Math.max(1, longWordWrapThreshold.getNumberValue());
|
|
253
|
+
const perLetter = letterWave.getBooleanValue();
|
|
226
254
|
const lyricGroup = doc.createElement("span");
|
|
227
255
|
const highlightGroup = doc.createElement("span");
|
|
228
256
|
for (const groupElement of [lyricGroup, highlightGroup]) {
|
|
@@ -239,8 +267,8 @@ function createWordGroup(doc, group, lineData) {
|
|
|
239
267
|
for (const token of group.tokens) {
|
|
240
268
|
if (token.kind === "space")
|
|
241
269
|
continue;
|
|
242
|
-
const { span, highlight } = createTimedWordSpan(doc, token.part, wrapThreshold);
|
|
243
|
-
lineData.parts.push(newPartData(token.part, span, highlight));
|
|
270
|
+
const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter);
|
|
271
|
+
lineData.parts.push(newPartData(token.part, span, highlight, letters, highlightLetters));
|
|
244
272
|
lyricGroup.appendChild(span);
|
|
245
273
|
highlightGroup.appendChild(highlight);
|
|
246
274
|
}
|
package/dist/styles/lyrics.css
CHANGED
|
@@ -117,13 +117,13 @@
|
|
|
117
117
|
|
|
118
118
|
@property --lyric-transition-amount-start {
|
|
119
119
|
syntax: "<number>";
|
|
120
|
-
inherits:
|
|
120
|
+
inherits: true;
|
|
121
121
|
initial-value: -0.2;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
@property --lyric-transition-amount-end {
|
|
125
125
|
syntax: "<number>";
|
|
126
|
-
inherits:
|
|
126
|
+
inherits: true;
|
|
127
127
|
initial-value: -0.1;
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -157,6 +157,25 @@
|
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
.blyrics--letter {
|
|
161
|
+
display: inline-block;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.blyrics-word-highlight:has(.blyrics--letter) {
|
|
165
|
+
background-image: none;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.blyrics-word-highlight .blyrics--letter {
|
|
169
|
+
background-image: linear-gradient(
|
|
170
|
+
90deg,
|
|
171
|
+
var(--blyrics-lyric-active-color)
|
|
172
|
+
calc(100% * (var(--lyric-transition-amount-start) * var(--letters, 1) - var(--letter-index, 0))),
|
|
173
|
+
#00000000
|
|
174
|
+
calc(100% * (var(--lyric-transition-amount-end) * var(--letters, 1) - var(--letter-index, 0)) + 1px)
|
|
175
|
+
);
|
|
176
|
+
background-clip: text;
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
[blyrics-alt-hover] .blyrics--word:hover {
|
|
161
180
|
text-decoration: underline;
|
|
162
181
|
text-underline-offset: 0.15em;
|
|
@@ -184,6 +203,8 @@
|
|
|
184
203
|
|
|
185
204
|
.blyrics--translated,
|
|
186
205
|
.blyrics--romanized {
|
|
206
|
+
animation: blyrics-decoration-in calc(var(--blyrics-animate-decoration-entry) * 0.35s)
|
|
207
|
+
cubic-bezier(0.25, 1, 0.5, 1) both;
|
|
187
208
|
color: var(--blyrics-translated-color);
|
|
188
209
|
display: block;
|
|
189
210
|
font-family: var(--blyrics-translated-font-family);
|
|
@@ -197,6 +218,13 @@
|
|
|
197
218
|
white-space: normal;
|
|
198
219
|
}
|
|
199
220
|
|
|
221
|
+
@keyframes blyrics-decoration-in {
|
|
222
|
+
from {
|
|
223
|
+
opacity: 0;
|
|
224
|
+
transform: translateY(-0.4em);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
200
228
|
.blyrics--romanized {
|
|
201
229
|
background: rgba(255, 255, 255, 0.05);
|
|
202
230
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
--blyrics-animate-highlight-fade: 1;
|
|
64
64
|
--blyrics-animate-scroll: 1;
|
|
65
65
|
--blyrics-animate-instrumental: 1;
|
|
66
|
+
--blyrics-animate-decoration-entry: 1;
|
|
66
67
|
|
|
67
68
|
--blyrics-loader-transition-duration: 0.6s;
|
|
68
69
|
--blyrics-loader-transition-easing: cubic-bezier(0.22, 1, 0.36, 1);
|
|
@@ -109,6 +110,13 @@
|
|
|
109
110
|
--blyrics-word-wobble-peak-easing: ease-in-out;
|
|
110
111
|
--blyrics-word-wobble-end-easing: ease-out;
|
|
111
112
|
|
|
113
|
+
--blyrics-letter-wave-transform: translateY(-0.05em);
|
|
114
|
+
--blyrics-letter-wave-settle: translateY(-0.02em);
|
|
115
|
+
--blyrics-letter-wave-emphasis-scale: 1.11;
|
|
116
|
+
--blyrics-letter-wave-duration: 0.9s;
|
|
117
|
+
--blyrics-letter-wave-rise-easing: ease-in-out;
|
|
118
|
+
--blyrics-letter-wave-fall-easing: ease-out;
|
|
119
|
+
|
|
112
120
|
--blyrics-instrumental-fill-fade-duration: 150ms;
|
|
113
121
|
--blyrics-instrumental-fill-fade-easing: ease;
|
|
114
122
|
--blyrics-instrumental-fill-transform-from: translateY(78%);
|
|
@@ -176,6 +184,7 @@
|
|
|
176
184
|
--blyrics-animate-line-scale: 0;
|
|
177
185
|
--blyrics-animate-word-wobble: 0;
|
|
178
186
|
--blyrics-animate-instrumental: 0;
|
|
187
|
+
--blyrics-animate-decoration-entry: 0;
|
|
179
188
|
|
|
180
189
|
--blyrics-scale: 1;
|
|
181
190
|
--blyrics-active-scale: 1;
|
package/dist/types.d.ts
CHANGED
|
@@ -163,9 +163,14 @@ export interface LyricsRenderer {
|
|
|
163
163
|
*/
|
|
164
164
|
clearOnScreenLyrics(): boolean;
|
|
165
165
|
/**
|
|
166
|
-
* Re-measures the lines on the next frame and renders the view again against them.
|
|
167
|
-
*
|
|
168
|
-
*
|
|
166
|
+
* Re-measures the lines on the next frame and renders the view again against them. This is the door
|
|
167
|
+
* a consumer knocks on after streaming in a translation or romanization, and it is what slides the
|
|
168
|
+
* lines the new decoration moved from where they were to where it put them, together with any
|
|
169
|
+
* surviving decorator that shifted within its own line, so the rest of the lyric eases down rather
|
|
170
|
+
* than jumping. The slide honours reduced motion and `--blyrics-animate-decoration-entry`; with
|
|
171
|
+
* either off the lines are re-measured with no slide. The predicate is the caller's half of whether
|
|
172
|
+
* that frame does anything, and only that half: whether the lines are on the screen to be measured
|
|
173
|
+
* at all is the renderer's own question, and it asks it itself.
|
|
169
174
|
*
|
|
170
175
|
* @param isTicking - Whether whoever drives this view is still driving it, asked on the frame
|
|
171
176
|
* rather than now. A driver that has stopped is one whose lines may no longer be rendered, and
|