@braccato/core 1.7.1 → 1.9.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
@@ -76,7 +76,7 @@ connects, and everything it was handed by then is applied at once.
76
76
  | Property | Attribute | Type | Default | Description |
77
77
  | --------------- | -------------- | ----------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
78
78
  | `lyrics` | | `Lyric[] \| null` | `null` | The song. Null means it was never given one, and an empty array clears the view, so there is a way to say both. |
79
- | `lyricsOptions` | | `{ loaderVisible?, noLyrics? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. |
79
+ | `lyricsOptions` | | `{ loaderVisible?, noLyrics?, language? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. |
80
80
  | `source` | `source` | `string \| HTMLMediaElement \| null` | `null` | A selector or the media element itself. See Following a media element. |
81
81
  | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
82
82
  | `currentTime` | `current-time` | `number` | `0` | Playback position in **seconds**. Writing it renders the view again, so whoever holds the clock drives the lyrics by writing this. |
@@ -150,6 +150,32 @@ stylesheet must not be able to configure the module by accident. An empty theme
150
150
  back to its default. The stylesheet itself goes into the document head under the
151
151
  `blyrics-custom-style` id.
152
152
 
153
+ ### Autoscroll grouping and animation
154
+
155
+ `blyrics-early-scroll-consider-s` is a comment setting with an independent default of `0.54`
156
+ seconds. When a lyric reaches its scroll time, the renderer includes nearby upcoming lines in
157
+ its target calculation. Entering the lookahead window alone does not scroll. A line already
158
+ included in a committed scroll does not scroll again when its own time arrives. Seeking,
159
+ resuming autoscroll and relayout can still reposition the view.
160
+
161
+ ```css
162
+ /* blyrics-early-scroll-consider-s = 0.54; */
163
+ /* blyrics-line-scroll-duration = 750ms; */
164
+ ```
165
+
166
+ New groups scroll immediately. Previous per-line `translate` animations continue and compose
167
+ additively, so their duration does not block the next scroll. The line-scroll duration knobs
168
+ control visual motion. The legacy `--blyrics-lyric-scroll-duration` variable and its
169
+ `--blyrics-lyric-transition-duration` alias have been removed, along with the container transform
170
+ transition. `blyrics-queue-scroll-ms` is ignored, and no timing equation needs balancing.
171
+ Missing or invalid line durations fall back to an internal `750ms` duration; nonpositive resolved
172
+ durations also use that fallback. Themes should set the line-scroll duration knobs directly
173
+ instead of referencing the removed variables.
174
+
175
+ Themes that previously relied on duration-derived lookahead should set their preferred
176
+ `blyrics-early-scroll-consider-s` explicitly. The default remains close to the former default
177
+ of approximately `0.54s`, but a custom animation duration no longer changes grouping.
178
+
153
179
  There is no `longWordThreshold`, `lineSyncedDelay` or `disableRichsync` property. Those are theme
154
180
  settings (`blyrics-long-word-threshold`, `blyrics-line-synced-animation-delay`,
155
181
  `blyrics-disable-richsync`), read from the stylesheet you already hand over. A theme that set one
@@ -365,3 +391,28 @@ repository and run `pnpm -C demo dev`, then open `http://localhost:5173/`.
365
391
  ## Licence
366
392
 
367
393
  MIT. See `LICENSE`.
394
+
395
+ ### Content language and CJK fonts
396
+
397
+ Pass the source BCP 47 language to `renderer.setLyrics(lyrics, { language: "ja" })`
398
+ (or `element.lyricsOptions = { language: "ja" }` before assigning lyrics). If language detection
399
+ finishes later, `renderer.setLanguage("ja")` updates the existing lines and their measurements
400
+ without replacing their DOM or animations. Repeating the same language (including equivalent spellings
401
+ such as `ja-JP` and `ja_JP`) does not write to the DOM or measure again. Omitting the language on a
402
+ new song clears the old hint.
403
+
404
+ The renderer preserves Chinese script/region tags and uses kana or Hangul as a fallback when
405
+ metadata is missing or contradicts the text. Han characters alone cannot identify a language;
406
+ untagged Han-only lyrics remain unknown. Translation and romanization settings are not needed
407
+ for this local inference.
408
+
409
+ Use `injectTranslation(document, lineElement, text, "zh-Hant")` to label a translation separately
410
+ from its source. The language argument is optional; unknown translations get `lang=""` so they
411
+ do not inherit the original lyrics' language. Romanizations use the source language with `Latn`.
412
+
413
+ Default font stacks now resolve on each lyric and decoration instead of only at the root. Hosts
414
+ loading regional CJK web fonts should set `--noto-sans-universal` on these elements according to
415
+ `:lang()`: a Japanese line and its Chinese translation can then choose different font subsets.
416
+ `--blyrics-font-family` and `--blyrics-translated-font-family` remain explicit theme overrides.
417
+ They are unset by default; custom CSS that reads them directly should use
418
+ `var(--blyrics-font-family, var(--blyrics-default-font-family))` for the default stack.
package/dist/README.md CHANGED
@@ -76,7 +76,7 @@ connects, and everything it was handed by then is applied at once.
76
76
  | Property | Attribute | Type | Default | Description |
77
77
  | --------------- | -------------- | ----------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
78
78
  | `lyrics` | | `Lyric[] \| null` | `null` | The song. Null means it was never given one, and an empty array clears the view, so there is a way to say both. |
79
- | `lyricsOptions` | | `{ loaderVisible?, noLyrics? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. |
79
+ | `lyricsOptions` | | `{ loaderVisible?, noLyrics?, language? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. |
80
80
  | `source` | `source` | `string \| HTMLMediaElement \| null` | `null` | A selector or the media element itself. See Following a media element. |
81
81
  | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
82
82
  | `currentTime` | `current-time` | `number` | `0` | Playback position in **seconds**. Writing it renders the view again, so whoever holds the clock drives the lyrics by writing this. |
@@ -150,6 +150,32 @@ stylesheet must not be able to configure the module by accident. An empty theme
150
150
  back to its default. The stylesheet itself goes into the document head under the
151
151
  `blyrics-custom-style` id.
152
152
 
153
+ ### Autoscroll grouping and animation
154
+
155
+ `blyrics-early-scroll-consider-s` is a comment setting with an independent default of `0.54`
156
+ seconds. When a lyric reaches its scroll time, the renderer includes nearby upcoming lines in
157
+ its target calculation. Entering the lookahead window alone does not scroll. A line already
158
+ included in a committed scroll does not scroll again when its own time arrives. Seeking,
159
+ resuming autoscroll and relayout can still reposition the view.
160
+
161
+ ```css
162
+ /* blyrics-early-scroll-consider-s = 0.54; */
163
+ /* blyrics-line-scroll-duration = 750ms; */
164
+ ```
165
+
166
+ New groups scroll immediately. Previous per-line `translate` animations continue and compose
167
+ additively, so their duration does not block the next scroll. The line-scroll duration knobs
168
+ control visual motion. The legacy `--blyrics-lyric-scroll-duration` variable and its
169
+ `--blyrics-lyric-transition-duration` alias have been removed, along with the container transform
170
+ transition. `blyrics-queue-scroll-ms` is ignored, and no timing equation needs balancing.
171
+ Missing or invalid line durations fall back to an internal `750ms` duration; nonpositive resolved
172
+ durations also use that fallback. Themes should set the line-scroll duration knobs directly
173
+ instead of referencing the removed variables.
174
+
175
+ Themes that previously relied on duration-derived lookahead should set their preferred
176
+ `blyrics-early-scroll-consider-s` explicitly. The default remains close to the former default
177
+ of approximately `0.54s`, but a custom animation duration no longer changes grouping.
178
+
153
179
  There is no `longWordThreshold`, `lineSyncedDelay` or `disableRichsync` property. Those are theme
154
180
  settings (`blyrics-long-word-threshold`, `blyrics-line-synced-animation-delay`,
155
181
  `blyrics-disable-richsync`), read from the stylesheet you already hand over. A theme that set one
@@ -365,3 +391,28 @@ repository and run `pnpm -C demo dev`, then open `http://localhost:5173/`.
365
391
  ## Licence
366
392
 
367
393
  MIT. See `LICENSE`.
394
+
395
+ ### Content language and CJK fonts
396
+
397
+ Pass the source BCP 47 language to `renderer.setLyrics(lyrics, { language: "ja" })`
398
+ (or `element.lyricsOptions = { language: "ja" }` before assigning lyrics). If language detection
399
+ finishes later, `renderer.setLanguage("ja")` updates the existing lines and their measurements
400
+ without replacing their DOM or animations. Repeating the same language (including equivalent spellings
401
+ such as `ja-JP` and `ja_JP`) does not write to the DOM or measure again. Omitting the language on a
402
+ new song clears the old hint.
403
+
404
+ The renderer preserves Chinese script/region tags and uses kana or Hangul as a fallback when
405
+ metadata is missing or contradicts the text. Han characters alone cannot identify a language;
406
+ untagged Han-only lyrics remain unknown. Translation and romanization settings are not needed
407
+ for this local inference.
408
+
409
+ Use `injectTranslation(document, lineElement, text, "zh-Hant")` to label a translation separately
410
+ from its source. The language argument is optional; unknown translations get `lang=""` so they
411
+ do not inherit the original lyrics' language. Romanizations use the source language with `Latn`.
412
+
413
+ Default font stacks now resolve on each lyric and decoration instead of only at the root. Hosts
414
+ loading regional CJK web fonts should set `--noto-sans-universal` on these elements according to
415
+ `:lang()`: a Japanese line and its Chinese translation can then choose different font subsets.
416
+ `--blyrics-font-family` and `--blyrics-translated-font-family` remain explicit theme overrides.
417
+ They are unset by default; custom CSS that reads them directly should use
418
+ `var(--blyrics-font-family, var(--blyrics-default-font-family))` for the default stack.
package/dist/engine.d.ts CHANGED
@@ -19,10 +19,8 @@ interface AnimEngineViewState {
19
19
  scrollResumeTime: number;
20
20
  scrollPos: number;
21
21
  selectedElementIndex: number;
22
- nextScrollAllowedTime: number;
23
22
  wasUserScrolling: boolean;
24
- lastActiveElements: LineData[];
25
- queuedScroll: boolean;
23
+ lastScrollElements: LineData[];
26
24
  lastScrollDebugContext: {
27
25
  activeElms: LineData[];
28
26
  centers: number[];
@@ -210,12 +208,7 @@ interface AnimationConfig {
210
208
  waveOscillationDurationMs: number;
211
209
  waveOscillationEasing: string;
212
210
  };
213
- scroll: {
214
- durationMs: number;
215
- easing: string;
216
- };
217
211
  lineScroll: {
218
- durationMs: number;
219
212
  easing: string;
220
213
  differentialEffects: boolean;
221
214
  };
@@ -224,7 +217,6 @@ interface AnimationSettings {
224
217
  config: AnimationConfig;
225
218
  scrollTiming: {
226
219
  earlyScrollConsiderS: number;
227
- queueScrollMs: number;
228
220
  };
229
221
  }
230
222
  export declare function noteVisibilityChange(engine: AnimationEngineInstance): void;
package/dist/engine.js CHANGED
@@ -26,18 +26,11 @@ const PAUSING_LYRICS_SCROLL_LOG = "Pausing Lyrics Autoscroll Due to User Scroll"
26
26
  const USER_SCROLL_RESUME_DELAY_MS = 25000;
27
27
  const PASSIVE_USER_SCROLL_RESUME_DELAY_MS = 5000;
28
28
  const LYRIC_ENDING_THRESHOLD_S = registerThemeSetting("blyrics-lyric-ending-threshold-s", 0.5);
29
- const EARLY_SCROLL_CONSIDER = registerThemeSetting("blyrics-early-scroll-consider-s", 0.62);
30
- const QUEUE_SCROLL_THRESHOLD = registerThemeSetting("blyrics-queue-scroll-ms", 150);
29
+ const EARLY_SCROLL_CONSIDER = registerThemeSetting("blyrics-early-scroll-consider-s", 0.54);
31
30
  const TIME_JUMP_THRESHOLD = 0.5;
32
31
  const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
33
- const SCROLL_TIMING_RATIO_BASE_DURATION_MS = 750;
34
- const SCROLL_TIMING_RATIO_BASE_EARLY_SCROLL_CONSIDER_S = 0.62;
35
- const SCROLL_TIMING_RATIO_BASE_QUEUE_SCROLL_THRESHOLD_MS = 150;
36
- const MAX_AUTO_QUEUE_SCROLL_THRESHOLD_MS = 200;
37
32
  const SCROLL_PREPARE_LEAD_MS = 120;
38
- const SCROLL_TIMING_RATIO_BASE_TOTAL_MS = SCROLL_TIMING_RATIO_BASE_EARLY_SCROLL_CONSIDER_S * 1000 + SCROLL_TIMING_RATIO_BASE_QUEUE_SCROLL_THRESHOLD_MS;
39
- const SCROLL_TIMING_BUFFER_MS = SCROLL_TIMING_RATIO_BASE_TOTAL_MS - SCROLL_TIMING_RATIO_BASE_DURATION_MS;
40
- const AUTO_QUEUE_SCROLL_RATIO = SCROLL_TIMING_RATIO_BASE_QUEUE_SCROLL_THRESHOLD_MS / SCROLL_TIMING_RATIO_BASE_TOTAL_MS;
33
+ const LINE_SCROLL_FALLBACK_DURATION_MS = 750;
41
34
  const SWIPE_LEAD_RATIO = registerThemeSetting("blyrics-swipe-lead-ratio", 0.1);
42
35
  const SWIPE_DURATION_RATIO = registerThemeSetting("blyrics-swipe-duration-ratio", 1.6);
43
36
  const ENABLE_DEBUG_RENDER = registerThemeSetting("blyrics-debug-renderer", false);
@@ -125,10 +118,8 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
125
118
  scrollResumeTime: 0,
126
119
  scrollPos: 0,
127
120
  selectedElementIndex: 0,
128
- nextScrollAllowedTime: 0,
129
121
  wasUserScrolling: false,
130
- lastActiveElements: [],
131
- queuedScroll: false,
122
+ lastScrollElements: [],
132
123
  lastScrollDebugContext: {
133
124
  activeElms: [],
134
125
  centers: [],
@@ -237,7 +228,6 @@ export function noteUserScroll(engine, isPassive) {
237
228
  export function noteContainerResize(engine, width, height) {
238
229
  if (width === engine.lyricWidth && height === engine.lyricHeight)
239
230
  return false;
240
- engine.nextScrollAllowedTime = 0;
241
231
  return true;
242
232
  }
243
233
  /**
@@ -282,10 +272,9 @@ export function clearLyrics(engine) {
282
272
  line.isSelected = false;
283
273
  }
284
274
  engine.skipScrollsDecayTimes = [];
285
- engine.lastActiveElements = [];
275
+ engine.lastScrollElements = [];
286
276
  engine.lastScrollDebugContext.activeElms = [];
287
277
  engine.lastScrollDebugContext.centers = [];
288
- engine.queuedScroll = false;
289
278
  engine.passiveScrollAccumulatedTime = 0;
290
279
  engine.passiveLastWallTime = 0;
291
280
  stopPassiveScrollLoop(engine);
@@ -1198,7 +1187,6 @@ function isGlowRestingInvisible(glowTo) {
1198
1187
  }
1199
1188
  function readAnimationConfig(engine, lyricsElement) {
1200
1189
  const prefersReducedMotion = engine.window.matchMedia(REDUCED_MOTION_QUERY).matches;
1201
- const scrollDurationMs = getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-lyric-scroll-duration", "650ms");
1202
1190
  const scrollEasing = getCSSValue(engine, lyricsElement, "--blyrics-lyric-scroll-timing-function", "cubic-bezier(0.86, 0, 0.2, 1)");
1203
1191
  return {
1204
1192
  enabled: {
@@ -1271,53 +1259,21 @@ function readAnimationConfig(engine, lyricsElement) {
1271
1259
  waveOscillationDurationMs: getCSSDurationWithFallback(engine, lyricsElement, "--blyrics-instrumental-wave-oscillation-duration", "1.25s"),
1272
1260
  waveOscillationEasing: getCSSValue(engine, lyricsElement, "--blyrics-instrumental-wave-oscillation-easing", "ease-in-out"),
1273
1261
  },
1274
- scroll: {
1275
- durationMs: scrollDurationMs,
1276
- easing: scrollEasing,
1277
- },
1278
1262
  lineScroll: {
1279
- durationMs: scrollDurationMs,
1280
1263
  easing: scrollEasing,
1281
1264
  differentialEffects: !prefersReducedMotion,
1282
1265
  },
1283
1266
  };
1284
1267
  }
1285
- function readScrollTiming(scrollDurationMs) {
1286
- const totalMs = Math.max(0, scrollDurationMs + SCROLL_TIMING_BUFFER_MS);
1287
- const earlyScrollConsiderWasSet = EARLY_SCROLL_CONSIDER.isManuallySet();
1288
- const queueScrollWasSet = QUEUE_SCROLL_THRESHOLD.isManuallySet();
1289
- if (earlyScrollConsiderWasSet && queueScrollWasSet) {
1290
- return {
1291
- earlyScrollConsiderS: Math.max(0, EARLY_SCROLL_CONSIDER.getNumberValue()),
1292
- queueScrollMs: Math.max(0, QUEUE_SCROLL_THRESHOLD.getNumberValue()),
1293
- };
1294
- }
1295
- if (earlyScrollConsiderWasSet) {
1296
- const earlyScrollConsiderS = Math.max(0, EARLY_SCROLL_CONSIDER.getNumberValue());
1297
- return {
1298
- earlyScrollConsiderS,
1299
- queueScrollMs: Math.min(Math.max(0, totalMs - earlyScrollConsiderS * 1000), MAX_AUTO_QUEUE_SCROLL_THRESHOLD_MS),
1300
- };
1301
- }
1302
- if (queueScrollWasSet) {
1303
- const queueScrollMs = Math.max(0, QUEUE_SCROLL_THRESHOLD.getNumberValue());
1304
- return {
1305
- earlyScrollConsiderS: Math.max(0, (totalMs - queueScrollMs) / 1000),
1306
- queueScrollMs,
1307
- };
1308
- }
1309
- const queueScrollMs = Math.min(totalMs * AUTO_QUEUE_SCROLL_RATIO, MAX_AUTO_QUEUE_SCROLL_THRESHOLD_MS);
1310
- return {
1311
- earlyScrollConsiderS: Math.max(0, (totalMs - queueScrollMs) / 1000),
1312
- queueScrollMs,
1313
- };
1268
+ function readScrollTiming() {
1269
+ return { earlyScrollConsiderS: Math.max(0, EARLY_SCROLL_CONSIDER.getNumberValue()) };
1314
1270
  }
1315
1271
  function getAnimationSettings(engine, lyricsElement) {
1316
1272
  if (!engine.cachedAnimationSettings) {
1317
1273
  const config = readAnimationConfig(engine, lyricsElement);
1318
1274
  engine.cachedAnimationSettings = {
1319
1275
  config,
1320
- scrollTiming: readScrollTiming(config.scroll.durationMs),
1276
+ scrollTiming: readScrollTiming(),
1321
1277
  };
1322
1278
  }
1323
1279
  return engine.cachedAnimationSettings;
@@ -1557,9 +1513,9 @@ function prepareLineScrollOffsets(engine, lines, activeLineIndex, scrollDeltaPx,
1557
1513
  const misses = requests.filter(request => !request.timing);
1558
1514
  if (misses.length > 0) {
1559
1515
  const missItems = misses.map(request => request.item);
1560
- const durations = batchResolveLineScrollProperty(engine, missItems, "transition-duration", item => lineScrollDurationProperty(item.side, config.lineScroll.durationMs, config.lineScroll.differentialEffects), style => {
1516
+ const durations = batchResolveLineScrollProperty(engine, missItems, "transition-duration", item => lineScrollDurationProperty(item.side, LINE_SCROLL_FALLBACK_DURATION_MS, config.lineScroll.differentialEffects), style => {
1561
1517
  const durationMs = toMs(style.transitionDuration.split(",")[0].trim());
1562
- return durationMs > 0 ? durationMs : config.lineScroll.durationMs;
1518
+ return durationMs > 0 ? durationMs : LINE_SCROLL_FALLBACK_DURATION_MS;
1563
1519
  });
1564
1520
  const startEasings = batchResolveLineScrollProperty(engine, missItems, "transition-timing-function", item => lineScrollEasingProperty(item.side, "start", config.lineScroll.easing, config.lineScroll.differentialEffects), style => style.transitionTimingFunction.trim() || config.lineScroll.easing);
1565
1521
  const endEasings = batchResolveLineScrollProperty(engine, missItems, "transition-timing-function", item => lineScrollEasingProperty(item.side, "end", config.lineScroll.easing, config.lineScroll.differentialEffects), style => style.transitionTimingFunction.trim() || config.lineScroll.easing);
@@ -1918,7 +1874,7 @@ export function tickView(engine, currentTime, options) {
1918
1874
  if (lyricScrollTime >= time - scrollTiming.earlyScrollConsiderS &&
1919
1875
  (lyricScrollTime < nextTime || lyricScrollTime < time + lineData.duration)) {
1920
1876
  activeElems.push(lineData);
1921
- if (!engine.lastActiveElements.includes(lineData) && lyricScrollTime >= time) {
1877
+ if (!engine.lastScrollElements.includes(lineData) && lyricScrollTime >= time) {
1922
1878
  newLyricSelected = true;
1923
1879
  }
1924
1880
  // const timeDelta = lyricScrollTime - time;
@@ -2032,8 +1988,6 @@ export function tickView(engine, currentTime, options) {
2032
1988
  if (activeElems.length == 0) {
2033
1989
  activeElems.push(lines[0]);
2034
1990
  }
2035
- engine.lastActiveElements = activeElems.filter(elm => lyricScrollTime >= elm.time // remove elements that haven't reached their scroll time yet.
2036
- );
2037
1991
  // Offset so lyrics appear towards the center of the screen.
2038
1992
  const scrollPosOffset = tabRendererHeight * SCROLL_POS_OFFSET_RATIO.getNumberValue();
2039
1993
  let lastActiveLyric = activeElems[activeElems.length - 1];
@@ -2134,39 +2088,34 @@ export function tickView(engine, currentTime, options) {
2134
2088
  !engine.wasUserScrolling &&
2135
2089
  timeUntilUpcomingScrollMs > 0 &&
2136
2090
  timeUntilUpcomingScrollMs <= SCROLL_PREPARE_LEAD_MS &&
2137
- Date.now() > engine.nextScrollAllowedTime &&
2091
+ !engine.lastScrollElements.includes(lastActiveLyric) &&
2138
2092
  Math.abs(scrollTop - scrollPos) > 2) {
2139
2093
  updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
2140
2094
  prepareUpcomingLineScroll(engine, getLineScrollItems(engine, lines), lastActiveLyric, scrollPos - scrollTop, scrollTop, scrollPos, tabRendererHeight, animationConfig);
2141
2095
  }
2142
- if (engine.wasUserScrolling || newLyricSelected || engine.queuedScroll) {
2143
- if (Date.now() > engine.nextScrollAllowedTime) {
2144
- engine.queuedScroll = false;
2145
- engine.lastScrollDebugContext.lyricScrollTime = lyricScrollTime;
2146
- engine.lastScrollDebugContext.centers = lyricPositions;
2147
- engine.lastScrollDebugContext.activeElms = activeElems;
2148
- if (smoothScroll && Math.abs(scrollTop - scrollPos) > 2) {
2149
- const scrollDeltaPx = scrollPos - scrollTop;
2150
- if (animationConfig.enabled.scroll) {
2151
- updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
2152
- const lineScrollItems = getLineScrollItems(engine, lines);
2153
- commitOrPrepareLineScroll(engine, lineScrollItems, lastActiveLyric, scrollDeltaPx, scrollTop, scrollPos, tabRendererHeight, animationConfig);
2154
- engine.nextScrollAllowedTime = animationConfig.scroll.durationMs + Date.now() + 20;
2155
- }
2156
- }
2157
- else {
2158
- dropPendingLineScroll(engine);
2096
+ if (engine.wasUserScrolling || newLyricSelected) {
2097
+ // Remember future lines only when their group is committed, so they cannot scroll
2098
+ // again at their start. Entering the lookahead window alone does not commit a group.
2099
+ engine.lastScrollElements = activeElems;
2100
+ engine.lastScrollDebugContext.lyricScrollTime = lyricScrollTime;
2101
+ engine.lastScrollDebugContext.centers = lyricPositions;
2102
+ engine.lastScrollDebugContext.activeElms = activeElems;
2103
+ if (smoothScroll && Math.abs(scrollTop - scrollPos) > 2) {
2104
+ const scrollDeltaPx = scrollPos - scrollTop;
2105
+ if (animationConfig.enabled.scroll) {
2106
+ updateVisibleLyricWillChange(engine, lines, scrollTop, scrollPos, tabRendererHeight);
2107
+ const lineScrollItems = getLineScrollItems(engine, lines);
2108
+ commitOrPrepareLineScroll(engine, lineScrollItems, lastActiveLyric, scrollDeltaPx, scrollTop, scrollPos, tabRendererHeight, animationConfig);
2159
2109
  }
2160
- scrollTop = scrollPos;
2161
- engine.scrollPos = scrollTop;
2162
- tabRenderer.scrollTop = scrollTop;
2163
- engine.skipScrolls += 1;
2164
- engine.skipScrollsDecayTimes.push(Date.now() + 2000);
2165
2110
  }
2166
- else if (engine.nextScrollAllowedTime - Date.now() < scrollTiming.queueScrollMs || timeJumped) {
2167
- // just missed out on being able to scroll, queue this once we finish our current lyric
2168
- engine.queuedScroll = true;
2111
+ else {
2112
+ dropPendingLineScroll(engine);
2169
2113
  }
2114
+ scrollTop = scrollPos;
2115
+ engine.scrollPos = scrollTop;
2116
+ tabRenderer.scrollTop = scrollTop;
2117
+ engine.skipScrolls += 1;
2118
+ engine.skipScrollsDecayTimes.push(Date.now() + 2000);
2170
2119
  }
2171
2120
  }
2172
2121
  if (engine.wasUserScrolling && engine.scrollResumeTime < Date.now()) {
package/dist/inject.d.ts CHANGED
@@ -43,4 +43,4 @@ export declare function createLyricsLine(doc: Document, parts: LyricPart[], line
43
43
  export declare function buildLineSyncedParts(item: Lyric): LyricPart[];
44
44
  export declare function addSeekHandler(seek: (timeS: number) => void, lyricElement: HTMLElement, allZero: boolean): void;
45
45
  export declare function injectRomanization(doc: Document, lyricElement: HTMLElement, lineData: LineData, text: string, timedRomanization?: LyricPart[] | null): void;
46
- export declare function injectTranslation(doc: Document, lyricElement: HTMLElement, text: string): void;
46
+ export declare function injectTranslation(doc: Document, lyricElement: HTMLElement, text: string, language?: string | null): void;
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 { normalizeLanguage } from "./language.js";
14
15
  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
16
  import { getSeekTimeFromClick } from "./seek.js";
16
17
  import { testRtl } from "./text.js";
@@ -405,6 +406,8 @@ export function injectRomanization(doc, lyricElement, lineData, text, timedRoman
405
406
  const romanizedLine = doc.createElement("div");
406
407
  romanizedLine.classList.add(ROMANIZED_LYRICS_CLASS, CONTENT_LINE_CLASS);
407
408
  romanizedLine.dir = "auto";
409
+ const language = normalizeLanguage(lyricElement.lang);
410
+ romanizedLine.lang = `${language ? new Intl.Locale(language).language : "und"}-Latn`;
408
411
  applyDirection(romanizedLine, text);
409
412
  if (timedRomanization && timedRomanization.length > 0 && !disableRichsync.getBooleanValue()) {
410
413
  createLyricsLine(doc, timedRomanization, lineData, romanizedLine, { splitBackgroundLine: false });
@@ -415,12 +418,13 @@ export function injectRomanization(doc, lyricElement, lineData, text, timedRoman
415
418
  const translation = lyricElement.querySelector(`.${TRANSLATED_LYRICS_CLASS}`);
416
419
  lyricElement.insertBefore(romanizedLine, translation);
417
420
  }
418
- export function injectTranslation(doc, lyricElement, text) {
421
+ export function injectTranslation(doc, lyricElement, text, language) {
419
422
  if (lyricElement.querySelector(`.${TRANSLATED_LYRICS_CLASS}`))
420
423
  return;
421
424
  const translatedLine = doc.createElement("div");
422
425
  translatedLine.classList.add(TRANSLATED_LYRICS_CLASS, CONTENT_LINE_CLASS);
423
426
  translatedLine.dir = "auto";
427
+ translatedLine.lang = normalizeLanguage(language);
424
428
  applyDirection(translatedLine, text);
425
429
  translatedLine.textContent = text;
426
430
  lyricElement.appendChild(translatedLine);
@@ -0,0 +1,9 @@
1
+ interface LyricText {
2
+ words: string;
3
+ isInstrumental?: boolean;
4
+ }
5
+ export declare function normalizeLanguage(language?: string | null): string;
6
+ export declare function resolveLyricLanguages(lyrics: readonly LyricText[], sourceLanguage?: string | null): string[];
7
+ /** Tags only lyric content, leaving the host UI and source footer in their own language. */
8
+ export declare function applyLyricLanguage(element: HTMLElement, language?: string | null): void;
9
+ export {};
@@ -0,0 +1,47 @@
1
+ import { ROMANIZED_LYRICS_CLASS } from "./constants.js";
2
+ export function normalizeLanguage(language) {
3
+ if (!language || language === "auto" || language === "und")
4
+ return "";
5
+ try {
6
+ return Intl.getCanonicalLocales(language.trim().replaceAll("_", "-"))[0] ?? "";
7
+ }
8
+ catch {
9
+ return "";
10
+ }
11
+ }
12
+ function detectCjkLanguage(text) {
13
+ if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text))
14
+ return "ja";
15
+ if (/\p{Script=Hangul}/u.test(text))
16
+ return "ko";
17
+ // Han alone cannot distinguish Japanese, Korean, or the Chinese writing systems.
18
+ return null;
19
+ }
20
+ function isCjkLanguage(language) {
21
+ return /^(ja|ko|zh|yue)(-|$)|-(Hans|Hant)(-|$)/i.test(language);
22
+ }
23
+ export function resolveLyricLanguages(lyrics, sourceLanguage) {
24
+ const language = normalizeLanguage(sourceLanguage);
25
+ const hints = lyrics.map(line => (line.isInstrumental ? null : detectCjkLanguage(line.words)));
26
+ const songHints = new Set(hints.filter(hint => hint !== null));
27
+ const songHint = songHints.size === 1 ? [...songHints][0] : "";
28
+ const fallback = isCjkLanguage(language) ? language : songHint || language;
29
+ return lyrics.map((line, index) => {
30
+ if (line.isInstrumental)
31
+ return "";
32
+ const hint = hints[index];
33
+ if (hint && fallback.split("-")[0] !== hint)
34
+ return hint;
35
+ if (/\p{Script=Han}/u.test(line.words) && !isCjkLanguage(fallback))
36
+ return "";
37
+ return fallback;
38
+ });
39
+ }
40
+ /** Tags only lyric content, leaving the host UI and source footer in their own language. */
41
+ export function applyLyricLanguage(element, language) {
42
+ const normalized = normalizeLanguage(language);
43
+ element.lang = normalized;
44
+ const romanization = element.querySelector(`.${ROMANIZED_LYRICS_CLASS}`);
45
+ if (romanization)
46
+ romanization.lang = `${normalized ? new Intl.Locale(normalized).language : "und"}-Latn`;
47
+ }
package/dist/renderer.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { CUSTOM_THEME_STYLE_ID } from "./constants.js";
13
13
  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
14
  import { parseThemeConfig, setThemeSettings } from "./themeSettings.js";
15
+ import { applyLyricLanguage, normalizeLanguage, resolveLyricLanguages } from "./language.js";
15
16
  import { setLyrics as buildLyricsView } from "./view.js";
16
17
  /**
17
18
  * What the default `seek` dispatches at the mount. A consumer that gave the renderer no way to
@@ -134,6 +135,8 @@ export function createLyricsRenderer(rendererOptions) {
134
135
  // Only the theme element this renderer created, which is the only one it may take away again.
135
136
  let createdThemeStyleElement = null;
136
137
  let isDestroyed = false;
138
+ let currentLyrics = [];
139
+ let currentLanguage = "";
137
140
  const { host, forgetScrollElement } = withHostDefaults(rendererOptions.host, rendererWindow, () => mount);
138
141
  const engine = createAnimationEngineInstance(rendererDocument, rendererWindow, host);
139
142
  /**
@@ -165,6 +168,8 @@ export function createLyricsRenderer(rendererOptions) {
165
168
  stopObservingContainer();
166
169
  engine.lyricsContainer?.remove();
167
170
  clearLyrics(engine);
171
+ currentLyrics = [];
172
+ currentLanguage = "";
168
173
  }
169
174
  /**
170
175
  * Watches the built container for the layout it settles into. The guard is what stops this
@@ -252,13 +257,25 @@ export function createLyricsRenderer(rendererOptions) {
252
257
  clearBuiltView();
253
258
  mount = nextMount;
254
259
  buildLyricsView(engine, nextMount, lyrics, {
260
+ language: options?.language,
255
261
  loaderVisible: options?.loaderVisible ?? false,
256
262
  noLyrics: options?.noLyrics ?? false,
257
263
  });
264
+ currentLyrics = lyrics;
265
+ currentLanguage = normalizeLanguage(options?.language);
258
266
  measure();
259
267
  if (engine.lyricsContainer)
260
268
  observeContainer(engine.lyricsContainer);
261
269
  },
270
+ setLanguage(language) {
271
+ const normalized = normalizeLanguage(language);
272
+ if (isDestroyed || currentLyrics.length === 0 || normalized === currentLanguage)
273
+ return;
274
+ currentLanguage = normalized;
275
+ const languages = resolveLyricLanguages(currentLyrics, normalized);
276
+ getRenderedLines(engine).forEach((line, index) => applyLyricLanguage(line.lyricElement, languages[index]));
277
+ measure();
278
+ },
262
279
  setTheme(css) {
263
280
  if (isDestroyed)
264
281
  return false;
@@ -1,6 +1,19 @@
1
+ /* Resolve the default stack at each language boundary. A root-level var() would freeze
2
+ the host's language-specific fallback before the lyric/translation language is known.
3
+ Theme overrides remain inherited through --blyrics-font-family. */
4
+ .blyrics--line,
5
+ .blyrics--translated,
6
+ .blyrics--romanized {
7
+ --blyrics-default-font-family:
8
+ Satoshi, var(--noto-sans-universal, "Noto Sans"), Avenir, -apple-system,
9
+ BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans,
10
+ Helvetica Neue, sans-serif;
11
+ font-family: var(--blyrics-font-family, var(--blyrics-default-font-family));
12
+ }
13
+
1
14
  /* Main container styles */
2
15
  .blyrics-container {
3
- font-family: var(--blyrics-font-family);
16
+ font-family: var(--blyrics-font-family, var(--blyrics-default-font-family));
4
17
  font-size: var(--blyrics-font-size);
5
18
  font-weight: var(--blyrics-font-weight);
6
19
  isolation: isolate;
@@ -8,8 +21,6 @@
8
21
  padding-bottom: calc(var(--blyrics-padding-bottom));
9
22
  padding-top: 2rem;
10
23
  position: relative !important;
11
- transition: transform var(--blyrics-lyric-scroll-duration)
12
- var(--blyrics-lyric-scroll-timing-function) 0s;
13
24
  will-change: transform;
14
25
  z-index: 1;
15
26
  }
@@ -215,7 +226,7 @@
215
226
  cubic-bezier(0.25, 1, 0.5, 1) both;
216
227
  color: var(--blyrics-translated-color);
217
228
  display: block;
218
- font-family: var(--blyrics-translated-font-family);
229
+ font-family: var(--blyrics-translated-font-family, var(--blyrics-font-family, var(--blyrics-default-font-family)));
219
230
  font-size: var(--blyrics-translated-font-size);
220
231
  font-weight: var(--blyrics-translated-font-weight);
221
232
  line-height: 1.1;
@@ -36,7 +36,7 @@
36
36
  so that a standalone consumer without it still falls through to the rest of this stack; the
37
37
  CJK, Devanagari, Arabic, Hebrew and Thai coverage is what such a consumer gives up either
38
38
  way. */
39
- --blyrics-font-family:
39
+ --blyrics-default-font-family:
40
40
  Satoshi, var(--noto-sans-universal, "Noto Sans"), Avenir, -apple-system,
41
41
  BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans,
42
42
  Helvetica Neue, sans-serif;
@@ -46,7 +46,6 @@
46
46
 
47
47
  --blyrics-translated-font-size: 2rem;
48
48
  --blyrics-translated-font-weight: 600;
49
- --blyrics-translated-font-family: var(--blyrics-font-family);
50
49
  --blyrics-translated-color: color(
51
50
  display-p3 1 1 1 / var(--blyrics-translated-opacity, 0.6)
52
51
  );
@@ -144,11 +143,7 @@
144
143
  --blyrics-background-blur: 100px;
145
144
  --blyrics-background-saturate: 2;
146
145
 
147
- /* Lyric Transition Properties */
148
- --blyrics-lyric-scroll-duration: var(
149
- --blyrics-lyric-transition-duration,
150
- 650ms
151
- );
146
+ /* Shared lyric scroll easing */
152
147
  --blyrics-lyric-scroll-timing-function: var(
153
148
  --blyrics-lyric-transition-timing-function,
154
149
  cubic-bezier(0.86, 0, 0.2, 1)
package/dist/types.d.ts CHANGED
@@ -109,6 +109,8 @@ export interface LyricsRenderer {
109
109
  setLyrics(lyrics: Lyric[], options?: Partial<SetLyricsOptions> & {
110
110
  mount?: HTMLElement;
111
111
  }): void;
112
+ /** Updates a late-detected source language and layout without rebuilding lines or animations. */
113
+ setLanguage(language?: string | null): void;
112
114
  /**
113
115
  * Applies a compiled theme. The `blyrics-*` configuration in its comments becomes the module's
114
116
  * theme settings, and the stylesheet itself is applied to the document this renderer builds in.
package/dist/view.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type AnimationEngineInstance } from "./engine.js";
2
2
  import type { Lyric } from "./types.js";
3
3
  export interface SetLyricsOptions {
4
+ /** BCP 47 language of the original lyrics. Kana/Hangul provide a fallback when omitted. */
5
+ language?: string | null;
4
6
  /**
5
7
  * Whether the loader is still covering the view. Recorded on the container for CSS to key on.
6
8
  */
package/dist/view.js CHANGED
@@ -13,6 +13,7 @@ import { 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, isNearestLyricRtl, newLineData, } from "./inject.js";
15
15
  import { createInstrumentalElement } from "./instrumental.js";
16
+ import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
16
17
  const INITIAL_SKIP_SCROLLS = 2;
17
18
  const SKIP_SCROLL_DECAY_MS = 2000;
18
19
  function buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex) {
@@ -58,6 +59,7 @@ export function setLyrics(engine, mount, lyrics, options) {
58
59
  const seek = (timeS) => engine.host.seek(timeS);
59
60
  const lines = [];
60
61
  const syncType = deriveSyncType(lyrics);
62
+ const languages = resolveLyricLanguages(lyrics, options.language);
61
63
  for (const [lineIndex, lyricItem] of lyrics.entries()) {
62
64
  const lyricElement = doc.createElement("div");
63
65
  const line = newLineData(lyricElement, lyricItem.startTimeMs, lyricItem.durationMs);
@@ -66,6 +68,7 @@ export function setLyrics(engine, mount, lyrics, options) {
66
68
  lyricElement.dataset.lineNumber = String(lineIndex);
67
69
  lyricElement.classList.add(LINE_CLASS);
68
70
  lyricElement.dir = "auto";
71
+ applyLyricLanguage(lyricElement, languages[lineIndex]);
69
72
  addSeekHandler(seek, lyricElement, allZero);
70
73
  lines.push(line);
71
74
  if (lyricItem.isInstrumental) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",