@braccato/core 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,7 +81,7 @@ connects, and everything it was handed by then is applied at once.
81
81
  | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
82
82
  | `currentTime` | `current-time` | `number` | `0` | Playback position in **seconds**. Writing it renders the view again, so whoever holds the clock drives the lyrics by writing this. |
83
83
  | `playing` | `playing` | `boolean` | `false` | A paused view animates differently from a playing one. |
84
- | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, and when the clock was sampled. |
84
+ | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, when the clock was sampled, and the rate the song is playing at. |
85
85
  | `theme` | `theme` | `string` | `""` | A compiled stylesheet. See Theming. |
86
86
  | `host` | | `Partial<LyricsRendererHost>` | `{}` | Overrides for what the renderer asks of its surroundings. Every member has a default. Writing it while connected rebuilds the view. |
87
87
  | `renderer` | | `LyricsRenderer \| null` (get) | `null` | The renderer underneath, for the day the tag runs out. A different one after every reconnection. |
@@ -256,6 +256,12 @@ either is dropped and the getter keeps reporting what the binding last read. Dro
256
256
  reported, because a consumer who bound a source and left their own frame loop running would otherwise
257
257
  be told about it sixty times a second. Unbind and the clock goes back to whoever asked for it.
258
258
 
259
+ The rate is read off the media element too, and passed on as `tickOptions.playbackRate`, so a song at
260
+ half or double speed animates at half or double speed rather than sweeping at 1x and being corrected
261
+ on the next tick. A consumer driving the clock itself sets that option instead. Only the animations
262
+ that follow the song are scaled: a line's exit, a word's fade and the scroll between lines keep the
263
+ timing the theme asked for at every rate.
264
+
259
265
  A reading the media element has not refreshed yet is carried forward at the playback rate it was
260
266
  taken at, capped at 100ms of frame time. That cap is what covers a stall: the view runs at most 100ms
261
267
  past the last real reading and then waits with it. What it costs is a step backwards when the clock
package/dist/README.md CHANGED
@@ -81,7 +81,7 @@ connects, and everything it was handed by then is applied at once.
81
81
  | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
82
82
  | `currentTime` | `current-time` | `number` | `0` | Playback position in **seconds**. Writing it renders the view again, so whoever holds the clock drives the lyrics by writing this. |
83
83
  | `playing` | `playing` | `boolean` | `false` | A paused view animates differently from a playing one. |
84
- | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, and when the clock was sampled. |
84
+ | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, when the clock was sampled, and the rate the song is playing at. |
85
85
  | `theme` | `theme` | `string` | `""` | A compiled stylesheet. See Theming. |
86
86
  | `host` | | `Partial<LyricsRendererHost>` | `{}` | Overrides for what the renderer asks of its surroundings. Every member has a default. Writing it while connected rebuilds the view. |
87
87
  | `renderer` | | `LyricsRenderer \| null` (get) | `null` | The renderer underneath, for the day the tag runs out. A different one after every reconnection. |
@@ -256,6 +256,12 @@ either is dropped and the getter keeps reporting what the binding last read. Dro
256
256
  reported, because a consumer who bound a source and left their own frame loop running would otherwise
257
257
  be told about it sixty times a second. Unbind and the clock goes back to whoever asked for it.
258
258
 
259
+ The rate is read off the media element too, and passed on as `tickOptions.playbackRate`, so a song at
260
+ half or double speed animates at half or double speed rather than sweeping at 1x and being corrected
261
+ on the next tick. A consumer driving the clock itself sets that option instead. Only the animations
262
+ that follow the song are scaled: a line's exit, a word's fade and the scroll between lines keep the
263
+ timing the theme asked for at every rate.
264
+
259
265
  A reading the media element has not refreshed yet is carried forward at the playback rate it was
260
266
  taken at, capped at 100ms of frame time. That cap is what covers a stall: the view runs at most 100ms
261
267
  past the last real reading and then waits with it. What it costs is a step backwards when the clock
package/dist/element.js CHANGED
@@ -422,8 +422,15 @@ export class BraccatoLyricsElement extends HTMLElement {
422
422
  // consumer whose clock runs before the lyrics arrive is the ordinary case rather than a fault.
423
423
  if (renderer === null || renderer.container === null)
424
424
  return;
425
+ // The rate a bound media element is playing at is the element's to report, the same way the
426
+ // clock is, so it overrides what the consumer wrote for exactly as long as the binding lasts.
427
+ const boundRate = this.#media === null ? undefined : this.#media.playbackRate;
425
428
  // The play state last, so a consumer writing plain JavaScript cannot answer that question twice.
426
- renderer.tick(this.#currentTimeS, { ...this.#tickOptions, isPlaying: this.#playing });
429
+ renderer.tick(this.#currentTimeS, {
430
+ ...this.#tickOptions,
431
+ ...(boundRate === undefined ? {} : { playbackRate: boundRate }),
432
+ isPlaying: this.#playing,
433
+ });
427
434
  }
428
435
  #upgradeProperty(name) {
429
436
  // Written through the class rather than `this`: TypeScript refuses an indexed write to a
package/dist/engine.d.ts CHANGED
@@ -56,6 +56,7 @@ export interface AnimationEngineInstance extends AnimEngineViewState {
56
56
  * from a tick, so it has no options object to read.
57
57
  */
58
58
  passiveScrollEnabled: boolean;
59
+ playbackRate: number;
59
60
  passiveRAFId: number | null;
60
61
  pendingLyricsUpdateFrame: number | null;
61
62
  learnedAnimationTimingOffsetMs: number;
package/dist/engine.js CHANGED
@@ -145,6 +145,7 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
145
145
  cachedCSSValues: new Map(),
146
146
  cachedAnimationSettings: null,
147
147
  passiveScrollEnabled: false,
148
+ playbackRate: 1,
148
149
  passiveRAFId: null,
149
150
  pendingLyricsUpdateFrame: null,
150
151
  learnedAnimationTimingOffsetMs: 0,
@@ -351,8 +352,29 @@ function trackLyricAnimationTiming(engine, animation, timing) {
351
352
  ...timing,
352
353
  appliedTimingOffsetMs: timing.appliedTimingOffsetMs ?? engine.learnedAnimationTimingOffsetMs,
353
354
  });
355
+ // Being tracked is what makes an animation the song's rather than the interface's, so it is also
356
+ // what decides which ones follow the song's rate.
357
+ animation.playbackRate = engine.playbackRate;
354
358
  return animation;
355
359
  }
360
+ /**
361
+ * Puts the animations already running onto a new rate. Setting `playbackRate` keeps `currentTime`,
362
+ * so each one carries on from where the song left it rather than restarting.
363
+ */
364
+ function applyPlaybackRateToRunningAnimations(engine) {
365
+ for (const line of engine.lines) {
366
+ for (const animation of line.animations) {
367
+ if (animationTimingTracks.has(animation))
368
+ animation.playbackRate = engine.playbackRate;
369
+ }
370
+ for (const part of line.parts) {
371
+ for (const animation of part.animations) {
372
+ if (animationTimingTracks.has(animation))
373
+ animation.playbackRate = engine.playbackRate;
374
+ }
375
+ }
376
+ }
377
+ }
356
378
  function correctedAnimationTimeMs(targetTimeMs, appliedTimingOffsetMs, maxTimeMs) {
357
379
  const scheduledTimeMs = targetTimeMs - appliedTimingOffsetMs;
358
380
  return maxTimeMs === undefined ? scheduledTimeMs : Math.min(scheduledTimeMs, maxTimeMs);
@@ -1452,14 +1474,24 @@ export function resolveTickOptions(options) {
1452
1474
  richsyncOffsetTrim: options.richsyncOffsetTrim ?? 0,
1453
1475
  lineOffsetTrim: options.lineOffsetTrim ?? 0,
1454
1476
  passiveScrollEnabled: options.passiveScrollEnabled ?? false,
1477
+ playbackRate: resolvePlaybackRate(options.playbackRate),
1455
1478
  };
1456
1479
  }
1480
+ // A rate of zero or less would freeze every animation that follows the song, which is a second
1481
+ // answer to the question `isPlaying` already answers.
1482
+ function resolvePlaybackRate(rate) {
1483
+ return rate !== undefined && Number.isFinite(rate) && rate > 0 ? rate : 1;
1484
+ }
1457
1485
  /**
1458
1486
  * Renders one view against a tick with nothing left out.
1459
1487
  */
1460
1488
  export function tickView(engine, currentTime, options) {
1461
1489
  const { eventCreationTime, isPlaying, smoothScroll } = options;
1462
1490
  engine.passiveScrollEnabled = options.passiveScrollEnabled;
1491
+ if (engine.playbackRate !== options.playbackRate) {
1492
+ engine.playbackRate = options.playbackRate;
1493
+ applyPlaybackRateToRunningAnimations(engine);
1494
+ }
1463
1495
  const now = Date.now();
1464
1496
  if (currentTime === 0 && !isPlaying) {
1465
1497
  return "ok";
package/dist/types.d.ts CHANGED
@@ -28,6 +28,13 @@ export interface TickOptions {
28
28
  * false.
29
29
  */
30
30
  passiveScrollEnabled?: boolean;
31
+ /**
32
+ * How fast the song moves through its own timeline, as a multiple of real time. Defaults to 1, and
33
+ * zero or less reads as 1 because `isPlaying` is what says a song is stopped. Scales the animations
34
+ * that follow the song; a line's exit, a word's fade and the scroll between lines are the
35
+ * interface's own gestures and keep the timing the theme asked for at every rate.
36
+ */
37
+ playbackRate?: number;
31
38
  }
32
39
  /**
33
40
  * A tick with nothing left out. The engine reads every field unconditionally, so the defaults are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",