Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/registry/lib/component-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 56 additions & 1 deletion apps/registry/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -6198,5 +6253,5 @@
}
],
"version": "0.3.0",
"generatedAt": "2026-07-13T15:22:06.484Z"
"generatedAt": "2026-09-07T16:35:01.371Z"
}
156 changes: 156 additions & 0 deletions apps/registry/registry/default/audio-player/audio-player.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useAudioPlayer>;

function AudioControls({
disabled,
playback,
}: {
disabled: boolean;
playback: Playback;
}) {
return (
<div className="flex items-center gap-x-3">
<Button
aria-label={playback.playing ? "Pause audio" : "Play audio"}
disabled={disabled}
onClick={() => void playback.togglePlayback()}
onKeyDown={playback.handleSeekKey}
size="icon"
type="button"
variant="outline"
>
{playback.playing ? (
<Pause aria-hidden="true" className="size-4" />
) : (
<Play aria-hidden="true" className="size-4" />
)}
</Button>
<input
aria-label="Seek audio"
aria-valuetext={`${timeLabel(playback.position)} of ${timeLabel(playback.duration)}`}
className="min-w-0 flex-1 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
disabled={!playback.duration || !!playback.error}
max={playback.duration || 1}
min={0}
onChange={(event) => {
playback.seek(Number(event.currentTarget.value));
}}
step={1}
type="range"
value={Math.min(playback.position, playback.duration)}
/>
<span className="text-xs tabular-nums text-muted-foreground">
{timeLabel(playback.position)} / {timeLabel(playback.duration)}
</span>
</div>
);
}

function PlaybackRate({ playback }: { playback: Playback }) {
return (
<label className="flex items-center gap-x-2 text-sm">
<span>Playback speed</span>
<select
className="rounded-md border bg-background p-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={(event) => {
playback.changeRate(Number(event.currentTarget.value));
}}
value={playback.rate}
>
{[0.5, 0.75, 1, 1.25, 1.5, 2].map((value) => (
<option key={value} value={value}>
{value}×
</option>
))}
</select>
</label>
);
}

function AudioPlayerSession({
captionsSrc,
className,
ref,
showPlaybackRate = false,
src,
title,
transcript,
...props
}: AudioPlayerProps) {
const playback = useAudioPlayer(src);
return (
<div
aria-label={title}
className={cn(
"rounded-md border bg-background p-4 text-foreground space-y-4",
className,
)}
ref={ref}
role="group"
{...props}
>
<audio
aria-label={title}
preload="metadata"
src={src || undefined}
{...playback.mediaProps}
>
<track kind="captions" src={captionsSrc} />
</audio>
<p className="text-sm font-medium">{title}</p>
<AudioControls disabled={!src} playback={playback} />
{showPlaybackRate ? <PlaybackRate playback={playback} /> : null}
<p
aria-live="polite"
className="text-sm text-muted-foreground"
role="status"
>
{playback.loading ? "Loading audio…" : ""}
</p>
{playback.error ? (
<p className="text-sm text-destructive" role="alert">
{playback.error}
</p>
) : null}
{transcript ? (
<details className="text-sm">
<summary className="cursor-pointer rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Transcript
</summary>
<div className="pt-3 text-muted-foreground">{transcript}</div>
</details>
) : null}
</div>
);
}

/** URL-backed audio controls. Changing src resets playback and all media state. */
export function AudioPlayer(props: AudioPlayerProps) {
return <AudioPlayerSession key={props.src} {...props} />;
}
AudioPlayer.displayName = "AudioPlayer";
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 44 additions & 0 deletions packages/ui/src/components/audio-player/audio-player.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Meta, Primary, Controls, Canvas } from '@storybook/addon-docs/blocks'
import * as Stories from './audio-player.stories'

<Meta of={Stories} />

# Audio player

URL-backed audio with native media events, accessible seeking and optional transcript and playback speed.

<Primary />

```tsx
import { AudioPlayer } from '@vllnt/ui'

<AudioPlayer
src="/interview.mp3"
title="Interview"
transcript="The complete spoken transcript…"
showPlaybackRate
/>
```

<Canvas of={Stories.WithTranscript} />

## 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

<Controls />

- `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.
17 changes: 17 additions & 0 deletions packages/ui/src/components/audio-player/audio-player.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AudioPlayer>;
export default meta;
type Story = StoryObj<typeof meta>;
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" } };
Loading
Loading