@braccato/core 1.19.0 → 1.20.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/dist/engine.d.ts CHANGED
@@ -31,6 +31,8 @@ interface AnimEngineViewState {
31
31
  };
32
32
  passiveScrollAccumulatedTime: number;
33
33
  passiveLastWallTime: number;
34
+ /** Where the passive drift last put the scroll, so a scroll that lands anywhere else is the reader's. */
35
+ passiveScrollTop: number | null;
34
36
  }
35
37
  /**
36
38
  * `Window` alone types neither `ResizeObserver` nor `DOMMatrix`: both are ambient `var`
@@ -116,7 +118,7 @@ export declare function resetScrollResume(engine: AnimationEngineInstance): void
116
118
  */
117
119
  export declare function resumeAllAutoscroll(): void;
118
120
  /**
119
- * The user scrolled this view. Scrolls the engine itself performed are swallowed one at a time;
121
+ * The user scrolled this view. Scrolls the engine itself performed are swallowed;
120
122
  * a real one pauses autoscroll long enough to read where it landed, and offers the way back.
121
123
  *
122
124
  * @param isPassive - Whether the lyrics on screen are unsynced, and so are drifting on their own
package/dist/engine.js CHANGED
@@ -26,6 +26,7 @@ const LYRICS_CHECK_INTERVAL_ERROR = "Error in lyrics check interval:";
26
26
  const PAUSING_LYRICS_SCROLL_LOG = "Pausing Lyrics Autoscroll Due to User Scroll";
27
27
  const USER_SCROLL_RESUME_DELAY_MS = 25000;
28
28
  const PASSIVE_USER_SCROLL_RESUME_DELAY_MS = 5000;
29
+ const PASSIVE_DRIFT_SCROLL_TOLERANCE_PX = 1;
29
30
  const LYRIC_ENDING_THRESHOLD_S = registerThemeSetting("blyrics-lyric-ending-threshold-s", 0.5);
30
31
  const EARLY_SCROLL_CONSIDER = registerThemeSetting("blyrics-early-scroll-consider-s", 0.54);
31
32
  const TIME_JUMP_THRESHOLD = 0.5;
@@ -134,6 +135,7 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
134
135
  },
135
136
  passiveScrollAccumulatedTime: 0,
136
137
  passiveLastWallTime: 0,
138
+ passiveScrollTop: null,
137
139
  cachedTabRendererHeight: null,
138
140
  cachedScrollInsets: NO_SCROLL_INSETS,
139
141
  cachedFooterItem: null,
@@ -231,14 +233,18 @@ export function resumeAllAutoscroll() {
231
233
  forEveryLiveView(resetScrollResume);
232
234
  }
233
235
  /**
234
- * The user scrolled this view. Scrolls the engine itself performed are swallowed one at a time;
236
+ * The user scrolled this view. Scrolls the engine itself performed are swallowed;
235
237
  * a real one pauses autoscroll long enough to read where it landed, and offers the way back.
236
238
  *
237
239
  * @param isPassive - Whether the lyrics on screen are unsynced, and so are drifting on their own
238
240
  * rather than following the song. Those resume sooner.
239
241
  */
240
242
  export function noteUserScroll(engine, isPassive) {
241
- if (engine.skipScrolls > 0) {
243
+ if (isPassive) {
244
+ if (isPassiveDriftScroll(engine))
245
+ return;
246
+ }
247
+ else if (engine.skipScrolls > 0) {
242
248
  engine.skipScrolls--;
243
249
  engine.skipScrollsDecayTimes.shift();
244
250
  return;
@@ -314,6 +320,7 @@ export function clearLyrics(engine) {
314
320
  engine.lastScrollDebugContext.centers = [];
315
321
  engine.passiveScrollAccumulatedTime = 0;
316
322
  engine.passiveLastWallTime = 0;
323
+ engine.passiveScrollTop = null;
317
324
  stopPassiveScrollLoop(engine);
318
325
  engine.lines = [];
319
326
  engine.cachedFooterItem = null;
@@ -2132,15 +2139,18 @@ function passiveScrollEngine(engine, isPlaying) {
2132
2139
  // Phase 4: hold at top
2133
2140
  targetScroll = 0;
2134
2141
  }
2135
- const prevScrollTop = tabRenderer.scrollTop;
2136
2142
  tabRenderer.scrollTop = targetScroll;
2137
- const appliedScrollTop = tabRenderer.scrollTop;
2138
- // Only skip the next scroll event if scrollTop actually changed.
2139
- // When it doesn't change (pause phases, sub-pixel rounding), no programmatic
2140
- // scroll event fires, so setting skipScrolls would eat user scroll events instead.
2141
- if (appliedScrollTop !== prevScrollTop) {
2142
- engine.skipScrolls = 1;
2143
- }
2143
+ engine.passiveScrollTop = tabRenderer.scrollTop;
2144
+ }
2145
+ /**
2146
+ * The drift writes the scroll every frame, and a browser folds that scroll and the reader's into one
2147
+ * event, so the drift's own scrolls are told apart by where they land rather than by counting them.
2148
+ */
2149
+ function isPassiveDriftScroll(engine) {
2150
+ const scrollTop = engine.host.getScrollElement()?.scrollTop;
2151
+ return (engine.passiveScrollTop !== null &&
2152
+ scrollTop !== undefined &&
2153
+ Math.abs(scrollTop - engine.passiveScrollTop) < PASSIVE_DRIFT_SCROLL_TOLERANCE_PX);
2144
2154
  }
2145
2155
  /**
2146
2156
  * Sets up a ResizeObserver on the tab renderer to cache its height.
package/dist/inject.js CHANGED
@@ -140,6 +140,7 @@ function splitPartIntoTokens(part) {
140
140
  part: {
141
141
  words: chunk,
142
142
  isBackground: part.isBackground,
143
+ backgroundGroup: part.backgroundGroup,
143
144
  explicit: part.explicit,
144
145
  },
145
146
  });
@@ -166,7 +167,7 @@ function splitPartIntoTokens(part) {
166
167
  function normalizeParts(parts) {
167
168
  return parts.flatMap(splitPartIntoTokens);
168
169
  }
169
- function groupTokensByWord(tokens) {
170
+ function groupTokensByWord(tokens, splitBackgroundGroups) {
170
171
  const groups = [];
171
172
  let current = null;
172
173
  const flush = () => {
@@ -182,9 +183,10 @@ function groupTokensByWord(tokens) {
182
183
  continue;
183
184
  }
184
185
  const isBackground = token.part?.isBackground === true;
185
- if (!current || current.isBackground !== isBackground) {
186
+ const backgroundGroup = splitBackgroundGroups ? token.part?.backgroundGroup : undefined;
187
+ if (!current || current.isBackground !== isBackground || current.backgroundGroup !== backgroundGroup) {
186
188
  flush();
187
- current = { text: "", isBackground, tokens: [] };
189
+ current = { text: "", isBackground, backgroundGroup, tokens: [] };
188
190
  }
189
191
  current.text += token.text;
190
192
  current.tokens.push(token);
@@ -331,24 +333,36 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
331
333
  .map(part => part.words)
332
334
  .join("")
333
335
  : lineText;
334
- const backgroundText = parts
335
- .filter(part => part.isBackground === true)
336
+ const backgroundTextOf = (group) => parts
337
+ .filter(part => part.isBackground === true && (part.backgroundGroup ?? 0) === group)
336
338
  .map(part => part.words)
337
339
  .join("");
338
340
  const main = createContentLine(doc, LINE_MAIN_CLASS, mainText);
339
341
  const mainRun = createBidiRun(doc, mainText);
340
342
  const mainHighlightRun = createHighlightRun(doc, mainText);
341
- const groupedTokens = groupTokensByWord(normalizeParts(parts));
342
- const backgroundLine = createContentLine(doc, BACKGROUND_LINE_CLASS, backgroundText);
343
- const backgroundRun = createBidiRun(doc, backgroundText);
344
- const backgroundHighlightRun = createHighlightRun(doc, backgroundText);
345
- let hasBackground = false;
343
+ const groupedTokens = groupTokensByWord(normalizeParts(parts), options.splitBackgroundLine);
344
+ const backgroundRows = new Map();
345
+ const backgroundRowOf = (group) => {
346
+ let row = backgroundRows.get(group);
347
+ if (!row) {
348
+ const text = backgroundTextOf(group);
349
+ row = {
350
+ line: createContentLine(doc, BACKGROUND_LINE_CLASS, text),
351
+ run: createBidiRun(doc, text),
352
+ highlightRun: createHighlightRun(doc, text),
353
+ used: false,
354
+ };
355
+ row.line.appendChild(row.highlightRun);
356
+ row.line.appendChild(row.run);
357
+ backgroundRows.set(group, row);
358
+ }
359
+ return row;
360
+ };
361
+ backgroundRowOf(0);
346
362
  let pendingForegroundSpace = "";
347
363
  let pendingBackgroundSpace = "";
348
364
  main.appendChild(mainHighlightRun);
349
365
  main.appendChild(mainRun);
350
- backgroundLine.appendChild(backgroundHighlightRun);
351
- backgroundLine.appendChild(backgroundRun);
352
366
  for (const item of groupedTokens) {
353
367
  if ("kind" in item) {
354
368
  // Is a RenderToken, not a WordGroup, only whitespace should enter this path
@@ -357,8 +371,9 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
357
371
  }
358
372
  else {
359
373
  const shouldUseBackgroundLine = options.splitBackgroundLine && item.isBackground;
360
- const target = shouldUseBackgroundLine ? backgroundRun : mainRun;
361
- const highlightRun = shouldUseBackgroundLine ? backgroundHighlightRun : mainHighlightRun;
374
+ const backgroundRow = shouldUseBackgroundLine ? backgroundRowOf(item.backgroundGroup ?? 0) : null;
375
+ const target = backgroundRow ? backgroundRow.run : mainRun;
376
+ const highlightRun = backgroundRow ? backgroundRow.highlightRun : mainHighlightRun;
362
377
  const pendingSpace = shouldUseBackgroundLine ? pendingBackgroundSpace : pendingForegroundSpace;
363
378
  if (target.childNodes.length > 0 && pendingSpace.length > 0) {
364
379
  target.appendChild(createSpaceNode(doc, pendingSpace, shouldUseBackgroundLine));
@@ -367,8 +382,8 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
367
382
  const { lyricGroup, highlightGroup } = createWordGroup(doc, item, line);
368
383
  target.appendChild(lyricGroup);
369
384
  highlightRun.appendChild(highlightGroup);
370
- if (shouldUseBackgroundLine) {
371
- hasBackground = true;
385
+ if (backgroundRow) {
386
+ backgroundRow.used = true;
372
387
  pendingBackgroundSpace = "";
373
388
  }
374
389
  else {
@@ -376,13 +391,16 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
376
391
  }
377
392
  }
378
393
  }
394
+ const usedBackgroundRows = [...backgroundRows]
395
+ .filter(([, row]) => row.used)
396
+ .sort(([a], [b]) => a - b)
397
+ .map(([, row]) => row);
379
398
  alignImageGlowRun(mainHighlightRun, line.parts);
380
- if (hasBackground)
381
- alignImageGlowRun(backgroundHighlightRun, line.parts);
399
+ for (const row of usedBackgroundRows)
400
+ alignImageGlowRun(row.highlightRun, line.parts);
382
401
  lyricElement.appendChild(main);
383
- if (hasBackground) {
384
- lyricElement.appendChild(backgroundLine);
385
- }
402
+ for (const row of usedBackgroundRows)
403
+ lyricElement.appendChild(row.line);
386
404
  return main;
387
405
  }
388
406
  export function buildLineSyncedParts(item) {
package/dist/view.js CHANGED
@@ -114,6 +114,7 @@ function lineSignature(lyrics, lineIndex) {
114
114
  part.durationMs,
115
115
  part.words,
116
116
  part.isBackground === true,
117
+ part.backgroundGroup ?? null,
117
118
  part.explicit === true,
118
119
  ]);
119
120
  // An instrumental line takes its side and direction from the lines around it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@
57
57
  "access": "public"
58
58
  },
59
59
  "dependencies": {
60
- "@braccato/types": "^1.0.0"
60
+ "@braccato/types": "^1.1.0"
61
61
  },
62
62
  "scripts": {
63
63
  "build": "tsx ../../tooling/build-package.ts",