@braccato/core 1.16.5 → 1.16.7

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
@@ -328,6 +328,35 @@ It is written only when a word's state changes rather than every frame, so selec
328
328
  theme nothing per tick. A line synced word, which has no duration of its own, is `active` from its
329
329
  start until the next word begins.
330
330
 
331
+ ### Browser engine
332
+
333
+ On Firefox the container carries `data-browser-engine="gecko"`. The renderer's Gecko-only CSS keys on
334
+ it, and a theme can do the same to target Firefox without sniffing the user agent. It is detected from
335
+ a feature only Gecko ships, so forks of Firefox carry it too. Script reads the same answer from
336
+ `isGecko()` in `@braccato/core/util`, which the renderer's Gecko-only script checks once on load.
337
+
338
+ ```css
339
+ .blyrics-container[data-browser-engine="gecko"] .blyrics--line {
340
+ /* Firefox only */
341
+ }
342
+ ```
343
+
344
+ The letter wave uses both. WebRender rounds text moved by a 2D translation to whole device
345
+ pixels, and it only redraws a cached tile once something on it has moved a quarter of a pixel, so the
346
+ wave stepped on Firefox. Lettered words on animating lines get a distant `perspective` there, and each
347
+ base letter in the wave runs an invisible additive opacity ripple that changes every frame and forces the
348
+ redraw. Chromium gets neither.
349
+
350
+ The playback clock checks `isGecko()`. Firefox advances a media element's `currentTime` about every 40ms, so
351
+ each read lags the true time by a different amount and never leads it. The renderer took each new lag
352
+ for drift and reseeked the line, so the highlight swipe jumped back mid-word. On Firefox the tick keeps
353
+ its own clock instead: a read ahead of it is taken, a read behind it pulls it back by a tenth per
354
+ second, and a read more than 60ms off, about one Firefox step plus margin, counts as a seek.
355
+
356
+ The instrumental fill uses the attribute. Gecko resolves a `fill-box` transform origin wrongly for a shape
357
+ inside a `clipPath`, so the flattening wave crest came loose from the rising fill. On Firefox the wave
358
+ scales from its floor in user units instead, which Chromium already renders identically.
359
+
331
360
  ### Class names
332
361
 
333
362
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -428,7 +457,7 @@ Four leaves import nothing at all, so taking one does not pull the engine into y
428
457
  - `@braccato/core/constants` for the class names and element ids above
429
458
  - `@braccato/core/text` for script detection: `testRtl`, `containsNonLatin`, `detectNonLatinLanguage`
430
459
  - `@braccato/core/themeSettings` for `parseThemeConfig`
431
- - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`
460
+ - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`, and `isGecko` for engine-specific fixes
432
461
 
433
462
  Two notes on the element entry point. A browser extension's isolated world has no custom element
434
463
  registry, so `window.customElements` is null there and importing this file throws where it registers.
package/dist/README.md CHANGED
@@ -328,6 +328,35 @@ It is written only when a word's state changes rather than every frame, so selec
328
328
  theme nothing per tick. A line synced word, which has no duration of its own, is `active` from its
329
329
  start until the next word begins.
330
330
 
331
+ ### Browser engine
332
+
333
+ On Firefox the container carries `data-browser-engine="gecko"`. The renderer's Gecko-only CSS keys on
334
+ it, and a theme can do the same to target Firefox without sniffing the user agent. It is detected from
335
+ a feature only Gecko ships, so forks of Firefox carry it too. Script reads the same answer from
336
+ `isGecko()` in `@braccato/core/util`, which the renderer's Gecko-only script checks once on load.
337
+
338
+ ```css
339
+ .blyrics-container[data-browser-engine="gecko"] .blyrics--line {
340
+ /* Firefox only */
341
+ }
342
+ ```
343
+
344
+ The letter wave uses both. WebRender rounds text moved by a 2D translation to whole device
345
+ pixels, and it only redraws a cached tile once something on it has moved a quarter of a pixel, so the
346
+ wave stepped on Firefox. Lettered words on animating lines get a distant `perspective` there, and each
347
+ base letter in the wave runs an invisible additive opacity ripple that changes every frame and forces the
348
+ redraw. Chromium gets neither.
349
+
350
+ The playback clock checks `isGecko()`. Firefox advances a media element's `currentTime` about every 40ms, so
351
+ each read lags the true time by a different amount and never leads it. The renderer took each new lag
352
+ for drift and reseeked the line, so the highlight swipe jumped back mid-word. On Firefox the tick keeps
353
+ its own clock instead: a read ahead of it is taken, a read behind it pulls it back by a tenth per
354
+ second, and a read more than 60ms off, about one Firefox step plus margin, counts as a seek.
355
+
356
+ The instrumental fill uses the attribute. Gecko resolves a `fill-box` transform origin wrongly for a shape
357
+ inside a `clipPath`, so the flattening wave crest came loose from the rising fill. On Firefox the wave
358
+ scales from its floor in user units instead, which Chromium already renders identically.
359
+
331
360
  ### Class names
332
361
 
333
362
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -428,7 +457,7 @@ Four leaves import nothing at all, so taking one does not pull the engine into y
428
457
  - `@braccato/core/constants` for the class names and element ids above
429
458
  - `@braccato/core/text` for script detection: `testRtl`, `containsNonLatin`, `detectNonLatinLanguage`
430
459
  - `@braccato/core/themeSettings` for `parseThemeConfig`
431
- - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`
460
+ - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`, and `isGecko` for engine-specific fixes
432
461
 
433
462
  Two notes on the element entry point. A browser extension's isolated world has no custom element
434
463
  registry, so `window.customElements` is null there and importing this file throws where it registers.
package/dist/engine.d.ts CHANGED
@@ -98,6 +98,7 @@ export declare function createAnimationEngineInstance(engineDocument: Document,
98
98
  * rather than as a jump away from the previous one.
99
99
  */
100
100
  export declare function resetPlaybackClock(): void;
101
+ export declare function smoothedPlaybackTime(previousTime: number, previousAt: number, rawTime: number, now: number, playbackRate: number): number;
101
102
  /**
102
103
  * The user asked for autoscroll back, now.
103
104
  */
package/dist/engine.js CHANGED
@@ -20,7 +20,7 @@ import { ANIMATING_CLASS, CREDITS_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LIN
20
20
  import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
21
21
  import { layoutStage, planStage, queuedStageY, stageTextSpan, } from "./stage.js";
22
22
  import { registerThemeSetting } from "./themeSettings.js";
23
- import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
23
+ import { clamp, getRelativeLayoutBounds, isGecko, positiveModulo, roundedMs, toMs } from "./util.js";
24
24
  const NO_LYRICS_ELEMENT_LOG = "No lyrics element found on the page, skipping lyrics injection";
25
25
  const LYRICS_CHECK_INTERVAL_ERROR = "Error in lyrics check interval:";
26
26
  const PAUSING_LYRICS_SCROLL_LOG = "Pausing Lyrics Autoscroll Due to User Scroll";
@@ -29,6 +29,11 @@ const PASSIVE_USER_SCROLL_RESUME_DELAY_MS = 5000;
29
29
  const LYRIC_ENDING_THRESHOLD_S = registerThemeSetting("blyrics-lyric-ending-threshold-s", 0.5);
30
30
  const EARLY_SCROLL_CONSIDER = registerThemeSetting("blyrics-early-scroll-consider-s", 0.54);
31
31
  const TIME_JUMP_THRESHOLD = 0.5;
32
+ // Gecko steps media time every ~40ms, so reads lag by a random amount (never lead) that reseeks lines as drift.
33
+ const SMOOTH_PLAYBACK_CLOCK = isGecko();
34
+ // One Gecko step (~45ms) plus margin: a read further off is a seek or a timer spike, not media lag.
35
+ const CLOCK_RESYNC_THRESHOLD_S = 0.06;
36
+ const CLOCK_SLEW_PER_S = 0.1;
32
37
  const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
33
38
  const SCROLL_PREPARE_LEAD_MS = 120;
34
39
  const LINE_SCROLL_FALLBACK_DURATION_MS = 750;
@@ -185,6 +190,8 @@ const playbackClock = {
185
190
  lastTime: 0,
186
191
  lastPlayState: false,
187
192
  lastEventCreationTime: NO_PLAYER_SNAPSHOT,
193
+ smoothedTime: 0,
194
+ smoothedAt: NO_PLAYER_SNAPSHOT,
188
195
  };
189
196
  /**
190
197
  * Forgets the last player snapshot, so the next tick is treated as the first one of a new song
@@ -194,6 +201,17 @@ export function resetPlaybackClock() {
194
201
  playbackClock.lastTime = 0;
195
202
  playbackClock.lastPlayState = false;
196
203
  playbackClock.lastEventCreationTime = NO_PLAYER_SNAPSHOT;
204
+ playbackClock.smoothedAt = NO_PLAYER_SNAPSHOT;
205
+ }
206
+ export function smoothedPlaybackTime(previousTime, previousAt, rawTime, now, playbackRate) {
207
+ if (previousAt === NO_PLAYER_SNAPSHOT)
208
+ return rawTime;
209
+ const elapsedS = Math.max(0, now - previousAt) / 1000;
210
+ const predictedTime = previousTime + elapsedS * playbackRate;
211
+ const errorS = rawTime - predictedTime;
212
+ if (errorS >= 0 || Math.abs(errorS) >= CLOCK_RESYNC_THRESHOLD_S)
213
+ return rawTime;
214
+ return predictedTime + errorS * Math.min(1, elapsedS * CLOCK_SLEW_PER_S);
197
215
  }
198
216
  // -- View operations --------------------------
199
217
  /**
@@ -436,7 +454,7 @@ function mirrorImageHighlightAnimations(part, animations) {
436
454
  mirror.currentTime = animation.currentTime;
437
455
  const timing = animationTimingTracks.get(animation);
438
456
  if (timing)
439
- animationTimingTracks.set(mirror, timing);
457
+ animationTimingTracks.set(mirror, { ...timing, skipDriftSample: true });
440
458
  mirrors.push(mirror);
441
459
  }
442
460
  return mirrors;
@@ -529,8 +547,11 @@ function timingValueToMs(value) {
529
547
  function animationCurrentTimeMs(animation) {
530
548
  return timingValueToMs(animation.currentTime);
531
549
  }
532
- function animationActiveDurationMs(animation) {
533
- return timingValueToMs(animation.effect?.getComputedTiming().activeDuration);
550
+ // A finished animation's currentTime stops at its end time, which counts the delay, so that is where
551
+ // the expected time has to stop too. Clamping to the active duration alone saturates a delayed
552
+ // animation as soon as it is created and hides any drift in it.
553
+ function animationEndTimeMs(animation) {
554
+ return timingValueToMs(animation.effect?.getComputedTiming().endTime);
534
555
  }
535
556
  function wrappedTimingOffsetMs(actualTimeMs, expectedTimeMs, wrapDurationMs) {
536
557
  return positiveModulo(actualTimeMs - expectedTimeMs + wrapDurationMs / 2, wrapDurationMs) - wrapDurationMs / 2;
@@ -542,18 +563,18 @@ function normalizeAnimationTimeMs(animation, timeMs, timing) {
542
563
  if (timing.wrapDurationMs && timing.wrapDurationMs > 0) {
543
564
  return positiveModulo(timeMs, timing.wrapDurationMs);
544
565
  }
545
- const activeDurationMs = animationActiveDurationMs(animation);
546
- if (activeDurationMs !== null && Number.isFinite(activeDurationMs)) {
547
- return Math.min(timeMs, activeDurationMs);
566
+ const endTimeMs = animationEndTimeMs(animation);
567
+ if (endTimeMs !== null && Number.isFinite(endTimeMs)) {
568
+ return Math.min(timeMs, endTimeMs);
548
569
  }
549
570
  return timeMs;
550
571
  }
551
572
  function animationTimingSample(part, animation, currentTime) {
552
- if (animation.playState === "idle") {
573
+ const timing = animationTimingTracks.get(animation);
574
+ if (!timing || timing.skipDriftSample) {
553
575
  return null;
554
576
  }
555
- const timing = animationTimingTracks.get(animation);
556
- if (!timing) {
577
+ if (animation.playState === "idle") {
557
578
  return null;
558
579
  }
559
580
  const actualTimeMs = animationCurrentTimeMs(animation);
@@ -844,6 +865,11 @@ function swipeRamp(config) {
844
865
  }
845
866
  // How far through its float a letter crests, as a keyframe offset.
846
867
  const LETTER_WAVE_CREST_OFFSET = 0.4;
868
+ // WebRender redraws a cached tile only after a quarter device pixel of movement, so Gecko letters step.
869
+ // An invisible additive opacity ripple changes every frame and forces the redraw.
870
+ const GECKO_REDRAW = isGecko();
871
+ const GECKO_REDRAW_KEYFRAMES = [{ opacity: 0 }, { opacity: -0.002 }, { opacity: 0 }];
872
+ const GECKO_REDRAW_PERIOD_MS = 200;
847
873
  // When each letter's wave starts, counted from the start of the word, and how long it lasts. A letter
848
874
  // crests just as the swipe finishes lighting it. If the swipe lights it faster than the float rises,
849
875
  // it starts early enough to make that, which on a short word means before the word itself does. If the
@@ -887,7 +913,11 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
887
913
  offset: frame.offset,
888
914
  maskPosition: frame.maskPosition,
889
915
  WebkitMaskPosition: frame.maskPosition,
890
- })), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }), swipeTiming);
916
+ })), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }),
917
+ // The last letter's sweep ends last, so it alone covers the whole word.
918
+ index === sweeps.length - 1 && target === highlightLetters[index]
919
+ ? swipeTiming
920
+ : { ...swipeTiming, skipDriftSample: true });
891
921
  animation.currentTime = swipeCurrentTimeMs;
892
922
  if (index === 0)
893
923
  swipeAnimation = animation;
@@ -1042,20 +1072,50 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
1042
1072
  // as the wave running behind once it finishes. Run the wave's clock ahead by the head start
1043
1073
  // instead, the way the swipe carries its lead, so every delay stays at zero or above.
1044
1074
  const waveLeadMs = Math.max(0, -Math.min(...waves.map(wave => wave.delayMs)));
1045
- const cascadeDurationMs = waveLeadMs + Math.max(...waves.map(wave => wave.delayMs + wave.durationMs));
1075
+ const waveEndsMs = waves.map(wave => wave.delayMs + wave.durationMs);
1076
+ const lastWaveEndMs = Math.max(...waveEndsMs);
1077
+ const lastEndingWaveIndex = waveEndsMs.lastIndexOf(lastWaveEndMs);
1078
+ const cascadeDurationMs = waveLeadMs + lastWaveEndMs;
1046
1079
  const floatStartMs = correctedAnimationTimeMs(wordTimeMs + waveLeadMs, appliedTimingOffsetMs, cascadeDurationMs);
1047
1080
  const floatKeyframeSignature = JSON.stringify(floatKeyframes);
1048
- for (const set of [part.letterElements, part.highlightLetterElements, part.imageLayers?.glowLetters]) {
1049
- set?.forEach((letterElement, index) => {
1081
+ // Glow letters only show while the glow does: never when it is off or renders nothing for this
1082
+ // word, and not once it has faded out. Their waves stop there instead of running to the end.
1083
+ const glowLetters = config.enabled.highlightGlow && !part.glowSuppressed ? part.imageLayers?.glowLetters : undefined;
1084
+ const glowEndMs = Number.parseFloat(config.highlight.imageGlowOpacityTo) === 0 ? waveLeadMs + glowDurationMs : Infinity;
1085
+ const animateLetters = (letters, endMs, sampled) => {
1086
+ letters?.forEach((letterElement, index) => {
1087
+ const delayMs = waves[index].delayMs + waveLeadMs;
1088
+ if (delayMs >= endMs)
1089
+ return;
1090
+ const endDelayMs = Math.min(0, endMs - delayMs - waves[index].durationMs);
1050
1091
  const animation = trackLyricAnimationTiming(engine, acquireWaveAnimation(engine, letterElement, floatKeyframes, floatKeyframeSignature, {
1051
1092
  duration: waves[index].durationMs,
1052
- delay: waves[index].delayMs + waveLeadMs,
1093
+ delay: delayMs,
1094
+ endDelay: endDelayMs,
1053
1095
  fill: "forwards",
1054
- }), { appliedTimingOffsetMs, offsetMs: waveLeadMs });
1096
+ }), {
1097
+ appliedTimingOffsetMs,
1098
+ offsetMs: waveLeadMs,
1099
+ // The wave that ends last covers the whole word. A theme's swipe ramp can make that an early letter.
1100
+ skipDriftSample: !sampled || index !== lastEndingWaveIndex,
1101
+ });
1055
1102
  animation.currentTime = floatStartMs;
1056
1103
  wobbleAnimations.push(animation);
1104
+ if (!GECKO_REDRAW || !sampled)
1105
+ return;
1106
+ const redraw = trackLyricAnimationTiming(engine, letterElement.animate(GECKO_REDRAW_KEYFRAMES, {
1107
+ duration: GECKO_REDRAW_PERIOD_MS,
1108
+ iterations: waves[index].durationMs / GECKO_REDRAW_PERIOD_MS,
1109
+ delay: delayMs,
1110
+ composite: "add",
1111
+ }), { appliedTimingOffsetMs, offsetMs: waveLeadMs, skipDriftSample: true });
1112
+ redraw.currentTime = floatStartMs;
1113
+ wobbleAnimations.push(redraw);
1057
1114
  });
1058
- }
1115
+ };
1116
+ animateLetters(part.letterElements, Infinity, true);
1117
+ animateLetters(part.highlightLetterElements, Infinity, false);
1118
+ animateLetters(glowLetters, glowEndMs, false);
1059
1119
  }
1060
1120
  }
1061
1121
  part.animations = [
@@ -2259,6 +2319,13 @@ export function tickView(engine, currentTime, options) {
2259
2319
  timeOffset = 0;
2260
2320
  }
2261
2321
  currentTime += timeOffset / 1000;
2322
+ if (SMOOTH_PLAYBACK_CLOCK) {
2323
+ if (isPlaying && !timeJumped) {
2324
+ currentTime = smoothedPlaybackTime(playbackClock.smoothedTime, playbackClock.smoothedAt, currentTime, now, engine.playbackRate);
2325
+ }
2326
+ playbackClock.smoothedTime = currentTime;
2327
+ playbackClock.smoothedAt = isPlaying ? now : NO_PLAYER_SNAPSHOT;
2328
+ }
2262
2329
  if (!engine.host.isViewVisible()) {
2263
2330
  clearVisibleLyricWillChange(engine);
2264
2331
  return "ok";
@@ -68,6 +68,12 @@
68
68
  d: var(--blyrics-instrumental-wave-path-high);
69
69
  }
70
70
 
71
+ /* Gecko misplaces a fill-box origin inside a clipPath, so pin the wave's floor (y=4) in user units. */
72
+ .blyrics-container[data-browser-engine="gecko"] .blyrics--wave-path {
73
+ transform-box: view-box;
74
+ transform-origin: 0 4px;
75
+ }
76
+
71
77
  /* Trigger the Flattening */
72
78
  .blyrics--instrumental.blyrics--animating:not(.blyrics--paused) .blyrics--wave-path {
73
79
  /* Flatten the wave to 0 height */
@@ -177,6 +177,11 @@
177
177
  unicode-bidi: isolate;
178
178
  }
179
179
 
180
+ /* WebRender snaps 2D-translated glyphs to whole pixels; perspective rasterizes them in local space. */
181
+ .blyrics-container[data-browser-engine="gecko"] .blyrics--animating .blyrics-word--lettered {
182
+ perspective: 100000px;
183
+ }
184
+
180
185
  .blyrics-word-highlight.blyrics-word-highlight--lettered {
181
186
  background-image: none;
182
187
  }
package/dist/util.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export declare function clamp(value: number, min: number, max: number): number;
2
2
  export declare function positiveModulo(value: number, divisor: number): number;
3
+ /**
4
+ * Whether the page renders with Gecko (Firefox). Detects the engine by a feature only it ships, so
5
+ * spoofed user agents and Firefox forks resolve the same way.
6
+ */
7
+ export declare function isGecko(): boolean;
3
8
  export declare function roundedMs(value: number): number;
4
9
  /**
5
10
  * Converts CSS duration value to milliseconds.
package/dist/util.js CHANGED
@@ -4,6 +4,13 @@ export function clamp(value, min, max) {
4
4
  export function positiveModulo(value, divisor) {
5
5
  return ((value % divisor) + divisor) % divisor;
6
6
  }
7
+ /**
8
+ * Whether the page renders with Gecko (Firefox). Detects the engine by a feature only it ships, so
9
+ * spoofed user agents and Firefox forks resolve the same way.
10
+ */
11
+ export function isGecko() {
12
+ return typeof CSS !== "undefined" && CSS.supports("-moz-appearance", "none");
13
+ }
7
14
  export function roundedMs(value) {
8
15
  return Math.round(value * 10) / 10;
9
16
  }
package/dist/view.js CHANGED
@@ -15,6 +15,7 @@ import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine,
15
15
  import { refreshInstrumentalImages } from "./imageHighlights.js";
16
16
  import { createInstrumentalElement } from "./instrumental.js";
17
17
  import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
18
+ import { isGecko } from "./util.js";
18
19
  const INITIAL_SKIP_SCROLLS = 2;
19
20
  const SKIP_SCROLL_DECAY_MS = 2000;
20
21
  function buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex) {
@@ -73,6 +74,8 @@ export function setLyrics(engine, mount, lyrics, options) {
73
74
  const doc = engine.document;
74
75
  const container = doc.createElement("div");
75
76
  container.className = LYRICS_CLASS;
77
+ if (isGecko())
78
+ container.dataset.browserEngine = "gecko";
76
79
  mount.replaceChildren(container);
77
80
  const allZero = lyrics.every(item => item.startTimeMs === 0);
78
81
  const seek = (timeS) => engine.host.seek(timeS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.16.5",
3
+ "version": "1.16.7",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",