diff --git a/apps/registry/lib/component-metadata.json b/apps/registry/lib/component-metadata.json index 91b8df8f..e5f15cea 100644 --- a/apps/registry/lib/component-metadata.json +++ b/apps/registry/lib/component-metadata.json @@ -463,6 +463,31 @@ ], "title": "Aspect Ratio" }, + "audio-player": { + "category": "content", + "defaultStoryId": "components-audioplayer--default", + "description": "URL-backed audio controls with accessible seeking, optional transcript disclosure, and playback speed.", + "name": "audio-player", + "stories": [ + { + "id": "components-audioplayer--default", + "name": "Default" + }, + { + "id": "components-audioplayer--with-transcript", + "name": "With Transcript" + }, + { + "id": "components-audioplayer--missing-source", + "name": "Missing Source" + }, + { + "id": "components-audioplayer--unavailable-source", + "name": "Unavailable Source" + } + ], + "title": "Audio player" + }, "auto-reload": { "category": "billing", "defaultStoryId": "billing-autoreload--default", diff --git a/apps/registry/registry.json b/apps/registry/registry.json index 94635c55..e873bded 100644 --- a/apps/registry/registry.json +++ b/apps/registry/registry.json @@ -486,6 +486,61 @@ "version": "0.3.0", "stability": "stable" }, + { + "name": "audio-player", + "type": "registry:component", + "title": "Audio player", + "description": "URL-backed audio controls with accessible seeking, optional transcript disclosure, and playback speed.", + "files": [ + { + "path": "registry/default/audio-player/audio-player.tsx", + "type": "registry:component" + } + ], + "registryDependencies": [], + "dependencies": [ + "@vllnt/ui@^0.3.0" + ], + "category": "content", + "version": "0.3.0", + "stability": "stable", + "a11y": { + "role": "group", + "keyboard": [ + { + "keys": "Tab / Shift+Tab", + "action": "move through playback, seek, optional speed and transcript controls" + }, + { + "keys": "Space / Enter", + "action": "play or pause when the playback button is focused; toggle transcript when its summary is focused" + }, + { + "keys": "+ / ArrowRight", + "action": "seek forward five seconds from the playback button" + }, + { + "keys": "- / ArrowLeft", + "action": "seek backward five seconds from the playback button" + }, + { + "keys": "Home / End", + "action": "seek to beginning or end from the playback button or slider" + }, + { + "keys": "Arrow keys", + "action": "adjust the native seek slider or playback speed select" + } + ], + "aria": [ + "aria-label", + "aria-valuetext", + "aria-live" + ], + "focusManagement": "manual", + "notes": "Native button, range, select and details semantics. Shortcuts are scoped to the playback button and never intercept descendant input. Supply a transcript for spoken audio; source replacement resets the media session." + } + }, { "name": "auto-reload", "type": "registry:component", @@ -6198,5 +6253,5 @@ } ], "version": "0.3.0", - "generatedAt": "2026-07-13T15:22:06.484Z" + "generatedAt": "2026-09-07T16:35:01.371Z" } diff --git a/apps/registry/registry/default/audio-player/audio-player.tsx b/apps/registry/registry/default/audio-player/audio-player.tsx new file mode 100644 index 00000000..277f153e --- /dev/null +++ b/apps/registry/registry/default/audio-player/audio-player.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { Pause, Play } from "lucide-react"; +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import { cn } from "@vllnt/ui"; +import { Button } from "@vllnt/ui"; + +import { useAudioPlayer } from "./use-audio-player"; + +export type AudioPlayerProps = Omit< + ComponentPropsWithRef<"div">, + "children" | "title" +> & { + captionsSrc?: string; + showPlaybackRate?: boolean; + src: string; + title: string; + transcript?: ReactNode; +}; + +function timeLabel(seconds: number) { + const whole = Math.floor(Math.max(0, seconds)); + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`; +} + +type Playback = ReturnType; + +function AudioControls({ + disabled, + playback, +}: { + disabled: boolean; + playback: Playback; +}) { + return ( +
+ + { + playback.seek(Number(event.currentTarget.value)); + }} + step={1} + type="range" + value={Math.min(playback.position, playback.duration)} + /> + + {timeLabel(playback.position)} / {timeLabel(playback.duration)} + +
+ ); +} + +function PlaybackRate({ playback }: { playback: Playback }) { + return ( + + ); +} + +function AudioPlayerSession({ + captionsSrc, + className, + ref, + showPlaybackRate = false, + src, + title, + transcript, + ...props +}: AudioPlayerProps) { + const playback = useAudioPlayer(src); + return ( +
+ +

{title}

+ + {showPlaybackRate ? : null} +

+ {playback.loading ? "Loading audio…" : ""} +

+ {playback.error ? ( +

+ {playback.error} +

+ ) : null} + {transcript ? ( +
+ + Transcript + +
{transcript}
+
+ ) : null} +
+ ); +} + +/** URL-backed audio controls. Changing src resets playback and all media state. */ +export function AudioPlayer(props: AudioPlayerProps) { + return ; +} +AudioPlayer.displayName = "AudioPlayer"; diff --git a/packages/ui/.snapshots/audio-player/audio-player.visual.tsx-chromium/audio-player-default.png b/packages/ui/.snapshots/audio-player/audio-player.visual.tsx-chromium/audio-player-default.png new file mode 100644 index 00000000..8e9aaab2 Binary files /dev/null and b/packages/ui/.snapshots/audio-player/audio-player.visual.tsx-chromium/audio-player-default.png differ diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 21f32fdf..a1bef596 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] -_Nothing yet._ +### Added + +- `AudioPlayer` — URL-backed audio with keyboard controls, accessible seeking, playback speed, transcript disclosure, and native loading/error state. ## [0.3.0] - 2026-06-26 diff --git a/packages/ui/src/components/audio-player/audio-player.mdx b/packages/ui/src/components/audio-player/audio-player.mdx new file mode 100644 index 00000000..c09e954c --- /dev/null +++ b/packages/ui/src/components/audio-player/audio-player.mdx @@ -0,0 +1,44 @@ +import { Meta, Primary, Controls, Canvas } from '@storybook/addon-docs/blocks' +import * as Stories from './audio-player.stories' + + + +# Audio player + +URL-backed audio with native media events, accessible seeking and optional transcript and playback speed. + + + +```tsx +import { AudioPlayer } from '@vllnt/ui' + + +``` + + + +## Behavior + +The browser owns playback, buffering, duration and position. Playback rejection and media errors show actionable messages; play retries the source. Changing `src` stops the old audio and resets position, duration, speed and errors. There is no autoplay. Metadata is preloaded; unknown or infinite duration disables seeking. URLs must point to browser-supported audio, not an embed page. + +## Keyboard and accessibility + +Tab reaches the native controls. Space or Enter on the playback button plays or pauses. On that button, + / Right and - / Left seek five seconds; Home / End seek to the beginning / end. The native range supports arrows and Home / End, and announces elapsed and total time. Shortcuts never intercept the speed select or transcript. Source changes remount controls and may lose focus; consumers managing source selection should keep focus on their source selector. + +Provide `transcript` for spoken audio. It uses native details/summary disclosure. Optional `captionsSrc` accepts a WebVTT URL for the native text track; this custom audio UI does not display timed captions. There is no waveform, recording or download behavior. + +## Props + + + +- `src`: required audio URL. +- `title`: required player accessible name and visible title. +- `transcript`: optional React content. +- `captionsSrc`: optional WebVTT URL. +- `showPlaybackRate`: show 0.5×–2× speed options; default false. +- Root div props and React 19 `ref` are forwarded. diff --git a/packages/ui/src/components/audio-player/audio-player.stories.tsx b/packages/ui/src/components/audio-player/audio-player.stories.tsx new file mode 100644 index 00000000..581f3bf2 --- /dev/null +++ b/packages/ui/src/components/audio-player/audio-player.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { AudioPlayer } from "./audio-player"; + +const meta = { + title: "Components/AudioPlayer", + component: AudioPlayer, + tags: ["autodocs"], + args: { src: "https://upload.wikimedia.org/wikipedia/commons/4/45/En-us-hello.ogg", title: "Hello pronunciation" }, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const WithTranscript: Story = { args: { transcript: "Hello.", showPlaybackRate: true } }; +export const MissingSource: Story = { args: { src: "" } }; +export const UnavailableSource: Story = { args: { src: "/unavailable-audio.wav" } }; diff --git a/packages/ui/src/components/audio-player/audio-player.test.tsx b/packages/ui/src/components/audio-player/audio-player.test.tsx new file mode 100644 index 00000000..529caf84 --- /dev/null +++ b/packages/ui/src/components/audio-player/audio-player.test.tsx @@ -0,0 +1,168 @@ +import { createRef } from "react"; + +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AudioPlayer } from "./audio-player"; + +function audioElement(container: HTMLElement) { + const audio = container.querySelector("audio"); + if (!audio) throw new Error("Missing audio"); + return audio; +} +function metadata(audio: HTMLAudioElement, duration = 120) { + Object.defineProperty(audio, "duration", { + configurable: true, + value: duration, + }); + fireEvent.loadedMetadata(audio); +} + +describe("AudioPlayer", () => { + beforeEach(() => { + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); + vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(vi.fn()); + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("forwards the React 19 ref and labels controls", () => { + const ref = createRef(); + render(); + expect(ref.current).toBe(screen.getByRole("group", { name: "Interview" })); + expect(screen.getByRole("slider", { name: "Seek audio" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Play audio" })).toBeEnabled(); + }); + + it("follows native playback, buffering, pause and ended events", async () => { + const { container } = render( + , + ); + const audio = audioElement(container); + fireEvent.loadStart(audio); + expect(screen.getByRole("status")).toHaveTextContent("Loading audio"); + fireEvent.canPlay(audio); + await act(async () => fireEvent.click(screen.getByRole("button"))); + expect(audio.play).toHaveBeenCalledOnce(); + fireEvent.play(audio); + expect(screen.getByRole("button", { name: "Pause audio" })).toBeEnabled(); + fireEvent.waiting(audio); + expect(screen.getByRole("status")).toHaveTextContent("Loading audio"); + fireEvent.playing(audio); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + Object.defineProperty(audio, "paused", { + configurable: true, + value: false, + }); + fireEvent.click(screen.getByRole("button")); + expect(audio.pause).toHaveBeenCalled(); + fireEvent.pause(audio); + expect(screen.getByRole("button", { name: "Play audio" })).toBeEnabled(); + fireEvent.play(audio); + fireEvent.ended(audio); + expect(screen.getByRole("button", { name: "Play audio" })).toBeEnabled(); + }); + + it("reports rejected play and native source errors", async () => { + vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValue( + new Error("denied"), + ); + const { container } = render( + , + ); + await act(async () => fireEvent.click(screen.getByRole("button"))); + expect(screen.getByRole("alert")).toHaveTextContent("could not play"); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + fireEvent.error(audioElement(container)); + expect(screen.getByRole("alert")).toHaveTextContent("could not load"); + }); + + it("seeks with accessible time values and clamps keyboard seeks", async () => { + const { container } = render( + , + ); + const audio = audioElement(container); + metadata(audio); + const group = screen.getByRole("button"); + const slider = screen.getByRole("slider"); + fireEvent.change(slider, { target: { value: "30" } }); + expect(audio.currentTime).toBe(30); + expect(slider).toHaveAttribute("aria-valuetext", "0:30 of 2:00"); + fireEvent.keyDown(group, { key: "+" }); + expect(audio.currentTime).toBe(35); + fireEvent.keyDown(group, { key: "-" }); + expect(audio.currentTime).toBe(30); + fireEvent.keyDown(group, { key: "End" }); + fireEvent.keyDown(group, { key: "ArrowRight" }); + expect(audio.currentTime).toBe(120); + fireEvent.keyDown(group, { key: "Home" }); + fireEvent.keyDown(group, { key: "ArrowLeft" }); + expect(audio.currentTime).toBe(0); + fireEvent.keyDown(slider, { key: "+" }); + expect(audio.currentTime).toBe(0); + await act(async () => fireEvent.click(group)); + expect(audio.play).toHaveBeenCalledOnce(); + }); + + it("supports transcript and native playback rate changes", () => { + const { container } = render( + , + ); + expect(screen.getByText("Transcript").tagName).toBe("SUMMARY"); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + const audio = audioElement(container); + fireEvent.change(screen.getByRole("combobox"), { + target: { value: "1.5" }, + }); + expect(audio.playbackRate).toBe(1.5); + fireEvent.rateChange(audio); + expect(screen.getByRole("combobox")).toHaveValue("1.5"); + }); + + it("resets on source replacement and ignores old play rejection", async () => { + let rejectPlay: ((reason: Error) => void) | undefined; + vi.mocked(HTMLMediaElement.prototype.play).mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectPlay = reject; + }), + ); + const { container, rerender } = render( + , + ); + const old = audioElement(container); + metadata(old); + fireEvent.click(screen.getByRole("button")); + rerender(); + expect(audioElement(container)).not.toBe(old); + expect(old.pause).toHaveBeenCalled(); + await act(async () => rejectPlay?.(new Error("old request"))); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Play audio" })).toBeEnabled(); + }); + + it("handles missing source and non-finite duration", () => { + const { container } = render(); + expect(screen.getByRole("button")).toBeDisabled(); + metadata(audioElement(container), Infinity); + expect(screen.getByRole("slider")).toBeDisabled(); + expect(screen.getByRole("slider")).toHaveAttribute( + "aria-valuetext", + "0:00 of 0:00", + ); + }); +}); diff --git a/packages/ui/src/components/audio-player/audio-player.tsx b/packages/ui/src/components/audio-player/audio-player.tsx new file mode 100644 index 00000000..1ffbb004 --- /dev/null +++ b/packages/ui/src/components/audio-player/audio-player.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { Pause, Play } from "lucide-react"; +import type { ComponentPropsWithRef, ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../button"; + +import { useAudioPlayer } from "./use-audio-player"; + +export type AudioPlayerProps = Omit< + ComponentPropsWithRef<"div">, + "children" | "title" +> & { + captionsSrc?: string; + showPlaybackRate?: boolean; + src: string; + title: string; + transcript?: ReactNode; +}; + +function timeLabel(seconds: number) { + const whole = Math.floor(Math.max(0, seconds)); + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`; +} + +type Playback = ReturnType; + +function AudioControls({ + disabled, + playback, +}: { + disabled: boolean; + playback: Playback; +}) { + return ( +
+ + { + playback.seek(Number(event.currentTarget.value)); + }} + step={1} + type="range" + value={Math.min(playback.position, playback.duration)} + /> + + {timeLabel(playback.position)} / {timeLabel(playback.duration)} + +
+ ); +} + +function PlaybackRate({ playback }: { playback: Playback }) { + return ( + + ); +} + +function AudioPlayerSession({ + captionsSrc, + className, + ref, + showPlaybackRate = false, + src, + title, + transcript, + ...props +}: AudioPlayerProps) { + const playback = useAudioPlayer(src); + return ( +
+ +

{title}

+ + {showPlaybackRate ? : null} +

+ {playback.loading ? "Loading audio…" : ""} +

+ {playback.error ? ( +

+ {playback.error} +

+ ) : null} + {transcript ? ( +
+ + Transcript + +
{transcript}
+
+ ) : null} +
+ ); +} + +/** URL-backed audio controls. Changing src resets playback and all media state. */ +export function AudioPlayer(props: AudioPlayerProps) { + return ; +} +AudioPlayer.displayName = "AudioPlayer"; diff --git a/packages/ui/src/components/audio-player/audio-player.visual.tsx b/packages/ui/src/components/audio-player/audio-player.visual.tsx new file mode 100644 index 00000000..3aa582d4 --- /dev/null +++ b/packages/ui/src/components/audio-player/audio-player.visual.tsx @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/experimental-ct-react"; + +import { AudioPlayer } from "./audio-player"; + +function silentWav() { + const bytes = Buffer.alloc(44 + 16000); + bytes.write("RIFF"); bytes.writeUInt32LE(bytes.length - 8, 4); bytes.write("WAVEfmt ", 8); + bytes.writeUInt32LE(16, 16); bytes.writeUInt16LE(1, 20); bytes.writeUInt16LE(1, 22); + bytes.writeUInt32LE(8000, 24); bytes.writeUInt32LE(16000, 28); bytes.writeUInt16LE(2, 32); + bytes.writeUInt16LE(16, 34); bytes.write("data", 36); bytes.writeUInt32LE(16000, 40); + return bytes; +} + +test("audio controls and keyboard playback", async ({ mount, page }) => { + await page.route("**/test-audio.wav", (route) => route.fulfill({ contentType: "audio/wav", body: silentWav() })); + const component = await mount(); + await expect(component.getByRole("slider")).toBeEnabled(); + await expect(component).toHaveScreenshot("audio-player-default.png"); + const play = component.getByRole("button", { name: "Play audio" }); + await play.focus(); + await page.keyboard.press("Space"); + await expect(component.getByRole("button", { name: "Pause audio" })).toBeVisible(); + await page.keyboard.press("Space"); + await expect(play).toBeVisible(); +}); diff --git a/packages/ui/src/components/audio-player/index.ts b/packages/ui/src/components/audio-player/index.ts new file mode 100644 index 00000000..15f32d9e --- /dev/null +++ b/packages/ui/src/components/audio-player/index.ts @@ -0,0 +1 @@ +export { AudioPlayer, type AudioPlayerProps } from "./audio-player"; diff --git a/packages/ui/src/components/audio-player/meta.json b/packages/ui/src/components/audio-player/meta.json new file mode 100644 index 00000000..a5349e59 --- /dev/null +++ b/packages/ui/src/components/audio-player/meta.json @@ -0,0 +1,16 @@ +{ + "a11y": { + "role": "group", + "keyboard": [ + { "keys": "Tab / Shift+Tab", "action": "move through playback, seek, optional speed and transcript controls" }, + { "keys": "Space / Enter", "action": "play or pause when the playback button is focused; toggle transcript when its summary is focused" }, + { "keys": "+ / ArrowRight", "action": "seek forward five seconds from the playback button" }, + { "keys": "- / ArrowLeft", "action": "seek backward five seconds from the playback button" }, + { "keys": "Home / End", "action": "seek to beginning or end from the playback button or slider" }, + { "keys": "Arrow keys", "action": "adjust the native seek slider or playback speed select" } + ], + "aria": ["aria-label", "aria-valuetext", "aria-live"], + "focusManagement": "manual", + "notes": "Native button, range, select and details semantics. Shortcuts are scoped to the playback button and never intercept descendant input. Supply a transcript for spoken audio; source replacement resets the media session." + } +} diff --git a/packages/ui/src/components/audio-player/use-audio-player.ts b/packages/ui/src/components/audio-player/use-audio-player.ts new file mode 100644 index 00000000..4e6b284a --- /dev/null +++ b/packages/ui/src/components/audio-player/use-audio-player.ts @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import type { KeyboardEvent } from "react"; + +export function useAudioPlayer(source: string) { + const media = useRef(null); + const request = useRef(0); + const [playing, setPlaying] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [duration, setDuration] = useState(0); + const [position, setPosition] = useState(0); + const [rate, setRate] = useState(1); + useEffect(() => { + const audio = media.current; + return () => { + request.current += 1; + audio?.pause(); + }; + }, []); + function syncTime() { + const audio = media.current; + if (!audio) return; + setDuration( + Number.isFinite(audio.duration) ? Math.max(0, audio.duration) : 0, + ); + setPosition( + Number.isFinite(audio.currentTime) ? Math.max(0, audio.currentTime) : 0, + ); + } + function seek(value: number) { + const audio = media.current; + if (!audio || !duration || error) return; + audio.currentTime = Math.min(duration, Math.max(0, value)); + setPosition(audio.currentTime); + } + async function togglePlayback() { + const audio = media.current; + if (!audio || !source) return; + const attempt = ++request.current; + if (!audio.paused) { + audio.pause(); + return; + } + setError(""); + setLoading(true); + try { + await audio.play(); + if (request.current === attempt) setLoading(false); + } catch { + if (request.current !== attempt) return; + setLoading(false); + setPlaying(false); + setError("Audio could not play. Check the source and try again."); + } + } + function handleSeekKey(event: KeyboardEvent) { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey + ) + return; + if ( + !["+", "-", "ArrowLeft", "ArrowRight", "End", "Home"].includes(event.key) + ) + return; + event.preventDefault(); + if (event.key === "Home") seek(0); + else if (event.key === "End") seek(duration); + else seek(position + (["+", "ArrowRight"].includes(event.key) ? 5 : -5)); + } + const mediaProps = { + onCanPlay: () => { + setLoading(false); + }, + onDurationChange: syncTime, + onEnded: () => { + request.current += 1; + setPlaying(false); + setLoading(false); + syncTime(); + }, + onError: () => { + request.current += 1; + setPlaying(false); + setLoading(false); + setError("Audio could not load. Check the source and try again."); + }, + onLoadedMetadata: syncTime, + onLoadStart: () => { + setLoading(true); + }, + onPause: () => { + request.current += 1; + setPlaying(false); + setLoading(false); + }, + onPlay: () => { + setPlaying(true); + }, + onPlaying: () => { + setPlaying(true); + setLoading(false); + setError(""); + }, + onRateChange: () => { + setRate(media.current?.playbackRate ?? 1); + }, + onStalled: () => { + setLoading(true); + }, + onTimeUpdate: syncTime, + onWaiting: () => { + setLoading(true); + }, + ref: media, + }; + function changeRate(value: number) { + if (media.current) media.current.playbackRate = value; + } + return { + changeRate, + duration, + error, + handleSeekKey, + loading, + mediaProps, + playing, + position, + rate, + seek, + togglePlayback, + }; +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 5b2619ea..1aa8ef4f 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,3 +1,5 @@ +export { AudioPlayer, type AudioPlayerProps } from "./audio-player"; + // Core UI primitives export { Badge, type BadgeProps, badgeVariants } from "./badge"; export {