@braccato/core 1.16.14 → 1.17.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 +43 -6
- package/dist/README.md +43 -6
- package/dist/engine.d.ts +37 -1
- package/dist/engine.js +197 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/inject.d.ts +17 -0
- package/dist/inject.js +86 -2
- package/dist/renderer.js +21 -9
- package/dist/themeSettings.d.ts +6 -0
- package/dist/themeSettings.js +13 -0
- package/dist/types.d.ts +3 -2
- package/dist/view.d.ts +25 -0
- package/dist/view.js +302 -47
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,6 +80,40 @@ view.lyricsOptions = { songwriters: parser.metadata(text).songwriters };
|
|
|
80
80
|
view.lyrics = parser.parse(text, player.duration * 1000);
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
### Replacing lyrics
|
|
84
|
+
|
|
85
|
+
Write a new array whenever the song's lyrics change, as often as they change: a better source
|
|
86
|
+
arriving for the song already playing, or an editor handing over every edit. Give each update the
|
|
87
|
+
same `songKey` (a `setLyrics` option, or a field of the element's `lyricsOptions`), and lyrics for the
|
|
88
|
+
song already on screen are swapped into it rather than built over it, so the only thing that changes
|
|
89
|
+
on screen is what changed in the lyrics. Lyrics with no `songKey`, or another song's, are built anew,
|
|
90
|
+
so a new song starts at the top with nothing of the last one's scroll.
|
|
91
|
+
|
|
92
|
+
- A line that builds the same as one already shown, by its words, timing, parts, singer, translation
|
|
93
|
+
and romanization, keeps that element and the animations running on it. The arrays do not need to be the same objects,
|
|
94
|
+
so a consumer that parses its source again on every change loses nothing.
|
|
95
|
+
- Lines that differ are built again in place. A line built where another one stood takes over that
|
|
96
|
+
line's place in the scroll, and on the stage its role and position, so a retimed line being sung
|
|
97
|
+
does not fade in again. A stage line replaced mid-glide finishes the glide it was in, unless the
|
|
98
|
+
new line is a different height, which moves it straight to its new place.
|
|
99
|
+
- The scroll stays where it was. When lines above the one being followed grow, shrink, arrive or go,
|
|
100
|
+
the scroll moves by however far that line moved, so it holds still on screen, and the lines that
|
|
101
|
+
moved relative to it slide there. A reader who scrolled away stays scrolled away.
|
|
102
|
+
- Translations and romanizations hung on a kept line stay, and so do those on a line rebuilt only for
|
|
103
|
+
its timing. Injecting the same one again does nothing, and injecting a different one replaces it. A
|
|
104
|
+
line whose `translation`, `translations`, `romanization` or `timedRomanization` changed is rebuilt
|
|
105
|
+
bare, ready to be decorated again. To drop one a consumer no longer wants, such as a translation
|
|
106
|
+
the listener turned off, call `removeTranslation` or `removeRomanization` (see Entry points).
|
|
107
|
+
- A swap that lands while the lines are still scrolling to the last line leaves the kept lines to
|
|
108
|
+
finish that scroll, and the new ones start at rest.
|
|
109
|
+
|
|
110
|
+
Some changes rebuild the whole view: another `songKey` or none, a different mount, a theme setting
|
|
111
|
+
that changes how lines are built, lyrics that gain or lose their timing, and anything written after
|
|
112
|
+
`clear()` or an empty array. A line whose times you changed after it was built, such as with an
|
|
113
|
+
offset you apply after every build, is built again from the lyrics rather than kept, so the offset is
|
|
114
|
+
not applied twice. A line built in place of another only takes over its place and sung state when
|
|
115
|
+
the two are sung over the same time.
|
|
116
|
+
|
|
83
117
|
## Properties
|
|
84
118
|
|
|
85
119
|
Every one of these may be written before the element is in a document. The renderer is built when it
|
|
@@ -408,12 +442,15 @@ package's stylesheets are yours to load for the same reason.
|
|
|
408
442
|
|
|
409
443
|
`@braccato/core` is the facade and registers nothing. `createLyricsRenderer(options)` returns one
|
|
410
444
|
`LyricsRenderer`: give it lyrics, tick it, and it owns the DOM it builds and every re-measurement
|
|
411
|
-
that DOM needs. `resetPlaybackClock`, `resumeAllAutoscroll`, `injectRomanization
|
|
412
|
-
`injectTranslation` are published beside it, for what
|
|
413
|
-
one
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
both for an instant insert, and
|
|
445
|
+
that DOM needs. `resetPlaybackClock`, `resumeAllAutoscroll`, `injectRomanization`,
|
|
446
|
+
`injectTranslation`, `removeRomanization` and `removeTranslation` are published beside it, for what
|
|
447
|
+
one instance cannot answer for on its own. Hang one onto a built line and call
|
|
448
|
+
`renderer.scheduleLyricPositionUpdate` the way you already would to catch the layout up: the hung line
|
|
449
|
+
floats into place while the lines around it slide to make room, rather than the rest of the lyric
|
|
450
|
+
jumping down. `--blyrics-animate-decoration-entry` set to `0` drops both for an instant insert, and
|
|
451
|
+
reduced-motion does the same. Taking one off with `removeTranslation(lineElement)` or
|
|
452
|
+
`removeRomanization(lineElement, line)` is the same: call `scheduleLyricPositionUpdate` after, and the
|
|
453
|
+
lines below slide up into the space.
|
|
417
454
|
|
|
418
455
|
`createLyricsRenderer({ layout: "stage" })` builds a view for subtitles over a video: one line at a
|
|
419
456
|
time, and only while it is being sung. It fills its nearest positioned ancestor. There is no scroll
|
package/dist/README.md
CHANGED
|
@@ -80,6 +80,40 @@ view.lyricsOptions = { songwriters: parser.metadata(text).songwriters };
|
|
|
80
80
|
view.lyrics = parser.parse(text, player.duration * 1000);
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
### Replacing lyrics
|
|
84
|
+
|
|
85
|
+
Write a new array whenever the song's lyrics change, as often as they change: a better source
|
|
86
|
+
arriving for the song already playing, or an editor handing over every edit. Give each update the
|
|
87
|
+
same `songKey` (a `setLyrics` option, or a field of the element's `lyricsOptions`), and lyrics for the
|
|
88
|
+
song already on screen are swapped into it rather than built over it, so the only thing that changes
|
|
89
|
+
on screen is what changed in the lyrics. Lyrics with no `songKey`, or another song's, are built anew,
|
|
90
|
+
so a new song starts at the top with nothing of the last one's scroll.
|
|
91
|
+
|
|
92
|
+
- A line that builds the same as one already shown, by its words, timing, parts, singer, translation
|
|
93
|
+
and romanization, keeps that element and the animations running on it. The arrays do not need to be the same objects,
|
|
94
|
+
so a consumer that parses its source again on every change loses nothing.
|
|
95
|
+
- Lines that differ are built again in place. A line built where another one stood takes over that
|
|
96
|
+
line's place in the scroll, and on the stage its role and position, so a retimed line being sung
|
|
97
|
+
does not fade in again. A stage line replaced mid-glide finishes the glide it was in, unless the
|
|
98
|
+
new line is a different height, which moves it straight to its new place.
|
|
99
|
+
- The scroll stays where it was. When lines above the one being followed grow, shrink, arrive or go,
|
|
100
|
+
the scroll moves by however far that line moved, so it holds still on screen, and the lines that
|
|
101
|
+
moved relative to it slide there. A reader who scrolled away stays scrolled away.
|
|
102
|
+
- Translations and romanizations hung on a kept line stay, and so do those on a line rebuilt only for
|
|
103
|
+
its timing. Injecting the same one again does nothing, and injecting a different one replaces it. A
|
|
104
|
+
line whose `translation`, `translations`, `romanization` or `timedRomanization` changed is rebuilt
|
|
105
|
+
bare, ready to be decorated again. To drop one a consumer no longer wants, such as a translation
|
|
106
|
+
the listener turned off, call `removeTranslation` or `removeRomanization` (see Entry points).
|
|
107
|
+
- A swap that lands while the lines are still scrolling to the last line leaves the kept lines to
|
|
108
|
+
finish that scroll, and the new ones start at rest.
|
|
109
|
+
|
|
110
|
+
Some changes rebuild the whole view: another `songKey` or none, a different mount, a theme setting
|
|
111
|
+
that changes how lines are built, lyrics that gain or lose their timing, and anything written after
|
|
112
|
+
`clear()` or an empty array. A line whose times you changed after it was built, such as with an
|
|
113
|
+
offset you apply after every build, is built again from the lyrics rather than kept, so the offset is
|
|
114
|
+
not applied twice. A line built in place of another only takes over its place and sung state when
|
|
115
|
+
the two are sung over the same time.
|
|
116
|
+
|
|
83
117
|
## Properties
|
|
84
118
|
|
|
85
119
|
Every one of these may be written before the element is in a document. The renderer is built when it
|
|
@@ -408,12 +442,15 @@ package's stylesheets are yours to load for the same reason.
|
|
|
408
442
|
|
|
409
443
|
`@braccato/core` is the facade and registers nothing. `createLyricsRenderer(options)` returns one
|
|
410
444
|
`LyricsRenderer`: give it lyrics, tick it, and it owns the DOM it builds and every re-measurement
|
|
411
|
-
that DOM needs. `resetPlaybackClock`, `resumeAllAutoscroll`, `injectRomanization
|
|
412
|
-
`injectTranslation` are published beside it, for what
|
|
413
|
-
one
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
both for an instant insert, and
|
|
445
|
+
that DOM needs. `resetPlaybackClock`, `resumeAllAutoscroll`, `injectRomanization`,
|
|
446
|
+
`injectTranslation`, `removeRomanization` and `removeTranslation` are published beside it, for what
|
|
447
|
+
one instance cannot answer for on its own. Hang one onto a built line and call
|
|
448
|
+
`renderer.scheduleLyricPositionUpdate` the way you already would to catch the layout up: the hung line
|
|
449
|
+
floats into place while the lines around it slide to make room, rather than the rest of the lyric
|
|
450
|
+
jumping down. `--blyrics-animate-decoration-entry` set to `0` drops both for an instant insert, and
|
|
451
|
+
reduced-motion does the same. Taking one off with `removeTranslation(lineElement)` or
|
|
452
|
+
`removeRomanization(lineElement, line)` is the same: call `scheduleLyricPositionUpdate` after, and the
|
|
453
|
+
lines below slide up into the space.
|
|
417
454
|
|
|
418
455
|
`createLyricsRenderer({ layout: "stage" })` builds a view for subtitles over a video: one line at a
|
|
419
456
|
time, and only while it is being sung. It fills its nearest positioned ancestor. There is no scroll
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LineData } from "./inject.js";
|
|
1
|
+
import type { LineData, PartData } from "./inject.js";
|
|
2
2
|
import { type StageBox, type StageMetrics } from "./stage.js";
|
|
3
3
|
import type { LyricsLayout, LyricsRendererHost, LyricSyncType, ResolvedTickOptions, TickOptions } from "./types.js";
|
|
4
4
|
/**
|
|
@@ -148,6 +148,42 @@ export declare function getRenderedSyncType(engine: AnimationEngineInstance): Ly
|
|
|
148
148
|
* hold are released along with it.
|
|
149
149
|
*/
|
|
150
150
|
export declare function clearLyrics(engine: AnimationEngineInstance): void;
|
|
151
|
+
/**
|
|
152
|
+
* How far a scroll still running has carried a line from its place, or zero with none running on it.
|
|
153
|
+
*/
|
|
154
|
+
export declare function lineScrollOffset(engine: AnimationEngineInstance, element: HTMLElement): number;
|
|
155
|
+
/**
|
|
156
|
+
* Hands the engine the lines of a song swapped into the view it already holds. A kept line is the
|
|
157
|
+
* same record and keeps everything; a dropped line's animations stop and nothing goes on holding it;
|
|
158
|
+
* a line built in place of one is handed what the engine held for that one, so the scroll does not
|
|
159
|
+
* see a line it has not met and the stage does not fade in a line that was already up.
|
|
160
|
+
*
|
|
161
|
+
* @param lines - The new song's records, kept ones included, in order
|
|
162
|
+
* @param dropped - Records of the song before that the new one no longer has
|
|
163
|
+
* @param replaced - Each new record built in place of a dropped one, to the one it replaced
|
|
164
|
+
*/
|
|
165
|
+
export declare function replaceRenderedLines(engine: AnimationEngineInstance, lines: LineData[], dropped: readonly LineData[], replaced: ReadonlyMap<LineData, LineData>): void;
|
|
166
|
+
/**
|
|
167
|
+
* Carries a swapped view across the layout change it caused. The line the reader is following holds
|
|
168
|
+
* still on screen: the scroll moves by however far that line moved in the container. Every other
|
|
169
|
+
* line that moved, relative to it, slides from where it was rather than jumping.
|
|
170
|
+
*
|
|
171
|
+
* Which line is followed depends on who owns the scroll. While the engine does, it is the line the
|
|
172
|
+
* last scroll aimed at; while the reader does, it is the first line they could see.
|
|
173
|
+
*
|
|
174
|
+
* @param before - Where each line was in the container before the swap and how tall, from `swapLyrics`
|
|
175
|
+
* @param scrollTopBefore - The scroll position the swap began at
|
|
176
|
+
*/
|
|
177
|
+
export declare function settleSwappedLines(engine: AnimationEngineInstance, before: ReadonlyMap<LineData, {
|
|
178
|
+
position: number;
|
|
179
|
+
height: number;
|
|
180
|
+
offset: number;
|
|
181
|
+
}>, scrollTopBefore: number): void;
|
|
182
|
+
/**
|
|
183
|
+
* Lets go of a part moving to another line or leaving the view: its animations stop, back to the pool
|
|
184
|
+
* they came from, and it shows no sung state until a line that is being sung sets it up again.
|
|
185
|
+
*/
|
|
186
|
+
export declare function releasePart(part: PartData): void;
|
|
151
187
|
export declare function updateWordStates(lineData: LineData, currentTime: number): void;
|
|
152
188
|
type LineScrollSide = "above" | "active" | "below";
|
|
153
189
|
interface LineScrollItem {
|
package/dist/engine.js
CHANGED
|
@@ -323,6 +323,185 @@ export function clearLyrics(engine) {
|
|
|
323
323
|
engine.waveAnimationPool.length = 0;
|
|
324
324
|
resetStage(engine);
|
|
325
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* How far a scroll still running has carried a line from its place, or zero with none running on it.
|
|
328
|
+
*/
|
|
329
|
+
export function lineScrollOffset(engine, element) {
|
|
330
|
+
if (!engine.lineScrollAnimations.some(record => record.lineElement === element))
|
|
331
|
+
return 0;
|
|
332
|
+
return currentTranslateY(engine, element, 0);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Hands the engine the lines of a song swapped into the view it already holds. A kept line is the
|
|
336
|
+
* same record and keeps everything; a dropped line's animations stop and nothing goes on holding it;
|
|
337
|
+
* a line built in place of one is handed what the engine held for that one, so the scroll does not
|
|
338
|
+
* see a line it has not met and the stage does not fade in a line that was already up.
|
|
339
|
+
*
|
|
340
|
+
* @param lines - The new song's records, kept ones included, in order
|
|
341
|
+
* @param dropped - Records of the song before that the new one no longer has
|
|
342
|
+
* @param replaced - Each new record built in place of a dropped one, to the one it replaced
|
|
343
|
+
*/
|
|
344
|
+
export function replaceRenderedLines(engine, lines, dropped, replaced) {
|
|
345
|
+
const successors = new Map();
|
|
346
|
+
for (const [line, previous] of replaced)
|
|
347
|
+
successors.set(previous, line);
|
|
348
|
+
const droppedElements = new Set(dropped.map(line => line.lyricElement));
|
|
349
|
+
const carry = (line) => successors.get(line) ?? (droppedElements.has(line.lyricElement) ? null : line);
|
|
350
|
+
const carryAll = (from) => from.map(carry).filter((line) => line !== null);
|
|
351
|
+
// The line being sung is still the line being sung, so a theme keyed on those classes does not dim it
|
|
352
|
+
// and bring it back. Its animations are set up again from the new timing on the next tick. Read
|
|
353
|
+
// before the dropped lines are let go, which clears their selection.
|
|
354
|
+
for (const [line, previous] of replaced) {
|
|
355
|
+
line.isScrolled = previous.isScrolled;
|
|
356
|
+
line.isSelected = previous.isSelected;
|
|
357
|
+
togglePartClass(line, CURRENT_LYRICS_CLASS, previous.lyricElement.classList.contains(CURRENT_LYRICS_CLASS));
|
|
358
|
+
togglePartClass(line, ANIMATING_CLASS, previous.lyricElement.classList.contains(ANIMATING_CLASS));
|
|
359
|
+
}
|
|
360
|
+
// A planned scroll was planned against lines and places this swap is about to change.
|
|
361
|
+
dropPendingLineScroll(engine);
|
|
362
|
+
for (const record of [...engine.lineScrollAnimations]) {
|
|
363
|
+
if (!droppedElements.has(record.lineElement))
|
|
364
|
+
continue;
|
|
365
|
+
record.animation.cancel();
|
|
366
|
+
removeLineScrollAnimation(engine, record);
|
|
367
|
+
}
|
|
368
|
+
for (const line of dropped) {
|
|
369
|
+
resetLineAnimationState(line);
|
|
370
|
+
line.isSelected = false;
|
|
371
|
+
engine.visibleWillChangeElements.delete(line.lyricElement);
|
|
372
|
+
engine.culledLineElements.delete(line.lyricElement);
|
|
373
|
+
}
|
|
374
|
+
engine.lastScrollElements = carryAll(engine.lastScrollElements);
|
|
375
|
+
engine.lastScrollDebugContext.activeElms = carryAll(engine.lastScrollDebugContext.activeElms);
|
|
376
|
+
engine.pinnedScrollLine = engine.pinnedScrollLine === null ? null : carry(engine.pinnedScrollLine);
|
|
377
|
+
engine.lines = lines;
|
|
378
|
+
if (engine.layout === "stage")
|
|
379
|
+
replaceStageElements(engine, dropped, replaced);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Starts the same animation on another element at the point the first one has reached, or answers
|
|
383
|
+
* null when there is nothing left of it to carry on.
|
|
384
|
+
*/
|
|
385
|
+
function continueAnimation(animation, element) {
|
|
386
|
+
const effect = animation?.effect;
|
|
387
|
+
if (!animation || !effect || typeof effect.getKeyframes !== "function" || animation.playState === "finished") {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
if (animation.playState === "idle")
|
|
391
|
+
return null;
|
|
392
|
+
const continued = element.animate(effect.getKeyframes(), { ...effect.getTiming(), composite: effect.composite });
|
|
393
|
+
continued.playbackRate = animation.playbackRate;
|
|
394
|
+
continued.currentTime = animation.currentTime;
|
|
395
|
+
if (animation.playState === "paused")
|
|
396
|
+
continued.pause();
|
|
397
|
+
return continued;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Moves what the stage held for each replaced line onto the line built in its place, and lets go of
|
|
401
|
+
* every dropped line. A replacement starts exactly where and as visible as the line it replaced, so
|
|
402
|
+
* placing the stage afterwards only moves it if the new song puts it somewhere else.
|
|
403
|
+
*/
|
|
404
|
+
function replaceStageElements(engine, dropped, replaced) {
|
|
405
|
+
for (const [line, previous] of replaced) {
|
|
406
|
+
const element = line.lyricElement;
|
|
407
|
+
const role = previous.lyricElement.dataset.stageRole;
|
|
408
|
+
const y = engine.stageY.get(previous.lyricElement);
|
|
409
|
+
if (role === undefined || y === undefined)
|
|
410
|
+
continue;
|
|
411
|
+
element.dataset.stageRole = role;
|
|
412
|
+
const visible = previous.lyricElement.dataset.stageVisible !== undefined;
|
|
413
|
+
if (visible)
|
|
414
|
+
element.dataset.stageVisible = "";
|
|
415
|
+
// A line replaced mid-glide carries on gliding from wherever the one it replaced had got to.
|
|
416
|
+
const move = continueAnimation(engine.stageMoves.get(previous.lyricElement), element);
|
|
417
|
+
if (move === null) {
|
|
418
|
+
snapStageElement(engine, element, y);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
engine.stageMoves.set(element, move);
|
|
422
|
+
engine.stageY.set(element, y);
|
|
423
|
+
}
|
|
424
|
+
const fade = continueAnimation(engine.stageFades.get(previous.lyricElement), element) ??
|
|
425
|
+
element.animate([{ [STAGE_OPACITY_PROPERTY]: isStageRoleVisible(role) ? 1 : 0 }], {
|
|
426
|
+
duration: 0,
|
|
427
|
+
fill: "forwards",
|
|
428
|
+
});
|
|
429
|
+
if (!isStageRoleVisible(role)) {
|
|
430
|
+
fade.onfinish = () => {
|
|
431
|
+
if (engine.stageFades.get(element) === fade)
|
|
432
|
+
delete element.dataset.stageVisible;
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
engine.stageFades.set(element, fade);
|
|
436
|
+
const blur = continueAnimation(engine.stageBlurs.get(previous.lyricElement), element);
|
|
437
|
+
if (blur !== null)
|
|
438
|
+
engine.stageBlurs.set(element, blur);
|
|
439
|
+
}
|
|
440
|
+
for (const line of dropped) {
|
|
441
|
+
const element = line.lyricElement;
|
|
442
|
+
for (const animations of [engine.stageMoves, engine.stageFades, engine.stageBlurs]) {
|
|
443
|
+
animations.get(element)?.cancel();
|
|
444
|
+
animations.delete(element);
|
|
445
|
+
}
|
|
446
|
+
engine.stageY.delete(element);
|
|
447
|
+
engine.stageMetrics.delete(element);
|
|
448
|
+
}
|
|
449
|
+
// The roles are planned again against the new lines, even where they come out the same.
|
|
450
|
+
engine.stageKey = "";
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Carries a swapped view across the layout change it caused. The line the reader is following holds
|
|
454
|
+
* still on screen: the scroll moves by however far that line moved in the container. Every other
|
|
455
|
+
* line that moved, relative to it, slides from where it was rather than jumping.
|
|
456
|
+
*
|
|
457
|
+
* Which line is followed depends on who owns the scroll. While the engine does, it is the line the
|
|
458
|
+
* last scroll aimed at; while the reader does, it is the first line they could see.
|
|
459
|
+
*
|
|
460
|
+
* @param before - Where each line was in the container before the swap and how tall, from `swapLyrics`
|
|
461
|
+
* @param scrollTopBefore - The scroll position the swap began at
|
|
462
|
+
*/
|
|
463
|
+
export function settleSwappedLines(engine, before, scrollTopBefore) {
|
|
464
|
+
const container = engine.lyricsContainer;
|
|
465
|
+
const scrollElement = engine.host.getScrollElement();
|
|
466
|
+
if (container === null || scrollElement === null || engine.layout === "stage")
|
|
467
|
+
return;
|
|
468
|
+
const readerOwnsScroll = engine.scrollResumeTime > Date.now();
|
|
469
|
+
const followed = readerOwnsScroll
|
|
470
|
+
? engine.lines.find(line => {
|
|
471
|
+
const was = before.get(line);
|
|
472
|
+
return was !== undefined && was.position + was.height > scrollTopBefore;
|
|
473
|
+
})
|
|
474
|
+
: (engine.lastScrollElements.findLast(line => before.has(line)) ??
|
|
475
|
+
engine.lines.find(line => before.has(line) && line.isScrolled));
|
|
476
|
+
const followedWas = followed === undefined ? undefined : before.get(followed)?.position;
|
|
477
|
+
// A line that has not been measured, because the view is not on screen, has nowhere to hold still.
|
|
478
|
+
if (followed !== undefined && followedWas !== undefined && followedWas >= 0 && followed.position >= 0) {
|
|
479
|
+
const moved = followed.position - followedWas;
|
|
480
|
+
if (Math.abs(moved) >= DECORATION_MIN_SHIFT_PX)
|
|
481
|
+
scrollElement.scrollTop = scrollTopBefore + moved;
|
|
482
|
+
}
|
|
483
|
+
// Read back rather than assumed: the browser may have clamped the write, or clamped the scroll for
|
|
484
|
+
// a song that got shorter before anything here wrote it. Either way the event that follows is not
|
|
485
|
+
// the reader's, and a write the browser clamped to nothing sends none.
|
|
486
|
+
const shift = scrollElement.scrollTop - scrollTopBefore;
|
|
487
|
+
if (shift !== 0) {
|
|
488
|
+
engine.skipScrolls += 1;
|
|
489
|
+
engine.skipScrollsDecayTimes.push(Date.now() + 2000);
|
|
490
|
+
if (engine.scrollPos >= 0)
|
|
491
|
+
engine.scrollPos += shift;
|
|
492
|
+
}
|
|
493
|
+
if (!decorationSlideAllowed(engine, container))
|
|
494
|
+
return;
|
|
495
|
+
for (const line of engine.lines) {
|
|
496
|
+
const was = before.get(line);
|
|
497
|
+
if (was === undefined || was.position < 0 || line.position < 0)
|
|
498
|
+
continue;
|
|
499
|
+
// From where it is on screen, which a line scroll still running carries away from its place.
|
|
500
|
+
const onScreenShift = was.position + was.offset - line.position + shift;
|
|
501
|
+
if (Math.abs(onScreenShift) >= DECORATION_MIN_SHIFT_PX)
|
|
502
|
+
slideElementBy(line.lyricElement, onScreenShift);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
326
505
|
function resetStage(engine) {
|
|
327
506
|
for (const animation of [
|
|
328
507
|
...engine.stageMoves.values(),
|
|
@@ -343,6 +522,19 @@ function resetStage(engine) {
|
|
|
343
522
|
engine.host.onStageLayout?.(null);
|
|
344
523
|
}
|
|
345
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Lets go of a part moving to another line or leaving the view: its animations stop, back to the pool
|
|
527
|
+
* they came from, and it shows no sung state until a line that is being sung sets it up again.
|
|
528
|
+
*/
|
|
529
|
+
export function releasePart(part) {
|
|
530
|
+
resetPartAnimations(part);
|
|
531
|
+
togglePartClass(part, PAUSED_CLASS, false);
|
|
532
|
+
togglePartClass(part, ANIMATING_CLASS, false);
|
|
533
|
+
part.wordState = WORD_STATE_UPCOMING;
|
|
534
|
+
part.lyricElement.setAttribute(WORD_STATE_ATTR, WORD_STATE_UPCOMING);
|
|
535
|
+
part.highlightElement.setAttribute(WORD_STATE_ATTR, WORD_STATE_UPCOMING);
|
|
536
|
+
part.imageLayers?.highlight?.setAttribute(WORD_STATE_ATTR, WORD_STATE_UPCOMING);
|
|
537
|
+
}
|
|
346
538
|
function resetPartAnimations(part) {
|
|
347
539
|
for (const animation of part.animations) {
|
|
348
540
|
animation.cancel();
|
|
@@ -2353,6 +2545,9 @@ export function tickView(engine, currentTime, options) {
|
|
|
2353
2545
|
const mediaSteppedBack = eventCreationTime === NO_PLAYER_SNAPSHOT &&
|
|
2354
2546
|
isPlaying &&
|
|
2355
2547
|
currentTime < playbackClock.lastTime - CLOCK_RESYNC_THRESHOLD_S;
|
|
2548
|
+
// A clock that was already paused only moves when the host seeks it, however small the step, as a
|
|
2549
|
+
// scrub does. The tick that pauses lands a moment past the last playing one and is not a seek.
|
|
2550
|
+
const pausedSeek = !isPlaying && !playbackClock.lastPlayState && currentTime !== playbackClock.lastTime;
|
|
2356
2551
|
if (timeJumped)
|
|
2357
2552
|
dropPendingLineScroll(engine);
|
|
2358
2553
|
playbackClock.lastTime = currentTime;
|
|
@@ -2465,7 +2660,7 @@ export function tickView(engine, currentTime, options) {
|
|
|
2465
2660
|
lineData.isAnimating = false; // reset the animation against current media time
|
|
2466
2661
|
}
|
|
2467
2662
|
// Lead-in animations have no timing sample for the drift check, so a jump while playing must reset them.
|
|
2468
|
-
if (((timeJumped && isPlaying) || mediaSteppedBack) && lineData.isAnimating)
|
|
2663
|
+
if (((timeJumped && isPlaying) || mediaSteppedBack || pausedSeek) && lineData.isAnimating)
|
|
2469
2664
|
resetLineAnimationState(lineData);
|
|
2470
2665
|
updateWordStates(lineData, currentTime);
|
|
2471
2666
|
const nativeTimingSample = lineNativeTimingSample(lineData, currentTime);
|
|
@@ -2500,7 +2695,7 @@ export function tickView(engine, currentTime, options) {
|
|
|
2500
2695
|
else {
|
|
2501
2696
|
const staleAnimationEndTime = effectiveEndTime + animationConfig.highlight.fadeOutDurationMs / 1000 + 0.05;
|
|
2502
2697
|
if (lineData.isSelected) {
|
|
2503
|
-
if (isPlaying || timeJumped) {
|
|
2698
|
+
if (isPlaying || timeJumped || pausedSeek) {
|
|
2504
2699
|
if (currentTime > staleAnimationEndTime) {
|
|
2505
2700
|
logAnimationCleanup(engine, "selected-stale-reset", lineData, index, currentTime, staleAnimationEndTime);
|
|
2506
2701
|
resetLineAnimationState(lineData);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { resetPlaybackClock, resumeAllAutoscroll } from "./engine.js";
|
|
2
|
-
export { injectRomanization, injectTranslation, type LineData } from "./inject.js";
|
|
2
|
+
export { injectRomanization, injectTranslation, type LineData, removeRomanization, removeTranslation, } from "./inject.js";
|
|
3
3
|
export { createLyricsRenderer } from "./renderer.js";
|
|
4
4
|
export type { Lyric, LyricsRenderer, LyricsRendererHost, TickOptions } from "./types.js";
|
|
5
5
|
/** @public */
|
package/dist/index.js
CHANGED
|
@@ -7,5 +7,5 @@
|
|
|
7
7
|
// answer for on its own: the song level operations address every live view at once, and the
|
|
8
8
|
// injection helpers decorate lines that are already built.
|
|
9
9
|
export { resetPlaybackClock, resumeAllAutoscroll } from "./engine.js";
|
|
10
|
-
export { injectRomanization, injectTranslation } from "./inject.js";
|
|
10
|
+
export { injectRomanization, injectTranslation, removeRomanization, removeTranslation, } from "./inject.js";
|
|
11
11
|
export { createLyricsRenderer } from "./renderer.js";
|
package/dist/inject.d.ts
CHANGED
|
@@ -46,5 +46,22 @@ export declare function createLyricsLine(doc: Document, parts: LyricPart[], line
|
|
|
46
46
|
}): HTMLElement;
|
|
47
47
|
export declare function buildLineSyncedParts(item: Lyric): LyricPart[];
|
|
48
48
|
export declare function addSeekHandler(seek: (timeS: number) => void, lyricElement: HTMLElement, allZero: boolean): void;
|
|
49
|
+
/**
|
|
50
|
+
* Takes the romanization off a line, along with any timed parts it brought. A line a swap kept goes on
|
|
51
|
+
* carrying what was hung on it, so this is how a consumer drops one it no longer wants. Answers
|
|
52
|
+
* whether there was one to take off.
|
|
53
|
+
*/
|
|
54
|
+
export declare function removeRomanization(lyricElement: HTMLElement, lineData: LineData): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Takes the translation off a line. A line a swap kept goes on carrying what was hung on it, so this is
|
|
57
|
+
* how a consumer drops one it no longer wants. Answers whether there was one to take off.
|
|
58
|
+
*/
|
|
59
|
+
export declare function removeTranslation(lyricElement: HTMLElement): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Moves the translation and romanization hung on one line onto the line built in its place, with any
|
|
62
|
+
* timed parts they brought, so a line rebuilt for its timing keeps what the consumer hung on it. The
|
|
63
|
+
* parts are let go of whatever the old line was showing; the new line sets them up again with its own.
|
|
64
|
+
*/
|
|
65
|
+
export declare function moveDecorations(from: LineData, to: LineData): void;
|
|
49
66
|
export declare function injectRomanization(doc: Document, lyricElement: HTMLElement, lineData: LineData, text: string, timedRomanization?: LyricPart[] | null): void;
|
|
50
67
|
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 { releasePart } from "./engine.js";
|
|
14
15
|
import { alignImageGlowRun, wrapImageHighlight } from "./imageHighlights.js";
|
|
15
16
|
import { normalizeLanguage } from "./language.js";
|
|
16
17
|
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";
|
|
@@ -421,8 +422,81 @@ function repeatsLine(lyricElement, text) {
|
|
|
421
422
|
].find(run => !run.classList.contains(HIGHLIGHT_RUN_CLASS));
|
|
422
423
|
return mainRun?.textContent != null && isSameText(mainRun.textContent, text);
|
|
423
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* What each injected decoration was made from. A line a swap kept still carries what was hung on it,
|
|
427
|
+
* so injecting the same again is nothing to do and injecting something else replaces it.
|
|
428
|
+
*/
|
|
429
|
+
const decorationSources = new WeakMap();
|
|
430
|
+
/**
|
|
431
|
+
* Has the next tick set a line's animations up again, the parts just added to it included. A line
|
|
432
|
+
* already being sung sets its parts up once, so parts added after that would stay still until the
|
|
433
|
+
* line came round again. Its running animations are left alone: the setup replaces each one, so
|
|
434
|
+
* nothing on the line goes still in between.
|
|
435
|
+
*/
|
|
436
|
+
function rearmLineAnimations(lineData) {
|
|
437
|
+
lineData.isAnimating = false;
|
|
438
|
+
}
|
|
439
|
+
/** Takes a decoration off its line, along with any timed parts it brought, and stops their animations. */
|
|
440
|
+
function removeDecoration(lineData, decoration) {
|
|
441
|
+
lineData.parts = lineData.parts.filter(part => {
|
|
442
|
+
if (!decoration.contains(part.lyricElement))
|
|
443
|
+
return true;
|
|
444
|
+
releasePart(part);
|
|
445
|
+
return false;
|
|
446
|
+
});
|
|
447
|
+
decoration.remove();
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Takes the romanization off a line, along with any timed parts it brought. A line a swap kept goes on
|
|
451
|
+
* carrying what was hung on it, so this is how a consumer drops one it no longer wants. Answers
|
|
452
|
+
* whether there was one to take off.
|
|
453
|
+
*/
|
|
454
|
+
export function removeRomanization(lyricElement, lineData) {
|
|
455
|
+
const existing = lyricElement.querySelector(`:scope > .${ROMANIZED_LYRICS_CLASS}`);
|
|
456
|
+
if (existing === null)
|
|
457
|
+
return false;
|
|
458
|
+
removeDecoration(lineData, existing);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Takes the translation off a line. A line a swap kept goes on carrying what was hung on it, so this is
|
|
463
|
+
* how a consumer drops one it no longer wants. Answers whether there was one to take off.
|
|
464
|
+
*/
|
|
465
|
+
export function removeTranslation(lyricElement) {
|
|
466
|
+
const existing = lyricElement.querySelector(`:scope > .${TRANSLATED_LYRICS_CLASS}`);
|
|
467
|
+
if (existing === null)
|
|
468
|
+
return false;
|
|
469
|
+
existing.remove();
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Moves the translation and romanization hung on one line onto the line built in its place, with any
|
|
474
|
+
* timed parts they brought, so a line rebuilt for its timing keeps what the consumer hung on it. The
|
|
475
|
+
* parts are let go of whatever the old line was showing; the new line sets them up again with its own.
|
|
476
|
+
*/
|
|
477
|
+
export function moveDecorations(from, to) {
|
|
478
|
+
const decorations = Array.from(from.lyricElement.childNodes).filter((node) => node.classList?.contains(ROMANIZED_LYRICS_CLASS) === true ||
|
|
479
|
+
node.classList?.contains(TRANSLATED_LYRICS_CLASS) === true);
|
|
480
|
+
for (const decoration of decorations) {
|
|
481
|
+
from.parts = from.parts.filter(part => {
|
|
482
|
+
if (!decoration.contains(part.lyricElement))
|
|
483
|
+
return true;
|
|
484
|
+
releasePart(part);
|
|
485
|
+
to.parts.push(part);
|
|
486
|
+
return false;
|
|
487
|
+
});
|
|
488
|
+
to.lyricElement.appendChild(decoration);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
424
491
|
export function injectRomanization(doc, lyricElement, lineData, text, timedRomanization = null) {
|
|
425
|
-
|
|
492
|
+
const source = JSON.stringify([text, timedRomanization ?? null]);
|
|
493
|
+
const existing = lyricElement.querySelector(`:scope > .${ROMANIZED_LYRICS_CLASS}`);
|
|
494
|
+
if (existing !== null) {
|
|
495
|
+
if (decorationSources.get(existing) === source)
|
|
496
|
+
return;
|
|
497
|
+
removeDecoration(lineData, existing);
|
|
498
|
+
}
|
|
499
|
+
if (repeatsLine(lyricElement, text))
|
|
426
500
|
return;
|
|
427
501
|
const romanizedLine = doc.createElement("div");
|
|
428
502
|
romanizedLine.classList.add(ROMANIZED_LYRICS_CLASS, CONTENT_LINE_CLASS);
|
|
@@ -432,15 +506,24 @@ export function injectRomanization(doc, lyricElement, lineData, text, timedRoman
|
|
|
432
506
|
applyDirection(romanizedLine, text);
|
|
433
507
|
if (timedRomanization && timedRomanization.length > 0 && !disableRichsync.getBooleanValue()) {
|
|
434
508
|
createLyricsLine(doc, timedRomanization, lineData, romanizedLine, { splitBackgroundLine: false });
|
|
509
|
+
rearmLineAnimations(lineData);
|
|
435
510
|
}
|
|
436
511
|
else {
|
|
437
512
|
romanizedLine.textContent = text;
|
|
438
513
|
}
|
|
514
|
+
decorationSources.set(romanizedLine, source);
|
|
439
515
|
const translation = lyricElement.querySelector(`.${TRANSLATED_LYRICS_CLASS}`);
|
|
440
516
|
lyricElement.insertBefore(romanizedLine, translation);
|
|
441
517
|
}
|
|
442
518
|
export function injectTranslation(doc, lyricElement, text, language) {
|
|
443
|
-
|
|
519
|
+
const source = JSON.stringify([text, normalizeLanguage(language)]);
|
|
520
|
+
const existing = lyricElement.querySelector(`:scope > .${TRANSLATED_LYRICS_CLASS}`);
|
|
521
|
+
if (existing !== null) {
|
|
522
|
+
if (decorationSources.get(existing) === source)
|
|
523
|
+
return;
|
|
524
|
+
existing.remove();
|
|
525
|
+
}
|
|
526
|
+
if (repeatsLine(lyricElement, text))
|
|
444
527
|
return;
|
|
445
528
|
const translatedLine = doc.createElement("div");
|
|
446
529
|
translatedLine.classList.add(TRANSLATED_LYRICS_CLASS, CONTENT_LINE_CLASS);
|
|
@@ -448,5 +531,6 @@ export function injectTranslation(doc, lyricElement, text, language) {
|
|
|
448
531
|
translatedLine.lang = normalizeLanguage(language);
|
|
449
532
|
applyDirection(translatedLine, text);
|
|
450
533
|
translatedLine.textContent = text;
|
|
534
|
+
decorationSources.set(translatedLine, source);
|
|
451
535
|
lyricElement.appendChild(translatedLine);
|
|
452
536
|
}
|
package/dist/renderer.js
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
// diagnostic overlay supplies the sink, and one that says nothing draws nothing.
|
|
12
12
|
import { imageHighlights, refreshInstrumentalImages } from "./imageHighlights.js";
|
|
13
13
|
import { CUSTOM_THEME_STYLE_ID } from "./constants.js";
|
|
14
|
-
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
|
+
import { clearLyrics, clearOnScreenLyrics as clearEngineOnScreenLyrics, clearStyleCaches as clearEngineStyleCaches, createAnimationEngineInstance, getRenderedLines, getRenderedSyncType, hasUnsyncedLyrics, noteContainerResize, noteUserScroll as noteEngineUserScroll, noteVisibilityChange as noteEngineVisibilityChange, relayout, resetScrollResume, resolveTickOptions, settleSwappedLines, retickFromPlaybackClock as retickEngineFromPlaybackClock, scheduleLyricPositionUpdate as scheduleEngineLyricPositionUpdate, tickView, } from "./engine.js";
|
|
15
15
|
import { parseThemeConfig, setThemeSettings } from "./themeSettings.js";
|
|
16
16
|
import { applyLyricLanguage, normalizeLanguage, resolveLyricLanguages } from "./language.js";
|
|
17
|
-
import { setLyrics as buildLyricsView } from "./view.js";
|
|
17
|
+
import { setLyrics as buildLyricsView, swapLyrics as swapLyricsView } from "./view.js";
|
|
18
18
|
/**
|
|
19
19
|
* What the default `seek` dispatches at the mount. A consumer that gave the renderer no way to
|
|
20
20
|
* reach its player can listen for this instead of writing a host.
|
|
@@ -269,22 +269,34 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
269
269
|
if (!nextMount) {
|
|
270
270
|
throw new Error("A lyrics renderer needs a mount: give one to createLyricsRenderer or to setLyrics");
|
|
271
271
|
}
|
|
272
|
-
|
|
273
|
-
// container with it rather than orphaning it in the mount it was built in.
|
|
274
|
-
clearBuiltView();
|
|
275
|
-
mount = nextMount;
|
|
276
|
-
buildLyricsView(engine, nextMount, lyrics, {
|
|
272
|
+
const viewOptions = {
|
|
277
273
|
language: options?.language,
|
|
278
274
|
loaderVisible: options?.loaderVisible ?? false,
|
|
279
275
|
noLyrics: options?.noLyrics ?? false,
|
|
280
276
|
songwriters: options?.songwriters,
|
|
281
|
-
|
|
277
|
+
songKey: options?.songKey,
|
|
278
|
+
};
|
|
279
|
+
// Lyrics for the song already on screen are swapped in, so whatever the two share stays exactly
|
|
280
|
+
// as it was. Read before the swap: the scroll is where the reader was.
|
|
281
|
+
const scrollTopBefore = host.getScrollElement()?.scrollTop ?? 0;
|
|
282
|
+
const swapped = swapLyricsView(engine, nextMount, lyrics, viewOptions);
|
|
283
|
+
if (swapped === null) {
|
|
284
|
+
// Before the mount moves, so a second song built somewhere else takes the first one's
|
|
285
|
+
// container with it rather than orphaning it in the mount it was built in.
|
|
286
|
+
clearBuiltView();
|
|
287
|
+
mount = nextMount;
|
|
288
|
+
buildLyricsView(engine, nextMount, lyrics, viewOptions);
|
|
289
|
+
}
|
|
282
290
|
syncImagePaint();
|
|
283
291
|
currentLyrics = lyrics;
|
|
284
292
|
currentLanguage = normalizeLanguage(options?.language);
|
|
285
293
|
measure();
|
|
286
|
-
if (
|
|
294
|
+
if (swapped !== null) {
|
|
295
|
+
settleSwappedLines(engine, swapped, scrollTopBefore);
|
|
296
|
+
}
|
|
297
|
+
else if (engine.lyricsContainer) {
|
|
287
298
|
observeContainer(engine.lyricsContainer);
|
|
299
|
+
}
|
|
288
300
|
},
|
|
289
301
|
setLanguage(language) {
|
|
290
302
|
const normalized = normalizeLanguage(language);
|
package/dist/themeSettings.d.ts
CHANGED
|
@@ -21,3 +21,9 @@ export declare class Setting {
|
|
|
21
21
|
export declare function parseThemeConfig(css: string): Map<string, string>;
|
|
22
22
|
export declare function registerThemeSetting(key: string, defaultValue: number | boolean | string, requiresLyricReload?: boolean): Setting;
|
|
23
23
|
export declare function setThemeSettings(map: Map<string, string>): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The values of every setting that changes how lines are built, as one comparable string. Two builds
|
|
26
|
+
* that answer the same here built their lines the same way, so a line built under one can stand in
|
|
27
|
+
* for the same line built under the other.
|
|
28
|
+
*/
|
|
29
|
+
export declare function lyricBuildSettingsKey(): string;
|
package/dist/themeSettings.js
CHANGED
|
@@ -124,3 +124,16 @@ export function setThemeSettings(map) {
|
|
|
124
124
|
}
|
|
125
125
|
return needsLyricReload;
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* The values of every setting that changes how lines are built, as one comparable string. Two builds
|
|
129
|
+
* that answer the same here built their lines the same way, so a line built under one can stand in
|
|
130
|
+
* for the same line built under the other.
|
|
131
|
+
*/
|
|
132
|
+
export function lyricBuildSettingsKey() {
|
|
133
|
+
const values = [];
|
|
134
|
+
for (const [key, setting] of keyToSettingMap) {
|
|
135
|
+
if (setting.requiresLyricReload)
|
|
136
|
+
values.push(`${key}=${String(setting.value)}`);
|
|
137
|
+
}
|
|
138
|
+
return values.join(";");
|
|
139
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -110,8 +110,9 @@ export interface LyricsRendererOptions {
|
|
|
110
110
|
*/
|
|
111
111
|
export interface LyricsRenderer {
|
|
112
112
|
/**
|
|
113
|
-
* Replaces whatever this renderer last built
|
|
114
|
-
*
|
|
113
|
+
* Replaces whatever this renderer last built, or, given the `songKey` of the song on screen, swaps
|
|
114
|
+
* these lyrics into it so the lines the two share stay as they were. Every option has a default, so a
|
|
115
|
+
* consumer with an opinion about only one of them says only that one. The mount rides along with the CSS flags
|
|
115
116
|
* rather than taking a parameter of its own: it is an option of this call like the others, and a
|
|
116
117
|
* third parameter would have every caller that only wants to move the mount pass over the two in
|
|
117
118
|
* front of it.
|
package/dist/view.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AnimationEngineInstance } from "./engine.js";
|
|
2
|
+
import { type LineData } from "./inject.js";
|
|
2
3
|
import type { Lyric } from "./types.js";
|
|
3
4
|
export interface SetLyricsOptions {
|
|
4
5
|
/** BCP 47 language of the original lyrics. Kana/Hangul provide a fallback when omitted. */
|
|
@@ -13,6 +14,12 @@ export interface SetLyricsOptions {
|
|
|
13
14
|
noLyrics: boolean;
|
|
14
15
|
/** Who wrote the song, shown after the last line. */
|
|
15
16
|
songwriters?: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Which song these lyrics are for. Lyrics given with the key the view on screen was given are
|
|
19
|
+
* swapped into it, so lines the two share stay as they were; without a key, or with another one,
|
|
20
|
+
* the view is built anew.
|
|
21
|
+
*/
|
|
22
|
+
songKey?: string;
|
|
16
23
|
}
|
|
17
24
|
/**
|
|
18
25
|
* Replaces whatever the mount holds with a container built from these lyrics, and hands the render
|
|
@@ -24,3 +31,21 @@ export interface SetLyricsOptions {
|
|
|
24
31
|
* @param options - What the container records for CSS to key on
|
|
25
32
|
*/
|
|
26
33
|
export declare function setLyrics(engine: AnimationEngineInstance, mount: HTMLElement, lyrics: Lyric[], options: SetLyricsOptions): void;
|
|
34
|
+
/**
|
|
35
|
+
* Where each line of a swap was before it, in the container's coordinates, and how tall it was: a
|
|
36
|
+
* kept line's own place, and for a line built in place of one, the place of the line it replaced.
|
|
37
|
+
*/
|
|
38
|
+
export type SwapPositions = Map<LineData, {
|
|
39
|
+
position: number;
|
|
40
|
+
height: number;
|
|
41
|
+
offset: number;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Swaps these lyrics into the view already on screen, rebuilding only the lines that differ, and
|
|
45
|
+
* answers where each line was before so the caller can carry the view across the change. Answers
|
|
46
|
+
* null without touching anything when the view cannot be swapped, and the caller builds it whole.
|
|
47
|
+
*
|
|
48
|
+
* Nothing about where the reader is goes back to the start of a song: the scroll, a pause the reader
|
|
49
|
+
* scrolled into, and the scrolls the engine is waiting to swallow are all the song's before.
|
|
50
|
+
*/
|
|
51
|
+
export declare function swapLyrics(engine: AnimationEngineInstance, mount: HTMLElement, lyrics: Lyric[], options: SetLyricsOptions): SwapPositions | null;
|
package/dist/view.js
CHANGED
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
// records the engine animates them through. `inject.ts` builds what goes inside a line; this builds
|
|
3
3
|
// the line and everything around it.
|
|
4
4
|
//
|
|
5
|
-
// A song
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// A first song builds the whole view. A song given to a view that already holds one is swapped into
|
|
6
|
+
// it line by line: a line that would build the same as one already on screen keeps that element, its
|
|
7
|
+
// record and the animations running against it, and only the lines that differ are built again. The
|
|
8
|
+
// engine is told which records went and which new ones stand where they stood, so nothing it holds
|
|
9
|
+
// keeps pointing into the song before. Anything that changes every line at once (the mount, a theme
|
|
10
|
+
// setting lines are built by, lyrics that lose or gain their timing) builds the whole view again.
|
|
8
11
|
//
|
|
9
12
|
// This is also the one place outside the engine that writes engine view state, because building the
|
|
10
13
|
// lines is where those values are first knowable: the container, the records, the sync type, the
|
|
11
14
|
// size they were measured at, and the scrolls to swallow before the view has settled.
|
|
12
15
|
import { CREDITS_CLASS, CREDITS_NAMES_CLASS, CREDITS_TEXT_CLASS, LINE_CLASS, LYRICS_CLASS, RTL_CLASS, } from "./constants.js";
|
|
13
|
-
import { playerTimeForLyricTime, setupLineCullObserver } from "./engine.js";
|
|
14
|
-
import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, hideCredits, isNearestLyricRtl, newLineData, } from "./inject.js";
|
|
16
|
+
import { lineScrollOffset, playerTimeForLyricTime, replaceRenderedLines, setupLineCullObserver, } from "./engine.js";
|
|
17
|
+
import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, hideCredits, isNearestLyricRtl, moveDecorations, newLineData, } from "./inject.js";
|
|
15
18
|
import { refreshInstrumentalImages } from "./imageHighlights.js";
|
|
16
19
|
import { createInstrumentalElement } from "./instrumental.js";
|
|
17
20
|
import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
|
|
21
|
+
import { lyricBuildSettingsKey } from "./themeSettings.js";
|
|
18
22
|
import { isGecko } from "./util.js";
|
|
19
23
|
const INITIAL_SKIP_SCROLLS = 2;
|
|
20
24
|
const SKIP_SCROLL_DECAY_MS = 2000;
|
|
@@ -61,6 +65,132 @@ function buildCredits(doc, songwriters) {
|
|
|
61
65
|
credits.appendChild(text);
|
|
62
66
|
return credits;
|
|
63
67
|
}
|
|
68
|
+
/** Everything a line's element is built from, so two lines that answer the same build the same. */
|
|
69
|
+
const lineSignatures = new WeakMap();
|
|
70
|
+
/**
|
|
71
|
+
* A line's words and what a consumer decorates it from. A line rebuilt for its timing alone answers
|
|
72
|
+
* the same, so the translation and romanization hung on the line it replaced still belong to it.
|
|
73
|
+
*/
|
|
74
|
+
const decorationKeys = new WeakMap();
|
|
75
|
+
function decorationKey(item) {
|
|
76
|
+
return JSON.stringify([
|
|
77
|
+
item.words,
|
|
78
|
+
item.translation ?? null,
|
|
79
|
+
item.translations ?? null,
|
|
80
|
+
item.romanization ?? null,
|
|
81
|
+
item.timedRomanization ?? null,
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
/** What the lines on screen were built against: the mount, the settings and whether they carry time. */
|
|
85
|
+
const builtViews = new WeakMap();
|
|
86
|
+
/** A line's own timing as built, so a line whose times a consumer moved since is not kept as it is. */
|
|
87
|
+
const builtTimings = new WeakMap();
|
|
88
|
+
function lineTimings(line, parts = line.parts) {
|
|
89
|
+
return [line.time, line.duration, ...parts.flatMap(part => [part.time, part.duration])];
|
|
90
|
+
}
|
|
91
|
+
function timingsUnchanged(line) {
|
|
92
|
+
const built = builtTimings.get(line);
|
|
93
|
+
if (built === undefined)
|
|
94
|
+
return false;
|
|
95
|
+
const now = lineTimings(line, line.parts.slice(0, (built.length - 2) / 2));
|
|
96
|
+
return now.length === built.length && now.every((value, index) => value === built[index]);
|
|
97
|
+
}
|
|
98
|
+
function buildContext(engine, lyrics, options) {
|
|
99
|
+
return {
|
|
100
|
+
allZero: lyrics.every(item => item.startTimeMs === 0),
|
|
101
|
+
languages: resolveLyricLanguages(lyrics, options.language),
|
|
102
|
+
seek: timeS => engine.host.seek(playerTimeForLyricTime(engine, timeS)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function viewKey(context) {
|
|
106
|
+
return `${context.allZero}|${lyricBuildSettingsKey()}`;
|
|
107
|
+
}
|
|
108
|
+
// The language is left out: it is an attribute rather than anything built, and a swap writes it onto
|
|
109
|
+
// every kept line instead.
|
|
110
|
+
function lineSignature(lyrics, lineIndex) {
|
|
111
|
+
const item = lyrics[lineIndex];
|
|
112
|
+
const parts = item.parts?.map(part => [
|
|
113
|
+
part.startTimeMs,
|
|
114
|
+
part.durationMs,
|
|
115
|
+
part.words,
|
|
116
|
+
part.isBackground === true,
|
|
117
|
+
part.explicit === true,
|
|
118
|
+
]);
|
|
119
|
+
// An instrumental line takes its side and direction from the lines around it.
|
|
120
|
+
const neighbours = item.isInstrumental
|
|
121
|
+
? [findNearestAgent(lyrics, lineIndex), isNearestLyricRtl(lyrics, lineIndex)]
|
|
122
|
+
: null;
|
|
123
|
+
return JSON.stringify([
|
|
124
|
+
item.startTimeMs,
|
|
125
|
+
item.durationMs,
|
|
126
|
+
item.words,
|
|
127
|
+
item.isInstrumental === true,
|
|
128
|
+
item.agent ?? null,
|
|
129
|
+
parts ?? null,
|
|
130
|
+
neighbours,
|
|
131
|
+
decorationKey(item),
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
function buildLine(doc, lyrics, lineIndex, context) {
|
|
135
|
+
const lyricItem = lyrics[lineIndex];
|
|
136
|
+
const lyricElement = doc.createElement("div");
|
|
137
|
+
const line = newLineData(lyricElement, lyricItem.startTimeMs, lyricItem.durationMs);
|
|
138
|
+
lyricElement.dataset.time = String(line.time);
|
|
139
|
+
lyricElement.dataset.duration = String(line.duration);
|
|
140
|
+
lyricElement.dataset.lineNumber = String(lineIndex);
|
|
141
|
+
lyricElement.classList.add(LINE_CLASS);
|
|
142
|
+
lyricElement.dir = "auto";
|
|
143
|
+
applyLyricLanguage(lyricElement, context.languages[lineIndex]);
|
|
144
|
+
addSeekHandler(context.seek, lyricElement, context.allZero);
|
|
145
|
+
if (lyricItem.isInstrumental) {
|
|
146
|
+
buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
buildSungLine(doc, lyricElement, lyricItem, line);
|
|
150
|
+
if (!lyricItem.words?.trim())
|
|
151
|
+
lyricElement.dataset.blank = "true";
|
|
152
|
+
}
|
|
153
|
+
lineSignatures.set(line, lineSignature(lyrics, lineIndex));
|
|
154
|
+
decorationKeys.set(line, decorationKey(lyricItem));
|
|
155
|
+
builtTimings.set(line, lineTimings(line));
|
|
156
|
+
return line;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Writes what the container records about the song as a whole, over whatever it recorded about the
|
|
160
|
+
* last one, and puts the credits after the last line.
|
|
161
|
+
*/
|
|
162
|
+
function writeContainerState(engine, container, lyrics, options, creditsBefore = null) {
|
|
163
|
+
const doc = engine.document;
|
|
164
|
+
const syncType = deriveSyncType(lyrics);
|
|
165
|
+
container.dataset.sync = syncType;
|
|
166
|
+
if (engine.layout === "stage") {
|
|
167
|
+
container.dataset.layout = "stage";
|
|
168
|
+
// Sides only when both are sung, so a song with one singer stays centred whichever voice it is.
|
|
169
|
+
const sung = lyrics.filter(item => !item.isInstrumental && item.words?.trim() && item.agent !== "v1000");
|
|
170
|
+
const onRight = (item) => item.agent === "v2" || item.agent === "v3";
|
|
171
|
+
if (sung.some(onRight) && sung.some(item => !onRight(item))) {
|
|
172
|
+
container.dataset.stageDuet = "";
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
delete container.dataset.stageDuet;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
container.dataset.loaderVisible = String(options.loaderVisible);
|
|
179
|
+
if (options.noLyrics) {
|
|
180
|
+
container.dataset.noLyrics = "true";
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
delete container.dataset.noLyrics;
|
|
184
|
+
}
|
|
185
|
+
// Built again on every song, even the same names: consumers hang their own things off the credits
|
|
186
|
+
// once per build, and a kept one would collect them.
|
|
187
|
+
container.querySelector(`:scope > .${CREDITS_CLASS}`)?.remove();
|
|
188
|
+
const songwriters = options.songwriters ?? [];
|
|
189
|
+
if (songwriters.length > 0 && !options.noLyrics && !hideCredits.getBooleanValue()) {
|
|
190
|
+
container.insertBefore(buildCredits(doc, songwriters), creditsBefore);
|
|
191
|
+
}
|
|
192
|
+
engine.syncType = syncType;
|
|
193
|
+
}
|
|
64
194
|
/**
|
|
65
195
|
* Replaces whatever the mount holds with a container built from these lyrics, and hands the render
|
|
66
196
|
* records, the container and its measured size to the engine that animates them.
|
|
@@ -77,56 +207,20 @@ export function setLyrics(engine, mount, lyrics, options) {
|
|
|
77
207
|
if (isGecko())
|
|
78
208
|
container.dataset.browserEngine = "gecko";
|
|
79
209
|
mount.replaceChildren(container);
|
|
80
|
-
const
|
|
81
|
-
const seek = (timeS) => engine.host.seek(playerTimeForLyricTime(engine, timeS));
|
|
210
|
+
const context = buildContext(engine, lyrics, options);
|
|
82
211
|
const lines = [];
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
for (const [lineIndex, lyricItem] of lyrics.entries()) {
|
|
86
|
-
const lyricElement = doc.createElement("div");
|
|
87
|
-
const line = newLineData(lyricElement, lyricItem.startTimeMs, lyricItem.durationMs);
|
|
88
|
-
lyricElement.dataset.time = String(line.time);
|
|
89
|
-
lyricElement.dataset.duration = String(line.duration);
|
|
90
|
-
lyricElement.dataset.lineNumber = String(lineIndex);
|
|
91
|
-
lyricElement.classList.add(LINE_CLASS);
|
|
92
|
-
lyricElement.dir = "auto";
|
|
93
|
-
applyLyricLanguage(lyricElement, languages[lineIndex]);
|
|
94
|
-
addSeekHandler(seek, lyricElement, allZero);
|
|
212
|
+
for (const lineIndex of lyrics.keys()) {
|
|
213
|
+
const line = buildLine(doc, lyrics, lineIndex, context);
|
|
95
214
|
lines.push(line);
|
|
96
|
-
|
|
97
|
-
buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex);
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
buildSungLine(doc, lyricElement, lyricItem, line);
|
|
101
|
-
if (!lyricItem.words?.trim())
|
|
102
|
-
lyricElement.dataset.blank = "true";
|
|
103
|
-
}
|
|
104
|
-
container.appendChild(lyricElement);
|
|
105
|
-
}
|
|
106
|
-
const songwriters = options.songwriters ?? [];
|
|
107
|
-
if (songwriters.length > 0 && !options.noLyrics && !hideCredits.getBooleanValue()) {
|
|
108
|
-
container.appendChild(buildCredits(doc, songwriters));
|
|
215
|
+
container.appendChild(line.lyricElement);
|
|
109
216
|
}
|
|
110
217
|
engine.skipScrolls = INITIAL_SKIP_SCROLLS;
|
|
111
218
|
engine.skipScrollsDecayTimes = Array.from({ length: INITIAL_SKIP_SCROLLS }, () => Date.now() + SKIP_SCROLL_DECAY_MS);
|
|
112
219
|
engine.scrollResumeTime = 0;
|
|
113
|
-
container
|
|
114
|
-
if (engine.layout === "stage") {
|
|
115
|
-
container.dataset.layout = "stage";
|
|
116
|
-
// Sides only when both are sung, so a song with one singer stays centred whichever voice it is.
|
|
117
|
-
const sung = lyrics.filter(item => !item.isInstrumental && item.words?.trim() && item.agent !== "v1000");
|
|
118
|
-
const onRight = (item) => item.agent === "v2" || item.agent === "v3";
|
|
119
|
-
if (sung.some(onRight) && sung.some(item => !onRight(item))) {
|
|
120
|
-
container.dataset.stageDuet = "";
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
container.dataset.loaderVisible = String(options.loaderVisible);
|
|
124
|
-
if (options.noLyrics) {
|
|
125
|
-
container.dataset.noLyrics = "true";
|
|
126
|
-
}
|
|
220
|
+
writeContainerState(engine, container, lyrics, options);
|
|
127
221
|
engine.lines = lines;
|
|
128
222
|
engine.lyricsContainer = container;
|
|
129
|
-
engine.
|
|
223
|
+
builtViews.set(engine, { mount, key: viewKey(context), songKey: options.songKey });
|
|
130
224
|
refreshInstrumentalImages(container, engine.window);
|
|
131
225
|
// Measured last: the container is in the document, filled, and carrying the attributes CSS
|
|
132
226
|
// sizes it by.
|
|
@@ -134,3 +228,164 @@ export function setLyrics(engine, mount, lyrics, options) {
|
|
|
134
228
|
engine.lyricHeight = container.clientHeight;
|
|
135
229
|
setupLineCullObserver(engine);
|
|
136
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Pairs each new line with an old one that builds the same, keeping their order, so a kept element is
|
|
233
|
+
* never moved: the longest run of matches, found after the ends both songs share are set aside.
|
|
234
|
+
*/
|
|
235
|
+
function matchLines(previous, next) {
|
|
236
|
+
const matches = next.map(() => null);
|
|
237
|
+
let start = 0;
|
|
238
|
+
while (start < previous.length && start < next.length && previous[start] === next[start]) {
|
|
239
|
+
matches[start] = start;
|
|
240
|
+
start++;
|
|
241
|
+
}
|
|
242
|
+
let previousEnd = previous.length;
|
|
243
|
+
let nextEnd = next.length;
|
|
244
|
+
while (previousEnd > start && nextEnd > start && previous[previousEnd - 1] === next[nextEnd - 1]) {
|
|
245
|
+
previousEnd--;
|
|
246
|
+
nextEnd--;
|
|
247
|
+
matches[nextEnd] = previousEnd;
|
|
248
|
+
}
|
|
249
|
+
const rows = previousEnd - start;
|
|
250
|
+
const columns = nextEnd - start;
|
|
251
|
+
// A whole new song is no match for the one before and costs nothing to find out. Past this size
|
|
252
|
+
// the middle is rebuilt rather than searched, which is what every song was before swaps existed.
|
|
253
|
+
if (rows === 0 || columns === 0 || rows * columns > MATCH_SEARCH_LIMIT)
|
|
254
|
+
return matches;
|
|
255
|
+
const lengths = new Uint16Array((rows + 1) * (columns + 1));
|
|
256
|
+
const at = (row, column) => row * (columns + 1) + column;
|
|
257
|
+
for (let row = rows - 1; row >= 0; row--) {
|
|
258
|
+
for (let column = columns - 1; column >= 0; column--) {
|
|
259
|
+
lengths[at(row, column)] =
|
|
260
|
+
previous[start + row] === next[start + column]
|
|
261
|
+
? lengths[at(row + 1, column + 1)] + 1
|
|
262
|
+
: Math.max(lengths[at(row + 1, column)], lengths[at(row, column + 1)]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
let row = 0;
|
|
266
|
+
let column = 0;
|
|
267
|
+
while (row < rows && column < columns) {
|
|
268
|
+
if (previous[start + row] === next[start + column]) {
|
|
269
|
+
matches[start + column] = start + row;
|
|
270
|
+
row++;
|
|
271
|
+
column++;
|
|
272
|
+
}
|
|
273
|
+
else if (lengths[at(row + 1, column)] >= lengths[at(row, column + 1)]) {
|
|
274
|
+
row++;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
column++;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return matches;
|
|
281
|
+
}
|
|
282
|
+
const MATCH_SEARCH_LIMIT = 250_000;
|
|
283
|
+
/**
|
|
284
|
+
* Takes the first old line sung over the same time as a new one, so a line rebuilt for a retiming or
|
|
285
|
+
* a richer source takes over its predecessor's place, and an unrelated line never inherits a sung state.
|
|
286
|
+
*/
|
|
287
|
+
function takeOverlapping(candidates, line) {
|
|
288
|
+
const end = line.time + line.duration;
|
|
289
|
+
const index = candidates.findIndex(old => {
|
|
290
|
+
const oldEnd = old.time + old.duration;
|
|
291
|
+
return old.time === line.time || (old.time < end && line.time < oldEnd);
|
|
292
|
+
});
|
|
293
|
+
return index === -1 ? undefined : candidates.splice(index, 1)[0];
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Swaps these lyrics into the view already on screen, rebuilding only the lines that differ, and
|
|
297
|
+
* answers where each line was before so the caller can carry the view across the change. Answers
|
|
298
|
+
* null without touching anything when the view cannot be swapped, and the caller builds it whole.
|
|
299
|
+
*
|
|
300
|
+
* Nothing about where the reader is goes back to the start of a song: the scroll, a pause the reader
|
|
301
|
+
* scrolled into, and the scrolls the engine is waiting to swallow are all the song's before.
|
|
302
|
+
*/
|
|
303
|
+
export function swapLyrics(engine, mount, lyrics, options) {
|
|
304
|
+
const container = engine.lyricsContainer;
|
|
305
|
+
const built = builtViews.get(engine);
|
|
306
|
+
if (container === null || built === undefined || built.mount !== mount || container.parentElement !== mount) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
// Another song, or one nobody named, starts from nothing rather than inheriting this one's place.
|
|
310
|
+
if (options.songKey === undefined || options.songKey !== built.songKey)
|
|
311
|
+
return null;
|
|
312
|
+
// Lines a consumer took off the screen without dropping the song are not there to keep.
|
|
313
|
+
const previousLines = engine.lines;
|
|
314
|
+
// An empty song is a fresh start rather than a song to carry anything into.
|
|
315
|
+
if (lyrics.length === 0 || previousLines.length === 0)
|
|
316
|
+
return null;
|
|
317
|
+
if (previousLines.some(line => line.lyricElement.parentNode !== container))
|
|
318
|
+
return null;
|
|
319
|
+
const context = buildContext(engine, lyrics, options);
|
|
320
|
+
if (built.key !== viewKey(context))
|
|
321
|
+
return null;
|
|
322
|
+
const doc = engine.document;
|
|
323
|
+
// A line whose times a consumer moved after it was built is built again, so the move is not applied twice.
|
|
324
|
+
const previousSignatures = previousLines.map(line => timingsUnchanged(line) ? (lineSignatures.get(line) ?? "") : `\u0000moved:${lineSignatures.get(line)}`);
|
|
325
|
+
const nextSignatures = lyrics.map((_, lineIndex) => lineSignature(lyrics, lineIndex));
|
|
326
|
+
// Whatever a consumer put after the lines and the credits, such as a footer, stays after them.
|
|
327
|
+
const children = Array.from(container.childNodes);
|
|
328
|
+
const oldCredits = container.querySelector(`:scope > .${CREDITS_CLASS}`);
|
|
329
|
+
const lastOwned = oldCredits ?? previousLines[previousLines.length - 1].lyricElement;
|
|
330
|
+
const trailing = children[children.indexOf(lastOwned) + 1] ?? null;
|
|
331
|
+
const matches = matchLines(previousSignatures, nextSignatures);
|
|
332
|
+
const kept = new Set();
|
|
333
|
+
const lines = [];
|
|
334
|
+
// A new line stands in for an old one when both sit in the same gap between kept lines, in order,
|
|
335
|
+
// so the engine can hand it what it held for the line it replaced.
|
|
336
|
+
const replaced = new Map();
|
|
337
|
+
const positions = new Map();
|
|
338
|
+
let previousCursor = 0;
|
|
339
|
+
let unmatchedOld = [];
|
|
340
|
+
const collectUnmatchedUntil = (end) => {
|
|
341
|
+
unmatchedOld = previousLines.slice(previousCursor, end);
|
|
342
|
+
previousCursor = end;
|
|
343
|
+
};
|
|
344
|
+
for (const [lineIndex, match] of matches.entries()) {
|
|
345
|
+
if (match !== null) {
|
|
346
|
+
const line = previousLines[match];
|
|
347
|
+
previousCursor = match + 1;
|
|
348
|
+
unmatchedOld = [];
|
|
349
|
+
kept.add(line);
|
|
350
|
+
line.lyricElement.dataset.lineNumber = String(lineIndex);
|
|
351
|
+
applyLyricLanguage(line.lyricElement, context.languages[lineIndex]);
|
|
352
|
+
lines.push(line);
|
|
353
|
+
positions.set(line, { position: line.position, height: line.height, offset: 0 });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (unmatchedOld.length === 0) {
|
|
357
|
+
const nextMatch = matches.slice(lineIndex).find(value => value !== null);
|
|
358
|
+
collectUnmatchedUntil(nextMatch ?? previousLines.length);
|
|
359
|
+
}
|
|
360
|
+
const line = buildLine(doc, lyrics, lineIndex, context);
|
|
361
|
+
const standsFor = takeOverlapping(unmatchedOld, line);
|
|
362
|
+
if (standsFor !== undefined) {
|
|
363
|
+
replaced.set(line, standsFor);
|
|
364
|
+
positions.set(line, {
|
|
365
|
+
position: standsFor.position,
|
|
366
|
+
height: standsFor.height,
|
|
367
|
+
// A kept line finishes its own line scroll; this one starts where the line it replaced was.
|
|
368
|
+
offset: lineScrollOffset(engine, standsFor.lyricElement),
|
|
369
|
+
});
|
|
370
|
+
if (decorationKeys.get(standsFor) === decorationKeys.get(line))
|
|
371
|
+
moveDecorations(standsFor, line);
|
|
372
|
+
}
|
|
373
|
+
lines.push(line);
|
|
374
|
+
}
|
|
375
|
+
const dropped = previousLines.filter(line => !kept.has(line));
|
|
376
|
+
// In order, each new line before whichever kept line follows it, so a kept element never leaves the
|
|
377
|
+
// document and never loses the animations running against it.
|
|
378
|
+
let following = oldCredits ?? trailing;
|
|
379
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
380
|
+
const element = lines[index].lyricElement;
|
|
381
|
+
if (!kept.has(lines[index]))
|
|
382
|
+
container.insertBefore(element, following);
|
|
383
|
+
following = element;
|
|
384
|
+
}
|
|
385
|
+
replaceRenderedLines(engine, lines, dropped, replaced);
|
|
386
|
+
for (const line of dropped)
|
|
387
|
+
line.lyricElement.remove();
|
|
388
|
+
writeContainerState(engine, container, lyrics, options, trailing);
|
|
389
|
+
refreshInstrumentalImages(container, engine.window);
|
|
390
|
+
return positions;
|
|
391
|
+
}
|