@braccato/core 1.12.6 → 1.13.1

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
@@ -176,6 +176,19 @@ before the theme is applied.
176
176
  }
177
177
  ```
178
178
 
179
+ A view whose edges are hidden, for example faded out by a mask, declares that with `scroll-padding`
180
+ on its scroll element. Active lines are then kept inside the band between the two insets instead of
181
+ the full viewport. When several active lines do not fit, the latest line still being sung keeps its
182
+ top in view, and an earlier line's tail or the next line's lookahead gives way. Give each inset as a
183
+ length or a percentage of the viewport height; a `calc()` that mixes the two is ignored, as are
184
+ insets that leave no band. The insets are measured on resize and relayout, not every tick.
185
+
186
+ ```css
187
+ .pip-view .scroller {
188
+ scroll-padding-block: 12% 16%;
189
+ }
190
+ ```
191
+
179
192
  ### Autoscroll grouping and animation
180
193
 
181
194
  `blyrics-early-scroll-consider-s` is a comment setting with an independent default of `0.54`
package/dist/README.md CHANGED
@@ -176,6 +176,19 @@ before the theme is applied.
176
176
  }
177
177
  ```
178
178
 
179
+ A view whose edges are hidden, for example faded out by a mask, declares that with `scroll-padding`
180
+ on its scroll element. Active lines are then kept inside the band between the two insets instead of
181
+ the full viewport. When several active lines do not fit, the latest line still being sung keeps its
182
+ top in view, and an earlier line's tail or the next line's lookahead gives way. Give each inset as a
183
+ length or a percentage of the viewport height; a `calc()` that mixes the two is ignored, as are
184
+ insets that leave no band. The insets are measured on resize and relayout, not every tick.
185
+
186
+ ```css
187
+ .pip-view .scroller {
188
+ scroll-padding-block: 12% 16%;
189
+ }
190
+ ```
191
+
179
192
  ### Autoscroll grouping and animation
180
193
 
181
194
  `blyrics-early-scroll-consider-s` is a comment setting with an independent default of `0.54`
package/dist/engine.d.ts CHANGED
@@ -21,6 +21,8 @@ interface AnimEngineViewState {
21
21
  selectedElementIndex: number;
22
22
  wasUserScrolling: boolean;
23
23
  lastScrollElements: LineData[];
24
+ /** The line a committed scroll kept in view because the active lines overflowed the band. */
25
+ pinnedScrollLine: LineData | null;
24
26
  lastScrollDebugContext: {
25
27
  activeElms: LineData[];
26
28
  centers: number[];
@@ -39,6 +41,7 @@ export interface AnimationEngineInstance extends AnimEngineViewState {
39
41
  window: EngineWindow;
40
42
  host: LyricsRendererHost;
41
43
  cachedTabRendererHeight: number | null;
44
+ cachedScrollInsets: ScrollInsets;
42
45
  cachedFooterItem: LineScrollItem | null;
43
46
  cachedCreditsItem: LineScrollItem | null;
44
47
  creditsFocused: boolean;
@@ -287,6 +290,39 @@ export declare function setupLineCullObserver(engine: AnimationEngineInstance):
287
290
  * from, so the container is the term that says they are still there.
288
291
  */
289
292
  export declare function hasUnsyncedLyrics(engine: AnimationEngineInstance): boolean;
293
+ export interface ScrollInsets {
294
+ readonly top: number;
295
+ readonly bottom: number;
296
+ }
297
+ /**
298
+ * The scroll element's `scroll-padding` is the part of its viewport a host has declared unreadable,
299
+ * such as an edge faded out by a mask. Insets that leave no band at all are ignored rather than
300
+ * obeyed, since there would be nowhere left to put a line.
301
+ */
302
+ export declare function resolveScrollInsets(scrollPaddingTop: string, scrollPaddingBottom: string, viewportHeight: number): ScrollInsets;
303
+ interface ScrollTargetLine {
304
+ readonly time: number;
305
+ readonly duration: number;
306
+ readonly position: number;
307
+ readonly height: number;
308
+ }
309
+ /**
310
+ * The line the singer is on: the latest one still being sung. An earlier line still running is a
311
+ * tail, such as a background echo or a held note, and a later one is still in its lookahead. A line
312
+ * stays active past its own end until the next one starts, so a started line only wins while it runs.
313
+ */
314
+ export declare function findScrollAnchor<Line extends ScrollTargetLine>(sungLines: readonly Line[], scrollTime: number): Line;
315
+ /**
316
+ * Where to scroll so the lines being sung sit around the target, inside the visible band. When they
317
+ * do not all fit, the anchor keeps its top in view and the rest give way. `overflowsBand` tells the
318
+ * caller the view is pinned to the anchor, and has to move on once that line ends.
319
+ *
320
+ * @param sungLines - In order, never empty. The last is the last active line.
321
+ */
322
+ export declare function computeActiveLinesScrollTop(sungLines: readonly ScrollTargetLine[], anchor: ScrollTargetLine, viewportHeight: number, insets: ScrollInsets, targetOffset: number): {
323
+ scrollTop: number;
324
+ overflowsBand: boolean;
325
+ };
290
326
  /**
291
327
  * "lyrics-missing" means this instance has nothing left to render. Whether that ends ticking is the
292
328
  * driver's call, not the instance's: one instance running dry must not silence another.
package/dist/engine.js CHANGED
@@ -120,6 +120,7 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
120
120
  selectedElementIndex: 0,
121
121
  wasUserScrolling: false,
122
122
  lastScrollElements: [],
123
+ pinnedScrollLine: null,
123
124
  lastScrollDebugContext: {
124
125
  activeElms: [],
125
126
  centers: [],
@@ -128,6 +129,7 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
128
129
  passiveScrollAccumulatedTime: 0,
129
130
  passiveLastWallTime: 0,
130
131
  cachedTabRendererHeight: null,
132
+ cachedScrollInsets: NO_SCROLL_INSETS,
131
133
  cachedFooterItem: null,
132
134
  cachedCreditsItem: null,
133
135
  creditsFocused: false,
@@ -275,6 +277,7 @@ export function clearLyrics(engine) {
275
277
  }
276
278
  engine.skipScrollsDecayTimes = [];
277
279
  engine.lastScrollElements = [];
280
+ engine.pinnedScrollLine = null;
278
281
  engine.lastScrollDebugContext.activeElms = [];
279
282
  engine.lastScrollDebugContext.centers = [];
280
283
  engine.passiveScrollAccumulatedTime = 0;
@@ -1752,12 +1755,68 @@ function setupTabRendererObserver(engine, element) {
1752
1755
  engine.tabRendererResizeObserver = new engine.window.ResizeObserver(() => {
1753
1756
  dropPendingLineScroll(engine);
1754
1757
  if (element && element.isConnected) {
1755
- engine.cachedTabRendererHeight = element.getBoundingClientRect().height;
1758
+ measureScrollViewport(engine, element);
1756
1759
  }
1757
1760
  });
1758
1761
  engine.tabRendererResizeObserver.observe(element);
1759
1762
  engine.observedTabRenderer = element;
1760
- engine.cachedTabRendererHeight = element.getBoundingClientRect().height;
1763
+ measureScrollViewport(engine, element);
1764
+ }
1765
+ const NO_SCROLL_INSETS = { top: 0, bottom: 0 };
1766
+ function resolveScrollInset(value, viewportHeight) {
1767
+ const amount = Number.parseFloat(value);
1768
+ if (!Number.isFinite(amount) || amount <= 0)
1769
+ return 0;
1770
+ return value.trim().endsWith("%") ? (viewportHeight * amount) / 100 : amount;
1771
+ }
1772
+ /**
1773
+ * The scroll element's `scroll-padding` is the part of its viewport a host has declared unreadable,
1774
+ * such as an edge faded out by a mask. Insets that leave no band at all are ignored rather than
1775
+ * obeyed, since there would be nowhere left to put a line.
1776
+ */
1777
+ export function resolveScrollInsets(scrollPaddingTop, scrollPaddingBottom, viewportHeight) {
1778
+ const top = resolveScrollInset(scrollPaddingTop, viewportHeight);
1779
+ const bottom = resolveScrollInset(scrollPaddingBottom, viewportHeight);
1780
+ return top + bottom < viewportHeight ? { top, bottom } : NO_SCROLL_INSETS;
1781
+ }
1782
+ function measureScrollViewport(engine, element) {
1783
+ const height = element.getBoundingClientRect().height;
1784
+ const { scrollPaddingTop, scrollPaddingBottom } = engine.window.getComputedStyle(element);
1785
+ engine.cachedTabRendererHeight = height;
1786
+ engine.cachedScrollInsets = resolveScrollInsets(scrollPaddingTop, scrollPaddingBottom, height);
1787
+ }
1788
+ /**
1789
+ * The line the singer is on: the latest one still being sung. An earlier line still running is a
1790
+ * tail, such as a background echo or a held note, and a later one is still in its lookahead. A line
1791
+ * stays active past its own end until the next one starts, so a started line only wins while it runs.
1792
+ */
1793
+ export function findScrollAnchor(sungLines, scrollTime) {
1794
+ return (sungLines.findLast(line => line.time <= scrollTime && scrollTime < line.time + line.duration) ??
1795
+ sungLines.findLast(line => line.time <= scrollTime) ??
1796
+ sungLines[0]);
1797
+ }
1798
+ /**
1799
+ * Where to scroll so the lines being sung sit around the target, inside the visible band. When they
1800
+ * do not all fit, the anchor keeps its top in view and the rest give way. `overflowsBand` tells the
1801
+ * caller the view is pinned to the anchor, and has to move on once that line ends.
1802
+ *
1803
+ * @param sungLines - In order, never empty. The last is the last active line.
1804
+ */
1805
+ export function computeActiveLinesScrollTop(sungLines, anchor, viewportHeight, insets, targetOffset) {
1806
+ const first = sungLines[0];
1807
+ const last = sungLines[sungLines.length - 1];
1808
+ const averageCentre = sungLines.reduce((total, line) => total + line.position + line.height / 2, 0) / sungLines.length;
1809
+ const visibleBottom = viewportHeight - insets.bottom;
1810
+ let scrollTop = averageCentre - targetOffset;
1811
+ scrollTop = Math.max(scrollTop, last.position + last.height - visibleBottom);
1812
+ scrollTop = Math.min(scrollTop, last.position - insets.top);
1813
+ scrollTop = Math.min(scrollTop, first.position - insets.top);
1814
+ scrollTop = Math.max(scrollTop, anchor.position + anchor.height - visibleBottom);
1815
+ scrollTop = Math.min(scrollTop, anchor.position - insets.top);
1816
+ return {
1817
+ scrollTop,
1818
+ overflowsBand: last.position + last.height - first.position > visibleBottom - insets.top,
1819
+ };
1761
1820
  }
1762
1821
  /**
1763
1822
  * Fills in everything a caller left out of a tick. The tick reads each of these arithmetically, so
@@ -2012,28 +2071,24 @@ export function tickView(engine, currentTime, options) {
2012
2071
  // Offset so lyrics appear towards the center of the screen.
2013
2072
  const scrollPosOffset = tabRendererHeight * getTargetScrollRatio(engine, lyricsElement);
2014
2073
  let lastActiveLyric = activeElems[activeElems.length - 1];
2015
- let lyricPositions = activeElems
2016
- .filter((lineData, index) => {
2017
- // Ignore lyrics close to finishing unless it last active lyric
2018
- return (lyricScrollTime < lineData.time + lineData.duration - LYRIC_ENDING_THRESHOLD_S.getNumberValue() ||
2019
- index == activeElems.length - 1);
2020
- })
2021
- // We subtract selectedLyricHeight / 2 to center the selected lyric line vertically within the offset region,
2022
- // so the lyric is not aligned at the very top of the offset but is visually centered.
2023
- .map(lineData => lineData.position + lineData.height / 2);
2024
- let avgPos = lyricPositions.reduce((accumulator, currentValue) => accumulator + currentValue, 0) / lyricPositions.length;
2025
- // Base position
2026
- let scrollPos = avgPos - scrollPosOffset;
2027
- // Make sure the first selected line is stays visible
2028
- scrollPos = Math.min(scrollPos, lyricPositions[0]);
2029
- // Make sure bottom of last active lyric is visible
2030
- scrollPos = Math.max(scrollPos, lastActiveLyric.position - tabRendererHeight + lastActiveLyric.height);
2031
- // Make sure top of last active lyric is visible.
2032
- scrollPos = Math.min(scrollPos, lastActiveLyric.position);
2074
+ // Ignore lyrics close to finishing unless it is the last active lyric.
2075
+ const sungLines = activeElems.filter((lineData, index) => lyricScrollTime < lineData.time + lineData.duration - LYRIC_ENDING_THRESHOLD_S.getNumberValue() ||
2076
+ index == activeElems.length - 1);
2077
+ const lyricPositions = sungLines.map(lineData => lineData.position + lineData.height / 2);
2078
+ const scrollInsets = engine.cachedScrollInsets;
2079
+ const scrollAnchor = findScrollAnchor(sungLines, lyricScrollTime);
2080
+ const scrollTarget = computeActiveLinesScrollTop(sungLines, scrollAnchor, tabRendererHeight, scrollInsets, scrollPosOffset);
2081
+ let scrollPos = scrollTarget.scrollTop;
2082
+ const pinned = engine.pinnedScrollLine;
2083
+ if (pinned &&
2084
+ pinned !== scrollAnchor &&
2085
+ (!sungLines.includes(pinned) || lyricScrollTime >= pinned.time + pinned.duration)) {
2086
+ newLyricSelected = true;
2087
+ }
2033
2088
  const credits = engine.cachedCreditsItem;
2034
2089
  if (engine.creditsFocused && credits && lastSungLine) {
2035
2090
  // Tall credits would otherwise carry the last line out of view while it is still being sung.
2036
- scrollPos = Math.min(credits.position + credits.height / 2 - scrollPosOffset, lastSungLine.position);
2091
+ scrollPos = Math.min(credits.position + credits.height / 2 - scrollPosOffset, lastSungLine.position - scrollInsets.top);
2037
2092
  }
2038
2093
  // Past either end the browser clamps the write and reports nothing, leaving the view aiming
2039
2094
  // at a position it never reached and re-aiming once per remaining line.
@@ -2123,6 +2178,7 @@ export function tickView(engine, currentTime, options) {
2123
2178
  // Remember future lines only when their group is committed, so they cannot scroll
2124
2179
  // again at their start. Entering the lookahead window alone does not commit a group.
2125
2180
  engine.lastScrollElements = activeElems;
2181
+ engine.pinnedScrollLine = scrollTarget.overflowsBand ? scrollAnchor : null;
2126
2182
  engine.lastScrollDebugContext.lyricScrollTime = lyricScrollTime;
2127
2183
  engine.lastScrollDebugContext.centers = lyricPositions;
2128
2184
  engine.lastScrollDebugContext.activeElms = activeElems;
@@ -2237,6 +2293,9 @@ function applyScrollPadding(engine) {
2237
2293
  */
2238
2294
  export function relayout(engine, measureLines) {
2239
2295
  applyScrollPadding(engine);
2296
+ const scrollElement = engine.host.getScrollElement();
2297
+ if (scrollElement)
2298
+ measureScrollViewport(engine, scrollElement);
2240
2299
  if (!measureLines)
2241
2300
  return;
2242
2301
  const lyricsElement = engine.lyricsContainer;
package/dist/inject.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import { normalizeLanguage } from "./language.js";
15
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";
16
16
  import { getSeekTimeFromClick } from "./seek.js";
17
- import { testRtl } from "./text.js";
17
+ import { testJoiningScript, testRtl } from "./text.js";
18
18
  import { registerThemeSetting } from "./themeSettings.js";
19
19
  export let disableRichsync = registerThemeSetting("blyrics-disable-richsync", false, true);
20
20
  let lineSyncedAnimationDelay = registerThemeSetting("blyrics-line-synced-animation-delay", 50, true);
@@ -238,7 +238,7 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
238
238
  wordElement.classList.add(BACKGROUND_LYRIC_CLASS);
239
239
  if (part.explicit)
240
240
  wordElement.classList.add(EXPLICIT_WORD_CLASS);
241
- if (perLetter) {
241
+ if (perLetter && !testJoiningScript(part.words)) {
242
242
  const collected = appendLetters(doc, wordElement, part.words);
243
243
  if (wordElement === span) {
244
244
  letters = collected;
package/dist/text.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export declare const testRtl: (text: string) => boolean;
2
+ export declare const testJoiningScript: (text: string) => boolean;
2
3
  /**
3
4
  * Checks if a given string contains any non-Latin characters.
4
5
  * @param text The string to check.
package/dist/text.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Script detection for the text the module renders. Pure string work, no DOM and no host state.
2
2
  export const testRtl = (text) => /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u.test(text);
3
+ export const testJoiningScript = (text) => /[\p{Script_Extensions=Arabic}\p{Script_Extensions=Syriac}\p{Script_Extensions=Nko}\p{Script_Extensions=Mongolian}\p{Script_Extensions=Adlam}\p{Script_Extensions=Mandaic}\p{Script_Extensions=Hanifi_Rohingya}]/u.test(text);
3
4
  /**
4
5
  * This regex is designed to detect any characters that are outside of the
5
6
  * standard "Basic Latin" and "Latin-1 Supplement" Unicode blocks, as well
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.12.6",
3
+ "version": "1.13.1",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",