@braccato/core 1.11.0 → 1.12.1

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
@@ -71,6 +71,15 @@ A `Lyric` is `{ startTimeMs, durationMs, words }`, with an optional `parts` arra
71
71
  fields for syllable or word timing, and optional `translation`, `romanization` and
72
72
  `timedRomanization` beside them.
73
73
 
74
+ Who wrote the song travels beside the lines rather than in them. Hand the names to `lyricsOptions` and
75
+ the view closes with a "Written by" line after the last lyric. The parsers read them out of the file:
76
+
77
+ ```js
78
+ const parser = detectParser(text);
79
+ view.lyricsOptions = { songwriters: parser.metadata(text).songwriters };
80
+ view.lyrics = parser.parse(text, player.duration * 1000);
81
+ ```
82
+
74
83
  ## Properties
75
84
 
76
85
  Every one of these may be written before the element is in a document. The renderer is built when it
@@ -79,7 +88,7 @@ connects, and everything it was handed by then is applied at once.
79
88
  | Property | Attribute | Type | Default | Description |
80
89
  | --------------- | -------------- | ----------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
81
90
  | `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. |
82
- | `lyricsOptions` | | `{ loaderVisible?, noLyrics?, language? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. |
91
+ | `lyricsOptions` | | `{ loaderVisible?, noLyrics?, language?, songwriters? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. `songwriters` closes the view with a credits line. |
83
92
  | `source` | `source` | `string \| HTMLMediaElement \| null` | `null` | A selector or the media element itself. See Following a media element. |
84
93
  | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
85
94
  | `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. |
@@ -229,6 +238,24 @@ frequency. Both must use the same commands in the same order with the same numbe
229
238
  browser only interpolates two paths smoothly when their command sequences match, and a mismatched
230
239
  pair snaps at the halfway point instead of flowing.
231
240
 
241
+ ### Credits
242
+
243
+ Given `songwriters`, the view ends with `A, B & C` after the last line. It sits dim through the song,
244
+ and once the last line has ended it brightens and takes the scroll focus, the way Apple Music closes a
245
+ song. Seeking back hands the focus to the lines again. Unsynced lyrics have no end, so their credits
246
+ show at full strength from the start.
247
+
248
+ | Custom property | Default | What it sets |
249
+ | ------------------------------------ | ---------------- | ---------------------------------------------------- |
250
+ | `--blyrics-credits-label` | `"Written by"` | The words before the names. Localise it here. |
251
+ | `--blyrics-credits-font-size` | `max(0.4em, 12px)` | The size of the whole line. |
252
+ | `--blyrics-credits-opacity` | `0.2` | While the song plays. |
253
+ | `--blyrics-credits-focused-opacity` | `0.85` | Once the song has ended. |
254
+
255
+ The container carries `data-credits-focused` while the credits hold the focus. To hide them, a
256
+ stylesheet can set `.blyrics-credits { display: none; }`, which the scroll then ignores, or a theme can
257
+ declare `/* blyrics-hide-credits = true; */` so they are never built.
258
+
232
259
  ### Letter wave (experimental)
233
260
 
234
261
  On by default; a theme opts out with `/* blyrics-letter-wave = false; */`. It splits every word into
@@ -296,6 +323,7 @@ refactor. Import them from `@braccato/core/constants` instead of typing them out
296
323
  | `USER_SCROLLING_CLASS` | `blyrics-user-scrolling` | Set while a reader has scrolled away and autoscroll waits. |
297
324
  | `TRANSLATED_LYRICS_CLASS` | `blyrics--translated` | A translation hung off a line that was already built. |
298
325
  | `EXPLICIT_WORD_CLASS` | `blyrics-explicit` | A word the lyrics flag as explicit. Unstyled unless a theme styles it. |
326
+ | `CREDITS_CLASS` | `blyrics-credits` | The songwriter credits after the last line. |
299
327
  | `CUSTOM_THEME_STYLE_ID` | `blyrics-custom-style` | The id of the `<style>` the theme lands in. |
300
328
 
301
329
  ## Stylesheets
package/dist/README.md CHANGED
@@ -71,6 +71,15 @@ A `Lyric` is `{ startTimeMs, durationMs, words }`, with an optional `parts` arra
71
71
  fields for syllable or word timing, and optional `translation`, `romanization` and
72
72
  `timedRomanization` beside them.
73
73
 
74
+ Who wrote the song travels beside the lines rather than in them. Hand the names to `lyricsOptions` and
75
+ the view closes with a "Written by" line after the last lyric. The parsers read them out of the file:
76
+
77
+ ```js
78
+ const parser = detectParser(text);
79
+ view.lyricsOptions = { songwriters: parser.metadata(text).songwriters };
80
+ view.lyrics = parser.parse(text, player.duration * 1000);
81
+ ```
82
+
74
83
  ## Properties
75
84
 
76
85
  Every one of these may be written before the element is in a document. The renderer is built when it
@@ -79,7 +88,7 @@ connects, and everything it was handed by then is applied at once.
79
88
  | Property | Attribute | Type | Default | Description |
80
89
  | --------------- | -------------- | ----------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
81
90
  | `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. |
82
- | `lyricsOptions` | | `{ loaderVisible?, noLyrics?, language? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. |
91
+ | `lyricsOptions` | | `{ loaderVisible?, noLyrics?, language?, songwriters? }` | `{}` | How the lines are built. `noLyrics` marks a message as a placeholder rather than a song, which keeps passive scrolling from drifting it. `songwriters` closes the view with a credits line. |
83
92
  | `source` | `source` | `string \| HTMLMediaElement \| null` | `null` | A selector or the media element itself. See Following a media element. |
84
93
  | `mediaElement` | | `HTMLMediaElement \| null` (get) | `null` | What `source` resolved to. Null while disconnected, and null for a selector that missed. |
85
94
  | `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. |
@@ -229,6 +238,24 @@ frequency. Both must use the same commands in the same order with the same numbe
229
238
  browser only interpolates two paths smoothly when their command sequences match, and a mismatched
230
239
  pair snaps at the halfway point instead of flowing.
231
240
 
241
+ ### Credits
242
+
243
+ Given `songwriters`, the view ends with `A, B & C` after the last line. It sits dim through the song,
244
+ and once the last line has ended it brightens and takes the scroll focus, the way Apple Music closes a
245
+ song. Seeking back hands the focus to the lines again. Unsynced lyrics have no end, so their credits
246
+ show at full strength from the start.
247
+
248
+ | Custom property | Default | What it sets |
249
+ | ------------------------------------ | ---------------- | ---------------------------------------------------- |
250
+ | `--blyrics-credits-label` | `"Written by"` | The words before the names. Localise it here. |
251
+ | `--blyrics-credits-font-size` | `max(0.4em, 12px)` | The size of the whole line. |
252
+ | `--blyrics-credits-opacity` | `0.2` | While the song plays. |
253
+ | `--blyrics-credits-focused-opacity` | `0.85` | Once the song has ended. |
254
+
255
+ The container carries `data-credits-focused` while the credits hold the focus. To hide them, a
256
+ stylesheet can set `.blyrics-credits { display: none; }`, which the scroll then ignores, or a theme can
257
+ declare `/* blyrics-hide-credits = true; */` so they are never built.
258
+
232
259
  ### Letter wave (experimental)
233
260
 
234
261
  On by default; a theme opts out with `/* blyrics-letter-wave = false; */`. It splits every word into
@@ -296,6 +323,7 @@ refactor. Import them from `@braccato/core/constants` instead of typing them out
296
323
  | `USER_SCROLLING_CLASS` | `blyrics-user-scrolling` | Set while a reader has scrolled away and autoscroll waits. |
297
324
  | `TRANSLATED_LYRICS_CLASS` | `blyrics--translated` | A translation hung off a line that was already built. |
298
325
  | `EXPLICIT_WORD_CLASS` | `blyrics-explicit` | A word the lyrics flag as explicit. Unstyled unless a theme styles it. |
326
+ | `CREDITS_CLASS` | `blyrics-credits` | The songwriter credits after the last line. |
299
327
  | `CUSTOM_THEME_STYLE_ID` | `blyrics-custom-style` | The id of the `<style>` the theme lands in. |
300
328
 
301
329
  ## Stylesheets
@@ -4,6 +4,9 @@ export declare const LINE_CLASS: "blyrics--line";
4
4
  export declare const WORD_CLASS: "blyrics--word";
5
5
  export declare const LETTER_CLASS: "blyrics--letter";
6
6
  export declare const FOOTER_CLASS: "blyrics-footer";
7
+ export declare const CREDITS_CLASS: "blyrics-credits";
8
+ export declare const CREDITS_TEXT_CLASS: "blyrics-credits__text";
9
+ export declare const CREDITS_NAMES_CLASS: "blyrics-credits__names";
7
10
  export declare const CURRENT_LYRICS_CLASS: "blyrics--active";
8
11
  export declare const ANIMATING_CLASS: "blyrics--animating";
9
12
  export declare const PAUSED_CLASS: "blyrics--paused";
package/dist/constants.js CHANGED
@@ -7,6 +7,9 @@ export const LINE_CLASS = "blyrics--line";
7
7
  export const WORD_CLASS = "blyrics--word";
8
8
  export const LETTER_CLASS = "blyrics--letter";
9
9
  export const FOOTER_CLASS = "blyrics-footer";
10
+ export const CREDITS_CLASS = "blyrics-credits";
11
+ export const CREDITS_TEXT_CLASS = "blyrics-credits__text";
12
+ export const CREDITS_NAMES_CLASS = "blyrics-credits__names";
10
13
  // -- Playback state --------------------------------------------
11
14
  export const CURRENT_LYRICS_CLASS = "blyrics--active";
12
15
  export const ANIMATING_CLASS = "blyrics--animating";
package/dist/engine.d.ts CHANGED
@@ -40,6 +40,8 @@ export interface AnimationEngineInstance extends AnimEngineViewState {
40
40
  host: LyricsRendererHost;
41
41
  cachedTabRendererHeight: number | null;
42
42
  cachedFooterItem: LineScrollItem | null;
43
+ cachedCreditsItem: LineScrollItem | null;
44
+ creditsFocused: boolean;
43
45
  cachedLineScrollTiming: Map<string, LineScrollTiming>;
44
46
  tabRendererResizeObserver: ResizeObserver | null;
45
47
  observedTabRenderer: HTMLElement | null;
package/dist/engine.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // playback clock the last tick wrote. Their unit is a bundle rather than a document, and this
17
17
  // module is bundled into the isolated world and the page world separately, so those are two clocks
18
18
  // that never meet. `themeSettings.ts` holds the third thing under that rule.
19
- import { ANIMATING_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, WORD_STATE_ACTIVE, WORD_STATE_ATTR, WORD_STATE_PAST, WORD_STATE_UPCOMING, } from "./constants.js";
19
+ import { ANIMATING_CLASS, CREDITS_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LINE_CLASS, PAUSED_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, USER_SCROLLING_CLASS, WORD_STATE_ACTIVE, WORD_STATE_ATTR, WORD_STATE_PAST, WORD_STATE_UPCOMING, } from "./constants.js";
20
20
  import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
21
21
  import { registerThemeSetting } from "./themeSettings.js";
22
22
  import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
@@ -129,6 +129,8 @@ export function createAnimationEngineInstance(engineDocument, engineWindow, host
129
129
  passiveLastWallTime: 0,
130
130
  cachedTabRendererHeight: null,
131
131
  cachedFooterItem: null,
132
+ cachedCreditsItem: null,
133
+ creditsFocused: false,
132
134
  cachedLineScrollTiming: new Map(),
133
135
  tabRendererResizeObserver: null,
134
136
  observedTabRenderer: null,
@@ -280,6 +282,8 @@ export function clearLyrics(engine) {
280
282
  stopPassiveScrollLoop(engine);
281
283
  engine.lines = [];
282
284
  engine.cachedFooterItem = null;
285
+ engine.cachedCreditsItem = null;
286
+ engine.creditsFocused = false;
283
287
  engine.lyricsContainer = null;
284
288
  engine.waveAnimationPool.length = 0;
285
289
  }
@@ -1464,19 +1468,19 @@ function updateVisibleLyricWillChange(engine, lines, fromScrollTop, toScrollTop,
1464
1468
  }
1465
1469
  engine.visibleWillChangeElements = nextVisibleElements;
1466
1470
  }
1467
- // Footer bounds come from the relayout-time cache, not a getBoundingClientRect here, which would force
1468
- // a synchronous recalc mid-tick after the scroll commit dirtied style.
1471
+ // The credits and the footer after the last line scroll with the lines. Their bounds come from the
1472
+ // relayout-time cache, not a getBoundingClientRect here, which would force a synchronous recalc
1473
+ // mid-tick after the scroll commit dirtied style.
1469
1474
  function getLineScrollItems(engine, lines) {
1470
- return engine.cachedFooterItem ? [...lines, engine.cachedFooterItem] : lines;
1475
+ const trailing = [engine.cachedCreditsItem, engine.cachedFooterItem].filter(item => item !== null);
1476
+ return trailing.length > 0 ? [...lines, ...trailing] : lines;
1471
1477
  }
1472
- function measureFooterItem(engine, lyricsElement) {
1473
- const footer = lyricsElement.querySelector(`:scope > .${FOOTER_CLASS}`);
1474
- if (!footer) {
1475
- engine.cachedFooterItem = null;
1476
- return;
1477
- }
1478
- const footerBounds = getRelativeLayoutBounds(lyricsElement, footer);
1479
- engine.cachedFooterItem = { lyricElement: footer, position: footerBounds.y, height: footerBounds.height };
1478
+ function measureTrailingItem(lyricsElement, className) {
1479
+ const element = lyricsElement.querySelector(`:scope > .${className}`);
1480
+ if (!element)
1481
+ return null;
1482
+ const bounds = getRelativeLayoutBounds(lyricsElement, element);
1483
+ return { lyricElement: element, position: bounds.y, height: bounds.height };
1480
1484
  }
1481
1485
  function prepareLineScrollOffsets(engine, lines, activeLineIndex, scrollDeltaPx, fromScrollTop, toScrollTop, viewportHeight, config) {
1482
1486
  if (!config.enabled.scroll || activeLineIndex < 0) {
@@ -1989,6 +1993,18 @@ export function tickView(engine, currentTime, options) {
1989
1993
  setAnimationsPlayState(lineData, false);
1990
1994
  }
1991
1995
  }
1996
+ const lastSungLine = lines.findLast(lineData => lineData.lyricElement.dataset.instrumental !== "true");
1997
+ const creditsFocused = lastSungLine !== undefined &&
1998
+ (engine.cachedCreditsItem?.height ?? 0) > 0 &&
1999
+ lyricScrollTime >= lastSungLine.time + lastSungLine.duration;
2000
+ if (creditsFocused !== engine.creditsFocused) {
2001
+ engine.creditsFocused = creditsFocused;
2002
+ if (creditsFocused)
2003
+ lyricsElement.dataset.creditsFocused = "true";
2004
+ else
2005
+ delete lyricsElement.dataset.creditsFocused;
2006
+ newLyricSelected = true;
2007
+ }
1992
2008
  if (engine.scrollResumeTime < Date.now() || engine.scrollPos === -1) {
1993
2009
  if (activeElems.length == 0) {
1994
2010
  activeElems.push(lines[0]);
@@ -2014,6 +2030,10 @@ export function tickView(engine, currentTime, options) {
2014
2030
  scrollPos = Math.max(scrollPos, lastActiveLyric.position - tabRendererHeight + lastActiveLyric.height);
2015
2031
  // Make sure top of last active lyric is visible.
2016
2032
  scrollPos = Math.min(scrollPos, lastActiveLyric.position);
2033
+ const credits = engine.cachedCreditsItem;
2034
+ if (engine.creditsFocused && credits) {
2035
+ scrollPos = credits.position + credits.height / 2 - scrollPosOffset;
2036
+ }
2017
2037
  // Past either end the browser clamps the write and reports nothing, leaving the view aiming
2018
2038
  // at a position it never reached and re-aiming once per remaining line.
2019
2039
  scrollPos = clamp(scrollPos, 0, maxScrollTop);
@@ -2183,7 +2203,14 @@ function applyScrollPadding(engine) {
2183
2203
  const lyricLines = lyricsElement.querySelectorAll(`:scope > .${LINE_CLASS}`);
2184
2204
  const firstLyric = lyricLines[0] ?? null;
2185
2205
  const lastLyric = lyricLines[lyricLines.length - 1] ?? null;
2186
- const lastLyricBounds = lastLyric ? getRelativeLayoutBounds(lyricsElement, lastLyric) : null;
2206
+ const credits = lyricsElement.querySelector(`:scope > .${CREDITS_CLASS}`);
2207
+ const creditsBounds = credits ? getRelativeLayoutBounds(lyricsElement, credits) : null;
2208
+ // The credits take the focus once the song ends, so they are the last thing the scroll must reach.
2209
+ const lastLyricBounds = creditsBounds && creditsBounds.height > 0
2210
+ ? creditsBounds
2211
+ : lastLyric
2212
+ ? getRelativeLayoutBounds(lyricsElement, lastLyric)
2213
+ : null;
2187
2214
  const footer = lyricsElement.querySelector(`:scope > .${FOOTER_CLASS}`);
2188
2215
  const { top, bottom } = computeScrollPadding({
2189
2216
  viewportHeight: tabRendererHeight,
@@ -2227,7 +2254,8 @@ export function relayout(engine, measureLines) {
2227
2254
  line.height = bounds.height;
2228
2255
  line.decorations = new Map(lineDecorators(line.lyricElement).map(element => [element, getRelativeLayoutBounds(lyricsElement, element).y]));
2229
2256
  }
2230
- measureFooterItem(engine, lyricsElement);
2257
+ engine.cachedCreditsItem = measureTrailingItem(lyricsElement, CREDITS_CLASS);
2258
+ engine.cachedFooterItem = measureTrailingItem(lyricsElement, FOOTER_CLASS);
2231
2259
  // Re-arm from the fresh measurements, so a line skipped after this holds its new placeholder height.
2232
2260
  setupLineCullObserver(engine);
2233
2261
  engine.wasUserScrolling = true; // trigger rescrolls
package/dist/inject.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type WordState } from "./constants.js";
2
2
  import type { Lyric, LyricPart, LyricSyncType } from "./types.js";
3
3
  export declare let disableRichsync: import("./themeSettings.js").Setting;
4
+ export declare const hideCredits: import("./themeSettings.js").Setting;
4
5
  export declare function findNearestAgent(lyrics: Lyric[], fromIndex: number): string | undefined;
5
6
  export declare function isNearestLyricRtl(lyrics: Lyric[], fromIndex: number): boolean;
6
7
  export declare function deriveSyncType(lyrics: Lyric[]): LyricSyncType;
package/dist/inject.js CHANGED
@@ -21,6 +21,7 @@ let lineSyncedAnimationDelay = registerThemeSetting("blyrics-line-synced-animati
21
21
  let longWordThreshold = registerThemeSetting("blyrics-long-word-threshold", 1500, true);
22
22
  let longWordWrapThreshold = registerThemeSetting("blyrics-long-word-wrap-threshold", 10, true);
23
23
  let letterWave = registerThemeSetting("blyrics-letter-wave", true, true);
24
+ export const hideCredits = registerThemeSetting("blyrics-hide-credits", false, true);
24
25
  const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
25
26
  const RTL_SCRIPT_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u;
26
27
  const LTR_SCRIPT_REGEX = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u;
package/dist/renderer.js CHANGED
@@ -260,6 +260,7 @@ export function createLyricsRenderer(rendererOptions) {
260
260
  language: options?.language,
261
261
  loaderVisible: options?.loaderVisible ?? false,
262
262
  noLyrics: options?.noLyrics ?? false,
263
+ songwriters: options?.songwriters,
263
264
  });
264
265
  currentLyrics = lyrics;
265
266
  currentLanguage = normalizeLanguage(options?.language);
@@ -239,6 +239,29 @@
239
239
  }
240
240
  }
241
241
 
242
+ .blyrics-container > .blyrics-credits {
243
+ cursor: default;
244
+ }
245
+
246
+ .blyrics-credits {
247
+ color: var(--blyrics-ui-text-color);
248
+ opacity: var(--blyrics-credits-opacity);
249
+ transition: opacity 0.35s cubic-bezier(0.25, 1, 0.5, 1);
250
+ }
251
+
252
+ .blyrics-credits__text {
253
+ font-size: var(--blyrics-credits-font-size);
254
+ }
255
+
256
+ .blyrics-credits__text::before {
257
+ content: var(--blyrics-credits-label) " ";
258
+ }
259
+
260
+ .blyrics-container[data-credits-focused] > .blyrics-credits,
261
+ .blyrics-container[data-sync="none"] > .blyrics-credits {
262
+ opacity: var(--blyrics-credits-focused-opacity);
263
+ }
264
+
242
265
  .blyrics--romanized {
243
266
  background: rgba(255, 255, 255, 0.05);
244
267
  border: 1px solid rgba(255, 255, 255, 0.1);
@@ -50,6 +50,11 @@
50
50
  display-p3 1 1 1 / var(--blyrics-translated-opacity, 0.6)
51
51
  );
52
52
 
53
+ --blyrics-credits-label: "Written by";
54
+ --blyrics-credits-font-size: max(0.4em, 12px);
55
+ --blyrics-credits-opacity: 0.2;
56
+ --blyrics-credits-focused-opacity: 0.85;
57
+
53
58
  --blyrics-footer-font-family: Satoshi, Roboto, Noto Naskh Arabic UI, Arial, sans-serif;
54
59
  --blyrics-footer-font-size: 14px;
55
60
  --blyrics-footer-font-weight: 400;
package/dist/view.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface SetLyricsOptions {
11
11
  * These lyrics are a "not found" placeholder rather than a real result.
12
12
  */
13
13
  noLyrics: boolean;
14
+ /** Who wrote the song, shown after the last line. */
15
+ songwriters?: readonly string[];
14
16
  }
15
17
  /**
16
18
  * Replaces whatever the mount holds with a container built from these lyrics, and hands the render
package/dist/view.js CHANGED
@@ -9,9 +9,9 @@
9
9
  // This is also the one place outside the engine that writes engine view state, because building the
10
10
  // lines is where those values are first knowable: the container, the records, the sync type, the
11
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";
12
+ import { CREDITS_CLASS, CREDITS_NAMES_CLASS, CREDITS_TEXT_CLASS, LINE_CLASS, LYRICS_CLASS, RTL_CLASS, } from "./constants.js";
13
13
  import { setupLineCullObserver } from "./engine.js";
14
- import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, isNearestLyricRtl, newLineData, } from "./inject.js";
14
+ import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, hideCredits, isNearestLyricRtl, newLineData, } from "./inject.js";
15
15
  import { createInstrumentalElement } from "./instrumental.js";
16
16
  import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
17
17
  const INITIAL_SKIP_SCROLLS = 2;
@@ -41,6 +41,23 @@ function buildSungLine(doc, lyricElement, lyricItem, line) {
41
41
  lyricElement.dataset.agent = lyricItem.agent;
42
42
  }
43
43
  }
44
+ function formatSongwriters(names) {
45
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} & ${names.at(-1)}` : (names[0] ?? "");
46
+ }
47
+ function buildCredits(doc, songwriters) {
48
+ const credits = doc.createElement("div");
49
+ credits.className = CREDITS_CLASS;
50
+ // The smaller size lives on an inner block so the credits keep the lines' em, and with it their inset.
51
+ const text = doc.createElement("div");
52
+ text.className = CREDITS_TEXT_CLASS;
53
+ const names = doc.createElement("span");
54
+ names.className = CREDITS_NAMES_CLASS;
55
+ names.dir = "auto";
56
+ names.textContent = formatSongwriters(songwriters);
57
+ text.appendChild(names);
58
+ credits.appendChild(text);
59
+ return credits;
60
+ }
44
61
  /**
45
62
  * Replaces whatever the mount holds with a container built from these lyrics, and hands the render
46
63
  * records, the container and its measured size to the engine that animates them.
@@ -79,6 +96,10 @@ export function setLyrics(engine, mount, lyrics, options) {
79
96
  }
80
97
  container.appendChild(lyricElement);
81
98
  }
99
+ const songwriters = options.songwriters ?? [];
100
+ if (songwriters.length > 0 && !options.noLyrics && !hideCredits.getBooleanValue()) {
101
+ container.appendChild(buildCredits(doc, songwriters));
102
+ }
82
103
  engine.skipScrolls = INITIAL_SKIP_SCROLLS;
83
104
  engine.skipScrollsDecayTimes = Array.from({ length: INITIAL_SKIP_SCROLLS }, () => Date.now() + SKIP_SCROLL_DECAY_MS);
84
105
  engine.scrollResumeTime = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.11.0",
3
+ "version": "1.12.1",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",