365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Slider } from '@/components/ui/slider'
|
|
|
|
// Icons
|
|
const PlayIcon = () => (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
)
|
|
|
|
const PauseIcon = () => (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
</svg>
|
|
)
|
|
|
|
const SkipBackIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
</svg>
|
|
)
|
|
|
|
const SkipForwardIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
|
</svg>
|
|
)
|
|
|
|
const ShuffleIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="16 3 21 3 21 8" />
|
|
<line x1="4" y1="20" x2="21" y2="3" />
|
|
<polyline points="21 16 21 21 16 21" />
|
|
<line x1="15" y1="15" x2="21" y2="21" />
|
|
<line x1="4" y1="4" x2="9" y2="9" />
|
|
</svg>
|
|
)
|
|
|
|
const RepeatIcon = () => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="17 1 21 5 17 9" />
|
|
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
|
<polyline points="7 23 3 19 7 15" />
|
|
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
|
</svg>
|
|
)
|
|
|
|
const VolumeIcon = ({ muted }: { muted: boolean }) => (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
{muted ? (
|
|
<>
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
<line x1="23" y1="9" x2="17" y2="15" />
|
|
<line x1="17" y1="9" x2="23" y2="15" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
</>
|
|
)}
|
|
</svg>
|
|
)
|
|
|
|
// Waveform visualization
|
|
function Waveform({
|
|
progress = 0,
|
|
bars = 50,
|
|
className
|
|
}: {
|
|
progress?: number
|
|
bars?: number
|
|
className?: string
|
|
}) {
|
|
const heights = React.useMemo(() => {
|
|
return Array.from({ length: bars }, () => 20 + Math.random() * 80)
|
|
}, [bars])
|
|
|
|
return (
|
|
<div className={cn('flex h-12 items-center gap-px', className)}>
|
|
{heights.map((height, i) => {
|
|
const isPlayed = (i / bars) * 100 <= progress
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
'flex-1 min-w-[2px] rounded-full transition-colors duration-100',
|
|
isPlayed ? 'bg-primary' : 'bg-muted-foreground/30'
|
|
)}
|
|
style={{ height: `${height}%` }}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Equalizer animation
|
|
function Equalizer({ playing = false, className }: { playing?: boolean; className?: string }) {
|
|
return (
|
|
<div className={cn('flex items-end gap-0.5 h-5', className)}>
|
|
{[0.4, 0.7, 0.5, 0.9, 0.6].map((delay, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
'w-[3px] bg-primary rounded-sm transition-all',
|
|
playing ? 'animate-[eq-bounce_0.8s_ease_infinite]' : 'h-1'
|
|
)}
|
|
style={{
|
|
height: playing ? `${40 + Math.random() * 60}%` : '20%',
|
|
animationDelay: `${delay * 0.2}s`
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Format time helper
|
|
function formatTime(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60)
|
|
const secs = Math.floor(seconds % 60)
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
}
|
|
|
|
// Main Audio Player
|
|
interface AudioPlayerProps {
|
|
title?: string
|
|
artist?: string
|
|
coverUrl?: string
|
|
duration?: number
|
|
variant?: 'default' | 'compact' | 'mini'
|
|
className?: string
|
|
}
|
|
|
|
function AudioPlayer({
|
|
title = 'Track Title',
|
|
artist = 'Artist Name',
|
|
coverUrl,
|
|
duration = 240,
|
|
variant = 'default',
|
|
className,
|
|
}: AudioPlayerProps) {
|
|
const [isPlaying, setIsPlaying] = React.useState(false)
|
|
const [progress, setProgress] = React.useState(35)
|
|
const [volume, setVolume] = React.useState(80)
|
|
const [isMuted, setIsMuted] = React.useState(false)
|
|
const [shuffle, setShuffle] = React.useState(false)
|
|
const [repeat, setRepeat] = React.useState(false)
|
|
|
|
const currentTime = (progress / 100) * duration
|
|
|
|
if (variant === 'mini') {
|
|
return (
|
|
<div className={cn(
|
|
'flex items-center gap-3 p-3 bg-card border border-border rounded-lg',
|
|
className
|
|
)}>
|
|
<div className="size-12 rounded-md bg-gradient-to-br from-primary/30 to-secondary/30 flex-shrink-0 overflow-hidden">
|
|
{coverUrl ? (
|
|
<img src={coverUrl || "/placeholder.svg"} alt={title} className="size-full object-cover" />
|
|
) : (
|
|
<div className="size-full flex items-center justify-center">
|
|
<Equalizer playing={isPlaying} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{title}</p>
|
|
<p className="text-xs text-muted-foreground truncate">{artist}</p>
|
|
</div>
|
|
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-8"
|
|
onClick={() => setIsPlaying(!isPlaying)}
|
|
>
|
|
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (variant === 'compact') {
|
|
return (
|
|
<div className={cn(
|
|
'flex items-center gap-4 p-4 bg-card border border-border rounded-xl',
|
|
className
|
|
)}>
|
|
<div className="size-14 rounded-lg bg-gradient-to-br from-secondary/40 to-primary/40 flex-shrink-0 overflow-hidden">
|
|
{coverUrl ? (
|
|
<img src={coverUrl || "/placeholder.svg"} alt={title} className="size-full object-cover" />
|
|
) : (
|
|
<div className="size-full flex items-center justify-center">
|
|
<Equalizer playing={isPlaying} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold truncate">{title}</p>
|
|
<p className="text-xs text-muted-foreground truncate">{artist}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={() => setIsPlaying(!isPlaying)}
|
|
>
|
|
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-mono text-muted-foreground w-8">
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
<Slider
|
|
value={[progress]}
|
|
max={100}
|
|
step={1}
|
|
onValueChange={([val]) => setProgress(val)}
|
|
className="flex-1"
|
|
/>
|
|
<span className="text-[10px] font-mono text-muted-foreground w-8 text-right">
|
|
{formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Default full player
|
|
return (
|
|
<div className={cn(
|
|
'bg-card border border-border rounded-2xl overflow-hidden',
|
|
className
|
|
)}>
|
|
{/* Header with artwork and info */}
|
|
<div className="flex items-center gap-4 p-5 border-b border-border">
|
|
<div className="size-20 rounded-lg bg-gradient-to-br from-secondary to-primary flex-shrink-0 overflow-hidden shadow-lg relative">
|
|
{coverUrl ? (
|
|
<img src={coverUrl || "/placeholder.svg"} alt={title} className="size-full object-cover" />
|
|
) : (
|
|
<div className="size-full flex items-center justify-center">
|
|
<span className="text-3xl">🎵</span>
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-0 bg-gradient-to-br from-white/20 via-transparent to-transparent pointer-events-none" />
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-base font-semibold truncate">{title}</h3>
|
|
<p className="text-sm text-muted-foreground truncate mb-2">{artist}</p>
|
|
<div className="flex items-center gap-2">
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-primary/15 text-primary rounded">
|
|
WAV
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-muted text-muted-foreground rounded font-mono">
|
|
128 BPM
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-muted text-muted-foreground rounded font-mono">
|
|
A min
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Waveform */}
|
|
<div className="px-5 py-4 bg-muted/30">
|
|
<Waveform progress={progress} bars={60} className="cursor-pointer" />
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="p-5 space-y-4">
|
|
{/* Progress */}
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs font-mono text-muted-foreground w-10">
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
<Slider
|
|
value={[progress]}
|
|
max={100}
|
|
step={0.1}
|
|
onValueChange={([val]) => setProgress(val)}
|
|
className="flex-1"
|
|
/>
|
|
<span className="text-xs font-mono text-muted-foreground w-10 text-right">
|
|
{formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Main controls */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className={cn(shuffle && 'text-primary')}
|
|
onClick={() => setShuffle(!shuffle)}
|
|
>
|
|
<ShuffleIcon />
|
|
</Button>
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className={cn(repeat && 'text-primary')}
|
|
onClick={() => setRepeat(!repeat)}
|
|
>
|
|
<RepeatIcon />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button size="icon-sm" variant="ghost">
|
|
<SkipBackIcon />
|
|
</Button>
|
|
<Button
|
|
size="icon-lg"
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-[0_0_30px_oklch(0.75_0.18_195_/_0.4)]"
|
|
onClick={() => setIsPlaying(!isPlaying)}
|
|
>
|
|
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
</Button>
|
|
<Button size="icon-sm" variant="ghost">
|
|
<SkipForwardIcon />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={() => setIsMuted(!isMuted)}
|
|
>
|
|
<VolumeIcon muted={isMuted} />
|
|
</Button>
|
|
<Slider
|
|
value={[isMuted ? 0 : volume]}
|
|
max={100}
|
|
step={1}
|
|
onValueChange={([val]) => {
|
|
setVolume(val)
|
|
setIsMuted(val === 0)
|
|
}}
|
|
className="w-20"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export { AudioPlayer, Waveform, Equalizer }
|