@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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Better Lyrics
3
+ Copyright (c) 2026 Boidushya Bhattacharya
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # @braccato/core
2
2
 
3
- `<braccato-lyrics>` web component for synchronized lyrics rendering with word-by-word animations. Built with Lit, extracted from the [Better Lyrics](https://better-lyrics.boidu.dev) rendering engine.
3
+ A custom element that renders synchronized lyrics and lights each syllable up as it is sung. No
4
+ runtime dependencies, only a types-only one on `@braccato/types`. The lines go into light DOM rather
5
+ than a shadow root, so the CSS already on your page reaches them.
6
+
7
+ Extracted from the [Better Lyrics](https://better-lyrics.boidu.dev) rendering engine, which is still
8
+ where it runs.
4
9
 
5
10
  ## Install
6
11
 
@@ -12,52 +17,282 @@ npm i @braccato/core
12
17
 
13
18
  ```html
14
19
  <audio id="player" src="song.mp3" controls></audio>
15
- <braccato-lyrics source="#player" src="lyrics.ttml"></braccato-lyrics>
20
+ <braccato-lyrics source="#player"></braccato-lyrics>
16
21
 
17
22
  <script type="module">
18
- import "@braccato/core";
23
+ import "@braccato/core/element";
24
+ import "@braccato/core/styles/variables.css";
25
+ import "@braccato/core/styles/lyrics.css";
26
+ import "@braccato/core/styles/instrumental.css";
27
+
28
+ document.querySelector("braccato-lyrics").lyrics = [
29
+ { startTimeMs: 0, durationMs: 4200, words: "The first line" },
30
+ { startTimeMs: 4200, durationMs: 3800, words: "The second" },
31
+ ];
19
32
  </script>
20
33
  ```
21
34
 
22
- The `source` attribute accepts a CSS selector for any `<audio>` or `<video>` element. The `src` attribute fetches and parses a lyrics file (TTML, LRC, SRT, QRC, or plain text are auto-detected).
35
+ `source` takes a CSS selector or a media element. It is resolved when the element connects, so put
36
+ the `<audio>` before the tag, or write the property from script. Without a source, drive the view
37
+ yourself by writing `currentTime` and `playing`.
23
38
 
24
- For manual control, set `lyrics`, `currentTime`, and `playing` directly instead.
39
+ Two things catch everybody once. The element has no `display` of its own:
40
+
41
+ ```css
42
+ braccato-lyrics {
43
+ display: block;
44
+ }
45
+ ```
46
+
47
+ And autoscroll writes `scrollTop` on the nearest ancestor that scrolls, falling through to the
48
+ document when nothing does. If the element is not inside its own scroller, say which one it is:
49
+
50
+ ```js
51
+ view.host = { getScrollElement: () => yourFrame };
52
+ ```
53
+
54
+ ## Lyrics
55
+
56
+ The array is the whole input, and nothing in this package produces one.
57
+ [`@braccato/parsers`](https://www.npmjs.com/package/@braccato/parsers) reads TTML, LRC, SRT, QRC and
58
+ plain text, and picks between them by looking at the file.
59
+
60
+ ```js
61
+ import { detectParser } from "@braccato/parsers";
62
+
63
+ const text = await fetch("song.ttml").then(response => response.text());
64
+ view.lyrics = detectParser(text).parse(text, player.duration * 1000);
65
+ ```
66
+
67
+ A `Lyric` is `{ startTimeMs, durationMs, words }`, with an optional `parts` array of the same three
68
+ fields for syllable or word timing, and optional `translation`, `romanization` and
69
+ `timedRomanization` beside them.
25
70
 
26
71
  ## Properties
27
72
 
28
- | Property | Attribute | Type | Default | Description |
29
- |----------|-----------|------|---------|-------------|
30
- | `source` | `source` | `string \| null` | `null` | CSS selector for the media element to sync with |
31
- | `src` | `src` | `string \| null` | `null` | URL to a lyrics file (auto-detected format) |
32
- | `lyrics` | | `Lyric[]` | `[]` | Parsed lyric data (set directly for manual control) |
33
- | `currentTime` | `current-time` | `number` | `0` | Playback position in ms (ignored when `source` is set) |
34
- | `playing` | `playing` | `boolean` | `false` | Whether playback is active (ignored when `source` is set) |
35
- | `scrollMode` | `scroll-mode` | `'internal' \| 'external'` | `'internal'` | Scroll container ownership |
36
- | `dir` | `dir` | `'auto' \| 'ltr' \| 'rtl'` | `'auto'` | Text direction |
73
+ Every one of these may be written before the element is in a document. The renderer is built when it
74
+ connects, and everything it was handed by then is applied at once.
75
+
76
+ | Property | Attribute | Type | Default | Description |
77
+ | --------------- | -------------- | ----------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
78
+ | `lyrics` | | `Lyric[] \| null` | `null` | The song. Null means it was never given one, and an empty array clears the view, so there is a way to say both. |
79
+ | `lyricsOptions` | | `{ loaderVisible?, noLyrics? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. |
80
+ | `source` | `source` | `string \| HTMLMediaElement \| null` | `null` | A selector or the media element itself. See Following a media element. |
81
+ | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
82
+ | `currentTime` | `current-time` | `number` | `0` | Playback position in **seconds**. Writing it renders the view again, so whoever holds the clock drives the lyrics by writing this. |
83
+ | `playing` | `playing` | `boolean` | `false` | A paused view animates differently from a playing one. |
84
+ | `tickOptions` | | `ElementTickOptions` | `{}` | The rest of a tick: four offsets taken off the clock before it is matched, whether passive scrolling is on, and when the clock was sampled. |
85
+ | `theme` | `theme` | `string` | `""` | A compiled stylesheet. See Theming. |
86
+ | `host` | | `Partial<LyricsRendererHost>` | `{}` | Overrides for what the renderer asks of its surroundings. Every member has a default. Writing it while connected rebuilds the view. |
87
+ | `renderer` | | `LyricsRenderer \| null` (get) | `null` | The renderer underneath, for the day the tag runs out. A different one after every reconnection. |
88
+ | `status` | | `ElementStatus` (get) | `"idle"` | `idle`, `rendering`, `theme-conflict` or `no-browsing-context`. |
89
+
90
+ `tickOptions` and `lyricsOptions` are stored on write and read by the next tick or the next build, so
91
+ writing options and the clock on the same frame renders once.
92
+
93
+ ## Attributes
94
+
95
+ An attribute writes its property, and a property never writes back. Reflecting `current-time` would
96
+ put the playback clock into the DOM sixty times a second, and one attribute reflecting while the rest
97
+ do not is worse than none of them doing it.
98
+
99
+ | Attribute | Writes | Notes |
100
+ | -------------- | ------------- | -------------------------------------------------------------------------------------------------- |
101
+ | `source` | `source` | The selector form only. Another selector moves the binding, and removing it unbinds. |
102
+ | `theme` | `theme` | A whole stylesheet in an attribute value. It works, but nobody would ship a theme this way. |
103
+ | `current-time` | `currentTime` | Seconds. A value that does not parse as a number is ignored rather than read as zero. |
104
+ | `playing` | `playing` | An ordinary boolean attribute: its presence is what counts, so `playing="false"` is playing. |
37
105
 
38
106
  ## Events
39
107
 
40
- | Event | Detail | Description |
41
- |-------|--------|-------------|
42
- | `braccato:line-click` | `{ time, lineIndex }` | User clicked a lyric line |
43
- | `braccato:word-click` | `{ time, lineIndex, wordIndex }` | Alt+click on a word (rich sync) |
44
- | `braccato:lyrics-loaded` | `{ syncType, lineCount }` | Lyrics injected into the DOM |
45
- | `braccato:scroll-state` | `{ userScrolling }` | Scroll state changed |
46
- | `braccato:error` | `{ error }` | Fetch or parse error (when using `src`) |
108
+ All four bubble and are composed, so an element you put inside your own shadow root still reaches
109
+ your listener.
110
+
111
+ | Event | Detail | When |
112
+ | ------------------------ | ------------------------- | --------------------------------------------------------------------------------- |
113
+ | `braccato:lyrics-loaded` | `{ lineCount, syncType }` | Lyrics were applied, including an empty array. A theme change that rebuilds the lines reports itself the same way. |
114
+ | `braccato:line-click` | `{ timeS }` | A line was clicked. The seek has already reached the bound media element by the time you hear about it. |
115
+ | `braccato:scroll-state` | `{ userScrolling }` | Autoscroll stopped following the song, or started again. |
116
+ | `braccato:error` | `{ phase, error }` | Connecting, resolving a source, or applying lyrics or a theme went wrong. `phase` is `connect`, `conflict`, `source`, `lyrics` or `theme`. |
117
+
118
+ Errors are dispatched a microtask after they happen rather than where they happen, which is what
119
+ makes them receivable at all: `connectedCallback` runs before any listener a page could have added.
120
+ A listener added later than that still misses them, so `status` answers the same question and needs
121
+ no listener. Nothing thrown by a tick lands here, because sixty error events a second would bury the
122
+ one that mattered.
123
+
124
+ There is no `braccato:word-click`. The renderer tells its host `seek(timeS)` and nothing else, so the
125
+ element cannot tell a word seek from a line seek without re-deriving the click branch off the DOM.
126
+ The DOM is light and the class names are published, so listen for `click` on the element and read
127
+ `.blyrics--word` yourself.
128
+
129
+ ## Theming
130
+
131
+ A theme is a stylesheet. Write CSS against the class names below and the module stays out of it. What
132
+ it does read is the `blyrics-*` lines inside the comments, which is how a theme changes behaviour
133
+ without a second configuration format.
134
+
135
+ ```js
136
+ view.theme = `
137
+ /* blyrics-target-scroll-pos-ratio = 0.5; */
138
+ /* blyrics-long-word-threshold = 900; */
139
+
140
+ .blyrics-container {
141
+ --blyrics-font-size: 3.5rem;
142
+ --blyrics-lyric-active-color: white;
143
+ --blyrics-lyric-inactive-color: rgb(255 255 255 / 0.25);
144
+ }
145
+ `;
146
+ ```
147
+
148
+ Settings are read from comments only. Everything else is CSS the browser is going to read, and a
149
+ stylesheet must not be able to configure the module by accident. An empty theme puts every setting
150
+ back to its default. The stylesheet itself goes into the document head under the
151
+ `blyrics-custom-style` id.
152
+
153
+ There is no `longWordThreshold`, `lineSyncedDelay` or `disableRichsync` property. Those are theme
154
+ settings (`blyrics-long-word-threshold`, `blyrics-line-synced-animation-delay`,
155
+ `blyrics-disable-richsync`), read from the stylesheet you already hand over. A theme that set one
156
+ while a property said otherwise would leave the module with two answers and no rule for picking.
157
+
158
+ `parseThemeConfig` is published on `@braccato/core/themeSettings` for reading the settings out of a
159
+ stylesheet somewhere no renderer is running.
160
+
161
+ ### Custom properties
47
162
 
48
- ## CSS Custom Properties
163
+ The ones a theme reaches for first. `variables.css` declares the rest.
49
164
 
50
165
  ```css
51
- braccato-lyrics {
52
- --braccato-font-family: system-ui, sans-serif;
53
- --braccato-font-size: 3rem;
54
- --braccato-font-weight: 600;
55
- --braccato-active-color: white;
56
- --braccato-inactive-opacity: 0.3;
57
- --braccato-glow-color: rgba(255, 255, 255, 0.5);
58
- --braccato-timing-offset: 0.115s;
59
- --braccato-richsync-timing-offset: 0.150s;
166
+ .blyrics-container {
167
+ --blyrics-font-family: system-ui, sans-serif;
168
+ --blyrics-font-size: 3rem;
169
+ --blyrics-line-height: 1.333;
170
+ --blyrics-padding: 2rem;
171
+ --blyrics-lyric-active-color: white;
172
+ --blyrics-lyric-inactive-color: rgb(255 255 255 / 0.3);
173
+ --blyrics-glow-color: rgb(255 255 255 / 0.5);
60
174
  }
61
175
  ```
62
176
 
63
- See the [full documentation](https://braccato.boidu.dev) for all properties and framework examples.
177
+ `--blyrics-font-size` is what everything else is sized off, including the instrumental dots.
178
+ `--blyrics-padding` is the vertical room around each line, and the one to reach for before
179
+ `line-height`. Every word is given the glow, so a theme that wants it to mean something selects on
180
+ `data-long-word`, which the module sets on any part held past `blyrics-long-word-threshold`.
181
+
182
+ ### Class names
183
+
184
+ These are published API rather than implementation. Renaming one costs a migration rather than a
185
+ refactor. Import them from `@braccato/core/constants` instead of typing them out.
186
+
187
+ | Constant | Class | What it is |
188
+ | ------------------------- | --------------------------- | ----------------------------------------------------------- |
189
+ | `LYRICS_CLASS` | `blyrics-container` | The view. One per renderer. |
190
+ | `LINE_CLASS` | `blyrics--line` | One line, carrying its own `dir="auto"`. |
191
+ | `CURRENT_LYRICS_CLASS` | `blyrics--active` | The line the song is on right now. |
192
+ | `WORD_CLASS` | `blyrics--word` | One word, and the unit the sweep animates. |
193
+ | `BACKGROUND_LYRIC_CLASS` | `blyrics-background-lyric` | A background vocal, sung over the line it answers. |
194
+ | `USER_SCROLLING_CLASS` | `blyrics-user-scrolling` | Set while a reader has scrolled away and autoscroll waits. |
195
+ | `TRANSLATED_LYRICS_CLASS` | `blyrics--translated` | A translation hung off a line that was already built. |
196
+ | `CUSTOM_THEME_STYLE_ID` | `blyrics-custom-style` | The id of the `<style>` the theme lands in. |
197
+
198
+ ## Stylesheets
199
+
200
+ Three sheets ship with the package, and loading them is yours, the way any package's CSS is. Leave
201
+ them out and you get lines that are in the document and unstyled, rather than lines that are missing.
202
+
203
+ | File | What it carries |
204
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
205
+ | `@braccato/core/styles/variables.css` | Every `--blyrics-*` default. It goes first, because the other two read from it. |
206
+ | `@braccato/core/styles/lyrics.css` | The container, the lines, the words and the sweep, plus two `@property` registrations the word animation interpolates through. |
207
+ | `@braccato/core/styles/instrumental.css` | The waveform that fills a bar nobody sings over, and the animation that walks it. |
208
+
209
+ One thing they do not do for you. The module measures the room the first and last lines need to reach
210
+ the view's target scroll position and writes it on the root as `--blyrics-padding-top` and
211
+ `--blyrics-padding-bottom`, but `lyrics.css` only spends the bottom one. Supply the top rule:
212
+
213
+ ```css
214
+ .blyrics-container {
215
+ padding-top: var(--blyrics-padding-top, 2rem);
216
+ }
217
+ ```
218
+
219
+ ## Light DOM, not shadow DOM
220
+
221
+ The element builds into itself. That is what lets a stylesheet at document level select the lines,
222
+ and what lets the package's own `@property` registrations apply to them, which they would not inside
223
+ a shadow root. The theme is adopted into the element's document rather than encapsulated, and the
224
+ package's stylesheets are yours to load for the same reason.
225
+
226
+ ## Entry points
227
+
228
+ `@braccato/core` is the facade and registers nothing. `createLyricsRenderer(options)` returns one
229
+ `LyricsRenderer`: give it lyrics, tick it, and it owns the DOM it builds and every re-measurement
230
+ that DOM needs. `resetPlaybackClock`, `resumeAllAutoscroll`, `injectRomanization` and
231
+ `injectTranslation` are published beside it, for what one instance cannot answer for on its own.
232
+
233
+ `@braccato/core/element` registers `<braccato-lyrics>`, and `<better-lyrics>` beside it, on import.
234
+ Registration is a side effect, which is why it is entered separately.
235
+
236
+ Four leaves import nothing at all, so taking one does not pull the engine into your bundle with it:
237
+
238
+ - `@braccato/core/constants` for the class names and element ids above
239
+ - `@braccato/core/text` for script detection: `testRtl`, `containsNonLatin`, `detectNonLatinLanguage`
240
+ - `@braccato/core/themeSettings` for `parseThemeConfig`
241
+ - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`
242
+
243
+ Two notes on the element entry point. A browser extension's isolated world has no custom element
244
+ registry, so `window.customElements` is null there and importing this file throws where it registers.
245
+ An extension that wants the tag has to run in the page's own world; one that stays isolated calls
246
+ `createLyricsRenderer` directly. And registration is silent about a name already taken, so two copies
247
+ of this package on one page means the first to load takes both names and `instanceof` against the
248
+ second copy's class is false for every element on the page. Load one copy.
249
+
250
+ ## Following a media element
251
+
252
+ While a `source` is bound, the element drives itself. It reads `currentTime` and `paused` off the
253
+ media element on a `requestAnimationFrame` loop that runs only while the song plays, and a click on a
254
+ lyric line sets `currentTime` back on it. So `currentTime` and `playing` become outputs: a write to
255
+ either is dropped and the getter keeps reporting what the binding last read. Dropped rather than
256
+ reported, because a consumer who bound a source and left their own frame loop running would otherwise
257
+ be told about it sixty times a second. Unbind and the clock goes back to whoever asked for it.
258
+
259
+ A reading the media element has not refreshed yet is carried forward at the playback rate it was
260
+ taken at, capped at 100ms of frame time. That cap is what covers a stall: the view runs at most 100ms
261
+ past the last real reading and then waits with it. What it costs is a step backwards when the clock
262
+ moves again, scaled by the rate. 100ms at 1x, 400ms at 4x.
263
+
264
+ `play`, `pause`, `seeking`, `seeked` and `ratechange` are listened to. The frame loop covers the rest
265
+ by asking the media element whether its clock is still going rather than trusting that something said
266
+ so. One gap is worth knowing: `emptied` while already paused leaves no loop running to notice, so
267
+ swapping `audio.src` between songs without playing goes on reporting the old position until the next
268
+ `play`.
269
+
270
+ ## One renderer per document
271
+
272
+ Two renderers in one document write over each other, so the module supports one. It is a constraint
273
+ rather than a setting, and it is stated rather than enforced: none of the points where two of them
274
+ collide is a crash.
275
+
276
+ Two things are written per document and belong to whichever renderer wrote them last: the theme's
277
+ `<style>` element, and the scroll padding on the root. Two more are per bundle, because a settings
278
+ registry and the playback clock both live at module scope: one theme means one set of values for
279
+ every view in that bundle, and whichever view ticked last is the one whose clock the others replay.
280
+
281
+ None of that is a limit on how many elements you may have. Two views handed the **same** theme share
282
+ only the settings both of them asked for, and the renderer adopts an existing theme element rather
283
+ than adding a rival under the same id. The line is drawn at the disagreement: when an element applies
284
+ a theme another element in its document was not given, both dispatch `braccato:error` with
285
+ `phase: "conflict"` and both read `status === "theme-conflict"`. Neither stops rendering, because a
286
+ blank view with a reason is worse than a themed one with a warning.
287
+
288
+ ## Docs and demo
289
+
290
+ Full documentation is at [braccato.boidu.dev](https://braccato.boidu.dev).
291
+
292
+ The demo page lives at [`demo/`](https://github.com/better-lyrics/braccato/tree/master/demo) in the
293
+ repository and runs against the emitted package, with a control for most of what is above. Clone the
294
+ repository and run `pnpm -C demo dev`, then open `http://localhost:5173/`.
295
+
296
+ ## Licence
297
+
298
+ MIT. See `LICENSE`.
package/dist/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boidushya Bhattacharya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.