@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 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.
@@ -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 createTimedWordSpan(doc, part, wrapThreshold) {
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
- appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
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
  }
@@ -117,13 +117,13 @@
117
117
 
118
118
  @property --lyric-transition-amount-start {
119
119
  syntax: "<number>";
120
- inherits: false;
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: false;
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. The predicate
167
- * is the caller's half of whether that frame does anything, and only that half: whether the lines
168
- * are on the screen to be measured at all is the renderer's own question, and it asks it itself.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",