@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,199 @@
1
+ import type { Lyric, LyricPart, LyricSyncType } from "@braccato/types";
2
+ import type { AnimationTickStatus } from "./engine.js";
3
+ import type { LineData } from "./inject.js";
4
+ import type { SetLyricsOptions } from "./view.js";
5
+ export type { Lyric, LyricPart, LyricSyncType };
6
+ /**
7
+ * The play state is the only thing a tick cannot be given a sensible default for. Everything else
8
+ * describes a setting the consumer may not have, so it may be left out.
9
+ */
10
+ export interface TickOptions {
11
+ /**
12
+ * Wall clock timestamp of the player snapshot the time came from, in milliseconds.
13
+ * Defaults to -1, which reads as a time that was not sampled from a live player.
14
+ */
15
+ eventCreationTime?: number;
16
+ isPlaying: boolean;
17
+ /** Defaults to true. */
18
+ smoothScroll?: boolean;
19
+ /**
20
+ * User offsets, in seconds, subtracted from the playback time before matching. Each defaults to 0.
21
+ */
22
+ globalLyricOffset?: number;
23
+ lyricOffset?: number;
24
+ richsyncOffsetTrim?: number;
25
+ lineOffsetTrim?: number;
26
+ /**
27
+ * Whether the user has passive scrolling switched on. Only unsynced lyrics use it. Defaults to
28
+ * false.
29
+ */
30
+ passiveScrollEnabled?: boolean;
31
+ }
32
+ /**
33
+ * A tick with nothing left out. The engine reads every field unconditionally, so the defaults are
34
+ * filled in at the edge rather than guessed at each read.
35
+ */
36
+ export type ResolvedTickOptions = Required<TickOptions>;
37
+ export interface LyricsRendererDebugSink {
38
+ /**
39
+ * Clears the debug surface and returns a context translated to the given scroll offset.
40
+ */
41
+ beginFrame(scrollOffset: number): CanvasRenderingContext2D | null;
42
+ resize(): void;
43
+ }
44
+ export interface LyricsRendererHost {
45
+ isViewVisible(): boolean;
46
+ isLoaderActive(): boolean;
47
+ /**
48
+ * Reports whether an ad is playing, and lets the host move whatever it shows in place of lyrics
49
+ * into the matching state. One call rather than a query and a command, because the renderer never
50
+ * needs one without the other and the host already knows the answer when it is asked.
51
+ */
52
+ syncAdState(): boolean;
53
+ /**
54
+ * Resolved per tick rather than handed over once: YouTube Music swaps its scroll container out,
55
+ * and the renderer is constructed before that container exists.
56
+ */
57
+ getScrollElement(): HTMLElement | null;
58
+ /**
59
+ * Shows or puts away whatever the host offers for resuming autoscroll. The host decides what it
60
+ * looks like and where it lives; the renderer only says whether it is wanted.
61
+ */
62
+ setResumeAffordanceVisible(visible: boolean): void;
63
+ /**
64
+ * Called when a lyric line is clicked. How the seek reaches the player is the host's business:
65
+ * this extension dispatches an event at the page world, another consumer might set
66
+ * currentTime on a media element directly.
67
+ */
68
+ seek(timeS: number): void;
69
+ /**
70
+ * Where the view's diagnostics go. The host owns the prefix and whether logging is on at all.
71
+ */
72
+ log(...args: unknown[]): void;
73
+ debug?: LyricsRendererDebugSink;
74
+ }
75
+ export interface LyricsRendererOptions {
76
+ document: Document;
77
+ window: Window;
78
+ /**
79
+ * Where the lyrics are built. `setLyrics` takes one too, for a consumer whose mount only exists
80
+ * once there is something to put in it; the last one given is the one in use.
81
+ */
82
+ mount?: HTMLElement;
83
+ /**
84
+ * Every member has a default, so a consumer with nothing to say about its surroundings says
85
+ * nothing at all.
86
+ */
87
+ host?: Partial<LyricsRendererHost>;
88
+ }
89
+ /**
90
+ * One rendered lyrics view, and everything it takes to keep it measured. The consumer owns the
91
+ * clock and the lyrics; the renderer owns the DOM it builds from them and every re-measurement that
92
+ * DOM needs.
93
+ */
94
+ export interface LyricsRenderer {
95
+ /**
96
+ * Replaces whatever this renderer last built. Every option has a default, so a consumer with an
97
+ * opinion about only one of them says only that one. The mount rides along with the CSS flags
98
+ * rather than taking a parameter of its own: it is an option of this call like the others, and a
99
+ * third parameter would have every caller that only wants to move the mount pass over the two in
100
+ * front of it.
101
+ */
102
+ setLyrics(lyrics: Lyric[], options?: Partial<SetLyricsOptions> & {
103
+ mount?: HTMLElement;
104
+ }): void;
105
+ /**
106
+ * Applies a compiled theme. The `blyrics-*` configuration in its comments becomes the module's
107
+ * theme settings, and the stylesheet itself is applied to the document this renderer builds in.
108
+ *
109
+ * The settings are the module's rather than this instance's, so a second renderer in the same
110
+ * realm renders against the last theme any of them was given. The stylesheet is per document.
111
+ *
112
+ * @returns Whether a setting the lines are built out of changed, so the caller can hand the
113
+ * lyrics back. The renderer does not hold them, so it cannot rebuild them itself.
114
+ */
115
+ setTheme(css: string): boolean;
116
+ /**
117
+ * Renders the view at a playback time, in seconds. The module owns no clock, so this is the whole
118
+ * of how the song reaches it, and the answer says whether there was a view here to render.
119
+ */
120
+ tick(currentTimeS: number, options: TickOptions): AnimationTickStatus;
121
+ /**
122
+ * Re-reads the layout and renders nothing, leaving the lines it just re-read to the next tick.
123
+ *
124
+ * @param measureLines - Defaults to true, and asking is not the same as getting: lines the page is
125
+ * not rendering measure as zero height at zero offset, so the renderer holds the line
126
+ * measurement back itself while they are off the screen. Pass false to rewrite only the padding,
127
+ * which is sized against the viewport and so is knowable either way.
128
+ */
129
+ relayout(measureLines?: boolean): void;
130
+ /**
131
+ * Drops the song and the DOM built for it. The renderer built that DOM, so it takes it away again.
132
+ */
133
+ clear(): void;
134
+ /**
135
+ * Clears, and gives up the window and document it was holding. Final: everything here does
136
+ * nothing afterwards.
137
+ */
138
+ destroy(): void;
139
+ /**
140
+ * The user scrolled the view. Whether that resumes sooner because the lyrics are unsynced is the
141
+ * renderer's own reading of what it has on screen, not something the caller has to supply.
142
+ */
143
+ noteUserScroll(): void;
144
+ /**
145
+ * The document this view is in was shown or hidden. Nothing is reset either way, and a view told
146
+ * nothing goes on rendering: this is where the view records what it decided about the animations
147
+ * it is running, and only when a theme has asked for the timing diagnostics.
148
+ */
149
+ noteVisibilityChange(): void;
150
+ resumeAutoscroll(): void;
151
+ /**
152
+ * Takes this view's lines off the screen, keeping the container they were in, and reports whether
153
+ * there were any there to take. For a consumer that is about to build the next song into the same
154
+ * place and wants the last one gone in the meantime: `clear` is the one that drops the song, and
155
+ * this one drops only what is on the screen.
156
+ */
157
+ clearOnScreenLyrics(): boolean;
158
+ /**
159
+ * Re-measures the lines on the next frame and renders the view again against them. The predicate
160
+ * is the caller's half of whether that frame does anything, and only that half: whether the lines
161
+ * are on the screen to be measured at all is the renderer's own question, and it asks it itself.
162
+ *
163
+ * @param isTicking - Whether whoever drives this view is still driving it, asked on the frame
164
+ * rather than now. A driver that has stopped is one whose lines may no longer be rendered, and
165
+ * an unrendered line measures as zero height at zero offset, which is what every scroll target
166
+ * for the rest of the song would then be read off. A false answer declines both the
167
+ * re-measurement and the render.
168
+ * @param retick - Runs after the re-measurement, on the frame.
169
+ */
170
+ scheduleLyricPositionUpdate(isTicking: () => boolean, retick: () => void): void;
171
+ /**
172
+ * Renders this view again against the last player snapshot, measuring nothing, which is what an
173
+ * offset nudge needs.
174
+ *
175
+ * The snapshot is the module's rather than this renderer's: whichever view in this bundle ticked
176
+ * last is the one that wrote it, so two renderers in one realm replay each other's clock here.
177
+ * That is the same constraint the theme settings are under, and `resetPlaybackClock` is what
178
+ * forgets the snapshot.
179
+ *
180
+ * @param buildOptions - Given the snapshot the tick will run against, returns what to render it
181
+ * with. Built here rather than handed in, so a caller that reads its settings at tick time still
182
+ * reads them at tick time.
183
+ */
184
+ retickFromPlaybackClock(buildOptions: (eventCreationTime: number, isPlaying: boolean) => TickOptions): AnimationTickStatus;
185
+ /** The element this view built its lines into, and null before it has built any. */
186
+ readonly container: HTMLElement | null;
187
+ /**
188
+ * The render records this view built, which are the view's own live state rather than a copy of
189
+ * it: each one holds the line's element and the `Animation` objects running against it.
190
+ *
191
+ * `readonly` freezes the array and nothing else. The records are writable, deliberately, because
192
+ * rewriting a line's time or hanging a translation off its element is how a consumer decorates a
193
+ * song that is already built. What it does not do is reach a second view: these elements belong
194
+ * to this renderer, so a consumer with two of them decorates each one.
195
+ */
196
+ readonly lines: readonly LineData[];
197
+ /** How the loaded lyrics are timed, and `"none"` once there are none loaded. */
198
+ readonly syncType: LyricSyncType;
199
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export declare function clamp(value: number, min: number, max: number): number;
2
+ export declare function positiveModulo(value: number, divisor: number): number;
3
+ export declare function roundedMs(value: number): number;
4
+ /**
5
+ * Converts CSS duration value to milliseconds.
6
+ *
7
+ * @returns Duration in milliseconds
8
+ */
9
+ export declare function toMs(cssDuration: string): number;
10
+ /**
11
+ * Forces a reflow/repaint of the element by accessing its offsetHeight.
12
+ *
13
+ * @param elt - Element to reflow
14
+ */
15
+ export declare function reflow(elt: HTMLElement): void;
16
+ /**
17
+ * Returns layout position/dimensions without including transient CSS transforms.
18
+ * This is important for lyric scroll math because line-scale and per-line scroll
19
+ * animations are transform based.
20
+ */
21
+ export declare function getRelativeLayoutBounds(parent: HTMLElement, child: HTMLElement): DOMRect;
package/dist/util.js ADDED
@@ -0,0 +1,64 @@
1
+ export function clamp(value, min, max) {
2
+ return Math.max(min, Math.min(max, value));
3
+ }
4
+ export function positiveModulo(value, divisor) {
5
+ return ((value % divisor) + divisor) % divisor;
6
+ }
7
+ export function roundedMs(value) {
8
+ return Math.round(value * 10) / 10;
9
+ }
10
+ /**
11
+ * Converts CSS duration value to milliseconds.
12
+ *
13
+ * @returns Duration in milliseconds
14
+ */
15
+ export function toMs(cssDuration) {
16
+ if (!cssDuration)
17
+ return 0;
18
+ if (cssDuration.endsWith("ms")) {
19
+ return parseFloat(cssDuration.slice(0, -2));
20
+ }
21
+ else if (cssDuration.endsWith("s")) {
22
+ return parseFloat(cssDuration.slice(0, -1)) * 1000;
23
+ }
24
+ return 0;
25
+ }
26
+ /**
27
+ * Forces a reflow/repaint of the element by accessing its offsetHeight.
28
+ *
29
+ * @param elt - Element to reflow
30
+ */
31
+ export function reflow(elt) {
32
+ void elt.offsetHeight;
33
+ }
34
+ /**
35
+ * Returns the position and dimensions of a child element relative to its parent.
36
+ *
37
+ * @param parent - The parent element
38
+ * @param child - The child element
39
+ * @returns Rectangle with relative position and dimensions
40
+ */
41
+ function getRelativeBounds(parent, child) {
42
+ const parentBound = parent.getBoundingClientRect();
43
+ const childBound = child.getBoundingClientRect();
44
+ return new DOMRect(childBound.x - parentBound.x, childBound.y - parentBound.y, childBound.width, childBound.height);
45
+ }
46
+ /**
47
+ * Returns layout position/dimensions without including transient CSS transforms.
48
+ * This is important for lyric scroll math because line-scale and per-line scroll
49
+ * animations are transform based.
50
+ */
51
+ export function getRelativeLayoutBounds(parent, child) {
52
+ let x = 0;
53
+ let y = 0;
54
+ let element = child;
55
+ while (element && element !== parent) {
56
+ x += element.offsetLeft;
57
+ y += element.offsetTop;
58
+ element = element.offsetParent;
59
+ }
60
+ if (element !== parent) {
61
+ return getRelativeBounds(parent, child);
62
+ }
63
+ return new DOMRect(x, y, Math.max(child.offsetWidth, child.scrollWidth), Math.max(child.offsetHeight, child.scrollHeight));
64
+ }
package/dist/view.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { AnimationEngineInstance } from "./engine.js";
2
+ import type { Lyric } from "./types.js";
3
+ export interface SetLyricsOptions {
4
+ /**
5
+ * Whether the loader is still covering the view. Recorded on the container for CSS to key on.
6
+ */
7
+ loaderVisible: boolean;
8
+ /**
9
+ * These lyrics are a "not found" placeholder rather than a real result.
10
+ */
11
+ noLyrics: boolean;
12
+ }
13
+ /**
14
+ * Replaces whatever the mount holds with a container built from these lyrics, and hands the render
15
+ * records, the container and its measured size to the engine that animates them.
16
+ *
17
+ * @param engine - Instance that owns the document to build in and the host to seek through
18
+ * @param mount - Element whose children the built container replaces
19
+ * @param lyrics - Lines to render, left untouched
20
+ * @param options - What the container records for CSS to key on
21
+ */
22
+ export declare function setLyrics(engine: AnimationEngineInstance, mount: HTMLElement, lyrics: Lyric[], options: SetLyricsOptions): void;
package/dist/view.js ADDED
@@ -0,0 +1,93 @@
1
+ // Builds the view: one container under the mount, one element per lyric line, and the render
2
+ // records the engine animates them through. `inject.ts` builds what goes inside a line; this builds
3
+ // the line and everything around it.
4
+ //
5
+ // A song replaces the whole view rather than being diffed into the last one. Every record the
6
+ // engine holds points at an element built here and carries the `Animation` objects running against
7
+ // it, so a partial update would leave the engine holding pieces of the song before.
8
+ //
9
+ // This is also the one place outside the engine that writes engine view state, because building the
10
+ // lines is where those values are first knowable: the container, the records, the sync type, the
11
+ // size they were measured at, and the scrolls to swallow before the view has settled.
12
+ import { LINE_CLASS, LYRICS_CLASS, RTL_CLASS } from "./constants.js";
13
+ import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, isNearestLyricRtl, newLineData, } from "./inject.js";
14
+ import { createInstrumentalElement } from "./instrumental.js";
15
+ const INITIAL_SKIP_SCROLLS = 2;
16
+ const SKIP_SCROLL_DECAY_MS = 2000;
17
+ function buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex) {
18
+ createInstrumentalElement(doc, lyricElement, lyrics[lineIndex].durationMs, lineIndex);
19
+ lyricElement.dataset.instrumental = "true";
20
+ const agent = findNearestAgent(lyrics, lineIndex);
21
+ if (agent) {
22
+ lyricElement.dataset.agent = agent;
23
+ }
24
+ if (isNearestLyricRtl(lyrics, lineIndex)) {
25
+ lyricElement.classList.add(RTL_CLASS);
26
+ lyricElement.dataset.direction = "rtl";
27
+ }
28
+ }
29
+ function buildSungLine(doc, lyricElement, lyricItem, line) {
30
+ // Rebuilt parts stay local so the provider's lyrics survive injection intact and a second
31
+ // build over the same array produces the same result.
32
+ const parts = lyricItem.parts && lyricItem.parts.length > 0 && !disableRichsync.getBooleanValue()
33
+ ? lyricItem.parts
34
+ : buildLineSyncedParts(lyricItem);
35
+ applyDirection(lyricElement, lyricItem.words);
36
+ createLyricsLine(doc, parts, line, lyricElement);
37
+ lyricElement.style.setProperty("--blyrics-duration", lyricItem.durationMs + "ms");
38
+ if (lyricItem.agent) {
39
+ lyricElement.dataset.agent = lyricItem.agent;
40
+ }
41
+ }
42
+ /**
43
+ * Replaces whatever the mount holds with a container built from these lyrics, and hands the render
44
+ * records, the container and its measured size to the engine that animates them.
45
+ *
46
+ * @param engine - Instance that owns the document to build in and the host to seek through
47
+ * @param mount - Element whose children the built container replaces
48
+ * @param lyrics - Lines to render, left untouched
49
+ * @param options - What the container records for CSS to key on
50
+ */
51
+ export function setLyrics(engine, mount, lyrics, options) {
52
+ const doc = engine.document;
53
+ const container = doc.createElement("div");
54
+ container.className = LYRICS_CLASS;
55
+ mount.replaceChildren(container);
56
+ const allZero = lyrics.every(item => item.startTimeMs === 0);
57
+ const seek = (timeS) => engine.host.seek(timeS);
58
+ const lines = [];
59
+ const syncType = deriveSyncType(lyrics);
60
+ for (const [lineIndex, lyricItem] of lyrics.entries()) {
61
+ const lyricElement = doc.createElement("div");
62
+ const line = newLineData(lyricElement, lyricItem.startTimeMs, lyricItem.durationMs);
63
+ lyricElement.dataset.time = String(line.time);
64
+ lyricElement.dataset.duration = String(line.duration);
65
+ lyricElement.dataset.lineNumber = String(lineIndex);
66
+ lyricElement.classList.add(LINE_CLASS);
67
+ lyricElement.dir = "auto";
68
+ addSeekHandler(seek, lyricElement, allZero);
69
+ lines.push(line);
70
+ if (lyricItem.isInstrumental) {
71
+ buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex);
72
+ }
73
+ else {
74
+ buildSungLine(doc, lyricElement, lyricItem, line);
75
+ }
76
+ container.appendChild(lyricElement);
77
+ }
78
+ engine.skipScrolls = INITIAL_SKIP_SCROLLS;
79
+ engine.skipScrollsDecayTimes = Array.from({ length: INITIAL_SKIP_SCROLLS }, () => Date.now() + SKIP_SCROLL_DECAY_MS);
80
+ engine.scrollResumeTime = 0;
81
+ container.dataset.sync = syncType;
82
+ container.dataset.loaderVisible = String(options.loaderVisible);
83
+ if (options.noLyrics) {
84
+ container.dataset.noLyrics = "true";
85
+ }
86
+ engine.lines = lines;
87
+ engine.lyricsContainer = container;
88
+ engine.syncType = syncType;
89
+ // Measured last: the container is in the document, filled, and carrying the attributes CSS
90
+ // sizes it by.
91
+ engine.lyricWidth = container.clientWidth;
92
+ engine.lyricHeight = container.clientHeight;
93
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "0.1.6",
4
- "description": "Synchronized lyrics web component with word-by-word animations",
3
+ "version": "1.0.0",
4
+ "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/better-lyrics/braccato",
9
+ "url": "git+https://github.com/better-lyrics/braccato.git",
10
10
  "directory": "packages/core"
11
11
  },
12
12
  "homepage": "https://braccato.boidu.dev",
@@ -14,19 +14,41 @@
14
14
  "lyrics",
15
15
  "sync",
16
16
  "web-component",
17
- "lit",
18
17
  "music",
19
18
  "karaoke",
20
19
  "ttml"
21
20
  ],
22
- "sideEffects": true,
23
- "main": "dist/index.js",
24
- "types": "dist/index.d.ts",
21
+ "sideEffects": [
22
+ "./dist/element.js",
23
+ "./dist/styles/*.css"
24
+ ],
25
25
  "exports": {
26
26
  ".": {
27
27
  "types": "./dist/index.d.ts",
28
28
  "import": "./dist/index.js"
29
- }
29
+ },
30
+ "./element": {
31
+ "types": "./dist/element.d.ts",
32
+ "import": "./dist/element.js"
33
+ },
34
+ "./constants": {
35
+ "types": "./dist/constants.d.ts",
36
+ "import": "./dist/constants.js"
37
+ },
38
+ "./text": {
39
+ "types": "./dist/text.d.ts",
40
+ "import": "./dist/text.js"
41
+ },
42
+ "./themeSettings": {
43
+ "types": "./dist/themeSettings.d.ts",
44
+ "import": "./dist/themeSettings.js"
45
+ },
46
+ "./util": {
47
+ "types": "./dist/util.d.ts",
48
+ "import": "./dist/util.js"
49
+ },
50
+ "./styles/*.css": "./dist/styles/*.css",
51
+ "./package.json": "./package.json"
30
52
  },
31
53
  "files": [
32
54
  "dist"
@@ -35,21 +57,12 @@
35
57
  "access": "public"
36
58
  },
37
59
  "dependencies": {
38
- "lit": "^3.2.1",
39
- "@braccato/parsers": "^0.1.2"
40
- },
41
- "devDependencies": {
42
- "jsdom": "^28.1.0",
43
- "typescript": "^5.7.3",
44
- "vite": "^6.0.7",
45
- "vite-plugin-dts": "^4.3.0",
46
- "vitest": "^3.0.4"
60
+ "@braccato/types": "^1.0.0"
47
61
  },
48
62
  "scripts": {
49
- "build": "vite build",
50
- "dev": "vite build --watch",
63
+ "build": "tsx ../../tooling/build-package.ts",
51
64
  "typecheck": "tsc --noEmit",
52
- "test": "vitest run",
53
- "test:watch": "vitest"
65
+ "selfcheck": "tsx ../../tooling/run-selfchecks.ts",
66
+ "clean": "rm -rf dist"
54
67
  }
55
68
  }