@braccato/core 1.14.0 → 1.15.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
@@ -345,14 +345,15 @@ refactor. Import them from `@braccato/core/constants` instead of typing them out
345
345
 
346
346
  ## Stylesheets
347
347
 
348
- Three sheets ship with the package, and loading them is yours, the way any package's CSS is. Leave
348
+ Four sheets ship with the package, and loading them is yours, the way any package's CSS is. Leave
349
349
  them out and you get lines that are in the document and unstyled, rather than lines that are missing.
350
350
 
351
351
  | File | What it carries |
352
352
  | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
353
- | `@braccato/core/styles/variables.css` | Every `--blyrics-*` default. It goes first, because the other two read from it. |
353
+ | `@braccato/core/styles/variables.css` | Every `--blyrics-*` default. It goes first, because the others read from it. |
354
354
  | `@braccato/core/styles/lyrics.css` | The container, the lines, the words and the sweep, plus two `@property` registrations the word animation interpolates through. |
355
355
  | `@braccato/core/styles/instrumental.css` | The waveform that fills a bar nobody sings over, and the animation that walks it. |
356
+ | `@braccato/core/styles/stage.css` | Placement for the stage layout, and nothing else. Only a renderer created with `layout: "stage"` needs it. |
356
357
 
357
358
  One thing they do not do for you. The module measures the room the first and last lines need to reach
358
359
  the view's target scroll position and writes it on the root as `--blyrics-padding-top` and
@@ -382,6 +383,26 @@ catch the layout up: the hung line floats into place while the lines around it s
382
383
  rather than the rest of the lyric jumping down. `--blyrics-animate-decoration-entry` set to `0` drops
383
384
  both for an instant insert, and reduced-motion does the same.
384
385
 
386
+ `createLyricsRenderer({ layout: "stage" })` builds a view for subtitles over a video: one line at a
387
+ time, and only while it is being sung. It fills its nearest positioned ancestor. There is no scroll
388
+ element to hand it, no scroll padding written and no line culling. Unsynced lyrics show nothing, because
389
+ a stage places lines by their time and unsynced lines have none.
390
+
391
+ The engine owns where a line is and whether you can see it. It moves each line with a `translate` Web
392
+ Animation and fades it through `--blyrics-stage-opacity`, blurring it slightly on the way in and out so
393
+ one line reads as turning into the next. Each line carries `data-stage-role` (`current`, `previous`,
394
+ `queued` or `gone`) and the container carries `data-layout="stage"`. `stage.css` sets a stage line's
395
+ `opacity` from that property with `!important` inside a cascade layer, and keeps the line
396
+ `visibility: hidden` until it is marked `data-stage-visible`. Layered important declarations outrank
397
+ unlayered ones whatever their specificity, so a theme that forces `opacity: 1 !important` on active
398
+ lines, which some do, can neither reveal a queued line nor hold one that is leaving. In a duet (any line sung by `v2` or `v3`) the
399
+ container also gets `data-stage-duet` and each singer keeps to their side, mirrored for right-to-left
400
+ lines; lines for everyone stay centred.
401
+
402
+ Everything else is the theme's. Lines are styled exactly as they would be in a scrolling view.
403
+ `host.onStageLayout(box)` reports the box around the sung lines in the container's coordinates, or null
404
+ when nothing sung is on stage, so you can draw a backdrop outside the container the theme styles.
405
+
385
406
  `@braccato/core/element` registers `<braccato-lyrics>`, and `<better-lyrics>` beside it, on import.
386
407
  Registration is a side effect, which is why it is entered separately.
387
408
 
@@ -429,7 +450,10 @@ swapping `audio.src` between songs without playing goes on reporting the old pos
429
450
 
430
451
  Two renderers in one document write over each other, so the module supports one. It is a constraint
431
452
  rather than a setting, and it is stated rather than enforced: none of the points where two of them
432
- collide is a crash.
453
+ collide is a crash. A stage renderer is the exception. It writes no scroll padding, so it can sit in
454
+ the same document as a scrolling one, with three rules: give both the same theme, tick both with the
455
+ same time and wall time in the same frame (whichever ticks first is the one that sees a seek), and if
456
+ the stage renderer applied the theme first, destroy it first, because its `<style>` element goes with it.
433
457
 
434
458
  Two things are written per document and belong to whichever renderer wrote them last: the theme's
435
459
  `<style>` element, and the scroll padding on the root. Two more are per bundle, because a settings
package/dist/README.md CHANGED
@@ -345,14 +345,15 @@ refactor. Import them from `@braccato/core/constants` instead of typing them out
345
345
 
346
346
  ## Stylesheets
347
347
 
348
- Three sheets ship with the package, and loading them is yours, the way any package's CSS is. Leave
348
+ Four sheets ship with the package, and loading them is yours, the way any package's CSS is. Leave
349
349
  them out and you get lines that are in the document and unstyled, rather than lines that are missing.
350
350
 
351
351
  | File | What it carries |
352
352
  | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
353
- | `@braccato/core/styles/variables.css` | Every `--blyrics-*` default. It goes first, because the other two read from it. |
353
+ | `@braccato/core/styles/variables.css` | Every `--blyrics-*` default. It goes first, because the others read from it. |
354
354
  | `@braccato/core/styles/lyrics.css` | The container, the lines, the words and the sweep, plus two `@property` registrations the word animation interpolates through. |
355
355
  | `@braccato/core/styles/instrumental.css` | The waveform that fills a bar nobody sings over, and the animation that walks it. |
356
+ | `@braccato/core/styles/stage.css` | Placement for the stage layout, and nothing else. Only a renderer created with `layout: "stage"` needs it. |
356
357
 
357
358
  One thing they do not do for you. The module measures the room the first and last lines need to reach
358
359
  the view's target scroll position and writes it on the root as `--blyrics-padding-top` and
@@ -382,6 +383,26 @@ catch the layout up: the hung line floats into place while the lines around it s
382
383
  rather than the rest of the lyric jumping down. `--blyrics-animate-decoration-entry` set to `0` drops
383
384
  both for an instant insert, and reduced-motion does the same.
384
385
 
386
+ `createLyricsRenderer({ layout: "stage" })` builds a view for subtitles over a video: one line at a
387
+ time, and only while it is being sung. It fills its nearest positioned ancestor. There is no scroll
388
+ element to hand it, no scroll padding written and no line culling. Unsynced lyrics show nothing, because
389
+ a stage places lines by their time and unsynced lines have none.
390
+
391
+ The engine owns where a line is and whether you can see it. It moves each line with a `translate` Web
392
+ Animation and fades it through `--blyrics-stage-opacity`, blurring it slightly on the way in and out so
393
+ one line reads as turning into the next. Each line carries `data-stage-role` (`current`, `previous`,
394
+ `queued` or `gone`) and the container carries `data-layout="stage"`. `stage.css` sets a stage line's
395
+ `opacity` from that property with `!important` inside a cascade layer, and keeps the line
396
+ `visibility: hidden` until it is marked `data-stage-visible`. Layered important declarations outrank
397
+ unlayered ones whatever their specificity, so a theme that forces `opacity: 1 !important` on active
398
+ lines, which some do, can neither reveal a queued line nor hold one that is leaving. In a duet (any line sung by `v2` or `v3`) the
399
+ container also gets `data-stage-duet` and each singer keeps to their side, mirrored for right-to-left
400
+ lines; lines for everyone stay centred.
401
+
402
+ Everything else is the theme's. Lines are styled exactly as they would be in a scrolling view.
403
+ `host.onStageLayout(box)` reports the box around the sung lines in the container's coordinates, or null
404
+ when nothing sung is on stage, so you can draw a backdrop outside the container the theme styles.
405
+
385
406
  `@braccato/core/element` registers `<braccato-lyrics>`, and `<better-lyrics>` beside it, on import.
386
407
  Registration is a side effect, which is why it is entered separately.
387
408
 
@@ -429,7 +450,10 @@ swapping `audio.src` between songs without playing goes on reporting the old pos
429
450
 
430
451
  Two renderers in one document write over each other, so the module supports one. It is a constraint
431
452
  rather than a setting, and it is stated rather than enforced: none of the points where two of them
432
- collide is a crash.
453
+ collide is a crash. A stage renderer is the exception. It writes no scroll padding, so it can sit in
454
+ the same document as a scrolling one, with three rules: give both the same theme, tick both with the
455
+ same time and wall time in the same frame (whichever ticks first is the one that sees a seek), and if
456
+ the stage renderer applied the theme first, destroy it first, because its `<style>` element goes with it.
433
457
 
434
458
  Two things are written per document and belong to whichever renderer wrote them last: the theme's
435
459
  `<style>` element, and the scroll padding on the root. Two more are per bundle, because a settings
package/dist/engine.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { LineData } from "./inject.js";
2
- import type { LyricsRendererHost, LyricSyncType, ResolvedTickOptions, TickOptions } from "./types.js";
2
+ import { type StageBox, type StageMetrics } from "./stage.js";
3
+ import type { LyricsLayout, LyricsRendererHost, LyricSyncType, ResolvedTickOptions, TickOptions } from "./types.js";
3
4
  /**
4
5
  * Everything that belongs to a single rendered lyrics view: one scroll container, one selection,
5
6
  * one set of pending scroll work.
@@ -69,6 +70,15 @@ export interface AnimationEngineInstance extends AnimEngineViewState {
69
70
  pendingLyricsUpdateFrame: number | null;
70
71
  learnedAnimationTimingOffsetMs: number;
71
72
  animationTimingVisibilityLogUntil: number;
73
+ layout: LyricsLayout;
74
+ stageKey: string;
75
+ stageBox: StageBox | null;
76
+ stageMetrics: Map<HTMLElement, StageMetrics>;
77
+ stageY: Map<HTMLElement, number>;
78
+ stageMoves: Map<HTMLElement, Animation>;
79
+ stageFades: Map<HTMLElement, Animation>;
80
+ stageBlurs: Map<HTMLElement, Animation>;
81
+ stageFontSize: number;
72
82
  /**
73
83
  * Releases everything the instance holds on its window: the reduced motion listener, the tab
74
84
  * renderer observer and any frame it still has queued.
@@ -80,7 +90,7 @@ export interface AnimationEngineInstance extends AnimEngineViewState {
80
90
  * deliberately not used inside the tick, which runs per frame and per line.
81
91
  */
82
92
  export declare function forEveryLiveView(runOperation: (engine: AnimationEngineInstance) => void): void;
83
- export declare function createAnimationEngineInstance(engineDocument: Document, engineWindow: EngineWindow, host: LyricsRendererHost): AnimationEngineInstance;
93
+ export declare function createAnimationEngineInstance(engineDocument: Document, engineWindow: EngineWindow, host: LyricsRendererHost, layout?: LyricsLayout): AnimationEngineInstance;
84
94
  /**
85
95
  * Forgets the last player snapshot, so the next tick is treated as the first one of a new song
86
96
  * rather than as a jump away from the previous one.
@@ -338,6 +348,7 @@ export type AnimationTickStatus = "ok" | "lyrics-missing";
338
348
  * matching no line at all.
339
349
  */
340
350
  export declare function resolveTickOptions(options: TickOptions): ResolvedTickOptions;
351
+ export declare function measureStage(engine: AnimationEngineInstance): void;
341
352
  /**
342
353
  * Renders one view against a tick with nothing left out.
343
354
  */
package/dist/engine.js CHANGED
@@ -18,6 +18,7 @@
18
18
  // that never meet. `themeSettings.ts` holds the third thing under that rule.
19
19
  import { ANIMATING_CLASS, CREDITS_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, WORD_STATE_ACTIVE, WORD_STATE_ATTR, WORD_STATE_PAST, WORD_STATE_UPCOMING, } from "./constants.js";
20
20
  import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
21
+ import { layoutStage, planStage } from "./stage.js";
21
22
  import { registerThemeSetting } from "./themeSettings.js";
22
23
  import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
23
24
  const NO_LYRICS_ELEMENT_LOG = "No lyrics element found on the page, skipping lyrics injection";
@@ -101,7 +102,7 @@ export function forEveryLiveView(runOperation) {
101
102
  runOperation(engine);
102
103
  }
103
104
  }
104
- export function createAnimationEngineInstance(engineDocument, engineWindow, host) {
105
+ export function createAnimationEngineInstance(engineDocument, engineWindow, host, layout = "scroll") {
105
106
  const reducedMotionQuery = engineWindow.matchMedia(REDUCED_MOTION_QUERY);
106
107
  const handleReducedMotionChange = () => clearStyleCaches(engine);
107
108
  const engine = {
@@ -153,6 +154,15 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
153
154
  pendingLyricsUpdateFrame: null,
154
155
  learnedAnimationTimingOffsetMs: 0,
155
156
  animationTimingVisibilityLogUntil: 0,
157
+ layout,
158
+ stageKey: "",
159
+ stageBox: null,
160
+ stageMetrics: new Map(),
161
+ stageY: new Map(),
162
+ stageMoves: new Map(),
163
+ stageFades: new Map(),
164
+ stageBlurs: new Map(),
165
+ stageFontSize: 16,
156
166
  destroy: () => {
157
167
  liveEngines.delete(engine);
158
168
  reducedMotionQuery.removeEventListener("change", handleReducedMotionChange);
@@ -242,6 +252,7 @@ export function clearOnScreenLyrics(engine) {
242
252
  if (!engine.lyricsContainer)
243
253
  return false;
244
254
  engine.lyricsContainer.replaceChildren();
255
+ resetStage(engine);
245
256
  return true;
246
257
  }
247
258
  export function hasRenderedLines(engine) {
@@ -289,6 +300,26 @@ export function clearLyrics(engine) {
289
300
  engine.creditsFocused = false;
290
301
  engine.lyricsContainer = null;
291
302
  engine.waveAnimationPool.length = 0;
303
+ resetStage(engine);
304
+ }
305
+ function resetStage(engine) {
306
+ for (const animation of [
307
+ ...engine.stageMoves.values(),
308
+ ...engine.stageFades.values(),
309
+ ...engine.stageBlurs.values(),
310
+ ]) {
311
+ animation.cancel();
312
+ }
313
+ engine.stageMoves.clear();
314
+ engine.stageFades.clear();
315
+ engine.stageBlurs.clear();
316
+ engine.stageMetrics.clear();
317
+ engine.stageY.clear();
318
+ engine.stageKey = "";
319
+ if (engine.stageBox !== null) {
320
+ engine.stageBox = null;
321
+ engine.host.onStageLayout?.(null);
322
+ }
292
323
  }
293
324
  function resetPartAnimations(part) {
294
325
  for (const animation of part.animations) {
@@ -1485,6 +1516,10 @@ function clearOffscreenLineCulling(engine) {
1485
1516
  export function setupLineCullObserver(engine) {
1486
1517
  engine.lineCullObserver?.disconnect();
1487
1518
  clearOffscreenLineCulling(engine);
1519
+ if (engine.layout === "stage") {
1520
+ engine.lineCullObserver = null;
1521
+ return;
1522
+ }
1488
1523
  const ObserverConstructor = engine.window.IntersectionObserver;
1489
1524
  if (typeof ObserverConstructor !== "function" || engine.lines.length === 0) {
1490
1525
  engine.lineCullObserver = null;
@@ -1901,6 +1936,181 @@ export function resolveTickOptions(options) {
1901
1936
  function resolvePlaybackRate(rate) {
1902
1937
  return rate !== undefined && Number.isFinite(rate) && rate > 0 ? rate : 1;
1903
1938
  }
1939
+ // -- Stage layout --------------------------------------------
1940
+ const STAGE_MOTION = { durationMs: 380, easing: "cubic-bezier(0.2, 0, 0, 1)" };
1941
+ // Read by an important rule in stage.css, so a theme's own important opacity cannot reveal a line
1942
+ // the stage is hiding or fading.
1943
+ const STAGE_OPACITY_PROPERTY = "--blyrics-stage-opacity";
1944
+ const stageOpacityRegistrations = new WeakSet();
1945
+ const STAGE_FADE_IN_MS = 300;
1946
+ const STAGE_FADE_IN_DELAY_MS = 70;
1947
+ const STAGE_FADE_OUT_MS = 220;
1948
+ // Lines blur out and in across a handoff, so one reads as turning into the next.
1949
+ const STAGE_BLUR = "blur(3px)";
1950
+ const STAGE_GAP_EM = 0.32;
1951
+ function stageElements(engine) {
1952
+ const elements = [];
1953
+ const items = [];
1954
+ let lastEnd = 0;
1955
+ for (const line of engine.lines) {
1956
+ const end = line.time + line.duration;
1957
+ lastEnd = Math.max(lastEnd, end);
1958
+ elements.push(line.lyricElement);
1959
+ items.push({
1960
+ kind: line.lyricElement.dataset.instrumental === "true" ? "instrumental" : "line",
1961
+ start: line.time,
1962
+ end,
1963
+ });
1964
+ }
1965
+ const credits = engine.lyricsContainer?.querySelector(`:scope > .${CREDITS_CLASS}`);
1966
+ if (credits) {
1967
+ elements.push(credits);
1968
+ items.push({ kind: "credits", start: lastEnd, end: Number.POSITIVE_INFINITY });
1969
+ }
1970
+ return { elements, items };
1971
+ }
1972
+ function originXOf(engine, element) {
1973
+ const align = engine.window.getComputedStyle(element).textAlign;
1974
+ if (align === "center")
1975
+ return 0.5;
1976
+ if (align === "right" || align === "end")
1977
+ return 1;
1978
+ return 0;
1979
+ }
1980
+ export function measureStage(engine) {
1981
+ engine.stageMetrics.clear();
1982
+ const container = engine.lyricsContainer;
1983
+ if (!container)
1984
+ return;
1985
+ engine.stageFontSize = Number.parseFloat(engine.window.getComputedStyle(container).fontSize) || 16;
1986
+ for (const element of stageElements(engine).elements) {
1987
+ engine.stageMetrics.set(element, {
1988
+ height: element.offsetHeight,
1989
+ left: element.offsetLeft,
1990
+ width: element.offsetWidth,
1991
+ originX: originXOf(engine, element),
1992
+ });
1993
+ }
1994
+ engine.stageKey = "";
1995
+ }
1996
+ function currentTranslateY(engine, element, fallback) {
1997
+ const parts = engine.window.getComputedStyle(element).translate.split(" ");
1998
+ const y = Number.parseFloat(parts[1] ?? "");
1999
+ return Number.isFinite(y) ? y : fallback;
2000
+ }
2001
+ function placeStageElement(engine, { element, placement, wasVisible, fromY, fromOpacity }, instant, reduced) {
2002
+ engine.stageMoves.get(element)?.cancel();
2003
+ engine.stageFades.get(element)?.cancel();
2004
+ const moveMs = instant || reduced ? 0 : STAGE_MOTION.durationMs;
2005
+ engine.stageMoves.set(element, element.animate([{ translate: `0 ${fromY}px` }, { translate: `0 ${placement.y}px` }], {
2006
+ duration: moveMs,
2007
+ easing: STAGE_MOTION.easing,
2008
+ fill: "forwards",
2009
+ }));
2010
+ const fadeMs = instant ? 0 : placement.visible ? STAGE_FADE_IN_MS : STAGE_FADE_OUT_MS;
2011
+ if (placement.visible)
2012
+ element.dataset.stageVisible = "";
2013
+ else if (fadeMs === 0)
2014
+ delete element.dataset.stageVisible;
2015
+ const fade = element.animate([{ [STAGE_OPACITY_PROPERTY]: fromOpacity }, { [STAGE_OPACITY_PROPERTY]: placement.visible ? 1 : 0 }], {
2016
+ duration: fadeMs,
2017
+ delay: placement.visible && !instant ? STAGE_FADE_IN_DELAY_MS : 0,
2018
+ easing: placement.visible ? "ease-out" : "ease",
2019
+ // Backwards too: the fade replaces one that was cancelled, and through its delay the line would
2020
+ // otherwise drop to the stylesheet's resting opacity of zero.
2021
+ fill: "both",
2022
+ });
2023
+ if (!placement.visible && fadeMs > 0) {
2024
+ fade.onfinish = () => {
2025
+ if (engine.stageFades.get(element) === fade)
2026
+ delete element.dataset.stageVisible;
2027
+ };
2028
+ }
2029
+ engine.stageFades.set(element, fade);
2030
+ engine.stageY.set(element, placement.y);
2031
+ engine.stageBlurs.get(element)?.cancel();
2032
+ engine.stageBlurs.delete(element);
2033
+ if (fadeMs === 0 || reduced || wasVisible === placement.visible)
2034
+ return;
2035
+ const blurs = ["none", STAGE_BLUR];
2036
+ engine.stageBlurs.set(element, element.animate((placement.visible ? blurs.reverse() : blurs).map(filter => ({ filter })), {
2037
+ duration: fadeMs,
2038
+ delay: placement.visible ? STAGE_FADE_IN_DELAY_MS : 0,
2039
+ easing: placement.visible ? "ease-out" : "ease",
2040
+ // Added to the theme's own filter rather than replacing it, and held only until the line is in focus.
2041
+ composite: "add",
2042
+ fill: placement.visible ? "backwards" : "forwards",
2043
+ }));
2044
+ }
2045
+ // Registered from script rather than with @property, which Firefox ignores in a stylesheet
2046
+ // cross-origin to the document. Unregistered, the fade would snap instead of interpolating.
2047
+ function registerStageOpacity(engine) {
2048
+ const css = engine.window.CSS;
2049
+ if (stageOpacityRegistrations.has(engine.window) || typeof css?.registerProperty !== "function")
2050
+ return;
2051
+ stageOpacityRegistrations.add(engine.window);
2052
+ try {
2053
+ css.registerProperty({ name: STAGE_OPACITY_PROPERTY, syntax: "<number>", inherits: false, initialValue: "0" });
2054
+ }
2055
+ catch (error) {
2056
+ engine.host.log("Stage opacity property was already registered", error);
2057
+ }
2058
+ }
2059
+ function isStageRoleVisible(role) {
2060
+ return role !== "queued" && role !== "gone";
2061
+ }
2062
+ function sameBox(a, b) {
2063
+ if (a === null || b === null)
2064
+ return a === b;
2065
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
2066
+ }
2067
+ function applyStage(engine, timeS, instant) {
2068
+ const container = engine.lyricsContainer;
2069
+ if (!container)
2070
+ return;
2071
+ registerStageOpacity(engine);
2072
+ const { elements, items } = stageElements(engine);
2073
+ const roles = planStage(items, timeS);
2074
+ const key = roles.join(",");
2075
+ if (key === engine.stageKey && !instant)
2076
+ return;
2077
+ engine.stageKey = key;
2078
+ const metrics = elements.map(element => engine.stageMetrics.get(element) ?? { height: 0, left: 0, width: 0, originX: 0 });
2079
+ const { placements, box } = layoutStage(roles, items, metrics, elements.map(element => engine.stageY.get(element) ?? null), {
2080
+ stageHeight: container.clientHeight,
2081
+ gap: engine.stageFontSize * STAGE_GAP_EM,
2082
+ activeScale: getCSSNumber(engine, container, "--blyrics-active-scale", 1),
2083
+ });
2084
+ // Only the elements whose place changed move. A line that stays where it is keeps its running
2085
+ // animations, and one already hidden stays hidden without being animated again.
2086
+ const moves = [];
2087
+ elements.forEach((element, index) => {
2088
+ const placement = placements[index];
2089
+ const previousY = engine.stageY.get(element);
2090
+ const wasVisible = element.dataset.stageRole !== undefined && isStageRoleVisible(element.dataset.stageRole);
2091
+ const unchanged = previousY !== undefined && wasVisible === placement.visible && (!placement.visible || previousY === placement.y);
2092
+ if (unchanged && !instant)
2093
+ return;
2094
+ moves.push({
2095
+ element,
2096
+ placement,
2097
+ wasVisible,
2098
+ fromY: currentTranslateY(engine, element, previousY ?? placement.y),
2099
+ fromOpacity: Number(engine.window.getComputedStyle(element).opacity) || 0,
2100
+ });
2101
+ });
2102
+ const reduced = engine.window.matchMedia(REDUCED_MOTION_QUERY).matches;
2103
+ elements.forEach((element, index) => {
2104
+ if (element.dataset.stageRole !== roles[index])
2105
+ element.dataset.stageRole = roles[index];
2106
+ });
2107
+ for (const move of moves)
2108
+ placeStageElement(engine, move, instant, reduced);
2109
+ if (!sameBox(box, engine.stageBox)) {
2110
+ engine.stageBox = box;
2111
+ engine.host.onStageLayout?.(box);
2112
+ }
2113
+ }
1904
2114
  /**
1905
2115
  * Renders one view against a tick with nothing left out.
1906
2116
  */
@@ -1920,6 +2130,9 @@ export function tickView(engine, currentTime, options) {
1920
2130
  return "ok";
1921
2131
  }
1922
2132
  if (hasUnsyncedLyrics(engine)) {
2133
+ // The stage shows lines by their time, and unsynced lines have none.
2134
+ if (engine.layout === "stage")
2135
+ return "ok";
1923
2136
  if (!playbackClock.lastPlayState && isPlaying) {
1924
2137
  engine.scrollResumeTime = 0;
1925
2138
  }
@@ -1969,19 +2182,22 @@ export function tickView(engine, currentTime, options) {
1969
2182
  getCSSDurationInMs(engine, lyricsElement, "--blyrics-scroll-timing-offset") / 1000;
1970
2183
  const { config: animationConfig, scrollTiming } = getAnimationSettings(engine, lyricsElement);
1971
2184
  // Read layout values before the loop writes class changes, to avoid forced reflow
1972
- const tabRenderer = engine.host.getScrollElement();
1973
- if (!tabRenderer) {
2185
+ const isStage = engine.layout === "stage";
2186
+ const tabRenderer = isStage ? null : engine.host.getScrollElement();
2187
+ if (!isStage && !tabRenderer) {
1974
2188
  clearVisibleLyricWillChange(engine);
1975
2189
  return "ok";
1976
2190
  }
1977
- if (tabRenderer !== engine.observedTabRenderer) {
2191
+ if (tabRenderer && tabRenderer !== engine.observedTabRenderer) {
1978
2192
  setupTabRendererObserver(engine, tabRenderer);
1979
2193
  }
1980
- const tabRendererHeight = engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height;
2194
+ const tabRendererHeight = tabRenderer
2195
+ ? (engine.cachedTabRendererHeight ?? tabRenderer.getBoundingClientRect().height)
2196
+ : 0;
1981
2197
  // Read before the loop's class writes so this layout read is batched and forces no mid-frame reflow.
1982
- let scrollTop = tabRenderer.scrollTop;
1983
- const maxScrollTop = Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight);
1984
- if (animationConfig.enabled.scroll) {
2198
+ let scrollTop = tabRenderer?.scrollTop ?? 0;
2199
+ const maxScrollTop = tabRenderer ? Math.max(0, tabRenderer.scrollHeight - tabRenderer.clientHeight) : 0;
2200
+ if (!isStage && animationConfig.enabled.scroll) {
1985
2201
  updateVisibleLyricWillChange(engine, lines, scrollTop, engine.pendingLineScroll?.toScrollTop ?? scrollTop, tabRendererHeight);
1986
2202
  }
1987
2203
  else {
@@ -2124,6 +2340,11 @@ export function tickView(engine, currentTime, options) {
2124
2340
  delete lyricsElement.dataset.creditsFocused;
2125
2341
  newLyricSelected = true;
2126
2342
  }
2343
+ if (isStage || !tabRenderer) {
2344
+ if (isStage)
2345
+ applyStage(engine, currentTime, timeJumped);
2346
+ return "ok";
2347
+ }
2127
2348
  if (engine.scrollResumeTime < Date.now() || engine.scrollPos === -1) {
2128
2349
  if (activeElems.length == 0) {
2129
2350
  activeElems.push(lines[0]);
@@ -2352,6 +2573,13 @@ function applyScrollPadding(engine) {
2352
2573
  * nothing to work from once rendering resumes.
2353
2574
  */
2354
2575
  export function relayout(engine, measureLines) {
2576
+ if (engine.layout === "stage") {
2577
+ if (!measureLines || !engine.lyricsContainer)
2578
+ return;
2579
+ engine.cachedCreditsItem = measureTrailingItem(engine.lyricsContainer, CREDITS_CLASS);
2580
+ measureStage(engine);
2581
+ return;
2582
+ }
2355
2583
  applyScrollPadding(engine);
2356
2584
  const scrollElement = engine.host.getScrollElement();
2357
2585
  if (scrollElement)
@@ -2408,7 +2636,7 @@ function lineDecorators(lineElement) {
2408
2636
  */
2409
2637
  function captureDecorationSlide(engine) {
2410
2638
  const container = engine.lyricsContainer;
2411
- if (!container || !decorationSlideAllowed(engine, container))
2639
+ if (!container || engine.layout === "stage" || !decorationSlideAllowed(engine, container))
2412
2640
  return null;
2413
2641
  const before = engine.lines.map(line => ({
2414
2642
  line,
package/dist/index.d.ts CHANGED
@@ -10,3 +10,7 @@ export type { PartData } from "./inject.js";
10
10
  export type { LyricPart, LyricsRendererDebugSink, LyricsRendererOptions, LyricSyncType } from "./types.js";
11
11
  /** @public */
12
12
  export type { SetLyricsOptions } from "./view.js";
13
+ /** @public */
14
+ export type { LyricsLayout } from "./types.js";
15
+ /** @public */
16
+ export type { StageBox } from "./stage.js";
@@ -6,7 +6,6 @@ export declare const INSTRUMENTAL_WAVE_PATH_LOW = "M -4 3 Q 1 4 5 3 Q 10 2 14 3
6
6
  * @param doc - Document the SVG nodes are created in
7
7
  * @param container - Element to place instrumental parts into
8
8
  * @param durationMs - Duration of the instrumental break in milliseconds
9
- * @param lineIndex - Line index for unique SVG element IDs
10
9
  * @returns HTMLDivElement representing the instrumental break
11
10
  */
12
- export declare function createInstrumentalElement(doc: Document, container: HTMLDivElement, durationMs: number, lineIndex: number): HTMLDivElement;
11
+ export declare function createInstrumentalElement(doc: Document, container: HTMLDivElement, durationMs: number): HTMLDivElement;
@@ -1,16 +1,16 @@
1
1
  import { imageHighlights } from "./imageHighlights.js";
2
2
  export const INSTRUMENTAL_WAVE_PATH_HIGH = "M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z";
3
3
  export const INSTRUMENTAL_WAVE_PATH_LOW = "M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z";
4
+ let nextInstrumentalId = 0;
4
5
  /**
5
6
  * Creates an HTML element representing an instrumental break in the lyrics.
6
7
  *
7
8
  * @param doc - Document the SVG nodes are created in
8
9
  * @param container - Element to place instrumental parts into
9
10
  * @param durationMs - Duration of the instrumental break in milliseconds
10
- * @param lineIndex - Line index for unique SVG element IDs
11
11
  * @returns HTMLDivElement representing the instrumental break
12
12
  */
13
- export function createInstrumentalElement(doc, container, durationMs, lineIndex) {
13
+ export function createInstrumentalElement(doc, container, durationMs) {
14
14
  container.classList.add("blyrics--instrumental");
15
15
  container.style.setProperty("--blyrics-duration", `${durationMs}ms`);
16
16
  const svgNS = "http://www.w3.org/2000/svg";
@@ -18,8 +18,11 @@ export function createInstrumentalElement(doc, container, durationMs, lineIndex)
18
18
  svg.classList.add("blyrics--instrumental-icon");
19
19
  svg.setAttribute("viewBox", "0 0 24 24");
20
20
  const defs = doc.createElementNS(svgNS, "defs");
21
- const filterId = `blyrics-glow-${lineIndex}`;
22
- const clipId = `blyrics-wave-clip-${lineIndex}`;
21
+ // Unique per document, not per view: two renderers sharing a document would otherwise resolve
22
+ // each other's url(#id) references.
23
+ const idSuffix = nextInstrumentalId++;
24
+ const filterId = `blyrics-glow-${idSuffix}`;
25
+ const clipId = `blyrics-wave-clip-${idSuffix}`;
23
26
  const filter = doc.createElementNS(svgNS, "filter");
24
27
  filter.setAttribute("id", filterId);
25
28
  filter.setAttribute("x", "-100%");
package/dist/renderer.js CHANGED
@@ -112,6 +112,7 @@ export function withHostDefaults(overrides, rendererWindow, currentMount) {
112
112
  currentMount()?.dispatchEvent(new rendererWindow.CustomEvent(SEEK_EVENT, { detail: timeS, bubbles: true }));
113
113
  }),
114
114
  log: given.log ?? noop,
115
+ onStageLayout: given.onStageLayout,
115
116
  debug: given.debug,
116
117
  },
117
118
  forgetScrollElement() {
@@ -139,7 +140,7 @@ export function createLyricsRenderer(rendererOptions) {
139
140
  let currentLyrics = [];
140
141
  let currentLanguage = "";
141
142
  const { host, forgetScrollElement } = withHostDefaults(rendererOptions.host, rendererWindow, () => mount);
142
- const engine = createAnimationEngineInstance(rendererDocument, rendererWindow, host);
143
+ const engine = createAnimationEngineInstance(rendererDocument, rendererWindow, host, rendererOptions.layout ?? "scroll");
143
144
  let imageMediaQueries = [];
144
145
  const refreshImagePaint = () => refreshInstrumentalImages(engine.lyricsContainer, rendererWindow);
145
146
  function syncImagePaint() {
@@ -0,0 +1,41 @@
1
+ export type StageItemKind = "line" | "instrumental" | "credits";
2
+ export type StageRole = "current" | "previous" | "queued" | "gone";
3
+ export interface StageItem {
4
+ kind: StageItemKind;
5
+ /** Seconds. */
6
+ start: number;
7
+ /** Seconds. The credits hold the stage until the song ends, so theirs is infinite. */
8
+ end: number;
9
+ }
10
+ export declare const STAGE_LEAD_S = 0.5;
11
+ export declare const STAGE_HANDOFF_S = 0.12;
12
+ export declare const STAGE_OVERLAP_S = 0.15;
13
+ export declare function overlapsPrevious(items: readonly StageItem[], index: number): boolean;
14
+ export declare function stageEnterTimes(items: readonly StageItem[]): number[];
15
+ export declare function planStage(items: readonly StageItem[], timeS: number): StageRole[];
16
+ export interface StageMetrics {
17
+ height: number;
18
+ left: number;
19
+ width: number;
20
+ /** Where the item scales from horizontally: 0 left, 0.5 centre, 1 right. */
21
+ originX: number;
22
+ }
23
+ export interface StageGeometry {
24
+ stageHeight: number;
25
+ gap: number;
26
+ activeScale: number;
27
+ }
28
+ export interface StagePlacement {
29
+ y: number;
30
+ visible: boolean;
31
+ }
32
+ export interface StageBox {
33
+ x: number;
34
+ y: number;
35
+ width: number;
36
+ height: number;
37
+ }
38
+ export declare function layoutStage(roles: readonly StageRole[], items: readonly StageItem[], metrics: readonly StageMetrics[], previousY: readonly (number | null)[], geometry: StageGeometry): {
39
+ placements: StagePlacement[];
40
+ box: StageBox | null;
41
+ };
package/dist/stage.js ADDED
@@ -0,0 +1,76 @@
1
+ export const STAGE_LEAD_S = 0.5;
2
+ export const STAGE_HANDOFF_S = 0.12;
3
+ export const STAGE_OVERLAP_S = 0.15;
4
+ export function overlapsPrevious(items, index) {
5
+ const previous = items[index - 1];
6
+ return previous !== undefined && previous.kind === "line" && items[index].start < previous.end - STAGE_OVERLAP_S;
7
+ }
8
+ export function stageEnterTimes(items) {
9
+ return items.map((item, index) => {
10
+ const early = item.start - STAGE_LEAD_S;
11
+ const previous = items[index - 1];
12
+ if (previous?.kind !== "line" || overlapsPrevious(items, index))
13
+ return early;
14
+ return Math.max(early, Math.min(previous.end, item.start) - STAGE_HANDOFF_S);
15
+ });
16
+ }
17
+ export function planStage(items, timeS) {
18
+ const enterTimes = stageEnterTimes(items);
19
+ let current = -1;
20
+ enterTimes.forEach((enterTime, index) => {
21
+ if (enterTime <= timeS)
22
+ current = index;
23
+ });
24
+ const roles = items.map((_, index) => index < current ? "gone" : index > current ? "queued" : "current");
25
+ if (current < 0)
26
+ return roles;
27
+ const previous = items[current - 1];
28
+ if (previous?.kind === "line" && overlapsPrevious(items, current) && timeS < previous.end) {
29
+ roles[current - 1] = "previous";
30
+ }
31
+ return roles;
32
+ }
33
+ function unionBox(box, next) {
34
+ if (box === null)
35
+ return next;
36
+ const x = Math.min(box.x, next.x);
37
+ const y = Math.min(box.y, next.y);
38
+ return {
39
+ x,
40
+ y,
41
+ width: Math.max(box.x + box.width, next.x + next.width) - x,
42
+ height: Math.max(box.y + box.height, next.y + next.height) - y,
43
+ };
44
+ }
45
+ export function layoutStage(roles, items, metrics, previousY, geometry) {
46
+ const { stageHeight, gap } = geometry;
47
+ const current = roles.indexOf("current");
48
+ const currentY = current >= 0 ? stageHeight - metrics[current].height : stageHeight;
49
+ const placements = roles.map((role, index) => {
50
+ const { height } = metrics[index];
51
+ switch (role) {
52
+ case "current":
53
+ return { y: currentY, visible: true };
54
+ case "previous":
55
+ return { y: currentY - gap - height, visible: true };
56
+ case "queued":
57
+ return { y: stageHeight - height + gap, visible: false };
58
+ case "gone":
59
+ return { y: previousY[index] ?? currentY, visible: false };
60
+ }
61
+ });
62
+ let box = null;
63
+ roles.forEach((role, index) => {
64
+ if ((role !== "current" && role !== "previous") || items[index].kind === "instrumental")
65
+ return;
66
+ const { height, left, width, originX } = metrics[index];
67
+ const scale = geometry.activeScale;
68
+ box = unionBox(box, {
69
+ x: left + width * originX * (1 - scale),
70
+ y: placements[index].y + (height * (1 - scale)) / 2,
71
+ width: width * scale,
72
+ height: height * scale,
73
+ });
74
+ });
75
+ return { placements, box };
76
+ }
@@ -0,0 +1,57 @@
1
+ /* Stage layout: lines absolutely placed by the engine, sized to their text. Only layout is set here;
2
+ colour, type and animation stay with lyrics.css and the theme. */
3
+ .blyrics-container[data-layout="stage"] {
4
+ position: absolute !important;
5
+ inset: 0;
6
+ padding: 0 !important;
7
+ overflow: visible;
8
+ pointer-events: none;
9
+ }
10
+
11
+ .blyrics-container[data-layout="stage"] > :is(.blyrics--line, .blyrics-credits) {
12
+ position: absolute !important;
13
+ top: 0;
14
+ right: 0;
15
+ left: 0;
16
+ width: fit-content;
17
+ max-width: 100%;
18
+ margin: 0 auto !important;
19
+ padding: 0.1em 0.25em !important;
20
+ }
21
+
22
+ /* Layered, so these important declarations outrank any unlayered important one a theme writes,
23
+ whatever its specificity: the stage alone decides which lines are seen. */
24
+ @layer braccato-stage {
25
+ .blyrics-container[data-layout="stage"] > :is(.blyrics--line, .blyrics-credits) {
26
+ opacity: var(--blyrics-stage-opacity, 0) !important;
27
+ }
28
+
29
+ .blyrics-container[data-layout="stage"] > :is(.blyrics--line, .blyrics-credits):not([data-stage-visible]) {
30
+ visibility: hidden !important;
31
+ }
32
+ }
33
+
34
+ .blyrics-container[data-layout="stage"] > :not(.blyrics--line, .blyrics-credits) {
35
+ display: none !important;
36
+ }
37
+
38
+ .blyrics-container[data-layout="stage"] > * {
39
+ text-align: center;
40
+ transform-origin: center center !important;
41
+ }
42
+
43
+ /* A duet puts each singer on their own side, mirrored for right-to-left lines; everyone together
44
+ stays centred. */
45
+ .blyrics-container[data-layout="stage"][data-stage-duet] > .blyrics--line:not([data-agent="v2"], [data-agent="v3"], [data-agent="v1000"]):not([data-direction="rtl"]),
46
+ .blyrics-container[data-layout="stage"][data-stage-duet] > .blyrics--line:is([data-agent="v2"], [data-agent="v3"])[data-direction="rtl"] {
47
+ margin: 0 auto 0 0 !important;
48
+ text-align: left;
49
+ transform-origin: left center !important;
50
+ }
51
+
52
+ .blyrics-container[data-layout="stage"][data-stage-duet] > .blyrics--line:is([data-agent="v2"], [data-agent="v3"]):not([data-direction="rtl"]),
53
+ .blyrics-container[data-layout="stage"][data-stage-duet] > .blyrics--line:not([data-agent="v2"], [data-agent="v3"], [data-agent="v1000"])[data-direction="rtl"] {
54
+ margin: 0 0 0 auto !important;
55
+ text-align: right;
56
+ transform-origin: right center !important;
57
+ }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Lyric, LyricPart, LyricSyncType } from "@braccato/types";
2
2
  import type { AnimationTickStatus } from "./engine.js";
3
3
  import type { LineData } from "./inject.js";
4
+ import type { StageBox } from "./stage.js";
4
5
  import type { SetLyricsOptions } from "./view.js";
5
6
  export type { Lyric, LyricPart, LyricSyncType };
6
7
  /**
@@ -77,8 +78,15 @@ export interface LyricsRendererHost {
77
78
  * Where the view's diagnostics go. The host owns the prefix and whether logging is on at all.
78
79
  */
79
80
  log(...args: unknown[]): void;
81
+ /**
82
+ * Stage layout only. Where the lines being sung sit, in the container's coordinates, or null when
83
+ * nothing sung is on stage. Called when it changes. A consumer draws its own backdrop from it,
84
+ * outside the container that themes style.
85
+ */
86
+ onStageLayout?(box: StageBox | null): void;
80
87
  debug?: LyricsRendererDebugSink;
81
88
  }
89
+ export type LyricsLayout = "scroll" | "stage";
82
90
  export interface LyricsRendererOptions {
83
91
  document: Document;
84
92
  window: Window;
@@ -92,6 +100,8 @@ export interface LyricsRendererOptions {
92
100
  * nothing at all.
93
101
  */
94
102
  host?: Partial<LyricsRendererHost>;
103
+ /** `"scroll"` (default) is the scrolling lyrics view. `"stage"` shows only what is being sung. */
104
+ layout?: LyricsLayout;
95
105
  }
96
106
  /**
97
107
  * One rendered lyrics view, and everything it takes to keep it measured. The consumer owns the
package/dist/view.js CHANGED
@@ -18,7 +18,7 @@ import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
18
18
  const INITIAL_SKIP_SCROLLS = 2;
19
19
  const SKIP_SCROLL_DECAY_MS = 2000;
20
20
  function buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex) {
21
- createInstrumentalElement(doc, lyricElement, lyrics[lineIndex].durationMs, lineIndex);
21
+ createInstrumentalElement(doc, lyricElement, lyrics[lineIndex].durationMs);
22
22
  lyricElement.dataset.instrumental = "true";
23
23
  const agent = findNearestAgent(lyrics, lineIndex);
24
24
  if (agent) {
@@ -106,6 +106,12 @@ export function setLyrics(engine, mount, lyrics, options) {
106
106
  engine.skipScrollsDecayTimes = Array.from({ length: INITIAL_SKIP_SCROLLS }, () => Date.now() + SKIP_SCROLL_DECAY_MS);
107
107
  engine.scrollResumeTime = 0;
108
108
  container.dataset.sync = syncType;
109
+ if (engine.layout === "stage") {
110
+ container.dataset.layout = "stage";
111
+ if (lyrics.some(item => item.agent === "v2" || item.agent === "v3")) {
112
+ container.dataset.stageDuet = "";
113
+ }
114
+ }
109
115
  container.dataset.loaderVisible = String(options.loaderVisible);
110
116
  if (options.noLyrics) {
111
117
  container.dataset.noLyrics = "true";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",