Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,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,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,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.
|
||||
Reference in New Issue
Block a user