@braccato/core 1.6.3 → 1.7.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
@@ -213,6 +213,31 @@ The split multiplies the DOM per character and reruns the karaoke sweep per lett
213
213
  does not want the cost turns it off with `/* blyrics-letter-wave = false; */`. `blyrics-letter-wave`
214
214
  reloads the lines when it changes, the way every build-time setting does.
215
215
 
216
+ ### Word state
217
+
218
+ The renderer writes `data-word-state` on every word as the song crosses it, so a theme can tell a
219
+ word being sung from one already sung or one not yet reached. It carries `upcoming` before the word
220
+ starts, `active` while it is being sung, and `past` once it has finished, and it is written on both
221
+ layers of a word, the base `.blyrics--word` and its highlight overlay, so a theme can key either. A
222
+ word stays `active` for exactly as long as it is sung, so a transition on the flip is the whole of a
223
+ per-word karaoke effect, the one ramansg/Max-Performance built before the swept overlay.
224
+
225
+ ```css
226
+ .blyrics--word {
227
+ color: var(--blyrics-lyric-inactive-color);
228
+ transition: color 180ms ease;
229
+ }
230
+
231
+ .blyrics--word[data-word-state="active"],
232
+ .blyrics--word[data-word-state="past"] {
233
+ color: var(--blyrics-lyric-active-color);
234
+ }
235
+ ```
236
+
237
+ It is written only when a word's state changes rather than every frame, so selecting on it costs a
238
+ theme nothing per tick. A line synced word, which has no duration of its own, is `active` from its
239
+ start until the next word begins.
240
+
216
241
  ### Class names
217
242
 
218
243
  These are published API rather than implementation. Renaming one costs a migration rather than a
package/dist/README.md CHANGED
@@ -213,6 +213,31 @@ The split multiplies the DOM per character and reruns the karaoke sweep per lett
213
213
  does not want the cost turns it off with `/* blyrics-letter-wave = false; */`. `blyrics-letter-wave`
214
214
  reloads the lines when it changes, the way every build-time setting does.
215
215
 
216
+ ### Word state
217
+
218
+ The renderer writes `data-word-state` on every word as the song crosses it, so a theme can tell a
219
+ word being sung from one already sung or one not yet reached. It carries `upcoming` before the word
220
+ starts, `active` while it is being sung, and `past` once it has finished, and it is written on both
221
+ layers of a word, the base `.blyrics--word` and its highlight overlay, so a theme can key either. A
222
+ word stays `active` for exactly as long as it is sung, so a transition on the flip is the whole of a
223
+ per-word karaoke effect, the one ramansg/Max-Performance built before the swept overlay.
224
+
225
+ ```css
226
+ .blyrics--word {
227
+ color: var(--blyrics-lyric-inactive-color);
228
+ transition: color 180ms ease;
229
+ }
230
+
231
+ .blyrics--word[data-word-state="active"],
232
+ .blyrics--word[data-word-state="past"] {
233
+ color: var(--blyrics-lyric-active-color);
234
+ }
235
+ ```
236
+
237
+ It is written only when a word's state changes rather than every frame, so selecting on it costs a
238
+ theme nothing per tick. A line synced word, which has no duration of its own, is `active` from its
239
+ start until the next word begins.
240
+
216
241
  ### Class names
217
242
 
218
243
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -9,6 +9,11 @@ export declare const ANIMATING_CLASS: "blyrics--animating";
9
9
  export declare const PAUSED_CLASS: "blyrics--paused";
10
10
  export declare const ZERO_DURATION_ANIMATION_CLASS: "blyrics-zero-dur-animate";
11
11
  export declare const USER_SCROLLING_CLASS: "blyrics-user-scrolling";
12
+ export declare const WORD_STATE_ATTR: "data-word-state";
13
+ export declare const WORD_STATE_UPCOMING: "upcoming";
14
+ export declare const WORD_STATE_ACTIVE: "active";
15
+ export declare const WORD_STATE_PAST: "past";
16
+ export type WordState = typeof WORD_STATE_UPCOMING | typeof WORD_STATE_ACTIVE | typeof WORD_STATE_PAST;
12
17
  export declare const BACKGROUND_LYRIC_CLASS: "blyrics-background-lyric";
13
18
  export declare const EXPLICIT_WORD_CLASS: "blyrics-explicit";
14
19
  export declare const RTL_CLASS: "blyrics-rtl";
package/dist/constants.js CHANGED
@@ -13,6 +13,10 @@ export const ANIMATING_CLASS = "blyrics--animating";
13
13
  export const PAUSED_CLASS = "blyrics--paused";
14
14
  export const ZERO_DURATION_ANIMATION_CLASS = "blyrics-zero-dur-animate";
15
15
  export const USER_SCROLLING_CLASS = "blyrics-user-scrolling";
16
+ export const WORD_STATE_ATTR = "data-word-state";
17
+ export const WORD_STATE_UPCOMING = "upcoming";
18
+ export const WORD_STATE_ACTIVE = "active";
19
+ export const WORD_STATE_PAST = "past";
16
20
  // -- Line and word variants --------------------------------------------
17
21
  export const BACKGROUND_LYRIC_CLASS = "blyrics-background-lyric";
18
22
  export const EXPLICIT_WORD_CLASS = "blyrics-explicit";
package/dist/engine.d.ts CHANGED
@@ -127,6 +127,7 @@ export declare function getRenderedSyncType(engine: AnimationEngineInstance): Ly
127
127
  * hold are released along with it.
128
128
  */
129
129
  export declare function clearLyrics(engine: AnimationEngineInstance): void;
130
+ export declare function updateWordStates(lineData: LineData, currentTime: number): void;
130
131
  type LineScrollSide = "above" | "active" | "below";
131
132
  interface LineScrollItem {
132
133
  lyricElement: HTMLElement;
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, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, } from "./constants.js";
19
+ import { ANIMATING_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, WORD_STATE_ACTIVE, WORD_STATE_ATTR, WORD_STATE_PAST, WORD_STATE_UPCOMING, } from "./constants.js";
20
20
  import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
21
21
  import { registerThemeSetting } from "./themeSettings.js";
22
22
  import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
@@ -345,6 +345,26 @@ function clearLineStateClasses(lineData) {
345
345
  togglePartClass(part, PAUSED_CLASS, false);
346
346
  }
347
347
  }
348
+ export function updateWordStates(lineData, currentTime) {
349
+ const parts = lineData.parts;
350
+ const lineEndTime = lineData.time + lineData.duration;
351
+ for (let i = 0; i < parts.length; i++) {
352
+ const part = parts[i];
353
+ const endTime = part.duration > 0 ? part.time + part.duration : (parts[i + 1]?.time ?? lineEndTime);
354
+ let state = WORD_STATE_PAST;
355
+ if (currentTime < part.time) {
356
+ state = WORD_STATE_UPCOMING;
357
+ }
358
+ else if (currentTime < endTime) {
359
+ state = WORD_STATE_ACTIVE;
360
+ }
361
+ if (part.wordState === state)
362
+ continue;
363
+ part.wordState = state;
364
+ part.lyricElement.setAttribute(WORD_STATE_ATTR, state);
365
+ part.highlightElement.setAttribute(WORD_STATE_ATTR, state);
366
+ }
367
+ }
348
368
  const LINE_SYNCED_WORD_CLASS = "blyrics-line-synced-word";
349
369
  const INSTRUMENTAL_FILL_SELECTOR = ".blyrics--instrumental-fill";
350
370
  const INSTRUMENTAL_WAVE_CLIP_SELECTOR = ".blyrics--wave-clip";
@@ -1936,6 +1956,7 @@ export function tickView(engine, currentTime, options) {
1936
1956
  if (isPlaying)
1937
1957
  lineData.isAnimating = false; // reset the animation against current media time
1938
1958
  }
1959
+ updateWordStates(lineData, currentTime);
1939
1960
  const nativeTimingSample = lineNativeTimingSample(lineData, currentTime);
1940
1961
  let usedNativeTimingSampleForDrift = false;
1941
1962
  lineData.accumulatedOffsetMs = lineData.accumulatedOffsetMs / ANIMATION_TIMING_ACCUMULATION_DECAY;
@@ -1984,11 +2005,15 @@ export function tickView(engine, currentTime, options) {
1984
2005
  }
1985
2006
  lineData.isSelected = false;
1986
2007
  clearLineStateClasses(lineData);
2008
+ updateWordStates(lineData, currentTime);
1987
2009
  }
1988
2010
  else if (hasLineAnimations(lineData) && (timeJumped || currentTime > staleAnimationEndTime)) {
1989
2011
  logAnimationCleanup(engine, timeJumped ? "time-jump-reset" : "stale-reset", lineData, index, currentTime, staleAnimationEndTime);
1990
2012
  resetLineAnimationState(lineData);
1991
2013
  }
2014
+ if (timeJumped) {
2015
+ updateWordStates(lineData, currentTime);
2016
+ }
1992
2017
  }
1993
2018
  return true;
1994
2019
  });
package/dist/inject.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type WordState } from "./constants.js";
1
2
  import type { Lyric, LyricPart, LyricSyncType } from "./types.js";
2
3
  export declare let disableRichsync: import("./themeSettings.js").Setting;
3
4
  export declare function findNearestAgent(lyrics: Lyric[], fromIndex: number): string | undefined;
@@ -20,6 +21,7 @@ export interface PartData extends AnimationData {
20
21
  letterElements?: HTMLElement[];
21
22
  highlightLetterElements?: HTMLElement[];
22
23
  glowSuppressed?: boolean;
24
+ wordState: WordState;
23
25
  }
24
26
  export type LineData = {
25
27
  parts: PartData[];
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, 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, 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, WORD_HIGHLIGHT_LETTERED_CLASS, WORD_STATE_ATTR, WORD_STATE_UPCOMING, 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";
@@ -20,6 +20,7 @@ let lineSyncedAnimationDelay = registerThemeSetting("blyrics-line-synced-animati
20
20
  let longWordThreshold = registerThemeSetting("blyrics-long-word-threshold", 1500, true);
21
21
  let longWordWrapThreshold = registerThemeSetting("blyrics-long-word-wrap-threshold", 10, true);
22
22
  let letterWave = registerThemeSetting("blyrics-letter-wave", true, true);
23
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
23
24
  const RTL_SCRIPT_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u;
24
25
  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;
25
26
  const SPACE_REGEX = /^\s+$/u;
@@ -70,6 +71,7 @@ function newPartData(part, span, highlight, letterElements, highlightLetterEleme
70
71
  letterElements,
71
72
  highlightLetterElements,
72
73
  animations: [],
74
+ wordState: WORD_STATE_UPCOMING,
73
75
  };
74
76
  }
75
77
  export function newLineData(lyricElement, startTimeMs, durationMs) {
@@ -199,7 +201,7 @@ function appendLongWordBreaks(doc, span, text, threshold) {
199
201
  return true;
200
202
  }
201
203
  function appendLetters(doc, wordElement, text) {
202
- const chars = [...text];
204
+ const chars = [...graphemeSegmenter.segment(text)].map(segment => segment.segment);
203
205
  wordElement.style.setProperty("--letters", String(chars.length));
204
206
  // Width of the reveal mask's soft edge as a percentage of its (n+2)-letter-wide box, so the fade
205
207
  // spans the same 0.1*letters of a letter that the old gradient did. See the mask rule in lyrics.css.
@@ -249,6 +251,7 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
249
251
  wordElement.dataset.time = String(part.startTimeMs / 1000);
250
252
  wordElement.dataset.duration = String(part.durationMs / 1000);
251
253
  wordElement.dataset.content = part.words;
254
+ wordElement.setAttribute(WORD_STATE_ATTR, WORD_STATE_UPCOMING);
252
255
  wordElement.style.setProperty("--blyrics-duration", part.durationMs + "ms");
253
256
  }
254
257
  return { span, highlight, letters, highlightLetters };
@@ -298,6 +301,15 @@ function createHighlightRun(doc, text) {
298
301
  run.setAttribute("aria-hidden", "true");
299
302
  return run;
300
303
  }
304
+ // A background gap must carry the background size too, or it renders full-size and the gap looks oversized.
305
+ function createSpaceNode(doc, text, isBackground) {
306
+ if (!isBackground)
307
+ return doc.createTextNode(text);
308
+ const span = doc.createElement("span");
309
+ span.classList.add(BACKGROUND_LYRIC_CLASS);
310
+ span.textContent = text;
311
+ return span;
312
+ }
301
313
  export function createLyricsLine(doc, parts, line, lyricElement, options = { splitBackgroundLine: true }) {
302
314
  const lineText = parts.map(part => part.words).join("");
303
315
  const mainText = options.splitBackgroundLine
@@ -336,8 +348,8 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
336
348
  const highlightRun = shouldUseBackgroundLine ? backgroundHighlightRun : mainHighlightRun;
337
349
  const pendingSpace = shouldUseBackgroundLine ? pendingBackgroundSpace : pendingForegroundSpace;
338
350
  if (target.childNodes.length > 0 && pendingSpace.length > 0) {
339
- target.appendChild(doc.createTextNode(pendingSpace));
340
- highlightRun.appendChild(doc.createTextNode(pendingSpace));
351
+ target.appendChild(createSpaceNode(doc, pendingSpace, shouldUseBackgroundLine));
352
+ highlightRun.appendChild(createSpaceNode(doc, pendingSpace, shouldUseBackgroundLine));
341
353
  }
342
354
  const { lyricGroup, highlightGroup } = createWordGroup(doc, item, line);
343
355
  target.appendChild(lyricGroup);
@@ -400,7 +412,8 @@ export function injectRomanization(doc, lyricElement, lineData, text, timedRoman
400
412
  else {
401
413
  romanizedLine.textContent = text;
402
414
  }
403
- lyricElement.appendChild(romanizedLine);
415
+ const translation = lyricElement.querySelector(`.${TRANSLATED_LYRICS_CLASS}`);
416
+ lyricElement.insertBefore(romanizedLine, translation);
404
417
  }
405
418
  export function injectTranslation(doc, lyricElement, text) {
406
419
  if (lyricElement.querySelector(`.${TRANSLATED_LYRICS_CLASS}`))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",