Skip to content

Commit 4457c85

Browse files
feat(components): add playback speed control to audio player (#3)
* feat(components): add playback speed control to audio player * fix(components): preserve playback speed when switching tracks
1 parent 41ba5e4 commit 4457c85

File tree

11 files changed

+200
-16
lines changed

11 files changed

+200
-16
lines changed

apps/www/content/docs/components/audio-player.mdx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import {
5353
AudioPlayerDuration,
5454
AudioPlayerProgress,
5555
AudioPlayerProvider,
56+
AudioPlayerSpeed,
57+
AudioPlayerSpeedButtonGroup,
5658
AudioPlayerTime,
5759
useAudioPlayer,
5860
useAudioPlayerTime,
@@ -178,6 +180,43 @@ Displays the total duration of the current track or "--:--" when unavailable.
178180
| className | `string` | Optional CSS classes |
179181
| ...props | `HTMLSpanElement` | All standard span element props |
180182

183+
### AudioPlayerSpeed
184+
185+
A dropdown menu button (with settings icon) for controlling playback speed. Displays "Normal" for 1x speed and shows other speeds with "x" suffix (e.g., "0.5x", "1.5x").
186+
187+
#### Props
188+
189+
| Prop | Type | Default | Description |
190+
| -------- | ------------------- | ------------------------------------------ | ----------------------------------- |
191+
| speeds | `readonly number[]` | `[0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]` | Available playback speeds |
192+
| variant | `ButtonVariant` | `"ghost"` | Button variant |
193+
| size | `ButtonSize` | `"icon"` | Button size |
194+
| ...props | `ButtonProps` | - | All standard Button component props |
195+
196+
#### Example
197+
198+
```tsx
199+
<AudioPlayerSpeed variant="ghost" size="icon" />
200+
```
201+
202+
### AudioPlayerSpeedButtonGroup
203+
204+
A button group component for quick playback speed selection.
205+
206+
#### Props
207+
208+
| Prop | Type | Default | Description |
209+
| --------- | ------------------- | ------------------ | ------------------------------ |
210+
| speeds | `readonly number[]` | `[0.5, 1, 1.5, 2]` | Available playback speeds |
211+
| className | `string` | - | Optional CSS classes |
212+
| ...props | `HTMLDivElement` | - | All standard div element props |
213+
214+
#### Example
215+
216+
```tsx
217+
<AudioPlayerSpeedButtonGroup speeds={[0.5, 0.75, 1, 1.5, 2]} />
218+
```
219+
181220
### useAudioPlayer Hook
182221

183222
Access the audio player context to control playback programmatically.
@@ -190,11 +229,13 @@ const {
190229
error, // MediaError if any
191230
isPlaying, // Playing state
192231
isBuffering, // Buffering state
232+
playbackRate, // Current playback speed
193233
isItemActive, // Check if an item is active
194234
setActiveItem, // Set the active item
195235
play, // Play audio
196236
pause, // Pause audio
197237
seek, // Seek to time
238+
setPlaybackRate, // Change playback speed
198239
} = useAudioPlayer<TData>()
199240
```
200241

@@ -238,11 +279,30 @@ function CustomTimeDisplay() {
238279

239280
## Advanced Examples
240281

282+
### Player with Speed Control
283+
284+
```tsx showLineNumbers
285+
function AudioPlayerWithSpeed() {
286+
return (
287+
<AudioPlayerProvider>
288+
<div className="flex items-center gap-4">
289+
<AudioPlayerButton />
290+
<AudioPlayerTime />
291+
<AudioPlayerProgress className="flex-1" />
292+
<AudioPlayerDuration />
293+
<AudioPlayerSpeed />
294+
</div>
295+
</AudioPlayerProvider>
296+
)
297+
}
298+
```
299+
241300
### Custom Controls
242301

243302
```tsx showLineNumbers
244303
function CustomAudioPlayer() {
245-
const { play, pause, isPlaying, seek, duration } = useAudioPlayer()
304+
const { play, pause, isPlaying, seek, duration, setPlaybackRate } =
305+
useAudioPlayer()
246306

247307
return (
248308
<div className="space-y-4">
@@ -255,6 +315,8 @@ function CustomAudioPlayer() {
255315
<button onClick={() => duration && seek(duration * 0.5)}>
256316
Jump to 50%
257317
</button>
318+
319+
<button onClick={() => setPlaybackRate(1.5)}>Speed 1.5x</button>
258320
</div>
259321
)
260322
}
@@ -288,3 +350,5 @@ function AudioPlayerWithError() {
288350
- Space bar triggers play/pause when the progress slider is focused
289351
- The component includes TypeScript support with generic data types
290352
- Audio state is managed globally within the provider context
353+
- Playback speed displays "Normal" for 1x speed and numerical values (e.g., "0.5x", "1.5x") for other speeds
354+
- Playback speed is preserved when switching between tracks

apps/www/public/r/all.json

Lines changed: 3 additions & 1 deletion
Large diffs are not rendered by default.

apps/www/public/r/audio-player-demo.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"files": [
1212
{
1313
"path": "examples/audio-player-demo.tsx",
14-
"content": "\"use client\"\n\nimport { PauseIcon, PlayIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n AudioPlayerButton,\n AudioPlayerDuration,\n AudioPlayerProgress,\n AudioPlayerProvider,\n AudioPlayerTime,\n exampleTracks,\n useAudioPlayer,\n} from \"@/components/ui/audio-player\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card } from \"@/components/ui/card\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\n\ninterface Track {\n id: string\n name: string\n url: string\n}\n\nexport default function AudioPlayer() {\n return (\n <AudioPlayerProvider<Track>>\n <AudioPlayerDemo />\n </AudioPlayerProvider>\n )\n}\n\nconst AudioPlayerDemo = () => {\n return (\n <Card className=\"w-full overflow-hidden p-0\">\n <div className=\"flex flex-col lg:h-[180px] lg:flex-row\">\n <div className=\"bg-muted/50 flex flex-col overflow-hidden lg:h-full lg:w-64\">\n <ScrollArea className=\"h-48 w-full lg:h-full\">\n <div className=\"space-y-1 p-3\">\n {exampleTracks.map((song, index) => (\n <SongListItem\n key={song.id}\n song={song}\n trackNumber={index + 1}\n />\n ))}\n </div>\n </ScrollArea>\n </div>\n <Player />\n </div>\n </Card>\n )\n}\n\nconst Player = () => {\n const player = useAudioPlayer<Track>()\n\n return (\n <div className=\"flex flex-1 items-center p-4 sm:p-6\">\n <div className=\"mx-auto w-full max-w-2xl\">\n <div className=\"mb-4\">\n <h3 className=\"text-base font-semibold sm:text-lg\">\n {player.activeItem?.data?.name ?? \"No track selected\"}\n </h3>\n </div>\n <div className=\"flex items-center gap-3 sm:gap-4\">\n <AudioPlayerButton\n variant=\"outline\"\n size=\"default\"\n className=\"h-12 w-12 shrink-0 sm:h-10 sm:w-10\"\n disabled={!player.activeItem}\n />\n <div className=\"flex flex-1 items-center gap-2 sm:gap-3\">\n <AudioPlayerTime className=\"text-xs tabular-nums\" />\n <AudioPlayerProgress className=\"flex-1\" />\n <AudioPlayerDuration className=\"text-xs tabular-nums\" />\n </div>\n </div>\n </div>\n </div>\n )\n}\n\nconst SongListItem = ({\n song,\n trackNumber,\n}: {\n song: Track\n trackNumber: number\n}) => {\n const player = useAudioPlayer<Track>()\n const isActive = player.isItemActive(song.id)\n const isCurrentlyPlaying = isActive && player.isPlaying\n\n return (\n <div className=\"group/song relative\">\n <Button\n variant={isActive ? \"secondary\" : \"ghost\"}\n size=\"sm\"\n className={cn(\n \"h-10 w-full justify-start px-3 font-normal sm:h-9 sm:px-2\",\n isActive && \"bg-secondary\"\n )}\n onClick={() => {\n if (isCurrentlyPlaying) {\n player.pause()\n } else {\n player.play({\n id: song.id,\n src: song.url,\n data: song,\n })\n }\n }}\n >\n <div className=\"flex w-full items-center gap-3\">\n <div className=\"flex w-5 shrink-0 items-center justify-center\">\n {isCurrentlyPlaying ? (\n <PauseIcon className=\"h-4 w-4 sm:h-3.5 sm:w-3.5\" />\n ) : (\n <>\n <span className=\"text-muted-foreground/60 text-sm tabular-nums group-hover/song:invisible\">\n {trackNumber}\n </span>\n <PlayIcon className=\"invisible absolute h-4 w-4 group-hover/song:visible sm:h-3.5 sm:w-3.5\" />\n </>\n )}\n </div>\n <span className=\"truncate text-left text-sm\">{song.name}</span>\n </div>\n </Button>\n </div>\n )\n}\n",
14+
"content": "\"use client\"\n\nimport { PauseIcon, PlayIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n AudioPlayerButton,\n AudioPlayerDuration,\n AudioPlayerProgress,\n AudioPlayerProvider,\n AudioPlayerSpeed,\n AudioPlayerTime,\n exampleTracks,\n useAudioPlayer,\n} from \"@/components/ui/audio-player\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card } from \"@/components/ui/card\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\n\ninterface Track {\n id: string\n name: string\n url: string\n}\n\nexport default function AudioPlayer() {\n return (\n <AudioPlayerProvider<Track>>\n <AudioPlayerDemo />\n </AudioPlayerProvider>\n )\n}\n\nconst AudioPlayerDemo = () => {\n return (\n <Card className=\"w-full overflow-hidden p-0\">\n <div className=\"flex flex-col lg:h-[180px] lg:flex-row\">\n <div className=\"bg-muted/50 flex flex-col overflow-hidden lg:h-full lg:w-64\">\n <ScrollArea className=\"h-48 w-full lg:h-full\">\n <div className=\"space-y-1 p-3\">\n {exampleTracks.map((song, index) => (\n <SongListItem\n key={song.id}\n song={song}\n trackNumber={index + 1}\n />\n ))}\n </div>\n </ScrollArea>\n </div>\n <Player />\n </div>\n </Card>\n )\n}\n\nconst Player = () => {\n const player = useAudioPlayer<Track>()\n\n return (\n <div className=\"flex flex-1 items-center p-4 sm:p-6\">\n <div className=\"mx-auto w-full max-w-2xl\">\n <div className=\"mb-4\">\n <h3 className=\"text-base font-semibold sm:text-lg\">\n {player.activeItem?.data?.name ?? \"No track selected\"}\n </h3>\n </div>\n <div className=\"flex items-center gap-3 sm:gap-4\">\n <AudioPlayerButton\n variant=\"outline\"\n size=\"default\"\n className=\"h-12 w-12 shrink-0 sm:h-10 sm:w-10\"\n disabled={!player.activeItem}\n />\n <div className=\"flex flex-1 items-center gap-2 sm:gap-3\">\n <AudioPlayerTime className=\"text-xs tabular-nums\" />\n <AudioPlayerProgress className=\"flex-1\" />\n <AudioPlayerDuration className=\"text-xs tabular-nums\" />\n <AudioPlayerSpeed variant=\"ghost\" size=\"icon\" />\n </div>\n </div>\n </div>\n </div>\n )\n}\n\nconst SongListItem = ({\n song,\n trackNumber,\n}: {\n song: Track\n trackNumber: number\n}) => {\n const player = useAudioPlayer<Track>()\n const isActive = player.isItemActive(song.id)\n const isCurrentlyPlaying = isActive && player.isPlaying\n\n return (\n <div className=\"group/song relative\">\n <Button\n variant={isActive ? \"secondary\" : \"ghost\"}\n size=\"sm\"\n className={cn(\n \"h-10 w-full justify-start px-3 font-normal sm:h-9 sm:px-2\",\n isActive && \"bg-secondary\"\n )}\n onClick={() => {\n if (isCurrentlyPlaying) {\n player.pause()\n } else {\n player.play({\n id: song.id,\n src: song.url,\n data: song,\n })\n }\n }}\n >\n <div className=\"flex w-full items-center gap-3\">\n <div className=\"flex w-5 shrink-0 items-center justify-center\">\n {isCurrentlyPlaying ? (\n <PauseIcon className=\"h-4 w-4 sm:h-3.5 sm:w-3.5\" />\n ) : (\n <>\n <span className=\"text-muted-foreground/60 text-sm tabular-nums group-hover/song:invisible\">\n {trackNumber}\n </span>\n <PlayIcon className=\"invisible absolute h-4 w-4 group-hover/song:visible sm:h-3.5 sm:w-3.5\" />\n </>\n )}\n </div>\n <span className=\"truncate text-left text-sm\">{song.name}</span>\n </div>\n </Button>\n </div>\n )\n}\n",
1515
"type": "registry:example"
1616
}
1717
]

0 commit comments

Comments
 (0)