@braccato/core 1.15.3 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,8 +96,9 @@ connects, and everything it was handed by then is applied at once.
96
96
  | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, when the clock was sampled, and the rate the song is playing at. |
97
97
  | `theme` | `theme` | `string` | `""` | A compiled stylesheet. See Theming. |
98
98
  | `host` | | `Partial<LyricsRendererHost>` | `{}` | Overrides for what the renderer asks of its surroundings. Every member has a default. Writing it while connected rebuilds the view. |
99
+ | `layout` | `layout` | `"scroll" \| "stage"` | `"scroll"` | `stage` shows only the lines being sung. See Entry points. Writing it while connected rebuilds the view. |
99
100
  | `renderer` | | `LyricsRenderer \| null` (get) | `null` | The renderer underneath, for the day the tag runs out. A different one after every reconnection. |
100
- | `status` | | `ElementStatus` (get) | `"idle"` | `idle`, `rendering`, `theme-conflict` or `no-browsing-context`. |
101
+ | `status` | | `ElementStatus` (get) | `"idle"` | `idle`, `rendering`, `theme-conflict`, `unsynced-on-stage` or `no-browsing-context`. |
101
102
 
102
103
  `tickOptions` and `lyricsOptions` are stored on write and read by the next tick or the next build, so
103
104
  writing options and the clock on the same frame renders once.
@@ -112,12 +113,13 @@ do not is worse than none of them doing it.
112
113
  | -------------- | ------------- | -------------------------------------------------------------------------------------------------- |
113
114
  | `source` | `source` | The selector form only. Another selector moves the binding, and removing it unbinds. |
114
115
  | `theme` | `theme` | A whole stylesheet in an attribute value. It works, but nobody would ship a theme this way. |
116
+ | `layout` | `layout` | `stage` puts the view on a stage. Any other value, or no attribute, scrolls. |
115
117
  | `current-time` | `currentTime` | Seconds. A value that does not parse as a number is ignored rather than read as zero. |
116
118
  | `playing` | `playing` | An ordinary boolean attribute: its presence is what counts, so `playing="false"` is playing. |
117
119
 
118
120
  ## Events
119
121
 
120
- All four bubble and are composed, so an element you put inside your own shadow root still reaches
122
+ All five bubble and are composed, so an element you put inside your own shadow root still reaches
121
123
  your listener.
122
124
 
123
125
  | Event | Detail | When |
@@ -125,7 +127,8 @@ your listener.
125
127
  | `braccato:lyrics-loaded` | `{ lineCount, syncType }` | Lyrics were applied, including an empty array. A theme change that rebuilds the lines reports itself the same way. |
126
128
  | `braccato:line-click` | `{ timeS }` | A line was clicked. The seek has already reached the bound media element by the time you hear about it. |
127
129
  | `braccato:scroll-state` | `{ userScrolling }` | Autoscroll stopped following the song, or started again. |
128
- | `braccato:error` | `{ phase, error }` | Connecting, resolving a source, or applying lyrics or a theme went wrong. `phase` is `connect`, `conflict`, `source`, `lyrics` or `theme`. |
130
+ | `braccato:stage-layout` | `{ box }` | Stage layout only. The box around the sung lines in the container's coordinates, or null when nothing sung is on stage. |
131
+ | `braccato:error` | `{ phase, error }` | Connecting, resolving a source, or applying lyrics or a theme went wrong, or a stage cannot show what it was given. `phase` is `connect`, `conflict`, `layout`, `source`, `lyrics` or `theme`. |
129
132
 
130
133
  Errors are dispatched a microtask after they happen rather than where they happen, which is what
131
134
  makes them receivable at all: `connectedCallback` runs before any listener a page could have added.
@@ -403,6 +406,20 @@ Everything else is the theme's. Lines are styled exactly as they would be in a s
403
406
  `host.onStageLayout(box)` reports the box around the sung lines in the container's coordinates, or null
404
407
  when nothing sung is on stage, so you can draw a backdrop outside the container the theme styles.
405
408
 
409
+ The element takes the same layout as `layout="stage"`, and reports the same box as
410
+ `braccato:stage-layout`. Give the element or its parent a size and a position, and load `stage.css`.
411
+ A stage that cannot show what it was given says so rather than staying blank: unsynced lyrics set
412
+ `status` to `unsynced-on-stage`, and both they and a missing `stage.css` dispatch `braccato:error`
413
+ with `phase: "layout"`. Each song is reported once, so a theme that rebuilds the lines stays quiet.
414
+ The stylesheet is checked a frame after the song is built, and only the event reports it.
415
+
416
+ ```html
417
+ <div style="position: relative; aspect-ratio: 16 / 9">
418
+ <video id="clip" src="clip.mp4"></video>
419
+ <braccato-lyrics source="#clip" layout="stage"></braccato-lyrics>
420
+ </div>
421
+ ```
422
+
406
423
  `@braccato/core/element` registers `<braccato-lyrics>`, and `<better-lyrics>` beside it, on import.
407
424
  Registration is a side effect, which is why it is entered separately.
408
425
 
package/dist/README.md CHANGED
@@ -96,8 +96,9 @@ connects, and everything it was handed by then is applied at once.
96
96
  | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, when the clock was sampled, and the rate the song is playing at. |
97
97
  | `theme` | `theme` | `string` | `""` | A compiled stylesheet. See Theming. |
98
98
  | `host` | | `Partial<LyricsRendererHost>` | `{}` | Overrides for what the renderer asks of its surroundings. Every member has a default. Writing it while connected rebuilds the view. |
99
+ | `layout` | `layout` | `"scroll" \| "stage"` | `"scroll"` | `stage` shows only the lines being sung. See Entry points. Writing it while connected rebuilds the view. |
99
100
  | `renderer` | | `LyricsRenderer \| null` (get) | `null` | The renderer underneath, for the day the tag runs out. A different one after every reconnection. |
100
- | `status` | | `ElementStatus` (get) | `"idle"` | `idle`, `rendering`, `theme-conflict` or `no-browsing-context`. |
101
+ | `status` | | `ElementStatus` (get) | `"idle"` | `idle`, `rendering`, `theme-conflict`, `unsynced-on-stage` or `no-browsing-context`. |
101
102
 
102
103
  `tickOptions` and `lyricsOptions` are stored on write and read by the next tick or the next build, so
103
104
  writing options and the clock on the same frame renders once.
@@ -112,12 +113,13 @@ do not is worse than none of them doing it.
112
113
  | -------------- | ------------- | -------------------------------------------------------------------------------------------------- |
113
114
  | `source` | `source` | The selector form only. Another selector moves the binding, and removing it unbinds. |
114
115
  | `theme` | `theme` | A whole stylesheet in an attribute value. It works, but nobody would ship a theme this way. |
116
+ | `layout` | `layout` | `stage` puts the view on a stage. Any other value, or no attribute, scrolls. |
115
117
  | `current-time` | `currentTime` | Seconds. A value that does not parse as a number is ignored rather than read as zero. |
116
118
  | `playing` | `playing` | An ordinary boolean attribute: its presence is what counts, so `playing="false"` is playing. |
117
119
 
118
120
  ## Events
119
121
 
120
- All four bubble and are composed, so an element you put inside your own shadow root still reaches
122
+ All five bubble and are composed, so an element you put inside your own shadow root still reaches
121
123
  your listener.
122
124
 
123
125
  | Event | Detail | When |
@@ -125,7 +127,8 @@ your listener.
125
127
  | `braccato:lyrics-loaded` | `{ lineCount, syncType }` | Lyrics were applied, including an empty array. A theme change that rebuilds the lines reports itself the same way. |
126
128
  | `braccato:line-click` | `{ timeS }` | A line was clicked. The seek has already reached the bound media element by the time you hear about it. |
127
129
  | `braccato:scroll-state` | `{ userScrolling }` | Autoscroll stopped following the song, or started again. |
128
- | `braccato:error` | `{ phase, error }` | Connecting, resolving a source, or applying lyrics or a theme went wrong. `phase` is `connect`, `conflict`, `source`, `lyrics` or `theme`. |
130
+ | `braccato:stage-layout` | `{ box }` | Stage layout only. The box around the sung lines in the container's coordinates, or null when nothing sung is on stage. |
131
+ | `braccato:error` | `{ phase, error }` | Connecting, resolving a source, or applying lyrics or a theme went wrong, or a stage cannot show what it was given. `phase` is `connect`, `conflict`, `layout`, `source`, `lyrics` or `theme`. |
129
132
 
130
133
  Errors are dispatched a microtask after they happen rather than where they happen, which is what
131
134
  makes them receivable at all: `connectedCallback` runs before any listener a page could have added.
@@ -403,6 +406,20 @@ Everything else is the theme's. Lines are styled exactly as they would be in a s
403
406
  `host.onStageLayout(box)` reports the box around the sung lines in the container's coordinates, or null
404
407
  when nothing sung is on stage, so you can draw a backdrop outside the container the theme styles.
405
408
 
409
+ The element takes the same layout as `layout="stage"`, and reports the same box as
410
+ `braccato:stage-layout`. Give the element or its parent a size and a position, and load `stage.css`.
411
+ A stage that cannot show what it was given says so rather than staying blank: unsynced lyrics set
412
+ `status` to `unsynced-on-stage`, and both they and a missing `stage.css` dispatch `braccato:error`
413
+ with `phase: "layout"`. Each song is reported once, so a theme that rebuilds the lines stays quiet.
414
+ The stylesheet is checked a frame after the song is built, and only the event reports it.
415
+
416
+ ```html
417
+ <div style="position: relative; aspect-ratio: 16 / 9">
418
+ <video id="clip" src="clip.mp4"></video>
419
+ <braccato-lyrics source="#clip" layout="stage"></braccato-lyrics>
420
+ </div>
421
+ ```
422
+
406
423
  `@braccato/core/element` registers `<braccato-lyrics>`, and `<better-lyrics>` beside it, on import.
407
424
  Registration is a side effect, which is why it is entered separately.
408
425
 
package/dist/element.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Lyric, LyricsRenderer, LyricsRendererHost, LyricSyncType, TickOptions } from "./types.js";
1
+ import type { StageBox } from "./stage.js";
2
+ import type { Lyric, LyricsLayout, LyricsRenderer, LyricsRendererHost, LyricSyncType, TickOptions } from "./types.js";
2
3
  import type { SetLyricsOptions } from "./view.js";
3
4
  /**
4
5
  * What a tick carries beyond the clock. `isPlaying` is not among them: `playing` is the property
@@ -15,7 +16,7 @@ export type ElementTickOptions = Omit<TickOptions, "isPlaying">;
15
16
  */
16
17
  export type ElementLyricsOptions = Partial<SetLyricsOptions>;
17
18
  /** @public */
18
- export type ElementErrorPhase = "connect" | "conflict" | "lyrics" | "source" | "theme";
19
+ export type ElementErrorPhase = "connect" | "conflict" | "layout" | "lyrics" | "source" | "theme";
19
20
  /** @public */
20
21
  export interface ElementErrorDetail {
21
22
  /** What the element was doing. Nothing thrown by a tick is reported here: see the README. */
@@ -30,7 +31,7 @@ export interface ElementErrorDetail {
30
31
  *
31
32
  * @public
32
33
  */
33
- export type ElementStatus = "idle" | "rendering" | "theme-conflict" | "no-browsing-context";
34
+ export type ElementStatus = "idle" | "rendering" | "theme-conflict" | "unsynced-on-stage" | "no-browsing-context";
34
35
  /** @public */
35
36
  export interface LyricsLoadedDetail {
36
37
  lineCount: number;
@@ -42,6 +43,11 @@ export interface LineClickDetail {
42
43
  timeS: number;
43
44
  }
44
45
  /** @public */
46
+ export interface StageLayoutDetail {
47
+ /** Around the lines being sung, in the container's coordinates, or null when nothing sung is on stage. */
48
+ box: StageBox | null;
49
+ }
50
+ /** @public */
45
51
  export interface ScrollStateDetail {
46
52
  /** Whether the user has scrolled away, so autoscroll is waiting rather than following the song. */
47
53
  userScrolling: boolean;
@@ -132,6 +138,13 @@ export declare class BraccatoLyricsElement extends HTMLElement {
132
138
  */
133
139
  get host(): Partial<LyricsRendererHost>;
134
140
  set host(overrides: Partial<LyricsRendererHost>);
141
+ /**
142
+ * `"stage"` shows only the lines being sung, for subtitles over a video, and fills the nearest
143
+ * positioned ancestor. Anything else scrolls. Writing it while connected rebuilds the view: the
144
+ * renderer is handed its layout once, when it is created.
145
+ */
146
+ get layout(): LyricsLayout;
147
+ set layout(layout: LyricsLayout);
135
148
  /**
136
149
  * The renderer underneath, for a consumer who outgrows the element. Null while disconnected, and a
137
150
  * different one after every reconnection.
package/dist/element.js CHANGED
@@ -18,6 +18,7 @@ const ALIAS_TAG_NAME = "better-lyrics";
18
18
  // the DOM sixty times a second, and one attribute reflecting while the others do not is worse than
19
19
  // none of them doing it.
20
20
  const CURRENT_TIME_ATTRIBUTE = "current-time";
21
+ const LAYOUT_ATTRIBUTE = "layout";
21
22
  const PLAYING_ATTRIBUTE = "playing";
22
23
  const SOURCE_ATTRIBUTE = "source";
23
24
  const THEME_ATTRIBUTE = "theme";
@@ -52,9 +53,12 @@ const MAX_CLOCK_CARRY_MS = 100;
52
53
  const LINE_CLICK_EVENT = "braccato:line-click";
53
54
  const LYRICS_LOADED_EVENT = "braccato:lyrics-loaded";
54
55
  const SCROLL_STATE_EVENT = "braccato:scroll-state";
56
+ const STAGE_LAYOUT_EVENT = "braccato:stage-layout";
55
57
  const ERROR_EVENT = "braccato:error";
56
58
  const NO_BROWSING_CONTEXT_MESSAGE = "This element is in a document with no window, so there is nothing to build lyrics against";
57
59
  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";
60
+ const UNSYNCED_STAGE_MESSAGE = "A stage places lines by their time and these lyrics have none, so the stage shows nothing";
61
+ const MISSING_STAGE_STYLESHEET_MESSAGE = "The stage container is still in the document flow, so @braccato/core/styles/stage.css is not loaded and every line stacks where it was built";
58
62
  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
63
  // -- The views a document is rendering --------------------------------------------
60
64
  // Membership means rendering, so an element that threw on the way up or has been disconnected is
@@ -97,7 +101,13 @@ function defineOnce(tagName, elementConstructor) {
97
101
  * property here would be a second opinion about a question the platform has already answered.
98
102
  */
99
103
  export class BraccatoLyricsElement extends HTMLElement {
100
- static observedAttributes = [CURRENT_TIME_ATTRIBUTE, PLAYING_ATTRIBUTE, SOURCE_ATTRIBUTE, THEME_ATTRIBUTE];
104
+ static observedAttributes = [
105
+ CURRENT_TIME_ATTRIBUTE,
106
+ LAYOUT_ATTRIBUTE,
107
+ PLAYING_ATTRIBUTE,
108
+ SOURCE_ATTRIBUTE,
109
+ THEME_ATTRIBUTE,
110
+ ];
101
111
  #renderer = null;
102
112
  // The document this element registered itself in, rather than whatever it is in now: adopting an
103
113
  // element into another document changes `ownerDocument` under the callback that has to take it
@@ -113,6 +123,9 @@ export class BraccatoLyricsElement extends HTMLElement {
113
123
  #tickOptions = {};
114
124
  #theme = "";
115
125
  #hostOverrides = {};
126
+ #layout = "scroll";
127
+ // The song the stage last reported on, so a theme that rebuilds the same lines stays quiet.
128
+ #stageReportedLyrics = null;
116
129
  #source = null;
117
130
  // Non-null exactly while the element is listening to a media element, so there is no state where
118
131
  // one is remembered and its listeners are not.
@@ -246,6 +259,24 @@ export class BraccatoLyricsElement extends HTMLElement {
246
259
  this.#destroyRenderer();
247
260
  this.#build();
248
261
  }
262
+ /**
263
+ * `"stage"` shows only the lines being sung, for subtitles over a video, and fills the nearest
264
+ * positioned ancestor. Anything else scrolls. Writing it while connected rebuilds the view: the
265
+ * renderer is handed its layout once, when it is created.
266
+ */
267
+ get layout() {
268
+ return this.#layout;
269
+ }
270
+ set layout(layout) {
271
+ const next = layout === "stage" ? "stage" : "scroll";
272
+ if (next === this.#layout)
273
+ return;
274
+ this.#layout = next;
275
+ if (this.#renderer === null)
276
+ return;
277
+ this.#destroyRenderer();
278
+ this.#build();
279
+ }
249
280
  /**
250
281
  * The renderer underneath, for a consumer who outgrows the element. Null while disconnected, and a
251
282
  * different one after every reconnection.
@@ -267,6 +298,8 @@ export class BraccatoLyricsElement extends HTMLElement {
267
298
  get status() {
268
299
  if (this.#renderer === null)
269
300
  return this.#missingBrowsingContext ? "no-browsing-context" : "idle";
301
+ if (this.#isUnsyncedOnStage(this.#renderer))
302
+ return "unsynced-on-stage";
270
303
  return this.#disagreeingPeers().length > 0 ? "theme-conflict" : "rendering";
271
304
  }
272
305
  // -- Lifecycle --------------------------------------------
@@ -280,6 +313,7 @@ export class BraccatoLyricsElement extends HTMLElement {
280
313
  this.#upgradeProperty("playing");
281
314
  this.#upgradeProperty("theme");
282
315
  this.#upgradeProperty("host");
316
+ this.#upgradeProperty("layout");
283
317
  this.#upgradeProperty("source");
284
318
  this.#build();
285
319
  // After the view exists, so a build that threw on the way up leaves no listener on a media
@@ -305,6 +339,10 @@ export class BraccatoLyricsElement extends HTMLElement {
305
339
  this.currentTime = currentTimeS;
306
340
  return;
307
341
  }
342
+ if (name === LAYOUT_ATTRIBUTE) {
343
+ this.layout = newValue === "stage" ? "stage" : "scroll";
344
+ return;
345
+ }
308
346
  if (name === PLAYING_ATTRIBUTE) {
309
347
  this.playing = newValue !== null;
310
348
  return;
@@ -338,6 +376,7 @@ export class BraccatoLyricsElement extends HTMLElement {
338
376
  window: view,
339
377
  mount: this,
340
378
  host: this.#hostForRenderer(),
379
+ layout: this.#layout,
341
380
  });
342
381
  // After the renderer exists and never before, so that a build which threw on the way up leaves
343
382
  // nothing behind claiming to be one of the document's views.
@@ -348,6 +387,7 @@ export class BraccatoLyricsElement extends HTMLElement {
348
387
  #destroyRenderer() {
349
388
  this.#renderer?.destroy();
350
389
  this.#renderer = null;
390
+ this.#stageReportedLyrics = null;
351
391
  this.#leaveDocument();
352
392
  }
353
393
  /**
@@ -372,6 +412,10 @@ export class BraccatoLyricsElement extends HTMLElement {
372
412
  overrides.setResumeAffordanceVisible?.(visible);
373
413
  this.#emit(SCROLL_STATE_EVENT, { userScrolling: visible });
374
414
  },
415
+ onStageLayout: box => {
416
+ overrides.onStageLayout?.(box);
417
+ this.#emit(STAGE_LAYOUT_EVENT, { box });
418
+ },
375
419
  };
376
420
  }
377
421
  #applyLyrics() {
@@ -395,9 +439,33 @@ export class BraccatoLyricsElement extends HTMLElement {
395
439
  lineCount: renderer.lines.length,
396
440
  syncType: renderer.syncType,
397
441
  });
442
+ this.#reportStageProblems(renderer);
398
443
  // So the new lines are where the song is rather than at the top until the clock next moves.
399
444
  this.#tick();
400
445
  }
446
+ #isUnsyncedOnStage(renderer) {
447
+ return this.#layout === "stage" && renderer.lines.length > 0 && renderer.syncType === "none";
448
+ }
449
+ /** Both leave an empty or broken stage behind with nothing thrown, so nobody would hear of them. */
450
+ #reportStageProblems(renderer) {
451
+ const container = renderer.container;
452
+ const view = this.ownerDocument.defaultView;
453
+ if (this.#layout !== "stage" || container === null || view === null)
454
+ return;
455
+ if (this.#lyrics === this.#stageReportedLyrics)
456
+ return;
457
+ this.#stageReportedLyrics = this.#lyrics;
458
+ if (this.#isUnsyncedOnStage(renderer))
459
+ this.#emitError("layout", new Error(UNSYNCED_STAGE_MESSAGE));
460
+ // A frame later, so a stylesheet that lands just after the build is not reported as missing.
461
+ view.requestAnimationFrame(() => {
462
+ if (this.#renderer !== renderer || renderer.container !== container)
463
+ return;
464
+ if (view.getComputedStyle(container).position === "absolute")
465
+ return;
466
+ this.#emitError("layout", new Error(MISSING_STAGE_STYLESHEET_MESSAGE));
467
+ });
468
+ }
401
469
  #applyTheme() {
402
470
  const renderer = this.#renderer;
403
471
  if (renderer === null)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.15.3",
3
+ "version": "1.16.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",