AudioPlayer
Media player component with playback controls and progress tracking.
audio player
0:00
0:00
Also need Button and ScrollArea.
components/ui/audio-player/index.tsx
"use client"
import { Button, ButtonProps } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ComponentProps, useCallback, useEffect, useRef, useState } from "react"
import { ListIcon, NextIcon, PauseIcon, PlayIcon, PrevIcon } from "./icons"
export const TRACKS = [
"audio/Elysium_Sound_-_Cosmic_Dreamer.webm",
"audio/Greg_Kirkelie_-_1980s_Synthwave.webm",
"audio/Elysium_Sound_-_Stellar_Sunset_Middle.webm",
]
const formatTime = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`
export interface AudioPlayerProps extends ComponentProps<"div"> {
variant?: 1 | 2
showDecorativeSpeakers?: boolean
}
const getFilenameFromSrc = (src: string): string => {
const filename = src.split("/").pop() || src
return filename.replace(/\.[^/.]+$/, "").replace(/_/g, " ")
}
export function AudioPlayer({ className, variant = 1, showDecorativeSpeakers = true, ...props }: AudioPlayerProps) {
const audioPlayerRef = useRef<HTMLAudioElement>(null)
const audioCtxRef = useRef<AudioContext | null>(null)
const sourceNodeRef = useRef<MediaElementAudioSourceNode | null>(null)
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [progress, setProgress] = useState(0)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [trackIndex, setTrackIndex] = useState(0)
const [isPlayListOpen, setPlayListOpen] = useState(false)
const currentTrackSrc = TRACKS[trackIndex]!
const ensureAnalyser = useCallback(() => {
const player = audioPlayerRef.current
if (!player || audioCtxRef.current) return
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext
const ctx = new AudioCtx()
audioCtxRef.current = ctx
const analyserNode = ctx.createAnalyser()
analyserNode.fftSize = 2048
const source = ctx.createMediaElementSource(player)
sourceNodeRef.current = source
source.connect(analyserNode)
analyserNode.connect(ctx.destination)
setAnalyser(analyserNode)
}, [])
const playTrack = useCallback(
async (index: number, shouldPlay: boolean) => {
const player = audioPlayerRef.current
if (!player) return
ensureAnalyser()
if (audioCtxRef.current?.state === "suspended") {
await audioCtxRef.current.resume()
}
setTrackIndex(index)
setProgress(0)
setCurrentTime(0)
setDuration(0)
player.src = TRACKS[index]!
if (shouldPlay) {
const onCanPlay = async () => {
player.removeEventListener("canplay", onCanPlay)
try {
await player.play()
setIsPlaying(true)
} catch {
setIsPlaying(false)
}
}
player.addEventListener("canplay", onCanPlay)
} else {
setIsPlaying(false)
}
player.load()
},
[ensureAnalyser]
)
const togglePlay = useCallback(async () => {
const player = audioPlayerRef.current
if (!player) return
ensureAnalyser()
if (audioCtxRef.current?.state === "suspended") {
await audioCtxRef.current.resume()
}
if (isPlaying) {
player.pause()
setIsPlaying(false)
} else {
await player.play()
setIsPlaying(true)
}
}, [isPlaying, ensureAnalyser])
const prevTrack = useCallback(() => {
const player = audioPlayerRef.current
if (!player) return
if (currentTime >= 3) {
player.currentTime = 0
setProgress(0)
setCurrentTime(0)
if (!isPlaying) {
player.play().catch(() => {
setIsPlaying(false)
})
setIsPlaying(true)
}
} else {
const idx = (trackIndex - 1 + TRACKS.length) % TRACKS.length
playTrack(idx, true)
}
}, [currentTime, trackIndex, playTrack, isPlaying])
const nextTrack = useCallback(() => {
const idx = (trackIndex + 1) % TRACKS.length
playTrack(idx, true)
}, [trackIndex, playTrack])
const seekAudio = useCallback((value: number) => {
const player = audioPlayerRef.current
if (!player || !player.duration) return
player.currentTime = (value / 100) * player.duration
}, [])
useEffect(() => {
const player = audioPlayerRef.current
if (!player) return
const updateDuration = () => {
if (player.duration && isFinite(player.duration)) setDuration(player.duration)
}
const onTimeUpdate = () => {
if (!player.duration) return
setProgress((player.currentTime / player.duration) * 100)
setCurrentTime(player.currentTime)
}
const onEnded = () => {
const next = (trackIndex + 1) % TRACKS.length
playTrack(next, true)
}
player.addEventListener("loadedmetadata", updateDuration)
player.addEventListener("durationchange", updateDuration)
player.addEventListener("canplay", updateDuration)
player.addEventListener("timeupdate", onTimeUpdate)
player.addEventListener("ended", onEnded)
updateDuration()
return () => {
player.removeEventListener("loadedmetadata", updateDuration)
player.removeEventListener("durationchange", updateDuration)
player.removeEventListener("canplay", updateDuration)
player.removeEventListener("timeupdate", onTimeUpdate)
player.removeEventListener("ended", onEnded)
}
}, [trackIndex, playTrack])
useEffect(() => {
return () => {
audioCtxRef.current?.close()
}
}, [])
return (
<div
className={`w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-900 p-4 ${className || ""}`}
{...props}
>
<div className="flex items-center justify-between">
<p className="text-[0.6875rem] tracking-[0.075rem] text-neutral-500 uppercase">audio player</p>
</div>
<div className="relative mt-4 grid items-center overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900">
<Display analyser={analyser} isActive={isPlaying} barOrigin="center" className="py-1" />
{variant === 1 && <PlayList isOpen={isPlayListOpen} currentTrackIndex={trackIndex} onTrackSelect={playTrack} />}
</div>
<div className="mt-4 flex gap-3 max-sm:flex-col sm:items-center">
<div className="flex items-center gap-2">
<PrevButton size="icon-sm" onClick={prevTrack} />
<PlayButton size="icon-sm" isPlaying={isPlaying} onClick={togglePlay} />
<NextButton size="icon-sm" onClick={nextTrack} />
<PlayListButton
isOpen={isPlayListOpen}
onClick={() => setPlayListOpen((v) => !v)}
className="ml-auto sm:hidden"
/>
</div>
<div className="grid flex-1 grid-cols-[auto_1fr_auto] items-center gap-2">
<span className="text-xs text-neutral-600 tabular-nums">{formatTime(currentTime)}</span>
<SeekBar progress={progress} onSeek={seekAudio} />
<span className="text-xs text-neutral-600 tabular-nums">{formatTime(duration)}</span>
</div>
<PlayListButton isOpen={isPlayListOpen} onClick={() => setPlayListOpen((v) => !v)} className="max-sm:hidden" />
</div>
{variant === 2 && (
<PlayList variant={2} isOpen={isPlayListOpen} currentTrackIndex={trackIndex} onTrackSelect={playTrack} />
)}
<audio ref={audioPlayerRef} src={currentTrackSrc} preload="metadata" className="hidden" />
{showDecorativeSpeakers && <DecorativeSpeakers className="mt-4" />}
</div>
)
}
type DisplayBarOrigin = "bottom" | "center"
export interface DisplayProps extends ComponentProps<"div"> {
analyser: AnalyserNode | null
isActive: boolean
barOrigin?: DisplayBarOrigin
}
export function Display({ analyser, isActive, barOrigin = "bottom", className, ...props }: DisplayProps) {
const BAR_WIDTH = 3
const BAR_GAP = 3
const SMOOTHING = 0.18
const STOP_SMOOTHING = 0.08
const PEAK_HOLD_MS = 100
const PEAK_FALL_RATE = 0.012
const canvasRef = useRef<HTMLCanvasElement>(null)
const dataArrayRef = useRef<Uint8Array<ArrayBuffer> | null>(null)
const animFrameRef = useRef(0)
const levelsRef = useRef<number[]>([])
const peaksRef = useRef<{ level: number; heldUntil: number }[]>([])
const barOriginRef = useRef<DisplayBarOrigin>(barOrigin)
const isActiveRef = useRef(isActive)
useEffect(() => {
barOriginRef.current = barOrigin
}, [barOrigin])
useEffect(() => {
isActiveRef.current = isActive
}, [isActive])
const getBarCount = useCallback((w: number, dpr: number) => {
const slot = (BAR_WIDTH + BAR_GAP) * dpr
return Math.ceil(w / slot)
}, [])
const drawFrame = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const dpr = window.devicePixelRatio
const { width: w, height: h } = canvas
const barCount = getBarCount(w, dpr)
const slot = (BAR_WIDTH + BAR_GAP) * dpr
const barW = BAR_WIDTH * dpr
const centered = barOriginRef.current === "center"
const half = Math.ceil(barCount / 2)
ctx.clearRect(0, 0, w, h)
for (let i = 0; i < barCount; i++) {
const binIndex = i < half ? half - 1 - i : i - half
const level = levelsRef.current[binIndex] ?? 0
const peak = peaksRef.current[binIndex] ?? { level: 0, heldUntil: 0 }
const x = i * slot
const barH = Math.max(2 * dpr, level * h)
ctx.fillStyle = "#fff"
if (centered) {
ctx.fillRect(x, (h - barH) / 2, barW, barH)
} else {
ctx.fillRect(x, h - barH, barW, barH)
}
if (peak.level > 0.02) {
ctx.fillStyle = "#f87171"
if (centered) {
const peakHalf = (peak.level * h) / 2
const centerY = h / 2
ctx.fillRect(x, centerY - peakHalf, barW, 2 * dpr)
ctx.fillRect(x, centerY + peakHalf - 2 * dpr, barW, 2 * dpr)
} else {
ctx.fillRect(x, h - peak.level * h, barW, 2 * dpr)
}
}
}
}, [getBarCount])
const drawIdle = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
canvas.width = canvas.offsetWidth * window.devicePixelRatio
canvas.height = canvas.offsetHeight * window.devicePixelRatio
const ctx = canvas.getContext("2d")
if (!ctx) return
const { width: w, height: h } = canvas
const dpr = window.devicePixelRatio
const slot = (BAR_WIDTH + BAR_GAP) * dpr
const barW = BAR_WIDTH * dpr
const barCount = getBarCount(w, dpr)
const stubH = 2 * dpr
ctx.fillStyle = "rgba(148,163,184,0.25)"
for (let i = 0; i < barCount; i++) {
ctx.fillRect(i * slot, (h - stubH) / 2, barW, stubH)
}
}, [getBarCount])
const decayLoop = useCallback(() => {
if (isActiveRef.current) return
const levels = levelsRef.current
const peaks = peaksRef.current
const now = Date.now()
let anyVisible = false
for (let i = 0; i < levels.length; i++) {
levels[i] = (levels[i] ?? 0) * (1 - STOP_SMOOTHING)
const peak = peaks[i]
if (peak) {
if (now > peak.heldUntil) {
peak.level = Math.max(0, peak.level - PEAK_FALL_RATE)
}
if (peak.level > 0.005) anyVisible = true
}
if ((levels[i] ?? 0) > 0.005) anyVisible = true
}
drawFrame()
if (anyVisible) {
animFrameRef.current = requestAnimationFrame(decayLoop)
} else {
drawIdle()
}
}, [drawFrame, drawIdle])
const draw = useCallback(() => {
const canvas = canvasRef.current
const dataArray = dataArrayRef.current
if (!analyser || !dataArray || !canvas) return
animFrameRef.current = requestAnimationFrame(draw)
analyser.getByteFrequencyData(dataArray)
const dpr = window.devicePixelRatio
const { width: w } = canvas
const barCount = getBarCount(w, dpr)
const half = Math.ceil(barCount / 2)
const binCount = dataArray.length
const now = Date.now()
if (levelsRef.current.length !== half) {
levelsRef.current = Array(half).fill(0)
peaksRef.current = Array.from({ length: half }, () => ({ level: 0, heldUntil: 0 }))
}
for (let i = 0; i < half; i++) {
const startBin = Math.floor(Math.pow(i / half, 1.6) * binCount)
const endBin = Math.floor(Math.pow((i + 1) / half, 1.6) * binCount)
let sum = 0
const count = Math.max(1, endBin - startBin)
for (let b = startBin; b < endBin; b++) sum += dataArray[b] ?? 0
const raw = Math.min(1, sum / count / 255)
const prev = levelsRef.current[i] ?? 0
const level = prev + (raw - prev) * (1 - SMOOTHING)
levelsRef.current[i] = level
const peak = peaksRef.current[i]!
if (level >= peak.level) {
peak.level = level
peak.heldUntil = now + PEAK_HOLD_MS
} else if (now > peak.heldUntil) {
peak.level = Math.max(0, peak.level - PEAK_FALL_RATE)
}
}
drawFrame()
}, [analyser, getBarCount, drawFrame])
useEffect(() => {
drawIdle()
window.addEventListener("resize", drawIdle)
return () => window.removeEventListener("resize", drawIdle)
}, [drawIdle])
useEffect(() => {
if (!isActive) drawIdle()
}, [barOrigin, isActive, drawIdle])
useEffect(() => {
if (isActive && analyser) {
const canvas = canvasRef.current
if (canvas) {
canvas.width = canvas.offsetWidth * window.devicePixelRatio
canvas.height = canvas.offsetHeight * window.devicePixelRatio
}
dataArrayRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount))
const barCount = getBarCount(canvasRef.current?.width ?? 0, window.devicePixelRatio)
const half = Math.ceil(barCount / 2)
levelsRef.current = Array(half).fill(0)
peaksRef.current = Array.from({ length: half }, () => ({ level: 0, heldUntil: 0 }))
draw()
} else {
cancelAnimationFrame(animFrameRef.current)
animFrameRef.current = requestAnimationFrame(decayLoop)
}
return () => cancelAnimationFrame(animFrameRef.current)
}, [isActive, analyser, draw, drawIdle, getBarCount, decayLoop])
return (
<div className={`overflow-hidden ${className || ""}`} {...props}>
<canvas ref={canvasRef} className="block h-20 w-full" />
</div>
)
}
export function PlayButton({ isPlaying, ...props }: { isPlaying: boolean } & ButtonProps) {
return (
<Button aria-label={isPlaying ? "Pause" : "Play"} variant="outline" size="icon" {...props}>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</Button>
)
}
export function PrevButton(props: ButtonProps) {
return (
<Button aria-label="Previous" variant="outline" size="icon" {...props}>
<PrevIcon />
</Button>
)
}
export function NextButton(props: ButtonProps) {
return (
<Button aria-label="Next" variant="outline" size="icon" {...props}>
<NextIcon />
</Button>
)
}
interface PlayListButtonProps extends ComponentProps<"button"> {
isOpen: boolean
}
export function PlayListButton({ isOpen, className, ...props }: PlayListButtonProps) {
return (
<button
aria-label={isOpen ? "Close play list" : "Open play list"}
className={`-m-2 p-2 text-[0.6875rem] tracking-[0.075rem] text-neutral-500 uppercase transition-colors hover:text-neutral-300 ${className || ""}`}
{...props}
>
<ListIcon />
</button>
)
}
interface SeekBarProps extends Omit<ComponentProps<"div">, "onChange"> {
progress: number
disabled?: boolean
onSeek?: (value: number) => void
}
export function SeekBar({ progress, disabled, className, onSeek, ...props }: SeekBarProps) {
return (
<div className={`relative isolate h-5 flex-1 ${className || ""}`} {...props}>
<div className="absolute top-1/2 -z-10 h-1 w-full -translate-y-1/2 rounded-full bg-neutral-800" />
<div
className="pointer-events-none absolute top-1/2 -z-10 h-1 -translate-y-1/2 rounded-full bg-neutral-400 transition-[width] duration-100"
style={{ width: `${progress}%` }}
/>
<input
type="range"
min={0}
max={100}
value={progress}
disabled={disabled}
onChange={(e) => onSeek?.(Number(e.target.value))}
className="absolute inset-0 h-full w-full appearance-none bg-transparent [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-transparent [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-transparent"
/>
</div>
)
}
interface PlayListProps {
variant?: 1 | 2
isOpen: boolean
currentTrackIndex: number
onTrackSelect: (index: number, shouldPlay: boolean) => void
}
export function PlayList({ variant = 1, isOpen, currentTrackIndex, onTrackSelect }: PlayListProps) {
return (
<div
className={`overflow-hidden transition-all duration-150 ease-in-out ${
variant === 1 ? "absolute inset-0 z-10 bg-neutral-900" : variant === 2 ? "mt-4" : ""
}`}
style={{
opacity: isOpen ? 1 : 0,
maxHeight: variant === 2 ? (isOpen ? "31.25rem" : "0") : "",
marginTop: variant === 2 ? (isOpen ? "" : "0") : "",
pointerEvents: variant === 1 ? (isOpen ? "auto" : "none") : "auto",
}}
>
<ScrollArea
className={variant === 1 ? "flex h-full flex-col gap-1 p-2" : variant === 2 ? "flex flex-col gap-1" : ""}
>
{TRACKS.map((track, i) => (
<button
key={track}
onClick={() => onTrackSelect(i, true)}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ${
i === currentTrackIndex
? "bg-neutral-800 text-neutral-100"
: "text-neutral-500 hover:bg-neutral-800/50 hover:text-neutral-300"
}`}
>
<span className="w-4 shrink-0 text-center text-[0.6875rem] text-neutral-600 tabular-nums">{i + 1}</span>
<span className="text-[0.6875rem] tracking-[0.075rem]">{getFilenameFromSrc(track)}</span>
</button>
))}
</ScrollArea>
</div>
)
}
export function DecorativeSpeakers({ className, ...props }: ComponentProps<"div">) {
const ASPECT_RATIO = 10 / 1
const DOT_SIZE = 3
const DOT_GAP = 5
const containerRef = useRef<HTMLDivElement>(null)
const [grid, setGrid] = useState({ cols: 0, rows: 0 })
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateGrid = () => {
const { width, height } = container.getBoundingClientRect()
const cell = DOT_SIZE + DOT_GAP
const cols = Math.max(0, Math.floor((width + DOT_GAP) / cell))
const rows = Math.max(0, Math.floor((height + DOT_GAP) / cell))
setGrid({ cols, rows })
}
updateGrid()
const observer = new ResizeObserver(updateGrid)
observer.observe(container)
return () => observer.disconnect()
}, [])
const dots = Array.from({ length: grid.cols * grid.rows })
return (
<div
ref={containerRef}
className={`relative w-full ${className || ""}`}
style={{ aspectRatio: `${ASPECT_RATIO}` }}
{...props}
>
<div
className="absolute inset-0 grid place-content-center"
style={{
gridTemplateColumns: `repeat(${grid.cols}, ${DOT_SIZE}px)`,
gridTemplateRows: `repeat(${grid.rows}, ${DOT_SIZE}px)`,
gap: `${DOT_GAP}px`,
}}
>
{dots.map((_, i) => (
<span key={i} className="rounded-full bg-black" style={{ width: DOT_SIZE, height: DOT_SIZE }} />
))}
</div>
</div>
)
}
components/ui/audio-player/icons.tsx
import { ComponentProps } from "react"
const DEFAULT_ICON_SIZE = 14
export interface IconProps extends ComponentProps<"svg"> {
size?: number
}
export function PlayIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
const left = (size * 3) / 14
const top = (size * 2.5) / 14
const dx = (size * 9) / 14
const dy = (size * 4.5) / 14
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" {...props}>
<path d={`M${left} ${top}l${dx} ${dy}-${dx} ${dy}V${top}z`} />
</svg>
)
}
export function PauseIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" {...props}>
<rect x={size / 7} y={size / 7} width={size / 3.5} height={size - (size / 7) * 2} rx={size / 14} />
<rect
x={size - size / 7 - size / 3.5}
y={size / 7}
width={size / 3.5}
height={size - (size / 7) * 2}
rx={size / 14}
/>
</svg>
)
}
export function PrevIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
const s = size / 14
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" {...props}>
<path
d={`M${2 * s} ${2 * s}h${1.5 * s}v${10 * s}H${2 * s}zM${12 * s} ${3.5 * s}L${5.5 * s} ${7 * s} ${12 * s} ${10.5 * s}V${3.5 * s}z`}
/>
</svg>
)
}
export function NextIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
const s = size / 14
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" {...props}>
<path
d={`M${10.5 * s} ${2 * s}H${12 * s}v${10 * s}h${-1.5 * s}zM${2 * s} ${3.5 * s}L${8.5 * s} ${7 * s} ${2 * s} ${10.5 * s}V${3.5 * s}z`}
/>
</svg>
)
}
export function ListIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
const s = size / 24
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
stroke="currentColor"
strokeWidth={2 * s}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d={`M${2 * s} ${4 * s} h${1 * s}`} />
<path d={`M${2 * s} ${11 * s} h${1 * s}`} />
<path d={`M${2 * s} ${18 * s} h${1 * s}`} />
<path d={`M${7 * s} ${4 * s} h${13 * s}`} />
<path d={`M${7 * s} ${11 * s} h${13 * s}`} />
<path d={`M${7 * s} ${18 * s} h${13 * s}`} />
</svg>
)
}
components/examples/audio-player.tsx
import { AudioPlayer } from "@/components/ui/audio-player"
export function AudioPlayerExample() {
return <AudioPlayer variant={1 /* or 2 */} />
}