Initial Commit
This commit is contained in:
136
packages/ui/react/react-audio-player/README.md
Normal file
136
packages/ui/react/react-audio-player/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# @suaveplan/react-audio-player - Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.
|
||||
|
||||
Track-style media-player surface for the Suaveplan audio family.
|
||||
The package ships two kinds of exports:
|
||||
|
||||
1. **Karaoke** — a self-contained LRC parser, `useLyrics` hook, and
|
||||
`<LyricsDisplay>` component implemented in this package.
|
||||
2. **Re-export façade** — namespace and flat aliases for the player
|
||||
UI that already lives in
|
||||
[`@suaveplan/react-audio`](../react-audio/README.md):
|
||||
`<AudioProvider>`, `<AudioPlayer>`, `<BasicPlayer>`,
|
||||
`<PremiumPlayer>`, `<TransportControls>`, `<Mixer>`,
|
||||
`<PluginChainView>`, `<PresetManager>`, plus hooks
|
||||
(`useTransport`, `useTransportTime`, `useAnalyser`, `usePlaylist`,
|
||||
…).
|
||||
|
||||
Consumers can either import directly from `@suaveplan/react-audio`
|
||||
(the original home of those components) or from
|
||||
`@suaveplan/react-audio-player`, whichever reads better in their
|
||||
codebase. The named entry points resolve to the same singletons via
|
||||
ESM re-export — no duplication, no extra bundle weight.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
|
||||
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
|
||||
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
|
||||
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
|
||||
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
|
||||
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
|
||||
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Status
|
||||
|
||||
This package targets the *additive* scope of the
|
||||
`enterprise-react-audio-player` change. The pre-existing player
|
||||
components (`<BasicPlayer>`, `<AudioPlayer>`, `<PremiumPlayer>`,
|
||||
visualisers, transport controls, mixer, preset manager) already live
|
||||
in `@suaveplan/react-audio`; duplicating them here would violate the
|
||||
"do not duplicate enterprise components" guidance. Instead this
|
||||
package adds:
|
||||
|
||||
- The karaoke surface — LRC parser, `useLyrics`, `<LyricsDisplay>`.
|
||||
- A namespaced re-export façade (`ReactAudio.*`) plus flat aliases
|
||||
for the most common entry points.
|
||||
- Stable `REACT_AUDIO_PLAYER.*` error codes.
|
||||
|
||||
### Deferred scope
|
||||
|
||||
- **Multi-peer playback sync** (`useMultiPeerSync`) is deferred until
|
||||
`@suaveplan/react-audio-stream` exists . The spec
|
||||
requirement is retained in the canonical capability spec with a
|
||||
`> Deferred (multi-peer)` marker; once the dependency lands, the
|
||||
hook adds in a follow-up change without breaking this package's
|
||||
public surface.
|
||||
- R3F screensavers (`<GLBViewer>`, `<ShaderBackground>`) — the spec
|
||||
requirement is retained; consumers needing these today can
|
||||
compose any R3F surface as a sibling of `<PremiumPlayer>`. The
|
||||
optional `./screensavers/*` subpaths will be filled by a focused
|
||||
follow-up change.
|
||||
|
||||
The two hooks consumed by visualisers (`useAnalyser`) and queues
|
||||
(`usePlaylist`) **are** shipped — they were added to
|
||||
`@suaveplan/react-audio` alongside this package and are re-exported
|
||||
here for ergonomic reach.
|
||||
|
||||
## Quick start — karaoke
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AudioProvider,
|
||||
AudioPlayer,
|
||||
LyricsDisplay,
|
||||
} from "@suaveplan/react-audio-player";
|
||||
|
||||
function App({ track, lrc }: { track: string; lrc: string }) {
|
||||
return (
|
||||
<AudioProvider options={{ /* … */ }}>
|
||||
<AudioPlayer source={track} />
|
||||
<KaraokeLine lrc={lrc} />
|
||||
</AudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
function KaraokeLine({ lrc }: { lrc: string }) {
|
||||
const seconds = useTransportTime();
|
||||
return <LyricsDisplay lrc={lrc} currentSec={seconds} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Quick start — standalone preview (no provider)
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
LyricsDisplay,
|
||||
} from "@suaveplan/react-audio-player";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function Preview({ lrc }: { lrc: string }) {
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
const [t, setT] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={0.1}
|
||||
value={t}
|
||||
onChange={(e) => setT(Number(e.currentTarget.value))}
|
||||
aria-label="time"
|
||||
/>
|
||||
<LyricsDisplay lyrics={lyrics} currentSec={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
| Subpath | Surface |
|
||||
| --- | --- |
|
||||
| `.` | Root barrel — karaoke + flat aliases + `ReactAudio` namespace + error codes. |
|
||||
| `./karaoke` | All karaoke exports. |
|
||||
| `./karaoke/lrc-parser` | `parseLrc`, `activeLyricIndex`, types. |
|
||||
| `./karaoke/use-lyrics` | `useLyrics` and types. |
|
||||
| `./karaoke/lyrics-display` | `<LyricsDisplay>` and types. |
|
||||
| `./error-codes` | `REACT_AUDIO_PLAYER.*` registry. |
|
||||
|
||||
See the per-feature `.md` files for full API references.
|
||||
|
||||
100
packages/ui/react/react-audio-player/package.json
Normal file
100
packages/ui/react/react-audio-player/package.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "@suaveplan/react-audio-player",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./karaoke": {
|
||||
"import": {
|
||||
"types": "./dist/karaoke/index.d.ts",
|
||||
"default": "./dist/karaoke/index.js"
|
||||
}
|
||||
},
|
||||
"./karaoke/lrc-parser": {
|
||||
"import": {
|
||||
"types": "./dist/karaoke/lrc-parser/lrc-parser.d.ts",
|
||||
"default": "./dist/karaoke/lrc-parser/lrc-parser.js"
|
||||
}
|
||||
},
|
||||
"./karaoke/use-lyrics": {
|
||||
"import": {
|
||||
"types": "./dist/karaoke/use-lyrics/use-lyrics.d.ts",
|
||||
"default": "./dist/karaoke/use-lyrics/use-lyrics.js"
|
||||
}
|
||||
},
|
||||
"./karaoke/lyrics-display": {
|
||||
"import": {
|
||||
"types": "./dist/karaoke/lyrics-display/lyrics-display.d.ts",
|
||||
"default": "./dist/karaoke/lyrics-display/lyrics-display.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/react-audio": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@testing-library/jest-dom": "catalog:testing",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@testing-library/user-event": "catalog:testing",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/react-audio-player`.
|
||||
Every error thrown by a public surface in this package carries a
|
||||
code from `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n
|
||||
resolution, and an optional `context` / `cause` chain. Codes are
|
||||
namespaced under the `REACT_AUDIO_PLAYER.` prefix so the wire format
|
||||
`{ code: "REACT_AUDIO_PLAYER.LRC_PARSE_FAILED" }` is greppable and
|
||||
stable across versions.
|
||||
|
||||
The registry is the single source of truth for the package's error
|
||||
contract — the test suite walks `REACT_AUDIO_PLAYER_ERROR_METADATA`
|
||||
and confirms every code is reachable from a real call site, so a
|
||||
code that gets added without a thrower (or a thrower whose code
|
||||
gets removed) is a build break.
|
||||
|
||||
## Features
|
||||
|
||||
- Five public codes: `NO_PROVIDER`, `PLAYLIST_EMPTY`,
|
||||
`PERSISTENCE_FAILED`, `LRC_PARSE_FAILED`, `INVALID_OPTIONS`.
|
||||
- Frozen metadata map keyed by code; every entry has a `description`
|
||||
and `severity` (`"error"` | `"warning"`).
|
||||
- `ReactAudioPlayerError` class extends `SuaveplanError`; carries a
|
||||
stable `category = "react-audio-player"` field.
|
||||
- Idempotent `registerReactAudioPlayerErrorCodes()` for parity with
|
||||
sibling audio packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
ReactAudioPlayerError,
|
||||
} from "@suaveplan/react-audio-player/error-codes";
|
||||
|
||||
if (!provider) {
|
||||
throw new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.NO_PROVIDER,
|
||||
"react-audio-player.no-provider"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
} from "@suaveplan/react-audio-player/error-codes";
|
||||
|
||||
try {
|
||||
parseLrc(input);
|
||||
} catch (cause) {
|
||||
const code = REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED;
|
||||
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
|
||||
throw new ReactAudioPlayerError(code, "lrc.parse.failed", {
|
||||
cause,
|
||||
context: { severity: meta.severity, bytes: input.length },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REACT_AUDIO_PLAYER_CODES` — `Readonly<Record<…, string>>` of the
|
||||
five public codes (frozen).
|
||||
- `ReactAudioPlayerErrorCode` — union of the code string-literals.
|
||||
- `REACT_AUDIO_PLAYER_ERROR_METADATA` — frozen metadata map.
|
||||
- `ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode>`.
|
||||
- `registerReactAudioPlayerErrorCodes(): void` — idempotent no-op.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is the workspace's standard helper for producing
|
||||
namespaced code maps; it freezes the result and asserts that every
|
||||
key is a valid identifier at module load. Metadata is declared with
|
||||
`Object.freeze` so consumers (and the test suite) cannot mutate it
|
||||
at runtime.
|
||||
|
||||
`SuaveplanError` is the universal base error from
|
||||
`@suaveplan/error`; it carries `code`, `messageKey`, `context`, and
|
||||
`cause` plus a stable wire-format serialisation. The
|
||||
`category = "react-audio-player"` literal is the discriminator used
|
||||
by the cross-package error router when an error crosses a package
|
||||
boundary.
|
||||
|
||||
`name` is set to `ReactAudioPlayerError(<severity>)` so stack-trace
|
||||
output includes the severity at a glance. This is a string-only
|
||||
augmentation; it does not change the prototype chain so
|
||||
`instanceof ReactAudioPlayerError` still narrows correctly.
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
type ReactAudioPlayerErrorCode,
|
||||
registerReactAudioPlayerErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("REACT_AUDIO_PLAYER error codes", () => {
|
||||
it("every code has metadata", { timeout: 5000 }, () => {
|
||||
for (const key of Object.keys(REACT_AUDIO_PLAYER_CODES)) {
|
||||
const code = REACT_AUDIO_PLAYER_CODES[
|
||||
key as keyof typeof REACT_AUDIO_PLAYER_CODES
|
||||
] as ReactAudioPlayerErrorCode;
|
||||
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta.description.length).toBeGreaterThan(10);
|
||||
expect(["error", "warning"]).toContain(meta.severity);
|
||||
}
|
||||
});
|
||||
|
||||
it("every code is reachable from a thrown error", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(REACT_AUDIO_PLAYER_CODES)) {
|
||||
const err = new ReactAudioPlayerError(code, `${code}.test`);
|
||||
expect(err.code).toBe(code);
|
||||
expect(err.category).toBe("react-audio-player");
|
||||
expect(err.name.startsWith("ReactAudioPlayerError(")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("error carries cause + context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
|
||||
"lrc.parse.failed",
|
||||
{ cause, context: { bytes: 42 } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ bytes: 42 });
|
||||
});
|
||||
|
||||
it("registerReactAudioPlayerErrorCodes is idempotent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registerReactAudioPlayerErrorCodes();
|
||||
registerReactAudioPlayerErrorCodes();
|
||||
// Re-import did not throw and metadata is still intact.
|
||||
expect(Object.isFrozen(REACT_AUDIO_PLAYER_ERROR_METADATA)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error codes for `@suaveplan/react-audio-player`. The five
|
||||
* codes cover the karaoke surface and the package's mount-time
|
||||
* invariants. Multi-peer codes are deliberately not declared here —
|
||||
* they will land alongside the multi-peer hook once
|
||||
* `@suaveplan/react-audio-stream` exists.
|
||||
*/
|
||||
export const REACT_AUDIO_PLAYER_CODES = defineCodes("REACT_AUDIO_PLAYER", [
|
||||
"NO_PROVIDER",
|
||||
"PLAYLIST_EMPTY",
|
||||
"PERSISTENCE_FAILED",
|
||||
"LRC_PARSE_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type ReactAudioPlayerErrorCode =
|
||||
(typeof REACT_AUDIO_PLAYER_CODES)[keyof typeof REACT_AUDIO_PLAYER_CODES];
|
||||
|
||||
export interface ReactAudioPlayerErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
export const REACT_AUDIO_PLAYER_ERROR_METADATA: Readonly<
|
||||
Record<ReactAudioPlayerErrorCode, ReactAudioPlayerErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[REACT_AUDIO_PLAYER_CODES.NO_PROVIDER]: {
|
||||
description:
|
||||
"a react-audio-player surface was rendered outside its required <AudioProvider> ancestor from @suaveplan/react-audio.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_PLAYER_CODES.PLAYLIST_EMPTY]: {
|
||||
description:
|
||||
"a transport-driving operation was attempted while the playlist contained zero items.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_PLAYER_CODES.PERSISTENCE_FAILED]: {
|
||||
description:
|
||||
"the persistence store (localStorage or injected) rejected a read or write.",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED]: {
|
||||
description:
|
||||
"parseLrc encountered input it could not validate (non-string, oversize, or no candidate timestamps).",
|
||||
severity: "error",
|
||||
},
|
||||
[REACT_AUDIO_PLAYER_CODES.INVALID_OPTIONS]: {
|
||||
description:
|
||||
"a public hook or component received an options bundle that failed boundary validation.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Base error thrown by every public surface of
|
||||
* `@suaveplan/react-audio-player`. Carries one of
|
||||
* `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n, and an
|
||||
* optional `context` / `cause` chain.
|
||||
*/
|
||||
export class ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode> {
|
||||
public readonly category = "react-audio-player" as const;
|
||||
|
||||
public constructor(
|
||||
code: ReactAudioPlayerErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = `ReactAudioPlayerError(${REACT_AUDIO_PLAYER_ERROR_METADATA[code].severity})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent registration hook. Metadata is module-static so the
|
||||
* function is a no-op; it exists for parity with the
|
||||
* `registerXxxErrorCodes` convention used by the rest of the audio
|
||||
* family.
|
||||
*/
|
||||
export function registerReactAudioPlayerErrorCodes(): void {
|
||||
/* metadata is module-static; no-op. */
|
||||
}
|
||||
|
||||
registerReactAudioPlayerErrorCodes();
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
ReactAudioPlayerErrorCode,
|
||||
ReactAudioPlayerErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
registerReactAudioPlayerErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
71
packages/ui/react/react-audio-player/src/index.ts
Normal file
71
packages/ui/react/react-audio-player/src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* `@suaveplan/react-audio-player` — track-style media-player surface.
|
||||
*
|
||||
* This root barrel ships two kinds of exports:
|
||||
*
|
||||
* 1. **Karaoke** (`parseLrc`, `activeLyricIndex`, `useLyrics`,
|
||||
* `<LyricsDisplay>`, types) — the package's own additive
|
||||
* surface, implemented in `./karaoke`.
|
||||
* 2. **Re-export façade** of the player UI from
|
||||
* `@suaveplan/react-audio` — namespaced under
|
||||
* `ReactAudio` so consumers can write
|
||||
* `import { ReactAudio } from "@suaveplan/react-audio-player"`
|
||||
* and reach the existing `AudioProvider`, `AudioPlayer`,
|
||||
* `BasicPlayer`, `PremiumPlayer`, `TransportControls`,
|
||||
* `Mixer`, `useAnalyser`, `usePlaylist`, `useTransport`,
|
||||
* hooks, stores, and error codes. A flat alias of the most
|
||||
* common entry points is also forwarded for ergonomic reach.
|
||||
*
|
||||
* Multi-peer playback synchronisation (`useMultiPeerSync` / WebRTC
|
||||
* via `@suaveplan/react-audio-stream`) is deferred — the upstream
|
||||
* `react-audio-stream` package does not yet exist on genesis. The
|
||||
* spec delta retains the requirement; archival folds it into the
|
||||
* canonical capability spec with a clear deferred marker.
|
||||
*/
|
||||
|
||||
// Namespace re-export of @suaveplan/react-audio so consumers can
|
||||
// reach the existing player components through this package's
|
||||
// surface. Using namespace form (`export * as`) rather than bare
|
||||
// `export *` keeps the package's own root barrel non-`export *` per
|
||||
// CLAUDE.md rule 10 while still avoiding hand-listing 60+ symbols.
|
||||
export * as ReactAudio from "@suaveplan/react-audio";
|
||||
// Ergonomic flat aliases for the most common entry points — these
|
||||
// resolve through the namespace export but spare the consumer the
|
||||
// `ReactAudio.` prefix. Each alias is an *explicit* named re-export
|
||||
// (rule 10 compliant).
|
||||
export {
|
||||
AudioPlayer,
|
||||
AudioProvider,
|
||||
BasicPlayer,
|
||||
Mixer,
|
||||
PluginChainView,
|
||||
PremiumPlayer,
|
||||
PresetManager,
|
||||
TrackList,
|
||||
TransportControls,
|
||||
useAnalyser,
|
||||
usePlaylist,
|
||||
useTransport,
|
||||
useTransportTime,
|
||||
} from "@suaveplan/react-audio";
|
||||
// Error codes.
|
||||
export {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
REACT_AUDIO_PLAYER_ERROR_METADATA,
|
||||
ReactAudioPlayerError,
|
||||
type ReactAudioPlayerErrorCode,
|
||||
type ReactAudioPlayerErrorMetadata,
|
||||
registerReactAudioPlayerErrorCodes,
|
||||
} from "./error-codes/index.js";
|
||||
// Karaoke (this package's own additive surface).
|
||||
export {
|
||||
activeLyricIndex,
|
||||
type Lyric,
|
||||
LyricsDisplay,
|
||||
type LyricsDisplayProps,
|
||||
type ParseLrcOptions,
|
||||
parseLrc,
|
||||
type UseLyricsOptions,
|
||||
type UseLyricsResult,
|
||||
useLyrics,
|
||||
} from "./karaoke/index.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export { activeLyricIndex, parseLrc } from "./lrc-parser/lrc-parser.js";
|
||||
export type { Lyric, ParseLrcOptions } from "./lrc-parser/lrc-parser.types.js";
|
||||
export { LyricsDisplay } from "./lyrics-display/lyrics-display.js";
|
||||
export type { LyricsDisplayProps } from "./lyrics-display/lyrics-display.types.js";
|
||||
export { useLyrics } from "./use-lyrics/use-lyrics.js";
|
||||
export type {
|
||||
UseLyricsOptions,
|
||||
UseLyricsResult,
|
||||
} from "./use-lyrics/use-lyrics.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { activeLyricIndex, parseLrc } from "./lrc-parser.js";
|
||||
export type { Lyric, ParseLrcOptions } from "./lrc-parser.types.js";
|
||||
@@ -0,0 +1,106 @@
|
||||
# `karaoke/lrc-parser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseLrc(source, opts?)` parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
|
||||
karaoke source into a timestamp-sorted array of `Lyric` entries
|
||||
suitable for the package's `<LyricsDisplay>` and any consumer-built
|
||||
karaoke surface. The parser is universal — it has no React, DOM, or
|
||||
audio-context dependency — so it is safe to call in workers, on the
|
||||
server during SSR, or inside other Suaveplan tools that need to
|
||||
preview lyric timing.
|
||||
|
||||
The companion `activeLyricIndex(lyrics, currentSec)` performs a
|
||||
binary search for the active line and is the bridge between the
|
||||
transport's `seconds` and the `<LyricsDisplay>` highlight state.
|
||||
Both functions are pure; the parser throws structured
|
||||
`ReactAudioPlayerError` for the two invalid-input cases.
|
||||
|
||||
## Features
|
||||
|
||||
- Recognises `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, and
|
||||
`[h:mm:ss(.xxx)]` timestamp forms.
|
||||
- Expands multi-timestamp lines (`[00:10][00:20]hello`) by default;
|
||||
togglable via `expandMultiTimestamp`.
|
||||
- Drops the LRC metadata tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]`,
|
||||
`[by:]`, `[offset:]`, `[re:]`, `[ve:]` — no extraction in v1.
|
||||
- Skips empty lines, malformed lines, and lines with no leading
|
||||
timestamp.
|
||||
- Output is sorted ascending by `timestampSec` and frozen.
|
||||
- Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
|
||||
or input exceeding `maxLength` (default 1 MiB).
|
||||
- `activeLyricIndex` is O(log n) and allocation-free.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
|
||||
const lyrics = parseLrc(`
|
||||
[ar:Test Artist]
|
||||
[00:10.50]First line
|
||||
[00:25.00]Second line
|
||||
`);
|
||||
// → [{ timestampSec: 10.5, text: "First line" },
|
||||
// { timestampSec: 25.0, text: "Second line" }]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
activeLyricIndex,
|
||||
} from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function LyricLine({ source }: { source: string }) {
|
||||
const lyrics = useMemo(
|
||||
() => parseLrc(source, { expandMultiTimestamp: false }),
|
||||
[source]
|
||||
);
|
||||
const seconds = useTransportTime();
|
||||
const idx = activeLyricIndex(lyrics, seconds);
|
||||
return (
|
||||
<span aria-live="polite">
|
||||
{idx >= 0 ? lyrics[idx].text : "…"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseLrc(source: unknown, opts?: ParseLrcOptions): readonly Lyric[]`
|
||||
- `activeLyricIndex(lyrics: readonly Lyric[], currentSec: number): number`
|
||||
- `Lyric` — `{ timestampSec: number; text: string }`.
|
||||
- `ParseLrcOptions` — `maxLength?: number` (default 1 MiB),
|
||||
`expandMultiTimestamp?: boolean` (default `true`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The timestamp regex `/\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g`
|
||||
captures optional hours, minutes, seconds, and an optional
|
||||
fractional component. The fractional value is padded right with
|
||||
zeros to three digits then divided by 1000, so `.5` parses to 0.500
|
||||
seconds, `.50` to 0.500, and `.500` to 0.500 — all equivalent. Only `.` is accepted as the
|
||||
fractional separator; the `:` form would conflict with the
|
||||
`[h:mm:ss]` extended timestamp grammar.
|
||||
|
||||
Multi-timestamp expansion walks the regex's global state, but only
|
||||
honours timestamps that form a contiguous prefix of the line. A
|
||||
bracketed sequence later in the line is treated as text — many
|
||||
karaoke services emit `[00:10]hello [00:11]world` to mean two
|
||||
separate lyric lines and this parser handles both layouts
|
||||
correctly.
|
||||
|
||||
`activeLyricIndex` uses the upper-bound binary search idiom: pick
|
||||
the greatest index whose timestamp is ≤ current. When `current` is
|
||||
between two entries, the earlier entry wins; when `current`
|
||||
precedes the first entry, `-1` is returned (so the consumer can
|
||||
render an empty active line until the first cue).
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { REACT_AUDIO_PLAYER_CODES } from "../../error-codes/error-codes.js";
|
||||
import { activeLyricIndex, parseLrc } from "./lrc-parser.js";
|
||||
|
||||
describe("parseLrc", () => {
|
||||
it("sorts by timestamp and skips invalid lines", { timeout: 5000 }, () => {
|
||||
const out = parseLrc(
|
||||
"[ar:Foo]\n[00:30.00]Line A\nBroken line\n[00:10.50]Line B"
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ timestampSec: 10.5, text: "Line B" },
|
||||
{ timestampSec: 30, text: "Line A" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips metadata tags", { timeout: 5000 }, () => {
|
||||
const out = parseLrc(
|
||||
"[ar:Artist]\n[ti:Title]\n[al:Album]\n[length:03:24]\n[by:User]\n[offset:0]\n[00:01.00]hello"
|
||||
);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.text).toBe("hello");
|
||||
});
|
||||
|
||||
it("parses fractional seconds in multiple precisions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseLrc(
|
||||
"[00:01.5]a\n[00:02.50]b\n[00:03.500]c\n[00:04.05]d"
|
||||
);
|
||||
expect(out[0]?.timestampSec).toBeCloseTo(1.5, 3);
|
||||
expect(out[1]?.timestampSec).toBeCloseTo(2.5, 3);
|
||||
expect(out[2]?.timestampSec).toBeCloseTo(3.5, 3);
|
||||
expect(out[3]?.timestampSec).toBeCloseTo(4.05, 3);
|
||||
});
|
||||
|
||||
it("parses h:mm:ss form", { timeout: 5000 }, () => {
|
||||
const out = parseLrc("[1:02:03.500]hour");
|
||||
expect(out[0]?.timestampSec).toBeCloseTo(3723.5, 3);
|
||||
});
|
||||
|
||||
it("expands multi-timestamp lines", { timeout: 5000 }, () => {
|
||||
const out = parseLrc("[00:10.00][00:20.00]hi");
|
||||
expect(out.length).toBe(2);
|
||||
expect(out[0]?.timestampSec).toBe(10);
|
||||
expect(out[1]?.timestampSec).toBe(20);
|
||||
expect(out[0]?.text).toBe("hi");
|
||||
expect(out[1]?.text).toBe("hi");
|
||||
});
|
||||
|
||||
it("expandMultiTimestamp=false keeps only first timestamp", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseLrc("[00:10.00][00:20.00]hi", {
|
||||
expandMultiTimestamp: false,
|
||||
});
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.timestampSec).toBe(10);
|
||||
expect(out[0]?.text).toBe("[00:20.00]hi");
|
||||
});
|
||||
|
||||
it("bracketed text after a non-prefix timestamp is treated as text", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseLrc("[00:10.00]hello[00:20.00]world");
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.text).toBe("hello[00:20.00]world");
|
||||
});
|
||||
|
||||
it("throws on non-string input", { timeout: 5000 }, () => {
|
||||
expect(() => parseLrc(null as unknown as string)).toThrowError(
|
||||
/lrc.parse.non-string/
|
||||
);
|
||||
try {
|
||||
parseLrc(42 as unknown as string);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as { code?: string }).code).toBe(
|
||||
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on oversize input", { timeout: 5000 }, () => {
|
||||
const huge = "a".repeat(20);
|
||||
expect(() => parseLrc(huge, { maxLength: 10 })).toThrowError(
|
||||
/lrc.parse.too-large/
|
||||
);
|
||||
});
|
||||
|
||||
it("returns frozen array", { timeout: 5000 }, () => {
|
||||
const out = parseLrc("[00:01]a");
|
||||
expect(Object.isFrozen(out)).toBe(true);
|
||||
});
|
||||
|
||||
it("CRLF line endings supported", { timeout: 5000 }, () => {
|
||||
const out = parseLrc("[00:01]a\r\n[00:02]b\r\n");
|
||||
expect(out.length).toBe(2);
|
||||
});
|
||||
|
||||
it("handles a 10k-line LRC under 50 ms", { timeout: 5000 }, () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const m = String(Math.floor(i / 60)).padStart(2, "0");
|
||||
const s = String(i % 60).padStart(2, "0");
|
||||
lines.push(`[${m}:${s}.00]line ${i}`);
|
||||
}
|
||||
const start = performance.now();
|
||||
const out = parseLrc(lines.join("\n"));
|
||||
const elapsed = performance.now() - start;
|
||||
expect(out.length).toBe(10_000);
|
||||
expect(elapsed).toBeLessThan(150); // generous CI ceiling
|
||||
});
|
||||
|
||||
it("empty source yields empty array", { timeout: 5000 }, () => {
|
||||
expect(parseLrc("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeLyricIndex", () => {
|
||||
const LYRICS = parseLrc("[00:00]a\n[00:10]b\n[00:20]c\n[00:30]d");
|
||||
|
||||
it("returns -1 for empty input", { timeout: 5000 }, () => {
|
||||
expect(activeLyricIndex([], 5)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns -1 before first lyric", { timeout: 5000 }, () => {
|
||||
const others = parseLrc("[00:05]a\n[00:10]b");
|
||||
expect(activeLyricIndex(others, 2)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns the greatest index ≤ currentSec", { timeout: 5000 }, () => {
|
||||
expect(activeLyricIndex(LYRICS, 0)).toBe(0);
|
||||
expect(activeLyricIndex(LYRICS, 9.99)).toBe(0);
|
||||
expect(activeLyricIndex(LYRICS, 10)).toBe(1);
|
||||
expect(activeLyricIndex(LYRICS, 25)).toBe(2);
|
||||
expect(activeLyricIndex(LYRICS, 999)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
REACT_AUDIO_PLAYER_CODES,
|
||||
ReactAudioPlayerError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { Lyric, ParseLrcOptions } from "./lrc-parser.types.js";
|
||||
|
||||
const DEFAULT_MAX_LENGTH = 1_048_576; // 1 MiB
|
||||
|
||||
// Matches `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, `[h:mm:ss(.xxx)]`.
|
||||
// Groups: 1=hours? 2=minutes 3=seconds 4=fractional?
|
||||
const TIMESTAMP_RE = /\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:\.(\d{1,3}))?\]/g;
|
||||
|
||||
// Metadata tags `[ar:…]` / `[ti:…]` / etc. — any bracketed lowercase
|
||||
// letter prefix followed by a colon then arbitrary content.
|
||||
const META_TAG_RE = /^\[[a-z]{1,8}:[^\]]*\]\s*$/;
|
||||
|
||||
function toSeconds(
|
||||
h: string | undefined,
|
||||
m: string,
|
||||
s: string,
|
||||
frac: string | undefined
|
||||
): number {
|
||||
const hours = h ? Number.parseInt(h, 10) : 0;
|
||||
const minutes = Number.parseInt(m, 10);
|
||||
const seconds = Number.parseInt(s, 10);
|
||||
if (frac === undefined) return hours * 3600 + minutes * 60 + seconds;
|
||||
const padded = `${frac}000`.slice(0, 3);
|
||||
const ms = Number.parseInt(padded, 10);
|
||||
return hours * 3600 + minutes * 60 + seconds + ms / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LRC source into a timestamp-sorted array of `Lyric` entries.
|
||||
*
|
||||
* - Multi-timestamp lines (`[00:10][00:20]hello`) expand to one
|
||||
* entry per timestamp when `expandMultiTimestamp` is true (default).
|
||||
* - Metadata tags (`[ar:…]`, `[ti:…]`, `[al:…]`, `[length:…]`,
|
||||
* `[by:…]`, `[offset:…]`, `[re:…]`, `[ve:…]`) are ignored.
|
||||
* - Lines without a leading timestamp are skipped.
|
||||
* - Negative or non-finite timestamps are dropped.
|
||||
*
|
||||
* Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
|
||||
* or input exceeding `maxLength`.
|
||||
*/
|
||||
export function parseLrc(
|
||||
source: unknown,
|
||||
opts: ParseLrcOptions = {}
|
||||
): readonly Lyric[] {
|
||||
if (typeof source !== "string") {
|
||||
throw new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
|
||||
"lrc.parse.non-string",
|
||||
{ context: { type: typeof source } }
|
||||
);
|
||||
}
|
||||
const maxLength = opts.maxLength ?? DEFAULT_MAX_LENGTH;
|
||||
if (source.length > maxLength) {
|
||||
throw new ReactAudioPlayerError(
|
||||
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
|
||||
"lrc.parse.too-large",
|
||||
{ context: { bytes: source.length, max: maxLength } }
|
||||
);
|
||||
}
|
||||
const expand = opts.expandMultiTimestamp ?? true;
|
||||
const out: Lyric[] = [];
|
||||
const lines = source.split(/\r?\n/);
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (line === "") continue;
|
||||
if (META_TAG_RE.test(line)) continue;
|
||||
|
||||
TIMESTAMP_RE.lastIndex = 0;
|
||||
const timestamps: number[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = TIMESTAMP_RE.exec(line);
|
||||
while (match !== null) {
|
||||
// Only honour timestamps forming a contiguous prefix.
|
||||
if (match.index !== lastIndex) break;
|
||||
timestamps.push(
|
||||
toSeconds(
|
||||
match[1],
|
||||
match[2] as string,
|
||||
match[3] as string,
|
||||
match[4]
|
||||
)
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
if (!expand) break;
|
||||
match = TIMESTAMP_RE.exec(line);
|
||||
}
|
||||
if (timestamps.length === 0) continue;
|
||||
const text = line.slice(lastIndex).trim();
|
||||
for (const ts of timestamps) {
|
||||
out.push({ timestampSec: ts, text });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.timestampSec - b.timestampSec);
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary search for the active lyric index — the greatest
|
||||
* `timestampSec` not exceeding `currentSec`. Returns `-1` if
|
||||
* `lyrics` is empty or `currentSec` precedes the first entry. Pure;
|
||||
* safe to call every animation frame.
|
||||
*/
|
||||
export function activeLyricIndex(
|
||||
lyrics: readonly Lyric[],
|
||||
currentSec: number
|
||||
): number {
|
||||
if (lyrics.length === 0) return -1;
|
||||
if (currentSec < (lyrics[0] as Lyric).timestampSec) return -1;
|
||||
let lo = 0;
|
||||
let hi = lyrics.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >>> 1;
|
||||
if ((lyrics[mid] as Lyric).timestampSec <= currentSec) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Single parsed lyric line. `timestampSec` is seconds (not
|
||||
* milliseconds) for direct alignment with `transport.seconds` from
|
||||
* `@suaveplan/react-audio`. `text` is the trimmed lyric content
|
||||
* with the leading timestamp tag(s) stripped.
|
||||
*/
|
||||
export interface Lyric {
|
||||
readonly timestampSec: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface ParseLrcOptions {
|
||||
/**
|
||||
* Maximum input length in characters. Inputs exceeding this are
|
||||
* rejected with `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED`. Defaults
|
||||
* to 1 MiB which comfortably covers a 4-hour album-length LRC.
|
||||
*/
|
||||
readonly maxLength?: number;
|
||||
/**
|
||||
* If true (default), one source line containing N timestamps
|
||||
* (e.g. `[00:10][00:20]hello`) yields N output entries. If
|
||||
* false, only the first timestamp is honoured.
|
||||
*/
|
||||
readonly expandMultiTimestamp?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LyricsDisplay } from "./lyrics-display.js";
|
||||
export type { LyricsDisplayProps } from "./lyrics-display.types.js";
|
||||
@@ -0,0 +1,109 @@
|
||||
# `karaoke/lyrics-display`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`<LyricsDisplay>` is the karaoke surface that renders a stack of
|
||||
lyric lines with the active line highlighted. It is the visible
|
||||
end-product of the `parseLrc` → `useLyrics` pipeline and the
|
||||
component the player variants compose when their `lrc` prop is
|
||||
populated.
|
||||
|
||||
The component is clock-agnostic by design: the caller passes
|
||||
`currentSec` (a plain number) and the component renders the
|
||||
highlighted line. The typical wiring is `useTransportTime()` from
|
||||
`@suaveplan/react-audio` when the display sits inside an
|
||||
`<AudioProvider>`, but standalone preview and test surfaces can
|
||||
hand in any number — Playwright spec runs use a deterministic
|
||||
clock; documentation pages drive a `<range>` input. Keeping the time
|
||||
source explicit removes a hidden provider dependency.
|
||||
|
||||
## Features
|
||||
|
||||
- Renders one `<span>` per lyric line with `data-active="true|false"`
|
||||
for CSS-driven styling; consumers needing structural control can
|
||||
pass `renderLine` for full custom rendering.
|
||||
- Active line carries `aria-live="polite"` by default so screen
|
||||
readers announce lyric changes; togglable via `announceLyrics`.
|
||||
- Wrapping `<div>` exposes `role="region"` + `aria-label="Lyrics"`.
|
||||
- Re-binds the active index every 100 ms (configurable) regardless
|
||||
of the consumer's render cadence.
|
||||
- Both time-source modes share the same internal render path; the
|
||||
outer dispatch is a one-time component-type pick.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function Player({ lrcSource }: { lrcSource: string }) {
|
||||
const seconds = useTransportTime();
|
||||
return <LyricsDisplay lrc={lrcSource} currentSec={seconds} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
|
||||
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
|
||||
import { useMemo } from "react";
|
||||
|
||||
function StyledLyrics({ lrc, time }: { lrc: string; time: number }) {
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
return (
|
||||
<LyricsDisplay
|
||||
lyrics={lyrics}
|
||||
currentSec={time}
|
||||
intervalMs={50}
|
||||
announceLyrics={false}
|
||||
renderLine={({ lyric, isActive }) => (
|
||||
<p
|
||||
style={{
|
||||
opacity: isActive ? 1 : 0.4,
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{lyric.text || <span aria-hidden="true">·</span>}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `<LyricsDisplay>` accepts `LyricsDisplayProps`:
|
||||
- `lyrics?: readonly Lyric[]` — preferred over `lrc`.
|
||||
- `lrc?: string` — parsed once per identity change.
|
||||
- `currentSec: number` — required.
|
||||
- `intervalMs?: number` (default 100).
|
||||
- `announceLyrics?: boolean` (default `true`).
|
||||
- `className?: string`.
|
||||
- `renderLine?: ({ lyric, isActive, index }) => ReactNode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The component is a single-branch render — no dual-component
|
||||
dispatch — because `currentSec` is always a plain prop. This keeps
|
||||
the Rules of Hooks trivially satisfied (one render path, one hook
|
||||
sequence) and removes a hidden coupling to the surrounding
|
||||
`<AudioProvider>`. Consumers that want the transport-driven path
|
||||
call `useTransportTime()` themselves and pass the result down.
|
||||
|
||||
The `data-active="true|false"` attribute is the recommended CSS
|
||||
selector hook. Toggling `aria-live` only on the active line ensures
|
||||
exactly one element narrates per change cycle; without that targeted
|
||||
gating, every line's render would re-announce on every re-mount,
|
||||
which is acoustically unusable.
|
||||
|
||||
When `renderLine` is supplied, the component delegates to it
|
||||
verbatim and skips the default `<span>` + `data-active` path. The
|
||||
delegated node is wrapped in a keyed `<Fragment>` so consumers can
|
||||
return any element they like — `<li>`, `<p>`, `<div>` — without
|
||||
fighting an enclosing wrapper.
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { parseLrc } from "../lrc-parser/lrc-parser.js";
|
||||
import { LyricsDisplay } from "./lyrics-display.js";
|
||||
|
||||
const SAMPLE = parseLrc(
|
||||
"[ar:Demo]\n[00:00.00]Verse one\n[00:08.00]Verse two\n[00:16.00]Chorus"
|
||||
);
|
||||
|
||||
const meta: Meta<typeof LyricsDisplay> = {
|
||||
title: "Karaoke/LyricsDisplay",
|
||||
component: LyricsDisplay,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LyricsDisplay>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
lyrics: SAMPLE,
|
||||
currentSec: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
const [t, setT] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={24}
|
||||
step={0.1}
|
||||
value={t}
|
||||
onChange={(e) => setT(Number(e.currentTarget.value))}
|
||||
aria-label="time"
|
||||
/>
|
||||
<LyricsDisplay lyrics={SAMPLE} currentSec={t} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgeEmpty: Story = {
|
||||
args: {
|
||||
lyrics: [],
|
||||
currentSec: 0,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { parseLrc } from "../lrc-parser/lrc-parser.js";
|
||||
import { LyricsDisplay } from "./lyrics-display.js";
|
||||
|
||||
const LRC = "[00:00.00]a\n[00:10.00]b\n[00:20.00]c";
|
||||
const PARSED = parseLrc(LRC);
|
||||
|
||||
describe("<LyricsDisplay>", () => {
|
||||
it("renders all lines with role=region", { timeout: 5000 }, () => {
|
||||
render(<LyricsDisplay lyrics={PARSED} currentSec={0} />);
|
||||
expect(screen.getByRole("region", { name: "Lyrics" })).toBeDefined();
|
||||
expect(screen.getByText("a")).toBeDefined();
|
||||
expect(screen.getByText("b")).toBeDefined();
|
||||
expect(screen.getByText("c")).toBeDefined();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("flags the active line via data-active", { timeout: 5000 }, () => {
|
||||
render(<LyricsDisplay lyrics={PARSED} currentSec={12} />);
|
||||
const active = screen.getByText("b");
|
||||
expect(active.getAttribute("data-active")).toBe("true");
|
||||
const inactive = screen.getByText("a");
|
||||
expect(inactive.getAttribute("data-active")).toBe("false");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("active line carries aria-live=polite by default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
render(<LyricsDisplay lyrics={PARSED} currentSec={12} />);
|
||||
const active = screen.getByText("b");
|
||||
expect(active.getAttribute("aria-live")).toBe("polite");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("announceLyrics=false suppresses aria-live", { timeout: 5000 }, () => {
|
||||
render(
|
||||
<LyricsDisplay
|
||||
lyrics={PARSED}
|
||||
currentSec={12}
|
||||
announceLyrics={false}
|
||||
/>
|
||||
);
|
||||
const active = screen.getByText("b");
|
||||
expect(active.getAttribute("aria-live")).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("custom renderLine delegates", { timeout: 5000 }, () => {
|
||||
render(
|
||||
<LyricsDisplay
|
||||
lyrics={PARSED}
|
||||
currentSec={5}
|
||||
renderLine={({ lyric, isActive }) => (
|
||||
<p data-testid={`line-${lyric.text}`}>
|
||||
{lyric.text}:{isActive ? "ON" : "OFF"}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("line-a").textContent).toBe("a:ON");
|
||||
expect(screen.getByTestId("line-b").textContent).toBe("b:OFF");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("accepts raw lrc source", { timeout: 5000 }, () => {
|
||||
render(<LyricsDisplay lrc={LRC} currentSec={0} />);
|
||||
expect(screen.getByText("a")).toBeDefined();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("custom className applies to wrapper", { timeout: 5000 }, () => {
|
||||
const { container } = render(
|
||||
<LyricsDisplay
|
||||
lyrics={PARSED}
|
||||
currentSec={0}
|
||||
className="my-lyrics"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector(".my-lyrics")).not.toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("updates active line on interval when currentSec rises", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<LyricsDisplay lyrics={PARSED} currentSec={5} intervalMs={50} />
|
||||
);
|
||||
expect(screen.getByText("a").getAttribute("data-active")).toBe(
|
||||
"true"
|
||||
);
|
||||
rerender(
|
||||
<LyricsDisplay
|
||||
lyrics={PARSED}
|
||||
currentSec={25}
|
||||
intervalMs={50}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60);
|
||||
});
|
||||
expect(screen.getByText("c").getAttribute("data-active")).toBe(
|
||||
"true"
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { useLyrics } from "../use-lyrics/use-lyrics.js";
|
||||
import type { LyricsDisplayProps } from "./lyrics-display.types.js";
|
||||
|
||||
/**
|
||||
* Karaoke surface. Renders a vertical list of lyric lines with the
|
||||
* currently-active line highlighted via `data-active="true"`. When
|
||||
* `announceLyrics` is true (default), the active line carries
|
||||
* `aria-live="polite"` so screen readers narrate it as the
|
||||
* transport advances.
|
||||
*
|
||||
* The component is intentionally clock-agnostic — it requires the
|
||||
* caller to supply `currentSec`. The typical wiring is
|
||||
* `useTransportTime()` from `@suaveplan/react-audio`; standalone
|
||||
* preview and test code can hand in any number. Keeping the time
|
||||
* source explicit avoids a hidden provider dependency and lets the
|
||||
* component render inside or outside `<AudioProvider>` without
|
||||
* branching its own internals.
|
||||
*/
|
||||
export function LyricsDisplay(props: LyricsDisplayProps): ReactElement {
|
||||
const announce = props.announceLyrics ?? true;
|
||||
const { lyrics, activeIndex } = useLyrics({
|
||||
...(props.lyrics !== undefined ? { lyrics: props.lyrics } : {}),
|
||||
...(props.lrc !== undefined ? { lrc: props.lrc } : {}),
|
||||
...(props.intervalMs !== undefined
|
||||
? { intervalMs: props.intervalMs }
|
||||
: {}),
|
||||
currentSec: props.currentSec,
|
||||
});
|
||||
const className = props.className ?? "lyrics-display";
|
||||
|
||||
return (
|
||||
<section className={className} aria-label="Lyrics">
|
||||
{lyrics.map((lyric, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
const key = `${lyric.timestampSec}-${i}`;
|
||||
if (props.renderLine) {
|
||||
const node: ReactNode = props.renderLine({
|
||||
lyric,
|
||||
isActive,
|
||||
index: i,
|
||||
});
|
||||
return <Fragment key={key}>{node}</Fragment>;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
data-active={isActive ? "true" : "false"}
|
||||
aria-live={isActive && announce ? "polite" : undefined}
|
||||
>
|
||||
{lyric.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
|
||||
|
||||
export interface LyricsDisplayProps {
|
||||
/** Pre-parsed lyrics. Either this or `lrc` must be supplied. */
|
||||
readonly lyrics?: readonly Lyric[];
|
||||
/** Raw LRC source — parsed once per identity change. */
|
||||
readonly lrc?: string;
|
||||
/**
|
||||
* Current time in seconds. The caller is responsible for sourcing
|
||||
* this — typically `useTransportTime()` from `@suaveplan/react-audio`
|
||||
* when used inside `<AudioProvider>`, or any external clock when
|
||||
* used standalone (test preview, documentation pages, etc.).
|
||||
* Keeping this prop explicit avoids a hidden provider dependency
|
||||
* and lets the component render in or out of a provider.
|
||||
*/
|
||||
readonly currentSec: number;
|
||||
/** Resample interval in milliseconds. Default 100. */
|
||||
readonly intervalMs?: number;
|
||||
/**
|
||||
* When true (default), the active line carries `aria-live="polite"`
|
||||
* so screen readers announce it as it changes.
|
||||
*/
|
||||
readonly announceLyrics?: boolean;
|
||||
/** Forwarded to the wrapping `<div>` for styling. */
|
||||
readonly className?: string;
|
||||
/**
|
||||
* Render-prop hook for custom line rendering. Receives `{ lyric,
|
||||
* isActive, index }`. When absent, the component renders a
|
||||
* `<span>` per line with `data-active="true|false"` attributes.
|
||||
*/
|
||||
readonly renderLine?: (args: {
|
||||
readonly lyric: Lyric;
|
||||
readonly isActive: boolean;
|
||||
readonly index: number;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useLyrics } from "./use-lyrics.js";
|
||||
export type { UseLyricsOptions, UseLyricsResult } from "./use-lyrics.types.js";
|
||||
@@ -0,0 +1,121 @@
|
||||
# `karaoke/use-lyrics`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the
|
||||
active-line state, samples it at a fixed interval (100 ms by
|
||||
default), and returns `{ lyrics, activeIndex, activeLyric }`. The
|
||||
consumer hands in either pre-parsed `Lyric[]` or raw LRC source,
|
||||
plus the current playback time in seconds. The hook deliberately
|
||||
does not call `useTransportTime` itself so it can be driven from any
|
||||
clock — `<LyricsDisplay>` wires the transport in for you, but a
|
||||
preview panel running under a deterministic test clock can call this
|
||||
hook directly.
|
||||
|
||||
The 100 ms re-bind ceiling matches the spec requirement that the
|
||||
active lyric advance within 100 ms of the transport. Sampling at
|
||||
that cadence (rather than per-frame) saves a substantial amount of
|
||||
React commit work on songs with hundreds of lyric lines.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure: no audio context, no global side-effect.
|
||||
- Pre-parsed `lyrics` or raw `lrc` source — both accepted; if both
|
||||
provided, `lyrics` wins (no double work).
|
||||
- Configurable `intervalMs` (default 100). The hook re-evaluates
|
||||
the active line on each tick and only commits when the index
|
||||
actually changes — silent renders cost nothing.
|
||||
- Returns `activeLyric: undefined` before the first cue or when
|
||||
`lyrics` is empty.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useLyrics } from "@suaveplan/react-audio-player/karaoke/use-lyrics";
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function Lyrics({ lrc }: { lrc: string }) {
|
||||
const seconds = useTransportTime();
|
||||
const { activeLyric } = useLyrics({ lrc, currentSec: seconds });
|
||||
return <span aria-live="polite">{activeLyric?.text ?? "…"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseLrc,
|
||||
useLyrics,
|
||||
} from "@suaveplan/react-audio-player/karaoke";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
function StandaloneLyricPreview({ lrc }: { lrc: string }) {
|
||||
// Parse once at the call site rather than on every mount.
|
||||
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
|
||||
// Deterministic clock — no <AudioProvider> required.
|
||||
const [tick, setTick] = useState(0);
|
||||
const { activeIndex, activeLyric, lyrics: ls } = useLyrics({
|
||||
lyrics,
|
||||
currentSec: tick * 0.5,
|
||||
intervalMs: 50,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setTick((t) => t + 1)}>
|
||||
+0.5 s
|
||||
</button>
|
||||
<ol>
|
||||
{ls.map((l, i) => (
|
||||
<li
|
||||
key={`${l.timestampSec}-${i}`}
|
||||
data-active={i === activeIndex}
|
||||
>
|
||||
{l.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p>Active: {activeLyric?.text ?? "—"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useLyrics(opts: UseLyricsOptions): UseLyricsResult`
|
||||
- `UseLyricsOptions`:
|
||||
- `lyrics?: readonly Lyric[]` — preferred.
|
||||
- `lrc?: string` — parsed once per identity change.
|
||||
- `intervalMs?: number` (default 100).
|
||||
- `currentSec: number` — required.
|
||||
- `UseLyricsResult`:
|
||||
- `lyrics: readonly Lyric[]`,
|
||||
- `activeIndex: number` (`-1` for "before first cue / empty"),
|
||||
- `activeLyric: Lyric | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook owns one piece of React state — the active index — and one
|
||||
`setInterval`. Both `lyrics` and `currentSec` are mirrored through
|
||||
refs so the interval callback always sees the latest values without
|
||||
re-binding the timer on every render. The interval only fires
|
||||
`setActiveIndex` when the recomputed index actually differs, which
|
||||
is the standard React micro-optimisation for high-frequency external
|
||||
clocks.
|
||||
|
||||
`activeLyricIndex` (from the sibling `lrc-parser` module) is a
|
||||
binary search; combined with the 100 ms sampling cadence, the hook's
|
||||
steady-state work per frame is one comparison plus a "same index?"
|
||||
branch. Long-form lyrics (10k+ lines) are not measurably more
|
||||
expensive than short ones.
|
||||
|
||||
The hook lazily initialises its `activeIndex` state from
|
||||
`activeLyricIndex(lyrics, currentSec)` so the first render — before
|
||||
the effect mounts — already reflects the correct active line. This
|
||||
matters for SSR-rendered previews and snapshot tests that read the
|
||||
hook's output without ever ticking the interval.
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { parseLrc } from "../lrc-parser/lrc-parser.js";
|
||||
import { useLyrics } from "./use-lyrics.js";
|
||||
|
||||
const LYRICS = parseLrc("[00:00.00]a\n[00:10.00]b\n[00:20.00]c\n[00:30.00]d");
|
||||
|
||||
describe("useLyrics", () => {
|
||||
it("derives initial active index from currentSec", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({ lyrics: LYRICS, currentSec: 15 })
|
||||
);
|
||||
expect(result.current.activeIndex).toBe(1);
|
||||
expect(result.current.activeLyric?.text).toBe("b");
|
||||
});
|
||||
|
||||
it("returns -1 / undefined before first cue", { timeout: 5000 }, () => {
|
||||
const earlier = parseLrc("[00:05]a");
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({ lyrics: earlier, currentSec: 0 })
|
||||
);
|
||||
expect(result.current.activeIndex).toBe(-1);
|
||||
expect(result.current.activeLyric).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses raw lrc source", { timeout: 5000 }, () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({ lrc: "[00:01.00]x", currentSec: 5 })
|
||||
);
|
||||
expect(result.current.lyrics.length).toBe(1);
|
||||
expect(result.current.activeLyric?.text).toBe("x");
|
||||
});
|
||||
|
||||
it("no lyrics + no lrc → empty list", { timeout: 5000 }, () => {
|
||||
const { result } = renderHook(() => useLyrics({ currentSec: 0 }));
|
||||
expect(result.current.lyrics).toEqual([]);
|
||||
expect(result.current.activeIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it("updates active index on rerender when currentSec changes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ t }: { t: number }) =>
|
||||
useLyrics({ lyrics: LYRICS, currentSec: t }),
|
||||
{ initialProps: { t: 5 } }
|
||||
);
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
rerender({ t: 25 });
|
||||
// Even before the interval fires, the next interval tick will
|
||||
// pick it up; we advance time to confirm.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// Re-render schedules the effect; advance fake timers to
|
||||
// run it.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(101);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect([1, 2]).toContain(result.current.activeIndex);
|
||||
});
|
||||
|
||||
it("samples on interval", { timeout: 5000 }, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let t = 0;
|
||||
const { result, rerender } = renderHook(
|
||||
({ x }: { x: number }) =>
|
||||
useLyrics({
|
||||
lyrics: LYRICS,
|
||||
currentSec: x,
|
||||
intervalMs: 50,
|
||||
}),
|
||||
{ initialProps: { x: t } }
|
||||
);
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
t = 25;
|
||||
rerender({ x: t });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60);
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("interval clamps to 1 ms and cleans up on unmount", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useLyrics({
|
||||
lyrics: LYRICS,
|
||||
currentSec: 5,
|
||||
intervalMs: 0,
|
||||
})
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5);
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
unmount();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
// No throw on cleanup.
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("lyrics prop wins over lrc prop when both supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({
|
||||
lyrics: LYRICS,
|
||||
lrc: "[00:01]ignored",
|
||||
currentSec: 15,
|
||||
})
|
||||
);
|
||||
expect(result.current.lyrics).toBe(LYRICS);
|
||||
expect(result.current.activeLyric?.text).toBe("b");
|
||||
});
|
||||
|
||||
it("no-op tick when index hasn't changed", { timeout: 5000 }, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { result } = renderHook(() =>
|
||||
useLyrics({
|
||||
lyrics: LYRICS,
|
||||
currentSec: 5,
|
||||
intervalMs: 20,
|
||||
})
|
||||
);
|
||||
const idx = result.current.activeIndex;
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(result.current.activeIndex).toBe(idx);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { activeLyricIndex, parseLrc } from "../lrc-parser/lrc-parser.js";
|
||||
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
|
||||
import type { UseLyricsOptions, UseLyricsResult } from "./use-lyrics.types.js";
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
* Engine-agnostic karaoke hook. Accepts a pre-parsed `lyrics` array
|
||||
* (preferred) or a raw `lrc` string, plus a `currentSec` value, and
|
||||
* returns the active line. Sampling cadence is decoupled from the
|
||||
* caller's render rate: a 100 ms interval (configurable) forces a
|
||||
* re-render even when neither `lyrics` nor `currentSec` mutate.
|
||||
*
|
||||
* The hook is deliberately decoupled from `useTransportTime` so it
|
||||
* can be driven from any clock — `<LyricsDisplay>` wires the
|
||||
* transport in for you when used inside `<AudioProvider>`, but a
|
||||
* preview panel running under a deterministic test clock can call
|
||||
* this hook directly with a hand-fed `currentSec`.
|
||||
*/
|
||||
export function useLyrics(opts: UseLyricsOptions): UseLyricsResult {
|
||||
const lyrics: readonly Lyric[] = useMemo(() => {
|
||||
if (opts.lyrics) return opts.lyrics;
|
||||
if (typeof opts.lrc === "string") return parseLrc(opts.lrc);
|
||||
return [];
|
||||
}, [opts.lyrics, opts.lrc]);
|
||||
|
||||
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
|
||||
// A heartbeat counter forces a re-render every `intervalMs` so
|
||||
// the memo below recomputes even when `currentSec` did not
|
||||
// change between commits — relevant when the consumer's clock
|
||||
// mutates a ref or external store the hook cannot subscribe to.
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(
|
||||
() => {
|
||||
setTick((t) => (t + 1) % 1_000_000);
|
||||
},
|
||||
Math.max(1, intervalMs)
|
||||
);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [intervalMs]);
|
||||
|
||||
const activeIndex = useMemo(
|
||||
() => activeLyricIndex(lyrics, opts.currentSec),
|
||||
[lyrics, opts.currentSec]
|
||||
);
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
activeIndex,
|
||||
activeLyric: activeIndex >= 0 ? lyrics[activeIndex] : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
|
||||
|
||||
export interface UseLyricsOptions {
|
||||
/**
|
||||
* Pre-parsed lyric list. Either `lyrics` or `lrc` must be
|
||||
* supplied; if both are present, `lyrics` wins.
|
||||
*/
|
||||
readonly lyrics?: readonly Lyric[];
|
||||
/** Raw LRC source. Parsed once per identity change. */
|
||||
readonly lrc?: string;
|
||||
/**
|
||||
* Active-line resample interval in milliseconds. Defaults to 100
|
||||
* ms — the spec ceiling for "active lyric must advance within 100
|
||||
* ms of the transport time".
|
||||
*/
|
||||
readonly intervalMs?: number;
|
||||
/**
|
||||
* Current time in seconds. The consumer is responsible for
|
||||
* sourcing this — typically from `useTransportTime()` in
|
||||
* `@suaveplan/react-audio`, but any clock works. The hook
|
||||
* re-evaluates the active line every `intervalMs` regardless of
|
||||
* how often this value mutates between renders.
|
||||
*/
|
||||
readonly currentSec: number;
|
||||
}
|
||||
|
||||
export interface UseLyricsResult {
|
||||
readonly lyrics: readonly Lyric[];
|
||||
/** `-1` before the first cue or when `lyrics` is empty. */
|
||||
readonly activeIndex: number;
|
||||
/** Convenience: `lyrics[activeIndex]` or `undefined`. */
|
||||
readonly activeLyric: Lyric | undefined;
|
||||
}
|
||||
46
packages/ui/react/react-audio-player/tests/exports.test.ts
Normal file
46
packages/ui/react/react-audio-player/tests/exports.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = resolve(__dirname, "..");
|
||||
const PKG_JSON = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown>; dependencies: Record<string, string> };
|
||||
|
||||
const REQUIRED_SURFACES = [
|
||||
".",
|
||||
"./karaoke",
|
||||
"./karaoke/lrc-parser",
|
||||
"./karaoke/use-lyrics",
|
||||
"./karaoke/lyrics-display",
|
||||
"./error-codes",
|
||||
];
|
||||
|
||||
describe("subpath exports", () => {
|
||||
it("every public surface has a subpath entry", { timeout: 5000 }, () => {
|
||||
for (const surface of REQUIRED_SURFACES) {
|
||||
expect(PKG_JSON.exports[surface]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("sideEffects=false", { timeout: 5000 }, () => {
|
||||
const raw = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { sideEffects: boolean };
|
||||
expect(raw.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("runtime dependency set matches allowlist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = Object.keys(PKG_JSON.dependencies).sort();
|
||||
expect(deps).toEqual(
|
||||
[
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/react-audio",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
38
packages/ui/react/react-audio-player/tests/facade.test.tsx
Normal file
38
packages/ui/react/react-audio-player/tests/facade.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as RAP from "../src/index.js";
|
||||
|
||||
describe("@suaveplan/react-audio-player root barrel", () => {
|
||||
it("exposes the namespaced ReactAudio re-export", { timeout: 5000 }, () => {
|
||||
expect(RAP.ReactAudio).toBeDefined();
|
||||
// The namespace must include the player components.
|
||||
expect(typeof RAP.ReactAudio.AudioProvider).toBe("function");
|
||||
expect(typeof RAP.ReactAudio.AudioPlayer).toBe("function");
|
||||
expect(typeof RAP.ReactAudio.useTransport).toBe("function");
|
||||
expect(typeof RAP.ReactAudio.useAnalyser).toBe("function");
|
||||
expect(typeof RAP.ReactAudio.usePlaylist).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes the flat alias façade", { timeout: 5000 }, () => {
|
||||
expect(typeof RAP.AudioPlayer).toBe("function");
|
||||
expect(typeof RAP.AudioProvider).toBe("function");
|
||||
expect(typeof RAP.BasicPlayer).toBe("function");
|
||||
expect(typeof RAP.PremiumPlayer).toBe("function");
|
||||
expect(typeof RAP.PresetManager).toBe("function");
|
||||
expect(typeof RAP.TransportControls).toBe("function");
|
||||
expect(typeof RAP.useAnalyser).toBe("function");
|
||||
expect(typeof RAP.usePlaylist).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes karaoke surface from root", { timeout: 5000 }, () => {
|
||||
expect(typeof RAP.parseLrc).toBe("function");
|
||||
expect(typeof RAP.activeLyricIndex).toBe("function");
|
||||
expect(typeof RAP.useLyrics).toBe("function");
|
||||
expect(typeof RAP.LyricsDisplay).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes error-code surface from root", { timeout: 5000 }, () => {
|
||||
expect(RAP.REACT_AUDIO_PLAYER_CODES).toBeDefined();
|
||||
expect(typeof RAP.ReactAudioPlayerError).toBe("function");
|
||||
expect(typeof RAP.registerReactAudioPlayerErrorCodes).toBe("function");
|
||||
});
|
||||
});
|
||||
19
packages/ui/react/react-audio-player/tsconfig.lib.json
Normal file
19
packages/ui/react/react-audio-player/tsconfig.lib.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../foundation/core/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.stories.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"tests/**",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
54
packages/ui/react/react-audio-player/vite.config.ts
Normal file
54
packages/ui/react/react-audio-player/vite.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/react-audio-player",
|
||||
plugins: [
|
||||
react(),
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"karaoke/index": "src/karaoke/index.ts",
|
||||
"karaoke/lrc-parser/lrc-parser":
|
||||
"src/karaoke/lrc-parser/lrc-parser.ts",
|
||||
"karaoke/use-lyrics/use-lyrics":
|
||||
"src/karaoke/use-lyrics/use-lyrics.ts",
|
||||
"karaoke/lyrics-display/lyrics-display":
|
||||
"src/karaoke/lyrics-display/lyrics-display.tsx",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/react-audio",
|
||||
"@suaveplan/types",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"tslib",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
42
packages/ui/react/react-audio-player/vitest.config.ts
Normal file
42
packages/ui/react/react-audio-player/vitest.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
esbuild: {
|
||||
legalComments: "inline",
|
||||
},
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "happy-dom",
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
setupFiles: ["@suaveplan/testing/polyfills"],
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/**/*.test.tsx",
|
||||
],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.stories.tsx",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user