@braccato/core 1.14.0 → 1.15.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 +27 -3
- package/dist/README.md +27 -3
- package/dist/engine.d.ts +13 -2
- package/dist/engine.js +237 -9
- package/dist/index.d.ts +4 -0
- package/dist/instrumental.d.ts +1 -2
- package/dist/instrumental.js +7 -4
- package/dist/renderer.js +2 -1
- package/dist/stage.d.ts +41 -0
- package/dist/stage.js +79 -0
- package/dist/styles/stage.css +57 -0
- package/dist/types.d.ts +10 -0
- package/dist/view.js +7 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1973
|
-
|
|
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 =
|
|
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
|
|
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";
|
package/dist/instrumental.d.ts
CHANGED
|
@@ -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
|
|
11
|
+
export declare function createInstrumentalElement(doc: Document, container: HTMLDivElement, durationMs: number): HTMLDivElement;
|
package/dist/instrumental.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
22
|
-
|
|
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() {
|
package/dist/stage.d.ts
ADDED
|
@@ -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,79 @@
|
|
|
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
|
+
// An outro note would otherwise keep the credits off stage until the song ends.
|
|
13
|
+
if (item.kind === "credits" && previous?.kind === "instrumental")
|
|
14
|
+
return (previous.start + previous.end) / 2;
|
|
15
|
+
if (previous?.kind !== "line" || overlapsPrevious(items, index))
|
|
16
|
+
return early;
|
|
17
|
+
return Math.max(early, Math.min(previous.end, item.start) - STAGE_HANDOFF_S);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function planStage(items, timeS) {
|
|
21
|
+
const enterTimes = stageEnterTimes(items);
|
|
22
|
+
let current = -1;
|
|
23
|
+
enterTimes.forEach((enterTime, index) => {
|
|
24
|
+
if (enterTime <= timeS)
|
|
25
|
+
current = index;
|
|
26
|
+
});
|
|
27
|
+
const roles = items.map((_, index) => index < current ? "gone" : index > current ? "queued" : "current");
|
|
28
|
+
if (current < 0)
|
|
29
|
+
return roles;
|
|
30
|
+
const previous = items[current - 1];
|
|
31
|
+
if (previous?.kind === "line" && overlapsPrevious(items, current) && timeS < previous.end) {
|
|
32
|
+
roles[current - 1] = "previous";
|
|
33
|
+
}
|
|
34
|
+
return roles;
|
|
35
|
+
}
|
|
36
|
+
function unionBox(box, next) {
|
|
37
|
+
if (box === null)
|
|
38
|
+
return next;
|
|
39
|
+
const x = Math.min(box.x, next.x);
|
|
40
|
+
const y = Math.min(box.y, next.y);
|
|
41
|
+
return {
|
|
42
|
+
x,
|
|
43
|
+
y,
|
|
44
|
+
width: Math.max(box.x + box.width, next.x + next.width) - x,
|
|
45
|
+
height: Math.max(box.y + box.height, next.y + next.height) - y,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function layoutStage(roles, items, metrics, previousY, geometry) {
|
|
49
|
+
const { stageHeight, gap } = geometry;
|
|
50
|
+
const current = roles.indexOf("current");
|
|
51
|
+
const currentY = current >= 0 ? stageHeight - metrics[current].height : stageHeight;
|
|
52
|
+
const placements = roles.map((role, index) => {
|
|
53
|
+
const { height } = metrics[index];
|
|
54
|
+
switch (role) {
|
|
55
|
+
case "current":
|
|
56
|
+
return { y: currentY, visible: true };
|
|
57
|
+
case "previous":
|
|
58
|
+
return { y: currentY - gap - height, visible: true };
|
|
59
|
+
case "queued":
|
|
60
|
+
return { y: stageHeight - height + gap, visible: false };
|
|
61
|
+
case "gone":
|
|
62
|
+
return { y: previousY[index] ?? currentY, visible: false };
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
let box = null;
|
|
66
|
+
roles.forEach((role, index) => {
|
|
67
|
+
if ((role !== "current" && role !== "previous") || items[index].kind === "instrumental")
|
|
68
|
+
return;
|
|
69
|
+
const { height, left, width, originX } = metrics[index];
|
|
70
|
+
const scale = geometry.activeScale;
|
|
71
|
+
box = unionBox(box, {
|
|
72
|
+
x: left + width * originX * (1 - scale),
|
|
73
|
+
y: placements[index].y + (height * (1 - scale)) / 2,
|
|
74
|
+
width: width * scale,
|
|
75
|
+
height: height * scale,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
return { placements, box };
|
|
79
|
+
}
|
|
@@ -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
|
|
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";
|