@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,349 @@
1
+ // What a consumer holds: one object per view, composing the engine that animates the lines, the
2
+ // builder that makes them and the theme settings both read. Nothing under here reaches for its
3
+ // surroundings on its own, so everything the module needs from outside arrives through
4
+ // `LyricsRendererOptions`, and everything it cannot do itself goes back out through the host.
5
+ //
6
+ // Only the document to build in and the window to schedule against are required. Every host member
7
+ // but `debug` has a default, which is what makes the host an extension point a consumer overrides
8
+ // one member at a time rather than a cost of entry: it answers the questions the module cannot (is
9
+ // this view on screen, is a loader up) and performs the actions it must not own (seek the player).
10
+ // `debug` is the exception because there is nothing to default it to. A consumer that wants the
11
+ // diagnostic overlay supplies the sink, and one that says nothing draws nothing.
12
+ import { CUSTOM_THEME_STYLE_ID } from "./constants.js";
13
+ 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
+ import { parseThemeConfig, setThemeSettings } from "./themeSettings.js";
15
+ import { setLyrics as buildLyricsView } from "./view.js";
16
+ /**
17
+ * What the default `seek` dispatches at the mount. A consumer that gave the renderer no way to
18
+ * reach its player can listen for this instead of writing a host.
19
+ */
20
+ const SEEK_EVENT = "braccato:seek";
21
+ const DESTROYED_SET_LYRICS_LOG = "Lyrics were handed to a renderer that has been destroyed; nothing was built";
22
+ const FONT_MEASURE_LOG = "The lines could not be re-measured after the document's faces loaded";
23
+ const SCROLLABLE_OVERFLOW = new Set(["auto", "scroll"]);
24
+ function noop() { }
25
+ /**
26
+ * Whether the lines generate boxes, and so have anything to measure. Lines the page is not
27
+ * rendering measure as zero height at zero offset, and every scroll target for the rest of the song
28
+ * is read off those numbers, so one measurement taken while they are off the screen strands the
29
+ * view until something measures it again.
30
+ *
31
+ * The lines rather than the container holding them, because the lines are what a re-measurement
32
+ * reads. A container under `display: contents` renders its lines while generating no box of its
33
+ * own, and a container emptied of its lines still generates one: asking the container answers
34
+ * backwards in both directions. Asking the lines covers the container's own case as well, since a
35
+ * container that generates no box takes everything inside it with it.
36
+ *
37
+ * `getClientRects` rather than `offsetParent`: it answers for the boxes an element generates and
38
+ * nothing else, while `offsetParent` is also null for a fixed or root element, so a consumer that
39
+ * positions its view differently than this module's own stylesheet does would silently stop being
40
+ * measured at all.
41
+ */
42
+ function areLinesMeasurable(lines) {
43
+ // A view with no lines has nothing to hold back, and holding it back anyway would leave the
44
+ // container's own size unrecorded, so every later report of that same size reads as a change.
45
+ if (lines.length === 0)
46
+ return true;
47
+ return lines.some(line => line.lyricElement.getClientRects().length > 0);
48
+ }
49
+ /**
50
+ * The nearest element that scrolls, starting at the mount itself: a consumer that mounts straight
51
+ * into its own scroll container means that container, not whatever else scrolls above it.
52
+ */
53
+ function findScrollElement(rendererWindow, mount) {
54
+ for (let element = mount; element !== null; element = element.parentElement) {
55
+ if (SCROLLABLE_OVERFLOW.has(rendererWindow.getComputedStyle(element).overflowY))
56
+ return element;
57
+ }
58
+ if (mount === null)
59
+ return null;
60
+ // On an ordinary page every ancestor computes to `visible` and the document is what scrolls.
61
+ // Standing the mount in for one leaves autoscroll writing scrollTop onto an element that cannot
62
+ // scroll, which reads as lyrics that highlight and never move. `scrollingElement` is typed as
63
+ // `Element` for documents whose root need not be an HTMLElement; in an HTML one it is html or body.
64
+ return mount.ownerDocument.scrollingElement;
65
+ }
66
+ /**
67
+ * Fills in every host member the consumer left out, so the host is an extension point rather than a
68
+ * cost of entry. The mount is read at call time rather than captured: `setLyrics` may be given a
69
+ * different one, and both defaults that use it have to follow.
70
+ *
71
+ * Each member is resolved on its own rather than spread over the defaults. A host assembled from
72
+ * optional pieces carries members that are present and undefined, which typecheck, and spreading
73
+ * one of those leaves the renderer holding nothing where it expects a function.
74
+ *
75
+ * The invalidator comes back alongside the host because the memo behind the default scroll element
76
+ * has no way to notice it went stale on its own.
77
+ *
78
+ * Exported for `renderer.selfcheck.ts` and not published from `index.ts`. A consumer reaches these
79
+ * defaults by leaving host members out, so nothing outside needs to name them; what does need to is
80
+ * the check that every one of them is still here and still answering.
81
+ */
82
+ export function withHostDefaults(overrides, rendererWindow, currentMount) {
83
+ const given = overrides ?? {};
84
+ // The engine resolves the scroll element on every tick, and the walk reads a computed style per
85
+ // ancestor, so an unmemoised default forces style resolution sixty times a second.
86
+ //
87
+ // A new mount is not the only thing that can change where the walk ends: an ancestor can turn
88
+ // scrollable, and the same mount can be moved under a different one. Neither is observable from
89
+ // here, so the memo is dropped by whoever does know the layout moved. `undefined` is the state
90
+ // before the first walk, because null is an answer a renderer with no mount yet keeps.
91
+ let walkedMount;
92
+ let walkedScrollElement = null;
93
+ function scrollElementForCurrentMount() {
94
+ const mount = currentMount();
95
+ if (mount !== walkedMount) {
96
+ walkedMount = mount;
97
+ walkedScrollElement = findScrollElement(rendererWindow, mount);
98
+ }
99
+ return walkedScrollElement;
100
+ }
101
+ return {
102
+ host: {
103
+ isViewVisible: given.isViewVisible ?? (() => true),
104
+ isLoaderActive: given.isLoaderActive ?? (() => false),
105
+ syncAdState: given.syncAdState ?? (() => false),
106
+ getScrollElement: given.getScrollElement ?? scrollElementForCurrentMount,
107
+ setResumeAffordanceVisible: given.setResumeAffordanceVisible ?? noop,
108
+ seek: given.seek ??
109
+ (timeS => {
110
+ currentMount()?.dispatchEvent(new rendererWindow.CustomEvent(SEEK_EVENT, { detail: timeS, bubbles: true }));
111
+ }),
112
+ log: given.log ?? noop,
113
+ debug: given.debug,
114
+ },
115
+ forgetScrollElement() {
116
+ walkedMount = undefined;
117
+ },
118
+ };
119
+ }
120
+ /**
121
+ * Builds a lyrics view and keeps it measured. Line positions are read once, when the lines are
122
+ * built, and everything the engine scrolls by comes from that reading, so a layout that settles
123
+ * afterwards leaves the whole song scrolling to stale targets. Three things settle afterwards: the
124
+ * container's own size, the document's font faces, and the window. This owns all three, because
125
+ * both of the views in this extension went into production having missed at least one of them.
126
+ */
127
+ export function createLyricsRenderer(rendererOptions) {
128
+ const rendererDocument = rendererOptions.document;
129
+ // `Window` types neither `ResizeObserver` nor `CustomEvent`: both are ambient `var` declarations,
130
+ // so they are only reachable through `typeof globalThis`. Every real window is one.
131
+ const rendererWindow = rendererOptions.window;
132
+ let mount = rendererOptions.mount ?? null;
133
+ let containerResizeObserver = null;
134
+ // Only the theme element this renderer created, which is the only one it may take away again.
135
+ let createdThemeStyleElement = null;
136
+ let isDestroyed = false;
137
+ const { host, forgetScrollElement } = withHostDefaults(rendererOptions.host, rendererWindow, () => mount);
138
+ const engine = createAnimationEngineInstance(rendererDocument, rendererWindow, host);
139
+ /**
140
+ * Every re-measurement runs through here, which makes it the one place that knows the layout may
141
+ * have moved under the view. The default scroll element is walked once and remembered, so this is
142
+ * also where that walk is allowed to go stale: a resize is exactly when an ancestor is most
143
+ * likely to have gained or lost its scrollbar, and it costs one walk per resize rather than one
144
+ * per tick.
145
+ *
146
+ * The lines are only measurable while they are on screen, and whether they are is read off the
147
+ * lines themselves rather than asked of the consumer: a view that is hidden while a song loads is
148
+ * the normal case for a side panel, and a consumer that has to know to say so is one that will
149
+ * forget. The padding is worth rewriting either way, so only the lines are held back.
150
+ */
151
+ function measure(measureLines = true) {
152
+ forgetScrollElement();
153
+ relayout(engine, measureLines && areLinesMeasurable(getRenderedLines(engine)));
154
+ }
155
+ function stopObservingContainer() {
156
+ containerResizeObserver?.disconnect();
157
+ containerResizeObserver = null;
158
+ }
159
+ /**
160
+ * Drops the song, DOM and all. The engine's own clear keeps the container it was handed, because
161
+ * the callers it was written for built that container themselves. This one built it, so leaving it
162
+ * behind would leave a cleared view showing the song it just dropped.
163
+ */
164
+ function clearBuiltView() {
165
+ stopObservingContainer();
166
+ engine.lyricsContainer?.remove();
167
+ clearLyrics(engine);
168
+ }
169
+ /**
170
+ * Watches the built container for the layout it settles into. The guard is what stops this
171
+ * feeding itself: re-measuring is what records the new size, so the observer has to ask whether
172
+ * the size actually changed before it re-measures.
173
+ */
174
+ function observeContainer(container) {
175
+ stopObservingContainer();
176
+ const observer = new rendererWindow.ResizeObserver(entries => {
177
+ const target = entries[entries.length - 1]?.target;
178
+ if (!target)
179
+ return;
180
+ if (noteContainerResize(engine, target.clientWidth, target.clientHeight))
181
+ measure();
182
+ });
183
+ observer.observe(container);
184
+ containerResizeObserver = observer;
185
+ }
186
+ /**
187
+ * Puts the theme's stylesheet where the document will read it. An element in the head rather than
188
+ * a constructed sheet in `adoptedStyleSheets`, for two reasons: adopted sheets are ordered after
189
+ * every sheet the document loaded, so one would give the theme a cascade position it does not have
190
+ * when a consumer writes the element itself, and an element is findable, which is how a second
191
+ * document is handed the same theme.
192
+ *
193
+ * Findable is why the element is resolved by id before one is created. A second renderer in the
194
+ * same document writes into the element that is already there rather than adding a rival under the
195
+ * same id, which would be invalid and would leave `getElementById` answering with whichever of the
196
+ * two came first. Only the element this renderer created is remembered, and only that one is taken
197
+ * away again: an adopted element belongs to whoever put it in the document.
198
+ *
199
+ * Rewriting a `<style>` with the text it already holds is not free. The sheet is re-parsed, so
200
+ * every face the theme imports is re-resolved and whatever is waiting on the font event that
201
+ * follows re-arms. This extension reaches a theme twice per edit, so that is the ordinary case.
202
+ */
203
+ function adoptThemeStyleSheet(css) {
204
+ const existingElement = createdThemeStyleElement ?? rendererDocument.getElementById(CUSTOM_THEME_STYLE_ID);
205
+ if (existingElement !== null) {
206
+ if (existingElement.textContent !== css)
207
+ existingElement.textContent = css;
208
+ return;
209
+ }
210
+ const styleElement = rendererDocument.createElement("style");
211
+ styleElement.id = CUSTOM_THEME_STYLE_ID;
212
+ // Filled before it is in the document, so the first theme is parsed once rather than once empty
213
+ // and once full.
214
+ styleElement.textContent = css;
215
+ rendererDocument.head.appendChild(styleElement);
216
+ createdThemeStyleElement = styleElement;
217
+ }
218
+ const remeasureForViewport = () => measure();
219
+ rendererWindow.addEventListener("resize", remeasureForViewport);
220
+ // Lines measured before the theme's faces have loaded are measured at the fallback face's
221
+ // metrics, which leaves every scroll target a little off for the rest of the song.
222
+ //
223
+ // The catch is what makes this measurement reportable. Every other door into `measure` is a call
224
+ // the consumer made or a platform callback it registered, so a throw comes back where it can be
225
+ // seen; this one is a promise nobody is holding, and without the catch a throw inside it is an
226
+ // unhandled rejection with no view attached to it.
227
+ void rendererDocument.fonts.ready
228
+ .then(() => {
229
+ if (isDestroyed)
230
+ return;
231
+ measure();
232
+ })
233
+ .catch(error => host.log(FONT_MEASURE_LOG, error));
234
+ // Destruction is final, and every entry point below says so by doing nothing. Silently, because
235
+ // the frame a consumer already queued arriving one tick after it tore the view down is the normal
236
+ // case rather than a mistake, and a throw there turns an orderly shutdown into an error report.
237
+ // The ones that answer something answer what an emptied view answers.
238
+ return {
239
+ setLyrics(lyrics, options) {
240
+ // The one entry point whose silence hides a real mistake: a renderer that was destroyed
241
+ // before it ever had a mount would otherwise swallow the throw below and look orderly.
242
+ if (isDestroyed) {
243
+ host.log(DESTROYED_SET_LYRICS_LOG);
244
+ return;
245
+ }
246
+ const nextMount = options?.mount ?? mount;
247
+ if (!nextMount) {
248
+ throw new Error("A lyrics renderer needs a mount: give one to createLyricsRenderer or to setLyrics");
249
+ }
250
+ // Before the mount moves, so a second song built somewhere else takes the first one's
251
+ // container with it rather than orphaning it in the mount it was built in.
252
+ clearBuiltView();
253
+ mount = nextMount;
254
+ buildLyricsView(engine, nextMount, lyrics, {
255
+ loaderVisible: options?.loaderVisible ?? false,
256
+ noLyrics: options?.noLyrics ?? false,
257
+ });
258
+ measure();
259
+ if (engine.lyricsContainer)
260
+ observeContainer(engine.lyricsContainer);
261
+ },
262
+ setTheme(css) {
263
+ if (isDestroyed)
264
+ return false;
265
+ // Settings first, so a caller acting on the answer is acting on a module that already holds
266
+ // the theme it is answering about.
267
+ const needsLyricRebuild = setThemeSettings(parseThemeConfig(css));
268
+ adoptThemeStyleSheet(css);
269
+ // Everything the engine resolved off the document was resolved against the theme that just
270
+ // went away.
271
+ clearEngineStyleCaches(engine);
272
+ return needsLyricRebuild;
273
+ },
274
+ tick(currentTimeS, options) {
275
+ if (isDestroyed)
276
+ return "lyrics-missing";
277
+ return tickView(engine, currentTimeS, resolveTickOptions(options));
278
+ },
279
+ relayout(measureLines = true) {
280
+ if (isDestroyed)
281
+ return;
282
+ measure(measureLines);
283
+ },
284
+ clear() {
285
+ if (isDestroyed)
286
+ return;
287
+ clearBuiltView();
288
+ },
289
+ destroy() {
290
+ if (isDestroyed)
291
+ return;
292
+ isDestroyed = true;
293
+ clearBuiltView();
294
+ // The theme outlives a song, so only this takes it away, and only the element this renderer
295
+ // created: leaving that one behind would leave a document nothing renders into carrying a
296
+ // stylesheet for lyrics, while taking away an adopted one would strip the renderer that owns
297
+ // it. Nothing was adopted in the one renderer per document this module supports.
298
+ createdThemeStyleElement?.remove();
299
+ createdThemeStyleElement = null;
300
+ rendererWindow.removeEventListener("resize", remeasureForViewport);
301
+ engine.destroy();
302
+ },
303
+ noteUserScroll() {
304
+ if (isDestroyed)
305
+ return;
306
+ noteEngineUserScroll(engine, hasUnsyncedLyrics(engine));
307
+ },
308
+ noteVisibilityChange() {
309
+ if (isDestroyed)
310
+ return;
311
+ noteEngineVisibilityChange(engine);
312
+ },
313
+ resumeAutoscroll() {
314
+ if (isDestroyed)
315
+ return;
316
+ resetScrollResume(engine);
317
+ },
318
+ clearOnScreenLyrics() {
319
+ if (isDestroyed)
320
+ return false;
321
+ return clearEngineOnScreenLyrics(engine);
322
+ },
323
+ scheduleLyricPositionUpdate(isTicking, retick) {
324
+ if (isDestroyed)
325
+ return;
326
+ // The caller's answer is one term rather than the whole of it. This is the measuring door that
327
+ // fires most, once per streamed translation and romanization, and it is reachable in exactly
328
+ // the state the guard exists for: the view ticks on while the page holds it off the screen.
329
+ scheduleEngineLyricPositionUpdate(engine, () => isTicking() && areLinesMeasurable(getRenderedLines(engine)), retick);
330
+ },
331
+ retickFromPlaybackClock(buildOptions) {
332
+ if (isDestroyed)
333
+ return "lyrics-missing";
334
+ return retickEngineFromPlaybackClock(engine, buildOptions);
335
+ },
336
+ // All three answer for an emptied view. `container` and `lines` are the state clearing drops, so
337
+ // they answer that way already; `syncType` is derived from lyrics that are gone and nothing
338
+ // resets it, so the container is the term that says they are still there.
339
+ get container() {
340
+ return engine.lyricsContainer;
341
+ },
342
+ get lines() {
343
+ return getRenderedLines(engine);
344
+ },
345
+ get syncType() {
346
+ return engine.lyricsContainer === null ? "none" : getRenderedSyncType(engine);
347
+ },
348
+ };
349
+ }
package/dist/seek.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function getSeekTimeFromClick(event: MouseEvent, lyricElement: HTMLElement): number | null;
package/dist/seek.js ADDED
@@ -0,0 +1,27 @@
1
+ import { LYRICS_CLASS, WORD_CLASS } from "./constants.js";
2
+ export function getSeekTimeFromClick(event, lyricElement) {
3
+ const target = event.target;
4
+ const container = lyricElement.closest(`.${LYRICS_CLASS}`);
5
+ const isRichsync = container?.dataset.sync === "richsync";
6
+ if (!isRichsync || !event.altKey) {
7
+ return parseFloat(lyricElement.dataset.time || "0");
8
+ }
9
+ let wordElement = target.closest(`.${WORD_CLASS}`);
10
+ if (!wordElement) {
11
+ const words = lyricElement.querySelectorAll(`.${WORD_CLASS}`);
12
+ let closestDist = Infinity;
13
+ words.forEach(word => {
14
+ const rect = word.getBoundingClientRect();
15
+ const centerX = rect.left + rect.width / 2;
16
+ const centerY = rect.top + rect.height / 2;
17
+ const dist = Math.hypot(event.clientX - centerX, event.clientY - centerY);
18
+ if (dist < closestDist) {
19
+ closestDist = dist;
20
+ wordElement = word;
21
+ }
22
+ });
23
+ }
24
+ if (!wordElement)
25
+ return null;
26
+ return parseFloat(wordElement.dataset.time || "0");
27
+ }
@@ -0,0 +1,103 @@
1
+ /* Instrumental break styles */
2
+ .blyrics--instrumental-icon {
3
+ height: var(--blyrics-font-size);
4
+ width: calc(var(--blyrics-font-size) + var(--blyrics-font-size) / 3);
5
+ overflow: visible;
6
+ margin-left: calc(var(--blyrics-font-size) / -3);
7
+ }
8
+
9
+ .blyrics-rtl.blyrics--instrumental .blyrics--instrumental-icon {
10
+ margin-left: 0;
11
+ margin-right: calc(var(--blyrics-font-size) / -3);
12
+ }
13
+
14
+ .blyrics--instrumental[data-agent="v2"] .blyrics--instrumental-icon,
15
+ .blyrics--instrumental[data-agent="v3"] .blyrics--instrumental-icon {
16
+ margin-left: 0px;
17
+ margin-right: calc(var(--blyrics-font-size) / -3);
18
+ }
19
+
20
+ .blyrics--instrumental[data-agent="v1000"] .blyrics--instrumental-icon {
21
+ margin-left: 0;
22
+ margin-right: 0;
23
+ }
24
+
25
+ .blyrics--instrumental-bg {
26
+ fill: var(--blyrics-lyric-inactive-color);
27
+ }
28
+
29
+ .blyrics--instrumental-fill {
30
+ fill: var(--blyrics-lyric-active-color);
31
+ transition: opacity var(--blyrics-lyric-highlight-fade-out-duration, 0.5s) ease;
32
+ opacity: 0;
33
+ }
34
+
35
+ .blyrics--instrumental.blyrics--animating .blyrics--instrumental-fill {
36
+ transition: none;
37
+ opacity: 1;
38
+ }
39
+
40
+ .blyrics--instrumental:not(.blyrics--pre-animating) .blyrics--wave-clip {
41
+ transition-property: transform;
42
+ transition-duration: 1000000s;
43
+ transition-delay: 0s;
44
+ transform: translateY(0%);
45
+ }
46
+
47
+ .blyrics--instrumental.blyrics--pre-animating:not(.blyrics--animating) .blyrics--wave-clip {
48
+ animation: none;
49
+ transform: translateY(78%);
50
+ }
51
+
52
+ .blyrics--instrumental.blyrics--animating .blyrics--wave-clip {
53
+ transition-property: transform;
54
+ transition-duration: calc(var(--blyrics-duration, 1ms) * 1);
55
+ transition-timing-function: linear;
56
+ transition-delay: var(--blyrics-anim-delay);
57
+ transform: translateY(-4%);
58
+ }
59
+
60
+ .blyrics--paused .blyrics--wave-clip {
61
+ transition-duration: 100000000s !important;
62
+ transform: translateY(100%) !important;
63
+ }
64
+
65
+ .blyrics--wave-path {
66
+ /* Set the transform origin to the bottom of the wave shape (y=4). */
67
+ transform-box: fill-box;
68
+ transform-origin: bottom;
69
+
70
+ /* Prepare the WAAPI flatten transition. */
71
+ transform: scaleY(1.2);
72
+ }
73
+
74
+ /* Trigger the Flattening */
75
+ .blyrics--instrumental.blyrics--animating:not(.blyrics--paused) .blyrics--wave-path {
76
+ /* Flatten the wave to 0 height */
77
+ transform: scaleY(0);
78
+
79
+ transition-property: transform;
80
+ transition-duration: calc(var(--blyrics-duration, 1ms) * 1);
81
+ transition-timing-function: ease-in;
82
+ transition-delay: var(--blyrics-anim-delay);
83
+ }
84
+
85
+ /* If paused, we freeze the flattening where it is */
86
+ .blyrics--instrumental.blyrics--animating.blyrics--paused .blyrics--wave-path {
87
+ transform: scaleY(0.0001); /* slightly different to force recalculation */
88
+ transition-duration: 10000000000s;
89
+ transition-property: transform;
90
+ transition-timing-function: ease-out;
91
+ transition-delay: 0s;
92
+ }
93
+
94
+ /* Update Keyframes for the "Split" shape */
95
+ /* These paths must close at 'L 30 4 L -4 4' to match the static rect overlap */
96
+ @keyframes blyrics-wave {
97
+ 0%, 100% {
98
+ d: path("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");
99
+ }
100
+ 50% {
101
+ d: path("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");
102
+ }
103
+ }