@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/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 && !testJoiningScript(part.words)) {
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
- const perLetter = letterWave.getBooleanValue();
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);
@@ -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,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, lineIndex) {
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
- const filterId = `blyrics-glow-${lineIndex}`;
21
- 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}`;
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
- g.appendChild(fillPath);
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() {
@@ -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
+ }
@@ -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, lineIndex);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.13.2",
3
+ "version": "1.15.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",