@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 +13 -0
- package/dist/README.md +13 -0
- package/dist/engine.d.ts +36 -0
- package/dist/engine.js +80 -21
- package/dist/inject.js +2 -2
- package/dist/text.d.ts +1 -0
- package/dist/text.js +1 -0
- package/package.json +1 -1
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
|
|
1758
|
+
measureScrollViewport(engine, element);
|
|
1756
1759
|
}
|
|
1757
1760
|
});
|
|
1758
1761
|
engine.tabRendererResizeObserver.observe(element);
|
|
1759
1762
|
engine.observedTabRenderer = element;
|
|
1760
|
-
engine
|
|
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
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
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
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
|