@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 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` and
412
- `injectTranslation` are published beside it, for what one instance cannot answer for on its own. Hang
413
- one onto a built line and call `renderer.scheduleLyricPositionUpdate` the way you already would to
414
- catch the layout up: the hung line floats into place while the lines around it slide to make room,
415
- rather than the rest of the lyric jumping down. `--blyrics-animate-decoration-entry` set to `0` drops
416
- both for an instant insert, and reduced-motion does the same.
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` and
412
- `injectTranslation` are published beside it, for what one instance cannot answer for on its own. Hang
413
- one onto a built line and call `renderer.scheduleLyricPositionUpdate` the way you already would to
414
- catch the layout up: the hung line floats into place while the lines around it slide to make room,
415
- rather than the rest of the lyric jumping down. `--blyrics-animate-decoration-entry` set to `0` drops
416
- both for an instant insert, and reduced-motion does the same.
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
- if (lyricElement.querySelector(`.${ROMANIZED_LYRICS_CLASS}`) || repeatsLine(lyricElement, text))
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
- if (lyricElement.querySelector(`.${TRANSLATED_LYRICS_CLASS}`) || repeatsLine(lyricElement, text))
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
- // Before the mount moves, so a second song built somewhere else takes the first one's
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 (engine.lyricsContainer)
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);
@@ -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;
@@ -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. Every option has a default, so a consumer with an
114
- * opinion about only one of them says only that one. The mount rides along with the CSS flags
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 replaces the whole view rather than being diffed into the last one. Every record the
6
- // engine holds points at an element built here and carries the `Animation` objects running against
7
- // it, so a partial update would leave the engine holding pieces of the song before.
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 allZero = lyrics.every(item => item.startTimeMs === 0);
81
- const seek = (timeS) => engine.host.seek(playerTimeForLyricTime(engine, timeS));
210
+ const context = buildContext(engine, lyrics, options);
82
211
  const lines = [];
83
- const syncType = deriveSyncType(lyrics);
84
- const languages = resolveLyricLanguages(lyrics, options.language);
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
- if (lyricItem.isInstrumental) {
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.dataset.sync = syncType;
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.syncType = syncType;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.16.14",
3
+ "version": "1.17.1",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",