Audio Player
Queue, transport, seek, volume — as a hook.
SwiftUI: MusicKit player
Nothing playing
Installation
npx shadcn@latest add https://cupertino-ui.baltoon.jp/r/audio-player.jsonRequires the theme tokens — see Installation if this is your first component.
Source
"use client";
import * as React from "react";
/**
* Audio engine for the music components. Wraps one HTMLAudioElement
* in a context: queue, transport, seeking, and volume — the model
* behind MusicKit's player, sized for the web.
*/
type AudioTrack = {
id: string;
title: string;
artist: string;
album?: string;
/** Square artwork URL. */
artworkUrl?: string;
/** Playable audio URL (mp3/m4a). */
audioUrl: string;
/** Known duration in seconds, shown before metadata loads. */
duration?: number;
};
type AudioPlayerState = {
queue: AudioTrack[];
index: number;
track: AudioTrack | null;
playing: boolean;
currentTime: number;
duration: number;
volume: number;
/** Replace the queue and start playing at startIndex. */
play: (queue?: AudioTrack[], startIndex?: number) => void;
/** Replace the queue WITHOUT starting playback (no autoplay). */
load: (queue: AudioTrack[], startIndex?: number) => void;
toggle: () => void;
next: () => void;
prev: () => void;
seek: (seconds: number) => void;
setVolume: (volume: number) => void;
};
const AudioPlayerContext = React.createContext<AudioPlayerState | null>(null);
function useAudioPlayer() {
const ctx = React.useContext(AudioPlayerContext);
if (!ctx)
throw new Error("useAudioPlayer must be used inside <AudioPlayerProvider>");
return ctx;
}
function AudioPlayerProvider({ children }: { children: React.ReactNode }) {
const audioRef = React.useRef<HTMLAudioElement | null>(null);
const [queue, setQueue] = React.useState<AudioTrack[]>([]);
const [index, setIndex] = React.useState(-1);
const [playing, setPlaying] = React.useState(false);
const [currentTime, setCurrentTime] = React.useState(0);
const [duration, setDuration] = React.useState(0);
const [volume, setVolumeState] = React.useState(0.8);
const track = index >= 0 ? (queue[index] ?? null) : null;
const audio = React.useCallback(() => {
if (!audioRef.current && typeof window !== "undefined") {
audioRef.current = new Audio();
audioRef.current.preload = "metadata";
}
return audioRef.current;
}, []);
// Load + play whenever the current track changes.
React.useEffect(() => {
const el = audio();
if (!el || !track) return;
el.src = track.audioUrl;
el.volume = volume;
setCurrentTime(0);
setDuration(track.duration ?? 0);
if (playing) void el.play().catch(() => setPlaying(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [track?.id]);
React.useEffect(() => {
const el = audio();
if (!el) return;
if (playing) void el.play().catch(() => setPlaying(false));
else el.pause();
}, [playing, audio]);
React.useEffect(() => {
const el = audio();
if (!el) return;
const onTime = () => setCurrentTime(el.currentTime);
const onMeta = () => setDuration(el.duration || 0);
const onEnded = () =>
setIndex((i) => {
if (i + 1 < queue.length) return i + 1;
setPlaying(false);
return i;
});
el.addEventListener("timeupdate", onTime);
el.addEventListener("loadedmetadata", onMeta);
el.addEventListener("ended", onEnded);
return () => {
el.removeEventListener("timeupdate", onTime);
el.removeEventListener("loadedmetadata", onMeta);
el.removeEventListener("ended", onEnded);
};
}, [audio, queue.length]);
// Release the element on unmount.
React.useEffect(
() => () => {
audioRef.current?.pause();
audioRef.current = null;
},
[]
);
const play = React.useCallback(
(nextQueue?: AudioTrack[], startIndex = 0) => {
if (nextQueue) {
setQueue(nextQueue);
setIndex(startIndex);
} else if (index < 0 && queue.length > 0) {
setIndex(0);
}
setPlaying(true);
},
[index, queue.length]
);
const load = React.useCallback((nextQueue: AudioTrack[], startIndex = 0) => {
setPlaying(false);
setQueue(nextQueue);
setIndex(startIndex);
}, []);
const toggle = React.useCallback(() => setPlaying((p) => !p), []);
const next = React.useCallback(
() => setIndex((i) => Math.min(i + 1, queue.length - 1)),
[queue.length]
);
const prev = React.useCallback(() => {
const el = audio();
// Like iOS: restart the track unless we're within the first 3 s.
if (el && el.currentTime > 3) {
el.currentTime = 0;
setCurrentTime(0);
return;
}
setIndex((i) => Math.max(0, i - 1));
}, [audio]);
const seek = React.useCallback(
(seconds: number) => {
const el = audio();
if (!el) return;
el.currentTime = seconds;
setCurrentTime(seconds);
},
[audio]
);
const setVolume = React.useCallback(
(v: number) => {
const el = audio();
if (el) el.volume = v;
setVolumeState(v);
},
[audio]
);
return (
<AudioPlayerContext.Provider
value={{
queue,
index,
track,
playing,
currentTime,
duration,
volume,
play,
load,
toggle,
next,
prev,
seek,
setVolume,
}}
>
{children}
</AudioPlayerContext.Provider>
);
}
/** 92 → "1:32" */
function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
export {
AudioPlayerProvider,
formatTime,
useAudioPlayer,
type AudioTrack,
};