@braccato/core 0.1.7 → 1.1.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,662 @@
1
+ // A custom element over the renderer, so a page mounts synchronized lyrics by writing a tag rather
2
+ // than a facade. Importing this file registers two element names, which is why it is an entry point
3
+ // of its own: a consumer that only wants the renderer must not pay for a registration it never
4
+ // asked for, and this extension mounts the renderer itself.
5
+ //
6
+ // Light DOM, deliberately, rather than the shadow root the component this replaces used. Themes
7
+ // select on the class names in `constants.ts` at document level, `@property` registrations do not
8
+ // apply to a stylesheet inside a shadow root, and the extension and a third party should be running
9
+ // the same code. The theme handed to `theme` is adopted into the element's own document by
10
+ // `setTheme`; the module's stylesheets are the consumer's to load, the way any package's CSS is.
11
+ import { createLyricsRenderer } from "./renderer.js";
12
+ // -- Names --------------------------------------------
13
+ const TAG_NAME = "braccato-lyrics";
14
+ // What this extension would publish the same component under. A constructor may only be registered
15
+ // once, so the second name costs a subclass.
16
+ const ALIAS_TAG_NAME = "better-lyrics";
17
+ // Attributes are read, never written back. Reflecting `current-time` would put the playback clock in
18
+ // the DOM sixty times a second, and one attribute reflecting while the others do not is worse than
19
+ // none of them doing it.
20
+ const CURRENT_TIME_ATTRIBUTE = "current-time";
21
+ const PLAYING_ATTRIBUTE = "playing";
22
+ const SOURCE_ATTRIBUTE = "source";
23
+ const THEME_ATTRIBUTE = "theme";
24
+ // -- Following a media element --------------------------------------------
25
+ // Every moment the clock moved, changed speed or stopped while no frame of this element's was
26
+ // looking. Each one means the same thing here, so they share a handler. `play` and `pause` are the
27
+ // loop's start and stop. `seeking` moves the view as a scrub happens, since the position is already
28
+ // the requested one by the time it fires, and `seeked` corrects it to where the media element
29
+ // actually landed, which is not always the same number. `ratechange` retakes the reading with the
30
+ // rate it will be carried at.
31
+ //
32
+ // The rest are deliberately not listened to, because the frame loop covers them by asking the media
33
+ // element whether its clock is still going rather than trusting that something said so. `ended` is
34
+ // covered by the `pause` a non-looping media element fires first and by that same question.
35
+ // `emptied` says the resource went away rather than that the clock moved, and leaves the media
36
+ // element paused at zero for the next frame to report; the README describes the one gap, which is
37
+ // `emptied` while already paused. `error` is the one that would otherwise spin, and `isClockRunning`
38
+ // below is what stops it. `waiting` and `stalled` are what the carry cap is for: both say the clock
39
+ // stopped advancing without stopping. `loadedmetadata` carries nothing this element reads, and
40
+ // `timeupdate` is a coarser copy of the frame loop while playing and of the seek events while not.
41
+ const MEDIA_CLOCK_EVENTS = ["play", "pause", "seeking", "seeked", "ratechange"];
42
+ // How far past its last reading the media clock may be carried, in milliseconds of frame time
43
+ // rather than of song time. `currentTime` is only as fresh as the media element chose to make it,
44
+ // which for video is once per presented frame, so a view rendering the raw reading steps where the
45
+ // song runs. The gap to fill is spaced in frame time whatever the playback rate is, which is why
46
+ // the ceiling is measured there; the README says what that costs at a rate above 1x.
47
+ const MAX_CLOCK_CARRY_MS = 100;
48
+ // The names the component this replaces dispatched, kept so its consumers port by changing an
49
+ // import. `braccato:word-click` is not among them: the renderer tells its host a seek happened and
50
+ // nothing more, so telling a word seek from a line seek here would mean this file re-deriving the
51
+ // module's own click branch off the DOM. The README says what to listen for instead.
52
+ const LINE_CLICK_EVENT = "braccato:line-click";
53
+ const LYRICS_LOADED_EVENT = "braccato:lyrics-loaded";
54
+ const SCROLL_STATE_EVENT = "braccato:scroll-state";
55
+ const ERROR_EVENT = "braccato:error";
56
+ const NO_BROWSING_CONTEXT_MESSAGE = "This element is in a document with no window, so there is nothing to build lyrics against";
57
+ const THEME_DISAGREEMENT_MESSAGE = "Another lyrics element in this document was given a different theme, and the module's theme settings are shared, so both views render against whichever theme was applied last";
58
+ const NON_MEDIA_SOURCE_MESSAGE = "The source given is not a media element in this element's document, so the lyrics have no clock to follow";
59
+ // -- The views a document is rendering --------------------------------------------
60
+ // Membership means rendering, so an element that threw on the way up or has been disconnected is
61
+ // not in here and is not one of the views a theme has to agree with.
62
+ const renderingElementsByDocument = new WeakMap();
63
+ // -- Helpers --------------------------------------------
64
+ function toError(thrown) {
65
+ return thrown instanceof Error ? thrown : new Error(String(thrown), { cause: thrown });
66
+ }
67
+ function unresolvedSourceMessage(selector) {
68
+ return `The source selector "${selector}" does not name a media element in this element's document, so the lyrics have no clock to follow`;
69
+ }
70
+ /**
71
+ * Whether the media element's clock is still going. A fatal decode or network failure mid-song sets
72
+ * `error` and fires one, and leaves `paused` alone: nothing runs the pause steps, so a loop that
73
+ * only asked about `paused` would spin against a stopped clock for the life of the element.
74
+ */
75
+ function isClockRunning(media) {
76
+ return !media.paused && media.error === null;
77
+ }
78
+ /**
79
+ * Registers a name only if it is free. A page that loads this module twice would otherwise throw out
80
+ * of an import and take the rest of that script with it, and the second registration could not have
81
+ * won anyway. Silently, because there is no consumer to tell at module scope: the README says what
82
+ * two copies on one page costs.
83
+ */
84
+ function defineOnce(tagName, elementConstructor) {
85
+ if (customElements.get(tagName) !== undefined)
86
+ return;
87
+ customElements.define(tagName, elementConstructor);
88
+ }
89
+ // -- The element --------------------------------------------
90
+ /**
91
+ * Mounts a lyrics view into itself. The renderer is built when the element is connected and
92
+ * destroyed when it is disconnected, so an element that is moved around the page rebuilds rather
93
+ * than going quiet, and every property may be written before either has happened.
94
+ *
95
+ * `dir` is not among the properties, and that is the point: `HTMLElement` already reflects it, the
96
+ * lines this module builds carry `dir="auto"` and resolve their own direction from their text, and a
97
+ * property here would be a second opinion about a question the platform has already answered.
98
+ */
99
+ export class BraccatoLyricsElement extends HTMLElement {
100
+ static observedAttributes = [CURRENT_TIME_ATTRIBUTE, PLAYING_ATTRIBUTE, SOURCE_ATTRIBUTE, THEME_ATTRIBUTE];
101
+ #renderer = null;
102
+ // The document this element registered itself in, rather than whatever it is in now: adopting an
103
+ // element into another document changes `ownerDocument` under the callback that has to take it
104
+ // back out of the first one.
105
+ #renderingDocument = null;
106
+ #missingBrowsingContext = false;
107
+ // Null until a consumer gives lyrics, which is not the same as being given none: an element that
108
+ // was never given any leaves whatever it is mounted over alone.
109
+ #lyrics = null;
110
+ #lyricsOptions = {};
111
+ #currentTimeS = 0;
112
+ #playing = false;
113
+ #tickOptions = {};
114
+ #theme = "";
115
+ #hostOverrides = {};
116
+ #source = null;
117
+ // Non-null exactly while the element is listening to a media element, so there is no state where
118
+ // one is remembered and its listeners are not.
119
+ #media = null;
120
+ // The last reading of the media clock, the frame it was taken on, and the rate it was taken at.
121
+ // Null means the next frame takes a new one, which is what a seek or a rate change leaves behind.
122
+ #clockAnchor = null;
123
+ #frameHandle = null;
124
+ // The window the pending frame was scheduled against rather than whatever the element is in now:
125
+ // adopting an element into another document would leave the cancellation aimed at the wrong one.
126
+ #frameWindow = null;
127
+ // -- Properties --------------------------------------------
128
+ /**
129
+ * The song. An empty array clears the view, so a consumer between songs has a way to say so.
130
+ */
131
+ get lyrics() {
132
+ return this.#lyrics;
133
+ }
134
+ set lyrics(lyrics) {
135
+ this.#lyrics = lyrics;
136
+ this.#applyLyrics();
137
+ }
138
+ /**
139
+ * How the lines are built, beyond the lines themselves: whether a loader is still covering the
140
+ * view, and whether these lyrics are a "not found" placeholder rather than a song. The second one
141
+ * is what keeps passive scrolling off a one line message it would otherwise drift for the length
142
+ * of the song.
143
+ *
144
+ * Read by the next build rather than causing one, so a consumer writes it beside `lyrics` rather
145
+ * than instead of it, and writing both renders once.
146
+ */
147
+ get lyricsOptions() {
148
+ return this.#lyricsOptions;
149
+ }
150
+ set lyricsOptions(options) {
151
+ this.#lyricsOptions = options;
152
+ }
153
+ /**
154
+ * The media element the lyrics follow, as a CSS selector resolved in this element's own document
155
+ * or as the element itself. Setting it binds and null unbinds, and while it is bound the element
156
+ * reads the clock rather than being told it: `currentTime` and `playing` become what it reports.
157
+ *
158
+ * Bound only while connected, the way the renderer is built only while connected, and a selector
159
+ * is resolved again every time it is written and every time the element connects.
160
+ */
161
+ get source() {
162
+ return this.#source;
163
+ }
164
+ set source(source) {
165
+ this.#source = source;
166
+ this.#bindSource();
167
+ }
168
+ /**
169
+ * The media element `source` resolved to. Null whenever nothing is being followed, which is the
170
+ * answer for a selector that matched nothing and for an element that is not connected.
171
+ */
172
+ get mediaElement() {
173
+ return this.#media;
174
+ }
175
+ /**
176
+ * Playback position in seconds, not milliseconds: the module ticks in seconds, and converting here
177
+ * would leave the element and the renderer underneath it disagreeing about what a number means.
178
+ * Writing it renders the view again, so whoever owns the clock drives the lyrics by writing this.
179
+ *
180
+ * While a media element is bound it is the one that owns the clock, so a write is dropped and this
181
+ * keeps reporting what the binding last read. Dropped rather than reported: a consumer who left
182
+ * their own frame loop running would otherwise be told about it sixty times a second.
183
+ */
184
+ get currentTime() {
185
+ return this.#currentTimeS;
186
+ }
187
+ set currentTime(currentTimeS) {
188
+ if (this.#media !== null)
189
+ return;
190
+ this.#currentTimeS = currentTimeS;
191
+ this.#tick();
192
+ }
193
+ /** An output rather than an input while a media element is bound, exactly as `currentTime` is. */
194
+ get playing() {
195
+ return this.#playing;
196
+ }
197
+ set playing(playing) {
198
+ if (this.#media !== null)
199
+ return;
200
+ this.#playing = playing;
201
+ this.#tick();
202
+ }
203
+ /**
204
+ * The rest of a tick: the user offsets the clock is matched against, whether passive scrolling is
205
+ * switched on for unsynced lyrics, and the timestamp of the player snapshot the clock came from.
206
+ *
207
+ * That last one matters beyond this element. The playback clock the module compares a tick
208
+ * against is module scope, so an element sharing a realm with another view has to be given the
209
+ * same snapshot timestamps that view is, or every tick reads as a jump away from the other one.
210
+ *
211
+ * Read by the next tick rather than causing one, so a consumer that writes these and the clock on
212
+ * the same frame renders the view once rather than twice.
213
+ */
214
+ get tickOptions() {
215
+ return this.#tickOptions;
216
+ }
217
+ set tickOptions(options) {
218
+ this.#tickOptions = options;
219
+ }
220
+ /**
221
+ * A compiled stylesheet. Its `blyrics-*` comments configure the module and the sheet itself goes
222
+ * into this element's document. An empty one puts every setting back to its default, and is
223
+ * applied like any other: the settings are module scope, so an element that applied nothing would
224
+ * render against whatever the last theme in that bundle left behind. What that costs is that
225
+ * connecting an element nobody gave a theme empties the theme element already in its document.
226
+ */
227
+ get theme() {
228
+ return this.#theme;
229
+ }
230
+ set theme(css) {
231
+ this.#theme = css;
232
+ this.#applyTheme();
233
+ }
234
+ /**
235
+ * Overrides for anything the renderer asks of its surroundings. Every member has a default, so a
236
+ * consumer with nothing to say leaves this alone. Writing it while connected rebuilds the view:
237
+ * the renderer is handed its host once, when it is created.
238
+ */
239
+ get host() {
240
+ return this.#hostOverrides;
241
+ }
242
+ set host(overrides) {
243
+ this.#hostOverrides = overrides;
244
+ if (this.#renderer === null)
245
+ return;
246
+ this.#destroyRenderer();
247
+ this.#build();
248
+ }
249
+ /**
250
+ * The renderer underneath, for a consumer who outgrows the element. Null while disconnected, and a
251
+ * different one after every reconnection.
252
+ */
253
+ get renderer() {
254
+ return this.#renderer;
255
+ }
256
+ /**
257
+ * What the element is doing, asked rather than listened for. `theme-conflict` is the one that says
258
+ * the view is on the screen but not necessarily the way it was asked for: the theme settings are
259
+ * module scope, so a document with two elements holding different themes renders both against
260
+ * whichever was applied last.
261
+ *
262
+ * A `source` that named nothing to follow is deliberately not one of these. The element is still
263
+ * rendering, and a status saying otherwise would trade one true answer for another. What a
264
+ * consumer who was not listening for the error reads instead is `mediaElement`, which is null
265
+ * while `source` still holds the selector it could not resolve.
266
+ */
267
+ get status() {
268
+ if (this.#renderer === null)
269
+ return this.#missingBrowsingContext ? "no-browsing-context" : "idle";
270
+ return this.#disagreeingPeers().length > 0 ? "theme-conflict" : "rendering";
271
+ }
272
+ // -- Lifecycle --------------------------------------------
273
+ connectedCallback() {
274
+ // A page that set a property before this module loaded set it on the instance, where it shadows
275
+ // the accessor above for the rest of that element's life unless it is run through it again.
276
+ this.#upgradeProperty("lyricsOptions");
277
+ this.#upgradeProperty("lyrics");
278
+ this.#upgradeProperty("tickOptions");
279
+ this.#upgradeProperty("currentTime");
280
+ this.#upgradeProperty("playing");
281
+ this.#upgradeProperty("theme");
282
+ this.#upgradeProperty("host");
283
+ this.#upgradeProperty("source");
284
+ this.#build();
285
+ // After the view exists, so a build that threw on the way up leaves no listener on a media
286
+ // element and no frame queued for a view that was never there.
287
+ this.#bindSource();
288
+ }
289
+ disconnectedCallback() {
290
+ const peers = this.#peers();
291
+ this.#unbindMedia();
292
+ this.#destroyRenderer();
293
+ this.#missingBrowsingContext = false;
294
+ // Destroying a renderer takes the theme element with it when that renderer is the one that
295
+ // created it, so whatever is still rendering in that document writes its own theme back in.
296
+ for (const peer of peers) {
297
+ peer.#applyTheme();
298
+ }
299
+ }
300
+ attributeChangedCallback(name, _oldValue, newValue) {
301
+ if (name === CURRENT_TIME_ATTRIBUTE) {
302
+ const currentTimeS = Number.parseFloat(newValue ?? "");
303
+ // A half written attribute must not send the lyrics back to the top of the song.
304
+ if (!Number.isNaN(currentTimeS))
305
+ this.currentTime = currentTimeS;
306
+ return;
307
+ }
308
+ if (name === PLAYING_ATTRIBUTE) {
309
+ this.playing = newValue !== null;
310
+ return;
311
+ }
312
+ if (name === SOURCE_ATTRIBUTE) {
313
+ this.source = newValue;
314
+ return;
315
+ }
316
+ if (name === THEME_ATTRIBUTE) {
317
+ this.theme = newValue ?? "";
318
+ }
319
+ }
320
+ // -- Building --------------------------------------------
321
+ /**
322
+ * A throw from inside the module while the view is being built is the one thing `braccato:error`
323
+ * deliberately does not cover. Nothing here catches it, so it comes out of `connectedCallback` and
324
+ * the page reports it as an uncaught error with the stack it happened on, which is worth more to
325
+ * whoever has to fix it than an event carrying the same error second hand.
326
+ */
327
+ #build() {
328
+ if (this.#renderer !== null)
329
+ return;
330
+ const view = this.ownerDocument.defaultView;
331
+ this.#missingBrowsingContext = view === null;
332
+ if (view === null) {
333
+ this.#emitError("connect", new Error(NO_BROWSING_CONTEXT_MESSAGE));
334
+ return;
335
+ }
336
+ this.#renderer = createLyricsRenderer({
337
+ document: this.ownerDocument,
338
+ window: view,
339
+ mount: this,
340
+ host: this.#hostForRenderer(),
341
+ });
342
+ // After the renderer exists and never before, so that a build which threw on the way up leaves
343
+ // nothing behind claiming to be one of the document's views.
344
+ this.#joinDocument();
345
+ this.#applyTheme();
346
+ this.#applyLyrics();
347
+ }
348
+ #destroyRenderer() {
349
+ this.#renderer?.destroy();
350
+ this.#renderer = null;
351
+ this.#leaveDocument();
352
+ }
353
+ /**
354
+ * The consumer's host with the two members the events are read off wrapped rather than replaced,
355
+ * so a consumer who wrote one is still called and the event fires either way.
356
+ */
357
+ #hostForRenderer() {
358
+ const overrides = this.#hostOverrides;
359
+ return {
360
+ ...overrides,
361
+ seek: timeS => {
362
+ overrides.seek?.(timeS);
363
+ // A bound media element is the player, so a click on a line reaches it here rather than
364
+ // through a consumer who would otherwise have to write the other half of their own binding.
365
+ // Before the event, so a listener reading the media element back sees where the click sent
366
+ // it.
367
+ if (this.#media !== null)
368
+ this.#media.currentTime = timeS;
369
+ this.#emit(LINE_CLICK_EVENT, { timeS });
370
+ },
371
+ setResumeAffordanceVisible: visible => {
372
+ overrides.setResumeAffordanceVisible?.(visible);
373
+ this.#emit(SCROLL_STATE_EVENT, { userScrolling: visible });
374
+ },
375
+ };
376
+ }
377
+ #applyLyrics() {
378
+ const renderer = this.#renderer;
379
+ const lyrics = this.#lyrics;
380
+ if (renderer === null || lyrics === null)
381
+ return;
382
+ try {
383
+ if (lyrics.length === 0) {
384
+ renderer.clear();
385
+ }
386
+ else {
387
+ renderer.setLyrics(lyrics, this.#lyricsOptions);
388
+ }
389
+ }
390
+ catch (thrown) {
391
+ this.#emitError("lyrics", toError(thrown));
392
+ return;
393
+ }
394
+ this.#emit(LYRICS_LOADED_EVENT, {
395
+ lineCount: renderer.lines.length,
396
+ syncType: renderer.syncType,
397
+ });
398
+ // So the new lines are where the song is rather than at the top until the clock next moves.
399
+ this.#tick();
400
+ }
401
+ #applyTheme() {
402
+ const renderer = this.#renderer;
403
+ if (renderer === null)
404
+ return;
405
+ let needsLyricRebuild = false;
406
+ try {
407
+ needsLyricRebuild = renderer.setTheme(this.#theme);
408
+ }
409
+ catch (thrown) {
410
+ this.#emitError("theme", toError(thrown));
411
+ return;
412
+ }
413
+ this.#reportThemeDisagreement();
414
+ // Only when there are lines to rebuild. A theme applied while the view is being built is applied
415
+ // before the lyrics are, and the build itself is what puts them there.
416
+ if (needsLyricRebuild && renderer.container !== null)
417
+ this.#applyLyrics();
418
+ }
419
+ #tick() {
420
+ const renderer = this.#renderer;
421
+ // A view with nothing built reports a missing container to the host on every tick, and a
422
+ // consumer whose clock runs before the lyrics arrive is the ordinary case rather than a fault.
423
+ if (renderer === null || renderer.container === null)
424
+ return;
425
+ // The rate a bound media element is playing at is the element's to report, the same way the
426
+ // clock is, so it overrides what the consumer wrote for exactly as long as the binding lasts.
427
+ const boundRate = this.#media === null ? undefined : this.#media.playbackRate;
428
+ // The play state last, so a consumer writing plain JavaScript cannot answer that question twice.
429
+ renderer.tick(this.#currentTimeS, {
430
+ ...this.#tickOptions,
431
+ ...(boundRate === undefined ? {} : { playbackRate: boundRate }),
432
+ isPlaying: this.#playing,
433
+ });
434
+ }
435
+ #upgradeProperty(name) {
436
+ // Written through the class rather than `this`: TypeScript refuses an indexed write to a
437
+ // polymorphic `this`, since a subclass may have narrowed the accessor it would land on.
438
+ const element = this;
439
+ if (!Object.hasOwn(element, name))
440
+ return;
441
+ const value = element[name];
442
+ Reflect.deleteProperty(element, name);
443
+ element[name] = value;
444
+ }
445
+ // -- Following a media element --------------------------------------------
446
+ /**
447
+ * Binds whatever `source` names now, unbinding first, so one call is also how the element moves
448
+ * from one media element to another and leaves nothing behind on the first.
449
+ *
450
+ * Bound only while there is a view to drive. A disconnected element holds no renderer, and a
451
+ * clock feeding nothing is a listener and a frame that nobody asked for.
452
+ */
453
+ #bindSource() {
454
+ this.#unbindMedia();
455
+ if (this.#renderer === null)
456
+ return;
457
+ const media = this.#resolveSource();
458
+ if (media === null)
459
+ return;
460
+ this.#media = media;
461
+ for (const type of MEDIA_CLOCK_EVENTS) {
462
+ media.addEventListener(type, this.#handleMediaClockEvent);
463
+ }
464
+ // Read now rather than waited for: a media element that was already playing when it was bound
465
+ // has no `play` event left to fire.
466
+ this.#driveFromMedia();
467
+ this.#syncFrameLoop();
468
+ }
469
+ #unbindMedia() {
470
+ this.#cancelFrame();
471
+ const media = this.#media;
472
+ this.#media = null;
473
+ this.#clockAnchor = null;
474
+ if (media === null)
475
+ return;
476
+ for (const type of MEDIA_CLOCK_EVENTS) {
477
+ media.removeEventListener(type, this.#handleMediaClockEvent);
478
+ }
479
+ }
480
+ #resolveSource() {
481
+ const source = this.#source;
482
+ if (source === null)
483
+ return null;
484
+ // The element's own realm rather than this one, so a source belonging to another document is
485
+ // judged against that document's constructor rather than against a foreign one it can never be.
486
+ const view = this.ownerDocument.defaultView;
487
+ // Held to the same test as a selector's answer. A consumer writing plain JavaScript can hand
488
+ // this property anything, and something with the two listener methods binds without complaint
489
+ // and then feeds `undefined` to every tick for the length of the song.
490
+ if (typeof source !== "string") {
491
+ if (view !== null && source instanceof view.HTMLMediaElement)
492
+ return source;
493
+ this.#emitError("source", new Error(NON_MEDIA_SOURCE_MESSAGE));
494
+ return null;
495
+ }
496
+ let matched;
497
+ try {
498
+ matched = this.ownerDocument.querySelector(source);
499
+ }
500
+ catch (thrown) {
501
+ // A string that is not a selector throws, and a property setter is not where a consumer
502
+ // expects to catch that.
503
+ this.#emitError("source", toError(thrown));
504
+ return null;
505
+ }
506
+ if (view === null || !(matched instanceof view.HTMLMediaElement)) {
507
+ this.#emitError("source", new Error(unresolvedSourceMessage(source)));
508
+ return null;
509
+ }
510
+ return matched;
511
+ }
512
+ // One handler for all of them, because what each event means to this element is the same thing:
513
+ // the clock moved, changed speed or stopped, and the reading being carried forward is stale.
514
+ #handleMediaClockEvent = () => {
515
+ this.#driveFromMedia();
516
+ this.#syncFrameLoop();
517
+ };
518
+ #driveFromMedia() {
519
+ const media = this.#media;
520
+ if (media === null)
521
+ return;
522
+ this.#clockAnchor = null;
523
+ this.#drive(media.currentTime, isClockRunning(media));
524
+ }
525
+ #drive(currentTimeS, playing) {
526
+ this.#currentTimeS = currentTimeS;
527
+ this.#playing = playing;
528
+ this.#tick();
529
+ }
530
+ /**
531
+ * A frame runs only while a bound clock is running against a view, so a stopped one costs
532
+ * nothing. The view is a term of that rather than an assumption: `connectedCallback` orders its
533
+ * build before its bind so a build that threw leaves no loop behind, and a `host` rewrite whose
534
+ * rebuild throws is the one road left to a live binding with no renderer under it.
535
+ */
536
+ #syncFrameLoop() {
537
+ if (this.#renderer !== null && this.#media !== null && this.#playing) {
538
+ this.#scheduleFrame();
539
+ return;
540
+ }
541
+ this.#cancelFrame();
542
+ }
543
+ #scheduleFrame() {
544
+ if (this.#frameHandle !== null)
545
+ return;
546
+ const view = this.ownerDocument.defaultView;
547
+ if (view === null)
548
+ return;
549
+ this.#frameWindow = view;
550
+ this.#frameHandle = view.requestAnimationFrame(this.#renderFrame);
551
+ }
552
+ #cancelFrame() {
553
+ if (this.#frameHandle === null)
554
+ return;
555
+ this.#frameWindow?.cancelAnimationFrame(this.#frameHandle);
556
+ this.#frameHandle = null;
557
+ this.#frameWindow = null;
558
+ }
559
+ #renderFrame = (frameTimeMs) => {
560
+ this.#frameHandle = null;
561
+ this.#frameWindow = null;
562
+ const media = this.#media;
563
+ if (media === null)
564
+ return;
565
+ // The loop asks rather than trusting the pause event, so a clock that stopped without one, at
566
+ // the end of a song, when its resource went away or when the stream feeding it failed, still
567
+ // stops the frames.
568
+ if (isClockRunning(media)) {
569
+ this.#drive(this.#carriedClock(media, frameTimeMs), true);
570
+ }
571
+ else {
572
+ this.#driveFromMedia();
573
+ }
574
+ this.#syncFrameLoop();
575
+ };
576
+ /**
577
+ * Where the song is on this frame. A reading the media element has not refreshed is carried
578
+ * forward at the rate it was taken at, which is what keeps a binding honest at any playback rate
579
+ * rather than only at 1x, and what turns a clock that only updates once per presented frame into
580
+ * one the lyrics can run against.
581
+ */
582
+ #carriedClock(media, frameTimeMs) {
583
+ const mediaTimeS = media.currentTime;
584
+ const anchor = this.#clockAnchor;
585
+ if (anchor === null || anchor.mediaTimeS !== mediaTimeS) {
586
+ this.#clockAnchor = { mediaTimeS, frameTimeMs, rate: media.playbackRate };
587
+ return mediaTimeS;
588
+ }
589
+ const carriedMs = Math.min(Math.max(frameTimeMs - anchor.frameTimeMs, 0), MAX_CLOCK_CARRY_MS);
590
+ return anchor.mediaTimeS + (carriedMs * anchor.rate) / 1000;
591
+ }
592
+ // -- More than one view in a document --------------------------------------------
593
+ /**
594
+ * The module's theme settings are module scope, so two views in one realm render against whichever
595
+ * theme either of them was applied last, and two in one document write the same theme element as
596
+ * well. Two views handed the same theme are not affected by any of that and render correctly, so
597
+ * that is the line: a second element builds, and what is reported is the disagreement rather than
598
+ * the company.
599
+ *
600
+ * Both sides are told, because the element that diverged is not the one that is now rendering
601
+ * against a theme it never asked for.
602
+ */
603
+ #reportThemeDisagreement() {
604
+ const disagreeing = this.#disagreeingPeers();
605
+ if (disagreeing.length === 0)
606
+ return;
607
+ const error = new Error(THEME_DISAGREEMENT_MESSAGE);
608
+ this.#emitError("conflict", error);
609
+ for (const peer of disagreeing) {
610
+ peer.#emitError("conflict", error);
611
+ }
612
+ }
613
+ #peers() {
614
+ const elementDocument = this.#renderingDocument;
615
+ if (elementDocument === null)
616
+ return [];
617
+ const rendering = renderingElementsByDocument.get(elementDocument);
618
+ if (rendering === undefined)
619
+ return [];
620
+ return [...rendering].filter(element => element !== this);
621
+ }
622
+ #disagreeingPeers() {
623
+ return this.#peers().filter(element => element.#theme !== this.#theme);
624
+ }
625
+ #joinDocument() {
626
+ const elementDocument = this.ownerDocument;
627
+ const rendering = renderingElementsByDocument.get(elementDocument) ?? new Set();
628
+ renderingElementsByDocument.set(elementDocument, rendering);
629
+ rendering.add(this);
630
+ this.#renderingDocument = elementDocument;
631
+ }
632
+ #leaveDocument() {
633
+ const elementDocument = this.#renderingDocument;
634
+ if (elementDocument === null)
635
+ return;
636
+ this.#renderingDocument = null;
637
+ renderingElementsByDocument.get(elementDocument)?.delete(this);
638
+ }
639
+ // -- Events --------------------------------------------
640
+ #emit(type, detail) {
641
+ // The element's own realm rather than this one, and the global as a last resort: an element in a
642
+ // document with no window still has an error to report about exactly that.
643
+ const EventConstructor = this.ownerDocument.defaultView?.CustomEvent ?? CustomEvent;
644
+ // Composed so an element a consumer put inside their own shadow root still reaches their
645
+ // listener; the element builds no shadow root of its own.
646
+ this.dispatchEvent(new EventConstructor(type, { detail, bubbles: true, composed: true }));
647
+ }
648
+ #emitError(phase, error) {
649
+ // A microtask later rather than where it happened. `connectedCallback` runs before any listener
650
+ // a page could have added, and for an element the parser built it runs before any script at all,
651
+ // so an error reported from a build is one nobody could ever have heard. `status` is the answer
652
+ // for a consumer that was not listening even then.
653
+ queueMicrotask(() => {
654
+ this.#emit(ERROR_EVENT, { phase, error });
655
+ });
656
+ }
657
+ }
658
+ // -- Registration --------------------------------------------
659
+ class BetterLyricsElement extends BraccatoLyricsElement {
660
+ }
661
+ defineOnce(TAG_NAME, BraccatoLyricsElement);
662
+ defineOnce(ALIAS_TAG_NAME, BetterLyricsElement);