@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 +3 -1
- package/dist/engine.js +20 -10
- package/dist/inject.js +39 -21
- package/dist/view.js +1 -0
- package/package.json +2 -2
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
361
|
-
const
|
|
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 (
|
|
371
|
-
|
|
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
|
-
|
|
381
|
-
alignImageGlowRun(
|
|
399
|
+
for (const row of usedBackgroundRows)
|
|
400
|
+
alignImageGlowRun(row.highlightRun, line.parts);
|
|
382
401
|
lyricElement.appendChild(main);
|
|
383
|
-
|
|
384
|
-
lyricElement.appendChild(
|
|
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.
|
|
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.
|
|
60
|
+
"@braccato/types": "^1.1.0"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"build": "tsx ../../tooling/build-package.ts",
|