@braccato/core 1.13.2 → 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 +66 -3
- package/dist/README.md +66 -3
- package/dist/engine.d.ts +17 -2
- package/dist/engine.js +321 -33
- package/dist/imageHighlights.d.ts +15 -0
- package/dist/imageHighlights.js +80 -0
- package/dist/index.d.ts +4 -0
- package/dist/inject.d.ts +2 -0
- package/dist/inject.js +16 -4
- package/dist/instrumental.d.ts +1 -2
- package/dist/instrumental.js +47 -5
- package/dist/renderer.js +23 -1
- package/dist/stage.d.ts +41 -0
- package/dist/stage.js +76 -0
- package/dist/styles/image-highlights.css +62 -0
- package/dist/styles/lyrics.css +8 -1
- package/dist/styles/stage.css +57 -0
- package/dist/styles/variables.css +5 -0
- package/dist/types.d.ts +10 -0
- package/dist/view.js +9 -1
- package/package.json +1 -1
package/dist/inject.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ImageHighlightLayers } from "./imageHighlights.js";
|
|
1
2
|
import { type WordState } from "./constants.js";
|
|
2
3
|
import type { Lyric, LyricPart, LyricSyncType } from "./types.js";
|
|
3
4
|
export declare let disableRichsync: import("./themeSettings.js").Setting;
|
|
@@ -19,6 +20,7 @@ export interface AnimationData {
|
|
|
19
20
|
}
|
|
20
21
|
export interface PartData extends AnimationData {
|
|
21
22
|
highlightElement: HTMLElement;
|
|
23
|
+
imageLayers?: ImageHighlightLayers;
|
|
22
24
|
letterElements?: HTMLElement[];
|
|
23
25
|
highlightLetterElements?: HTMLElement[];
|
|
24
26
|
wobbleElements: HTMLElement[];
|
package/dist/inject.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// animates is one word, so every part is split on whitespace with its timing pro-rated across the
|
|
12
12
|
// split by character count. A line that arrives with no timed parts at all is rebuilt the same way
|
|
13
13
|
// into zero duration words, so line synced lyrics reach the DOM the sweep already knows.
|
|
14
|
+
import { alignImageGlowRun, wrapImageHighlight } from "./imageHighlights.js";
|
|
14
15
|
import { normalizeLanguage } from "./language.js";
|
|
15
16
|
import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LETTER_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, WORD_HIGHLIGHT_LETTERED_CLASS, WORD_STATE_ATTR, WORD_STATE_UPCOMING, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
|
|
16
17
|
import { getSeekTimeFromClick } from "./seek.js";
|
|
@@ -69,6 +70,7 @@ function newPartData(part, span, highlight, wobbleElements, letterElements, high
|
|
|
69
70
|
duration: part.durationMs / 1000,
|
|
70
71
|
lyricElement: span,
|
|
71
72
|
highlightElement: highlight,
|
|
73
|
+
imageLayers: wrapImageHighlight(highlight),
|
|
72
74
|
letterElements,
|
|
73
75
|
highlightLetterElements,
|
|
74
76
|
wobbleElements,
|
|
@@ -216,7 +218,7 @@ function appendLetters(doc, wordElement, text) {
|
|
|
216
218
|
return letter;
|
|
217
219
|
});
|
|
218
220
|
}
|
|
219
|
-
function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
221
|
+
function createTimedWordSpan(doc, part, wrapThreshold, perLetter, preserveJoining) {
|
|
220
222
|
const span = doc.createElement("span");
|
|
221
223
|
const highlight = doc.createElement("span");
|
|
222
224
|
highlight.classList.add(WORD_HIGHLIGHT_CLASS);
|
|
@@ -237,7 +239,8 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
|
237
239
|
wordElement.classList.add(BACKGROUND_LYRIC_CLASS);
|
|
238
240
|
if (part.explicit)
|
|
239
241
|
wordElement.classList.add(EXPLICIT_WORD_CLASS);
|
|
240
|
-
if (perLetter
|
|
242
|
+
if (perLetter) {
|
|
243
|
+
wordElement.classList.add("blyrics-word--lettered");
|
|
241
244
|
const collected = appendLetters(doc, wordElement, part.words);
|
|
242
245
|
if (wordElement === span) {
|
|
243
246
|
letters = collected;
|
|
@@ -247,6 +250,9 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
|
247
250
|
wordElement.classList.add(WORD_HIGHLIGHT_LETTERED_CLASS);
|
|
248
251
|
}
|
|
249
252
|
}
|
|
253
|
+
else if (preserveJoining) {
|
|
254
|
+
wordElement.textContent = part.words;
|
|
255
|
+
}
|
|
250
256
|
else {
|
|
251
257
|
appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
|
|
252
258
|
}
|
|
@@ -260,7 +266,10 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
|
|
|
260
266
|
}
|
|
261
267
|
function createWordGroup(doc, group, lineData) {
|
|
262
268
|
const wrapThreshold = Math.max(1, longWordWrapThreshold.getNumberValue());
|
|
263
|
-
|
|
269
|
+
// Atomic grapheme spans disrupt the browser's bidi ordering inside a mixed word.
|
|
270
|
+
const mixedDirection = testRtl(group.text) && [...group.text].some(char => /[\p{Letter}\p{Number}]/u.test(char) && !testRtl(char));
|
|
271
|
+
const preserveJoining = testJoiningScript(group.text);
|
|
272
|
+
const perLetter = letterWave.getBooleanValue() && !preserveJoining && !mixedDirection;
|
|
264
273
|
const lyricGroup = doc.createElement("span");
|
|
265
274
|
const highlightGroup = doc.createElement("span");
|
|
266
275
|
for (const groupElement of [lyricGroup, highlightGroup]) {
|
|
@@ -277,7 +286,7 @@ function createWordGroup(doc, group, lineData) {
|
|
|
277
286
|
for (const token of group.tokens) {
|
|
278
287
|
if (token.kind === "space")
|
|
279
288
|
continue;
|
|
280
|
-
const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter);
|
|
289
|
+
const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter, preserveJoining);
|
|
281
290
|
const wobbleElements = lyricGroup.childNodes.length === 0 ? [lyricGroup, highlightGroup] : [];
|
|
282
291
|
lineData.parts.push(newPartData(token.part, span, highlight, wobbleElements, letters, highlightLetters));
|
|
283
292
|
lyricGroup.appendChild(span);
|
|
@@ -366,6 +375,9 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
|
|
|
366
375
|
}
|
|
367
376
|
}
|
|
368
377
|
}
|
|
378
|
+
alignImageGlowRun(mainHighlightRun, line.parts);
|
|
379
|
+
if (hasBackground)
|
|
380
|
+
alignImageGlowRun(backgroundHighlightRun, line.parts);
|
|
369
381
|
lyricElement.appendChild(main);
|
|
370
382
|
if (hasBackground) {
|
|
371
383
|
lyricElement.appendChild(backgroundLine);
|
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,15 +1,16 @@
|
|
|
1
|
+
import { imageHighlights } from "./imageHighlights.js";
|
|
1
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";
|
|
2
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;
|
|
3
5
|
/**
|
|
4
6
|
* Creates an HTML element representing an instrumental break in the lyrics.
|
|
5
7
|
*
|
|
6
8
|
* @param doc - Document the SVG nodes are created in
|
|
7
9
|
* @param container - Element to place instrumental parts into
|
|
8
10
|
* @param durationMs - Duration of the instrumental break in milliseconds
|
|
9
|
-
* @param lineIndex - Line index for unique SVG element IDs
|
|
10
11
|
* @returns HTMLDivElement representing the instrumental break
|
|
11
12
|
*/
|
|
12
|
-
export function createInstrumentalElement(doc, container, durationMs
|
|
13
|
+
export function createInstrumentalElement(doc, container, durationMs) {
|
|
13
14
|
container.classList.add("blyrics--instrumental");
|
|
14
15
|
container.style.setProperty("--blyrics-duration", `${durationMs}ms`);
|
|
15
16
|
const svgNS = "http://www.w3.org/2000/svg";
|
|
@@ -17,8 +18,11 @@ export function createInstrumentalElement(doc, container, durationMs, lineIndex)
|
|
|
17
18
|
svg.classList.add("blyrics--instrumental-icon");
|
|
18
19
|
svg.setAttribute("viewBox", "0 0 24 24");
|
|
19
20
|
const defs = doc.createElementNS(svgNS, "defs");
|
|
20
|
-
|
|
21
|
-
|
|
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}`;
|
|
22
26
|
const filter = doc.createElementNS(svgNS, "filter");
|
|
23
27
|
filter.setAttribute("id", filterId);
|
|
24
28
|
filter.setAttribute("x", "-100%");
|
|
@@ -72,7 +76,45 @@ export function createInstrumentalElement(doc, container, durationMs, lineIndex)
|
|
|
72
76
|
fillPath.classList.add("blyrics--instrumental-fill");
|
|
73
77
|
fillPath.setAttribute("clip-path", `url(#${clipId})`);
|
|
74
78
|
fillPath.setAttribute("d", "M10 21q-1.65 0-2.825-1.175T6 17t1.175-2.825T10 13q.575 0 1.063.138t.937.412V4q0-.425.288-.712T13 3h4q.425 0 .713.288T18 4v2q0 .425-.288.713T17 7h-3v10q0 1.65-1.175 2.825T10 21");
|
|
75
|
-
|
|
79
|
+
if (imageHighlights.getBooleanValue()) {
|
|
80
|
+
// Fade the common group so both the image and the SDR fallback follow the original
|
|
81
|
+
// instrumental animation. The wave and silhouette clips are inside the glow filter.
|
|
82
|
+
fillPath.classList.remove("blyrics--instrumental-fill");
|
|
83
|
+
fillPath.classList.add("blyrics-instrumental-color");
|
|
84
|
+
const fill = doc.createElementNS(svgNS, "g");
|
|
85
|
+
fill.classList.add("blyrics--instrumental-fill");
|
|
86
|
+
fill.appendChild(fillPath);
|
|
87
|
+
const shapeId = `${clipId}-image-shape`;
|
|
88
|
+
const shape = doc.createElementNS(svgNS, "clipPath");
|
|
89
|
+
shape.setAttribute("id", shapeId);
|
|
90
|
+
const shapePath = doc.createElementNS(svgNS, "path");
|
|
91
|
+
shapePath.setAttribute("d", fillPath.getAttribute("d"));
|
|
92
|
+
shape.appendChild(shapePath);
|
|
93
|
+
defs.appendChild(shape);
|
|
94
|
+
const silhouette = doc.createElementNS(svgNS, "g");
|
|
95
|
+
silhouette.setAttribute("clip-path", `url(#${shapeId})`);
|
|
96
|
+
const wave = doc.createElementNS(svgNS, "g");
|
|
97
|
+
wave.setAttribute("clip-path", `url(#${clipId})`);
|
|
98
|
+
const image = doc.createElementNS(svgNS, "image");
|
|
99
|
+
image.classList.add("blyrics-instrumental-image");
|
|
100
|
+
image.setAttribute("x", "0");
|
|
101
|
+
image.setAttribute("y", "0");
|
|
102
|
+
image.setAttribute("width", "24");
|
|
103
|
+
image.setAttribute("height", "24");
|
|
104
|
+
image.setAttribute("preserveAspectRatio", "none");
|
|
105
|
+
wave.appendChild(image);
|
|
106
|
+
silhouette.appendChild(wave);
|
|
107
|
+
fill.appendChild(silhouette);
|
|
108
|
+
g.appendChild(fill);
|
|
109
|
+
// Blur RGB directly instead of recoloring the halo with an SDR flood/shadow.
|
|
110
|
+
filter.setAttribute("color-interpolation-filters", "sRGB");
|
|
111
|
+
feGaussianBlur.setAttribute("stdDeviation", "1");
|
|
112
|
+
filter.removeChild(feColorMatrix);
|
|
113
|
+
feMergeNode1.setAttribute("in", "blur");
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
g.appendChild(fillPath);
|
|
117
|
+
}
|
|
76
118
|
svg.appendChild(g);
|
|
77
119
|
container.appendChild(svg);
|
|
78
120
|
return container;
|
package/dist/renderer.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// this view on screen, is a loader up) and performs the actions it must not own (seek the player).
|
|
10
10
|
// `debug` is the exception because there is nothing to default it to. A consumer that wants the
|
|
11
11
|
// diagnostic overlay supplies the sink, and one that says nothing draws nothing.
|
|
12
|
+
import { imageHighlights, refreshInstrumentalImages } from "./imageHighlights.js";
|
|
12
13
|
import { CUSTOM_THEME_STYLE_ID } from "./constants.js";
|
|
13
14
|
import { clearLyrics, clearOnScreenLyrics as clearEngineOnScreenLyrics, clearStyleCaches as clearEngineStyleCaches, createAnimationEngineInstance, getRenderedLines, getRenderedSyncType, hasUnsyncedLyrics, noteContainerResize, noteUserScroll as noteEngineUserScroll, noteVisibilityChange as noteEngineVisibilityChange, relayout, resetScrollResume, resolveTickOptions, retickFromPlaybackClock as retickEngineFromPlaybackClock, scheduleLyricPositionUpdate as scheduleEngineLyricPositionUpdate, tickView, } from "./engine.js";
|
|
14
15
|
import { parseThemeConfig, setThemeSettings } from "./themeSettings.js";
|
|
@@ -111,6 +112,7 @@ export function withHostDefaults(overrides, rendererWindow, currentMount) {
|
|
|
111
112
|
currentMount()?.dispatchEvent(new rendererWindow.CustomEvent(SEEK_EVENT, { detail: timeS, bubbles: true }));
|
|
112
113
|
}),
|
|
113
114
|
log: given.log ?? noop,
|
|
115
|
+
onStageLayout: given.onStageLayout,
|
|
114
116
|
debug: given.debug,
|
|
115
117
|
},
|
|
116
118
|
forgetScrollElement() {
|
|
@@ -138,7 +140,22 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
138
140
|
let currentLyrics = [];
|
|
139
141
|
let currentLanguage = "";
|
|
140
142
|
const { host, forgetScrollElement } = withHostDefaults(rendererOptions.host, rendererWindow, () => mount);
|
|
141
|
-
const engine = createAnimationEngineInstance(rendererDocument, rendererWindow, host);
|
|
143
|
+
const engine = createAnimationEngineInstance(rendererDocument, rendererWindow, host, rendererOptions.layout ?? "scroll");
|
|
144
|
+
let imageMediaQueries = [];
|
|
145
|
+
const refreshImagePaint = () => refreshInstrumentalImages(engine.lyricsContainer, rendererWindow);
|
|
146
|
+
function syncImagePaint() {
|
|
147
|
+
if (imageHighlights.getBooleanValue() && imageMediaQueries.length === 0) {
|
|
148
|
+
imageMediaQueries = ["(dynamic-range: high)", "(forced-colors: active)"].map(query => rendererWindow.matchMedia(query));
|
|
149
|
+
for (const query of imageMediaQueries)
|
|
150
|
+
query.addEventListener("change", refreshImagePaint);
|
|
151
|
+
}
|
|
152
|
+
else if (!imageHighlights.getBooleanValue()) {
|
|
153
|
+
for (const query of imageMediaQueries)
|
|
154
|
+
query.removeEventListener("change", refreshImagePaint);
|
|
155
|
+
imageMediaQueries = [];
|
|
156
|
+
}
|
|
157
|
+
refreshImagePaint();
|
|
158
|
+
}
|
|
142
159
|
/**
|
|
143
160
|
* Every re-measurement runs through here, which makes it the one place that knows the layout may
|
|
144
161
|
* have moved under the view. The default scroll element is walked once and remembered, so this is
|
|
@@ -262,6 +279,7 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
262
279
|
noLyrics: options?.noLyrics ?? false,
|
|
263
280
|
songwriters: options?.songwriters,
|
|
264
281
|
});
|
|
282
|
+
syncImagePaint();
|
|
265
283
|
currentLyrics = lyrics;
|
|
266
284
|
currentLanguage = normalizeLanguage(options?.language);
|
|
267
285
|
measure();
|
|
@@ -284,6 +302,7 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
284
302
|
// the theme it is answering about.
|
|
285
303
|
const needsLyricRebuild = setThemeSettings(parseThemeConfig(css));
|
|
286
304
|
adoptThemeStyleSheet(css);
|
|
305
|
+
syncImagePaint();
|
|
287
306
|
// Everything the engine resolved off the document was resolved against the theme that just
|
|
288
307
|
// went away.
|
|
289
308
|
clearEngineStyleCaches(engine);
|
|
@@ -320,6 +339,9 @@ export function createLyricsRenderer(rendererOptions) {
|
|
|
320
339
|
createdThemeStyleElement?.remove();
|
|
321
340
|
createdThemeStyleElement = null;
|
|
322
341
|
rendererWindow.removeEventListener("resize", remeasureForViewport);
|
|
342
|
+
for (const query of imageMediaQueries)
|
|
343
|
+
query.removeEventListener("change", refreshImagePaint);
|
|
344
|
+
imageMediaQueries = [];
|
|
323
345
|
engine.destroy();
|
|
324
346
|
},
|
|
325
347
|
noteUserScroll() {
|
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,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,62 @@
|
|
|
1
|
+
/* The renderer adds these classes only for blyrics-image-highlights = true.
|
|
2
|
+
An SDR image is equally valid. HDR assets and dynamic-range-limit belong to the theme. */
|
|
3
|
+
.blyrics--word.blyrics-word-highlight.blyrics-image-highlight {
|
|
4
|
+
background: none;
|
|
5
|
+
mask: none;
|
|
6
|
+
-webkit-mask: none;
|
|
7
|
+
color: transparent;
|
|
8
|
+
}
|
|
9
|
+
.blyrics-image-fill,
|
|
10
|
+
.blyrics-word-highlight.blyrics-image-highlight .blyrics--letter {
|
|
11
|
+
background-image: var(--blyrics-highlight-image, none);
|
|
12
|
+
background-color: var(--blyrics-lyric-active-color, white);
|
|
13
|
+
background-size: 100% 100%;
|
|
14
|
+
background-repeat: no-repeat;
|
|
15
|
+
background-clip: text;
|
|
16
|
+
-webkit-background-clip: text;
|
|
17
|
+
color: transparent;
|
|
18
|
+
-webkit-text-fill-color: transparent;
|
|
19
|
+
}
|
|
20
|
+
.blyrics-image-highlight:not(.blyrics-word-highlight--lettered) .blyrics-image-fill {
|
|
21
|
+
--blyrics-image-sweep-angle: 90deg;
|
|
22
|
+
mask-image: linear-gradient(var(--blyrics-image-sweep-angle),
|
|
23
|
+
#000 calc(100% * var(--lyric-transition-amount-start)),
|
|
24
|
+
transparent calc(100% * var(--lyric-transition-amount-end) + 1px));
|
|
25
|
+
mask-repeat: no-repeat;
|
|
26
|
+
}
|
|
27
|
+
.blyrics-image-highlight.blyrics-rtl:not(.blyrics-word-highlight--lettered) .blyrics-image-fill {
|
|
28
|
+
--blyrics-image-sweep-angle: 270deg;
|
|
29
|
+
}
|
|
30
|
+
.blyrics-image-highlight.blyrics-word-highlight--lettered .blyrics-image-fill {
|
|
31
|
+
background: none;
|
|
32
|
+
}
|
|
33
|
+
.blyrics-image-glow {
|
|
34
|
+
position: absolute;
|
|
35
|
+
inset: 0;
|
|
36
|
+
pointer-events: none;
|
|
37
|
+
user-select: none;
|
|
38
|
+
opacity: 0;
|
|
39
|
+
overflow: visible;
|
|
40
|
+
}
|
|
41
|
+
/* A parallel run reproduces the full inline formatting context, including bidi
|
|
42
|
+
reordering and every wrapped fragment. No generated text or per-frame measuring. */
|
|
43
|
+
.blyrics-image-glow-run .blyrics-image-glow {
|
|
44
|
+
position: static;
|
|
45
|
+
inset: auto;
|
|
46
|
+
display: inline;
|
|
47
|
+
}
|
|
48
|
+
.blyrics-instrumental-image { display: none; }
|
|
49
|
+
.blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-image { display: inline; }
|
|
50
|
+
.blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-color { display: none; }
|
|
51
|
+
@media (forced-colors: active) {
|
|
52
|
+
.blyrics-image-glow,
|
|
53
|
+
.blyrics-image-glow-run .blyrics-image-glow { display: none; }
|
|
54
|
+
.blyrics-image-fill,
|
|
55
|
+
.blyrics-word-highlight.blyrics-image-highlight .blyrics--letter {
|
|
56
|
+
background: none;
|
|
57
|
+
color: CanvasText;
|
|
58
|
+
-webkit-text-fill-color: CanvasText;
|
|
59
|
+
}
|
|
60
|
+
.blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-image { display: none; }
|
|
61
|
+
.blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-color { display: inline; }
|
|
62
|
+
}
|
package/dist/styles/lyrics.css
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import url("image-highlights.css");
|
|
2
|
+
|
|
1
3
|
/* Resolve the default stack at each language boundary. A root-level var() would freeze
|
|
2
4
|
the host's language-specific fallback before the lyric/translation language is known.
|
|
3
5
|
Theme overrides remain inherited through --blyrics-font-family. */
|
|
@@ -122,7 +124,6 @@
|
|
|
122
124
|
.blyrics-bidi-sensitive .blyrics--word,
|
|
123
125
|
.blyrics-bidi-sensitive .blyrics-word-group:not(.blyrics-word-group-long) .blyrics--word {
|
|
124
126
|
display: inline;
|
|
125
|
-
unicode-bidi: normal;
|
|
126
127
|
white-space: inherit;
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -170,6 +171,12 @@
|
|
|
170
171
|
display: inline-block;
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
/* Atomic letter boxes otherwise take their visual ordering from the surrounding
|
|
175
|
+
paragraph. Isolate each word's own dir=auto while keeping it inline/wrappable. */
|
|
176
|
+
.blyrics--word.blyrics-word--lettered {
|
|
177
|
+
unicode-bidi: isolate;
|
|
178
|
+
}
|
|
179
|
+
|
|
173
180
|
.blyrics-word-highlight.blyrics-word-highlight--lettered {
|
|
174
181
|
background-image: none;
|
|
175
182
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
:root {
|
|
2
|
+
/* Optional image paint; themes own the asset and any HDR brightness policy. */
|
|
3
|
+
--blyrics-highlight-image: none;
|
|
4
|
+
--blyrics-image-glow-opacity-from: 0.5;
|
|
5
|
+
--blyrics-image-glow-opacity-to: 0;
|
|
6
|
+
|
|
2
7
|
/* Colors */
|
|
3
8
|
--blyrics-ui-text-color: var(
|
|
4
9
|
--blyrics-text-color,
|
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
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
import { CREDITS_CLASS, CREDITS_NAMES_CLASS, CREDITS_TEXT_CLASS, LINE_CLASS, LYRICS_CLASS, RTL_CLASS, } from "./constants.js";
|
|
13
13
|
import { setupLineCullObserver } from "./engine.js";
|
|
14
14
|
import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, hideCredits, isNearestLyricRtl, newLineData, } from "./inject.js";
|
|
15
|
+
import { refreshInstrumentalImages } from "./imageHighlights.js";
|
|
15
16
|
import { createInstrumentalElement } from "./instrumental.js";
|
|
16
17
|
import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
|
|
17
18
|
const INITIAL_SKIP_SCROLLS = 2;
|
|
18
19
|
const SKIP_SCROLL_DECAY_MS = 2000;
|
|
19
20
|
function buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex) {
|
|
20
|
-
createInstrumentalElement(doc, lyricElement, lyrics[lineIndex].durationMs
|
|
21
|
+
createInstrumentalElement(doc, lyricElement, lyrics[lineIndex].durationMs);
|
|
21
22
|
lyricElement.dataset.instrumental = "true";
|
|
22
23
|
const agent = findNearestAgent(lyrics, lineIndex);
|
|
23
24
|
if (agent) {
|
|
@@ -105,6 +106,12 @@ export function setLyrics(engine, mount, lyrics, options) {
|
|
|
105
106
|
engine.skipScrollsDecayTimes = Array.from({ length: INITIAL_SKIP_SCROLLS }, () => Date.now() + SKIP_SCROLL_DECAY_MS);
|
|
106
107
|
engine.scrollResumeTime = 0;
|
|
107
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
|
+
}
|
|
108
115
|
container.dataset.loaderVisible = String(options.loaderVisible);
|
|
109
116
|
if (options.noLyrics) {
|
|
110
117
|
container.dataset.noLyrics = "true";
|
|
@@ -112,6 +119,7 @@ export function setLyrics(engine, mount, lyrics, options) {
|
|
|
112
119
|
engine.lines = lines;
|
|
113
120
|
engine.lyricsContainer = container;
|
|
114
121
|
engine.syncType = syncType;
|
|
122
|
+
refreshInstrumentalImages(container, engine.window);
|
|
115
123
|
// Measured last: the container is in the document, filled, and carrying the attributes CSS
|
|
116
124
|
// sizes it by.
|
|
117
125
|
engine.lyricWidth = container.clientWidth;
|