@braccato/core 0.1.6 → 1.0.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.
@@ -0,0 +1,37 @@
1
+ import type { Lyric, LyricPart, LyricSyncType } from "./types.js";
2
+ export declare let disableRichsync: import("./themeSettings.js").Setting;
3
+ export declare function findNearestAgent(lyrics: Lyric[], fromIndex: number): string | undefined;
4
+ export declare function isNearestLyricRtl(lyrics: Lyric[], fromIndex: number): boolean;
5
+ export declare function deriveSyncType(lyrics: Lyric[]): LyricSyncType;
6
+ export interface PartData {
7
+ /**
8
+ * Time of this part in seconds
9
+ */
10
+ time: number;
11
+ /**
12
+ * Duration of this part in seconds
13
+ */
14
+ duration: number;
15
+ lyricElement: HTMLElement;
16
+ animations: Animation[];
17
+ }
18
+ export type LineData = {
19
+ parts: PartData[];
20
+ isScrolled: boolean;
21
+ isAnimationPlayStatePlaying: boolean;
22
+ accumulatedOffsetMs: number;
23
+ isAnimating: boolean;
24
+ lastAnimSetupAt: number;
25
+ isSelected: boolean;
26
+ height: number;
27
+ position: number;
28
+ } & PartData;
29
+ export declare function newLineData(lyricElement: HTMLElement, startTimeMs: number, durationMs: number): LineData;
30
+ export declare function applyDirection(element: HTMLElement, text: string): void;
31
+ export declare function createLyricsLine(doc: Document, parts: LyricPart[], line: LineData, lyricElement: HTMLElement, options?: {
32
+ splitBackgroundLine: boolean;
33
+ }): HTMLElement;
34
+ export declare function buildLineSyncedParts(item: Lyric): LyricPart[];
35
+ export declare function addSeekHandler(seek: (timeS: number) => void, lyricElement: HTMLElement, allZero: boolean): void;
36
+ export declare function injectRomanization(doc: Document, lyricElement: HTMLElement, lineData: LineData, text: string, timedRomanization?: LyricPart[] | null): void;
37
+ export declare function injectTranslation(doc: Document, lyricElement: HTMLElement, text: string): void;
package/dist/inject.js ADDED
@@ -0,0 +1,376 @@
1
+ // Builds what goes inside a lyric line: the main and background content lines, the bidi runs inside
2
+ // them, the word groups and the timed word spans the sweep animates, and the two decorators that
3
+ // hang a translation or a romanization off a line that is already built. `view.ts` builds the line
4
+ // and the container around it.
5
+ //
6
+ // The structure emitted here is as much published contract as the names written into it.
7
+ // `constants.ts` says why a class name cannot be renamed; the nesting is under the same rule,
8
+ // because a marketplace theme selects on the shape as well as on the names.
9
+ //
10
+ // A part is not a word. Providers hand over parts that run to several words, and the unit the sweep
11
+ // animates is one word, so every part is split on whitespace with its timing pro-rated across the
12
+ // split by character count. A line that arrives with no timed parts at all is rebuilt the same way
13
+ // into zero duration words, so line synced lyrics reach the DOM the sweep already knows.
14
+ import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_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, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
15
+ import { getSeekTimeFromClick } from "./seek.js";
16
+ import { testRtl } from "./text.js";
17
+ import { registerThemeSetting } from "./themeSettings.js";
18
+ export let disableRichsync = registerThemeSetting("blyrics-disable-richsync", false, true);
19
+ let lineSyncedAnimationDelay = registerThemeSetting("blyrics-line-synced-animation-delay", 50, true);
20
+ let longWordThreshold = registerThemeSetting("blyrics-long-word-threshold", 1500, true);
21
+ let longWordWrapThreshold = registerThemeSetting("blyrics-long-word-wrap-threshold", 10, true);
22
+ const RTL_SCRIPT_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u;
23
+ const LTR_SCRIPT_REGEX = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u;
24
+ const SPACE_REGEX = /^\s+$/u;
25
+ export function findNearestAgent(lyrics, fromIndex) {
26
+ // Look in the downwards direction first
27
+ for (let i = fromIndex + 1; i < lyrics.length; i++) {
28
+ if (!lyrics[i].isInstrumental && lyrics[i].agent) {
29
+ return lyrics[i].agent;
30
+ }
31
+ }
32
+ for (let i = fromIndex - 1; i >= 0; i--) {
33
+ if (!lyrics[i].isInstrumental && lyrics[i].agent) {
34
+ return lyrics[i].agent;
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+ export function isNearestLyricRtl(lyrics, fromIndex) {
40
+ // Look in the downwards direction first
41
+ for (let i = fromIndex + 1; i < lyrics.length; i++) {
42
+ if (!lyrics[i].isInstrumental && lyrics[i].words?.trim()) {
43
+ return testRtl(lyrics[i].words);
44
+ }
45
+ }
46
+ for (let i = fromIndex - 1; i >= 0; i--) {
47
+ if (!lyrics[i].isInstrumental && lyrics[i].words?.trim()) {
48
+ return testRtl(lyrics[i].words);
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+ // -- Lyric shape --------------------------------------------
54
+ // A line whose parts are missing, empty or overridden by the theme is rebuilt as line synced words,
55
+ // which carry no duration, so only pre-existing timed parts count as rich sync.
56
+ export function deriveSyncType(lyrics) {
57
+ const hasTimedParts = !disableRichsync.getBooleanValue() &&
58
+ lyrics.some(item => !item.isInstrumental && item.parts?.some(part => part.durationMs !== 0) === true);
59
+ if (hasTimedParts)
60
+ return "richsync";
61
+ return lyrics.every(item => item.startTimeMs === 0) ? "none" : "synced";
62
+ }
63
+ function newPartData(part, span) {
64
+ return {
65
+ time: part.startTimeMs / 1000,
66
+ duration: part.durationMs / 1000,
67
+ lyricElement: span,
68
+ animations: [],
69
+ };
70
+ }
71
+ export function newLineData(lyricElement, startTimeMs, durationMs) {
72
+ return {
73
+ lyricElement,
74
+ time: startTimeMs / 1000,
75
+ duration: durationMs / 1000,
76
+ parts: [],
77
+ isScrolled: false,
78
+ isAnimationPlayStatePlaying: false,
79
+ accumulatedOffsetMs: 0,
80
+ isAnimating: false,
81
+ lastAnimSetupAt: 0,
82
+ isSelected: false,
83
+ height: -1,
84
+ position: -1,
85
+ animations: [],
86
+ };
87
+ }
88
+ function detectDirection(text) {
89
+ for (const char of text) {
90
+ if (RTL_SCRIPT_REGEX.test(char))
91
+ return "rtl";
92
+ if (LTR_SCRIPT_REGEX.test(char))
93
+ return "ltr";
94
+ }
95
+ return "auto";
96
+ }
97
+ export function applyDirection(element, text) {
98
+ const direction = detectDirection(text);
99
+ element.dir = "auto";
100
+ if (direction === "rtl") {
101
+ element.classList.add(RTL_CLASS);
102
+ element.dataset.direction = "rtl";
103
+ }
104
+ else if (direction === "ltr") {
105
+ element.dataset.direction = "ltr";
106
+ }
107
+ }
108
+ function applyBidiSensitivity(element, text) {
109
+ if (testRtl(text)) {
110
+ element.classList.add(BIDI_SENSITIVE_CLASS);
111
+ }
112
+ }
113
+ function splitPartIntoTokens(part) {
114
+ const chunks = part.words.match(/\s+|\S+/gu) ?? [];
115
+ if (chunks.length === 0)
116
+ return [];
117
+ const tokens = [];
118
+ let spaceChars = 0;
119
+ for (const chunk of chunks) {
120
+ if (SPACE_REGEX.test(chunk)) {
121
+ tokens.push({ kind: "space", text: chunk });
122
+ spaceChars += chunk.length;
123
+ continue;
124
+ }
125
+ tokens.push({
126
+ kind: "part",
127
+ text: chunk,
128
+ part: {
129
+ words: chunk,
130
+ isBackground: part.isBackground,
131
+ explicit: part.explicit,
132
+ },
133
+ });
134
+ }
135
+ const nonWhiteSpaceChars = part.words.length - spaceChars;
136
+ let cursor = 0;
137
+ return tokens.map(t => {
138
+ if (t.kind === "part") {
139
+ const startTimeMs = part.startTimeMs + Math.round((part.durationMs * cursor) / nonWhiteSpaceChars);
140
+ const endTimeMs = part.startTimeMs + Math.round((part.durationMs * (cursor + t.text.length)) / nonWhiteSpaceChars);
141
+ cursor += t.text.length;
142
+ return {
143
+ ...t,
144
+ part: {
145
+ ...t.part,
146
+ startTimeMs,
147
+ durationMs: endTimeMs - startTimeMs,
148
+ },
149
+ };
150
+ }
151
+ return t;
152
+ });
153
+ }
154
+ function normalizeParts(parts) {
155
+ return parts.flatMap(splitPartIntoTokens);
156
+ }
157
+ function groupTokensByWord(tokens) {
158
+ const groups = [];
159
+ let current = null;
160
+ const flush = () => {
161
+ if (current && current.tokens.length > 0) {
162
+ groups.push(current);
163
+ }
164
+ current = null;
165
+ };
166
+ for (const token of tokens) {
167
+ if (token.kind === "space") {
168
+ flush();
169
+ groups.push(token);
170
+ continue;
171
+ }
172
+ const isBackground = token.part?.isBackground === true;
173
+ if (!current || current.isBackground !== isBackground) {
174
+ flush();
175
+ current = { text: "", isBackground, tokens: [] };
176
+ }
177
+ current.text += token.text;
178
+ current.tokens.push(token);
179
+ }
180
+ flush();
181
+ return groups;
182
+ }
183
+ function appendLongWordBreaks(doc, span, text, threshold) {
184
+ if (text.length <= threshold) {
185
+ span.textContent = text;
186
+ return false;
187
+ }
188
+ for (let i = 0; i < text.length; i += threshold) {
189
+ span.appendChild(doc.createTextNode(text.slice(i, i + threshold)));
190
+ if (i + threshold < text.length) {
191
+ span.appendChild(doc.createElement("wbr"));
192
+ }
193
+ }
194
+ return true;
195
+ }
196
+ function cloneTextWithBreaks(doc, source) {
197
+ const fragment = doc.createDocumentFragment();
198
+ for (const node of source.childNodes) {
199
+ fragment.appendChild(node.cloneNode(true));
200
+ }
201
+ return fragment;
202
+ }
203
+ function createTimedWordSpan(doc, part, wrapThreshold) {
204
+ const span = doc.createElement("span");
205
+ span.classList.add(WORD_CLASS);
206
+ span.dir = "auto";
207
+ if (part.durationMs === 0) {
208
+ span.classList.add(ZERO_DURATION_ANIMATION_CLASS);
209
+ span.classList.add(LINE_SYNCED_WORD_CLASS);
210
+ }
211
+ if (testRtl(part.words)) {
212
+ span.classList.add(RTL_CLASS);
213
+ }
214
+ if (part.durationMs > longWordThreshold.getNumberValue()) {
215
+ span.dataset.longWord = "true";
216
+ }
217
+ if (part.isBackground) {
218
+ span.classList.add(BACKGROUND_LYRIC_CLASS);
219
+ }
220
+ if (part.explicit) {
221
+ span.classList.add(EXPLICIT_WORD_CLASS);
222
+ }
223
+ const hasBreaks = appendLongWordBreaks(doc, span, part.words, wrapThreshold);
224
+ if (hasBreaks) {
225
+ const highlight = doc.createElement("span");
226
+ highlight.classList.add(WORD_HIGHLIGHT_CLASS);
227
+ highlight.setAttribute("aria-hidden", "true");
228
+ highlight.appendChild(cloneTextWithBreaks(doc, span));
229
+ span.appendChild(highlight);
230
+ }
231
+ span.dataset.time = String(part.startTimeMs / 1000);
232
+ span.dataset.duration = String(part.durationMs / 1000);
233
+ span.dataset.content = part.words;
234
+ span.style.setProperty("--blyrics-duration", part.durationMs + "ms");
235
+ return span;
236
+ }
237
+ function createWordGroup(doc, group, lineData) {
238
+ const wrapThreshold = Math.max(1, longWordWrapThreshold.getNumberValue());
239
+ const groupElement = doc.createElement("span");
240
+ groupElement.classList.add(WORD_GROUP_CLASS);
241
+ groupElement.dir = "auto";
242
+ groupElement.dataset.content = group.text;
243
+ if (group.text.length > wrapThreshold * 2) {
244
+ groupElement.classList.add(LONG_WORD_GROUP_CLASS);
245
+ }
246
+ if (group.isBackground) {
247
+ groupElement.classList.add(BACKGROUND_LYRIC_CLASS);
248
+ }
249
+ for (const token of group.tokens) {
250
+ if (token.kind === "space")
251
+ continue;
252
+ const span = createTimedWordSpan(doc, token.part, wrapThreshold);
253
+ lineData.parts.push(newPartData(token.part, span));
254
+ groupElement.appendChild(span);
255
+ }
256
+ return groupElement;
257
+ }
258
+ function createContentLine(doc, className, text) {
259
+ const line = doc.createElement("div");
260
+ line.classList.add(className);
261
+ applyDirection(line, text);
262
+ applyBidiSensitivity(line, text);
263
+ return line;
264
+ }
265
+ function createBidiRun(doc, text) {
266
+ const run = doc.createElement("span");
267
+ run.classList.add(BIDI_RUN_CLASS);
268
+ applyDirection(run, text);
269
+ return run;
270
+ }
271
+ export function createLyricsLine(doc, parts, line, lyricElement, options = { splitBackgroundLine: true }) {
272
+ const lineText = parts.map(part => part.words).join("");
273
+ const mainText = options.splitBackgroundLine
274
+ ? parts
275
+ .filter(part => part.isBackground !== true)
276
+ .map(part => part.words)
277
+ .join("")
278
+ : lineText;
279
+ const backgroundText = parts
280
+ .filter(part => part.isBackground === true)
281
+ .map(part => part.words)
282
+ .join("");
283
+ const main = createContentLine(doc, LINE_MAIN_CLASS, mainText);
284
+ const mainRun = createBidiRun(doc, mainText);
285
+ const groupedTokens = groupTokensByWord(normalizeParts(parts));
286
+ const backgroundLine = createContentLine(doc, BACKGROUND_LINE_CLASS, backgroundText);
287
+ const backgroundRun = createBidiRun(doc, backgroundText);
288
+ let hasBackground = false;
289
+ let pendingForegroundSpace = "";
290
+ let pendingBackgroundSpace = "";
291
+ main.appendChild(mainRun);
292
+ backgroundLine.appendChild(backgroundRun);
293
+ for (const item of groupedTokens) {
294
+ if ("kind" in item) {
295
+ // Is a RenderToken, not a WordGroup, only whitespace should enter this path
296
+ pendingForegroundSpace += item.text;
297
+ pendingBackgroundSpace += item.text;
298
+ }
299
+ else {
300
+ const shouldUseBackgroundLine = options.splitBackgroundLine && item.isBackground;
301
+ const target = shouldUseBackgroundLine ? backgroundRun : mainRun;
302
+ const pendingSpace = shouldUseBackgroundLine ? pendingBackgroundSpace : pendingForegroundSpace;
303
+ if (target.childNodes.length > 0 && pendingSpace.length > 0) {
304
+ target.appendChild(doc.createTextNode(pendingSpace));
305
+ }
306
+ target.appendChild(createWordGroup(doc, item, line));
307
+ if (shouldUseBackgroundLine) {
308
+ hasBackground = true;
309
+ pendingBackgroundSpace = "";
310
+ }
311
+ else {
312
+ pendingForegroundSpace = "";
313
+ }
314
+ }
315
+ }
316
+ lyricElement.appendChild(main);
317
+ if (hasBackground) {
318
+ lyricElement.appendChild(backgroundLine);
319
+ }
320
+ return main;
321
+ }
322
+ export function buildLineSyncedParts(item) {
323
+ const parts = [];
324
+ const tokens = item.words.match(/\s+|\S+/gu) ?? [];
325
+ let wordIndex = 0;
326
+ for (const token of tokens) {
327
+ const isSpace = SPACE_REGEX.test(token);
328
+ const startTimeMs = item.startTimeMs + wordIndex * lineSyncedAnimationDelay.getNumberValue();
329
+ parts.push({
330
+ startTimeMs,
331
+ words: token,
332
+ durationMs: 0,
333
+ });
334
+ if (!isSpace) {
335
+ wordIndex += 1;
336
+ }
337
+ }
338
+ return parts;
339
+ }
340
+ export function addSeekHandler(seek, lyricElement, allZero) {
341
+ if (allZero) {
342
+ lyricElement.style.cursor = "unset";
343
+ return;
344
+ }
345
+ lyricElement.addEventListener("click", event => {
346
+ const seekTime = getSeekTimeFromClick(event, lyricElement);
347
+ if (seekTime === null)
348
+ return;
349
+ seek(seekTime);
350
+ });
351
+ }
352
+ export function injectRomanization(doc, lyricElement, lineData, text, timedRomanization = null) {
353
+ if (lyricElement.querySelector(`.${ROMANIZED_LYRICS_CLASS}`))
354
+ return;
355
+ const romanizedLine = doc.createElement("div");
356
+ romanizedLine.classList.add(ROMANIZED_LYRICS_CLASS, CONTENT_LINE_CLASS);
357
+ romanizedLine.dir = "auto";
358
+ applyDirection(romanizedLine, text);
359
+ if (timedRomanization && timedRomanization.length > 0 && !disableRichsync.getBooleanValue()) {
360
+ createLyricsLine(doc, timedRomanization, lineData, romanizedLine, { splitBackgroundLine: false });
361
+ }
362
+ else {
363
+ romanizedLine.textContent = text;
364
+ }
365
+ lyricElement.appendChild(romanizedLine);
366
+ }
367
+ export function injectTranslation(doc, lyricElement, text) {
368
+ if (lyricElement.querySelector(`.${TRANSLATED_LYRICS_CLASS}`))
369
+ return;
370
+ const translatedLine = doc.createElement("div");
371
+ translatedLine.classList.add(TRANSLATED_LYRICS_CLASS, CONTENT_LINE_CLASS);
372
+ translatedLine.dir = "auto";
373
+ applyDirection(translatedLine, text);
374
+ translatedLine.textContent = text;
375
+ lyricElement.appendChild(translatedLine);
376
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Creates an HTML element representing an instrumental break in the lyrics.
3
+ *
4
+ * @param doc - Document the SVG nodes are created in
5
+ * @param container - Element to place instrumental parts into
6
+ * @param durationMs - Duration of the instrumental break in milliseconds
7
+ * @param lineIndex - Line index for unique SVG element IDs
8
+ * @returns HTMLDivElement representing the instrumental break
9
+ */
10
+ export declare function createInstrumentalElement(doc: Document, container: HTMLDivElement, durationMs: number, lineIndex: number): HTMLDivElement;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Creates an HTML element representing an instrumental break in the lyrics.
3
+ *
4
+ * @param doc - Document the SVG nodes are created in
5
+ * @param container - Element to place instrumental parts into
6
+ * @param durationMs - Duration of the instrumental break in milliseconds
7
+ * @param lineIndex - Line index for unique SVG element IDs
8
+ * @returns HTMLDivElement representing the instrumental break
9
+ */
10
+ export function createInstrumentalElement(doc, container, durationMs, lineIndex) {
11
+ container.classList.add("blyrics--instrumental");
12
+ container.style.setProperty("--blyrics-duration", `${durationMs}ms`);
13
+ const svgNS = "http://www.w3.org/2000/svg";
14
+ const svg = doc.createElementNS(svgNS, "svg");
15
+ svg.classList.add("blyrics--instrumental-icon");
16
+ svg.setAttribute("viewBox", "0 0 24 24");
17
+ const defs = doc.createElementNS(svgNS, "defs");
18
+ const filterId = `blyrics-glow-${lineIndex}`;
19
+ const clipId = `blyrics-wave-clip-${lineIndex}`;
20
+ const filter = doc.createElementNS(svgNS, "filter");
21
+ filter.setAttribute("id", filterId);
22
+ filter.setAttribute("x", "-100%");
23
+ filter.setAttribute("y", "-100%");
24
+ filter.setAttribute("width", "300%");
25
+ filter.setAttribute("height", "300%");
26
+ const feGaussianBlur = doc.createElementNS(svgNS, "feGaussianBlur");
27
+ feGaussianBlur.setAttribute("in", "SourceGraphic");
28
+ feGaussianBlur.setAttribute("stdDeviation", "5");
29
+ feGaussianBlur.setAttribute("result", "blur");
30
+ filter.appendChild(feGaussianBlur);
31
+ const feColorMatrix = doc.createElementNS(svgNS, "feColorMatrix");
32
+ feColorMatrix.setAttribute("in", "blur");
33
+ feColorMatrix.setAttribute("type", "matrix");
34
+ feColorMatrix.setAttribute("values", "1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0.6 0");
35
+ feColorMatrix.setAttribute("result", "fadedBlur");
36
+ filter.appendChild(feColorMatrix);
37
+ const feMerge = doc.createElementNS(svgNS, "feMerge");
38
+ const feMergeNode1 = doc.createElementNS(svgNS, "feMergeNode");
39
+ feMergeNode1.setAttribute("in", "fadedBlur");
40
+ feMerge.appendChild(feMergeNode1);
41
+ const feMergeNode2 = doc.createElementNS(svgNS, "feMergeNode");
42
+ feMergeNode2.setAttribute("in", "SourceGraphic");
43
+ feMerge.appendChild(feMergeNode2);
44
+ filter.appendChild(feMerge);
45
+ defs.appendChild(filter);
46
+ const clipPath = doc.createElementNS(svgNS, "clipPath");
47
+ clipPath.setAttribute("id", clipId);
48
+ clipPath.classList.add("blyrics--wave-clip");
49
+ // Create the Static Block (The deep fill)
50
+ // This sits at y=4 (the lowest point of the wave) and extends to bottom
51
+ const waveRect = doc.createElementNS(svgNS, "path");
52
+ waveRect.classList.add("blyrics--wave-rect");
53
+ waveRect.setAttribute("d", "M -4 3.9 L 30 3.9 L 30 30 L -4 30 Z");
54
+ clipPath.appendChild(waveRect);
55
+ // Create the Wavy Top
56
+ // This only contains the surface water. It closes at y=4.
57
+ const wavePath = doc.createElementNS(svgNS, "path");
58
+ wavePath.classList.add("blyrics--wave-path");
59
+ // Initial draw (matches the 0% keyframe below)
60
+ wavePath.setAttribute("d", "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");
61
+ clipPath.appendChild(wavePath);
62
+ defs.appendChild(clipPath);
63
+ svg.appendChild(defs);
64
+ const bgPath = doc.createElementNS(svgNS, "path");
65
+ bgPath.classList.add("blyrics--instrumental-bg");
66
+ bgPath.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");
67
+ svg.appendChild(bgPath);
68
+ const g = doc.createElementNS(svgNS, "g");
69
+ g.setAttribute("filter", `url(#${filterId})`);
70
+ const fillPath = doc.createElementNS(svgNS, "path");
71
+ fillPath.classList.add("blyrics--instrumental-fill");
72
+ fillPath.setAttribute("clip-path", `url(#${clipId})`);
73
+ 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");
74
+ g.appendChild(fillPath);
75
+ svg.appendChild(g);
76
+ container.appendChild(svg);
77
+ return container;
78
+ }
@@ -0,0 +1,29 @@
1
+ import type { LyricsRenderer, LyricsRendererHost, LyricsRendererOptions } from "./types.js";
2
+ /**
3
+ * Fills in every host member the consumer left out, so the host is an extension point rather than a
4
+ * cost of entry. The mount is read at call time rather than captured: `setLyrics` may be given a
5
+ * different one, and both defaults that use it have to follow.
6
+ *
7
+ * Each member is resolved on its own rather than spread over the defaults. A host assembled from
8
+ * optional pieces carries members that are present and undefined, which typecheck, and spreading
9
+ * one of those leaves the renderer holding nothing where it expects a function.
10
+ *
11
+ * The invalidator comes back alongside the host because the memo behind the default scroll element
12
+ * has no way to notice it went stale on its own.
13
+ *
14
+ * Exported for `renderer.selfcheck.ts` and not published from `index.ts`. A consumer reaches these
15
+ * defaults by leaving host members out, so nothing outside needs to name them; what does need to is
16
+ * the check that every one of them is still here and still answering.
17
+ */
18
+ export declare function withHostDefaults(overrides: Partial<LyricsRendererHost> | undefined, rendererWindow: Window & typeof globalThis, currentMount: () => HTMLElement | null): {
19
+ host: LyricsRendererHost;
20
+ forgetScrollElement: () => void;
21
+ };
22
+ /**
23
+ * Builds a lyrics view and keeps it measured. Line positions are read once, when the lines are
24
+ * built, and everything the engine scrolls by comes from that reading, so a layout that settles
25
+ * afterwards leaves the whole song scrolling to stale targets. Three things settle afterwards: the
26
+ * container's own size, the document's font faces, and the window. This owns all three, because
27
+ * both of the views in this extension went into production having missed at least one of them.
28
+ */
29
+ export declare function createLyricsRenderer(rendererOptions: LyricsRendererOptions): LyricsRenderer;