feat: implement MusicPlayer component with volume control and playlist management

This commit is contained in:
JSC
2025-07-07 16:10:06 +02:00
parent 3fad1d773e
commit 8012a53235
5 changed files with 771 additions and 2 deletions

View File

@@ -0,0 +1,385 @@
import { useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { useMusicPlayer } from '@/contexts/MusicPlayerContext'
import {
Play,
Pause,
Square,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Repeat,
Repeat1,
Shuffle,
List,
Maximize2,
Minimize2
} from 'lucide-react'
export function MusicPlayer() {
const {
isPlaying,
currentTime,
duration,
volume,
isMuted,
playMode,
currentTrack,
playlist,
currentTrackIndex,
isMinimized,
showPlaylist,
togglePlayPause,
stop,
previousTrack,
nextTrack,
seekTo,
setVolume,
toggleMute,
setPlayMode,
playTrack,
toggleMaximize,
togglePlaylistVisibility,
} = useMusicPlayer()
const progressBarRef = useRef<HTMLDivElement>(null)
if (!currentTrack) {
return null
}
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value)
setVolume(newVolume)
}
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (progressBarRef.current) {
const rect = progressBarRef.current.getBoundingClientRect()
const clickX = e.clientX - rect.left
const percentage = clickX / rect.width
const newTime = percentage * duration
seekTo(newTime)
}
}
const getPlayModeIcon = () => {
switch (playMode) {
case 'loop-playlist':
return <Repeat className="h-4 w-4" />
case 'loop-one':
return <Repeat1 className="h-4 w-4" />
case 'random':
return <Shuffle className="h-4 w-4" />
default:
return <Play className="h-4 w-4" />
}
}
const handlePlayModeToggle = () => {
const modes = ['continuous', 'loop-playlist', 'loop-one', 'random'] as const
const currentIndex = modes.indexOf(playMode)
const nextIndex = (currentIndex + 1) % modes.length
setPlayMode(modes[nextIndex])
}
const progressPercentage = (currentTime / duration) * 100
if (isMinimized) {
return (
<Card className="fixed bottom-4 right-4 w-80 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border shadow-lg z-50">
{/* Thumbnail */}
{currentTrack.thumbnail && (
<div className="relative h-32 w-full overflow-hidden rounded-t-lg">
<img
src={currentTrack.thumbnail}
alt={currentTrack.title}
className="h-full w-full object-cover"
/>
<div className="absolute top-2 right-2">
<Button
variant="secondary"
size="sm"
onClick={toggleMaximize}
className="h-8 w-8 p-0 bg-black/50 hover:bg-black/70"
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="p-4 space-y-3">
{/* Track info */}
<div className="space-y-1">
<h3 className="font-medium text-sm leading-tight line-clamp-1">
{currentTrack.title}
</h3>
{currentTrack.artist && (
<p className="text-xs text-muted-foreground line-clamp-1">
{currentTrack.artist}
</p>
)}
</div>
{/* Progress bar */}
<div className="space-y-2">
<div
ref={progressBarRef}
className="w-full h-2 bg-muted rounded-full cursor-pointer"
onClick={handleProgressClick}
>
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${progressPercentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Main controls */}
<div className="flex items-center justify-center space-x-2">
<Button variant="ghost" size="sm" onClick={previousTrack}>
<SkipBack className="h-4 w-4" />
</Button>
<Button variant="default" size="sm" onClick={togglePlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="sm" onClick={stop}>
<Square className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={nextTrack}>
<SkipForward className="h-4 w-4" />
</Button>
</div>
{/* Secondary controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
onClick={handlePlayModeToggle}
className="h-8 w-8 p-0"
>
{getPlayModeIcon()}
</Button>
<Button
variant="ghost"
size="sm"
onClick={togglePlaylistVisibility}
className="h-8 w-8 p-0"
>
<List className="h-4 w-4" />
</Button>
</div>
{/* Volume control */}
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={toggleMute}
className="h-8 w-8 p-0"
>
{isMuted || volume === 0 ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-muted rounded-lg appearance-none cursor-pointer slider"
/>
</div>
</div>
{/* Playlist */}
{showPlaylist && (
<>
<Separator />
<div className="space-y-2 max-h-40 overflow-y-auto">
<h4 className="font-medium text-sm">Playlist</h4>
{playlist.map((track, index) => (
<div
key={track.id}
className={`flex items-center space-x-2 p-2 rounded cursor-pointer hover:bg-muted/50 ${
index === currentTrackIndex ? 'bg-muted' : ''
}`}
onClick={() => playTrack(index)}
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium line-clamp-1">{track.title}</p>
{track.artist && (
<p className="text-xs text-muted-foreground line-clamp-1">
{track.artist}
</p>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTime(track.duration)}
</span>
</div>
))}
</div>
</>
)}
</div>
</Card>
)
}
// Maximized view - overlay
return (
<Card className="fixed inset-0 bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/80 z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">Music Player</h2>
<Button variant="ghost" size="sm" onClick={toggleMaximize}>
<Minimize2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 flex">
{/* Main player area */}
<div className="flex-1 flex flex-col items-center justify-center p-8">
{/* Large thumbnail */}
{currentTrack.thumbnail && (
<div className="w-80 h-80 rounded-lg overflow-hidden mb-6 shadow-lg">
<img
src={currentTrack.thumbnail}
alt={currentTrack.title}
className="h-full w-full object-cover"
/>
</div>
)}
{/* Track info */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold mb-2">{currentTrack.title}</h1>
{currentTrack.artist && (
<p className="text-lg text-muted-foreground">{currentTrack.artist}</p>
)}
</div>
{/* Progress bar */}
<div className="w-full max-w-md space-y-4 mb-8">
<div
ref={progressBarRef}
className="w-full h-3 bg-muted rounded-full cursor-pointer"
onClick={handleProgressClick}
>
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${progressPercentage}%` }}
/>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Main controls */}
<div className="flex items-center justify-center space-x-4 mb-6">
<Button variant="ghost" size="lg" onClick={previousTrack}>
<SkipBack className="h-6 w-6" />
</Button>
<Button variant="default" size="lg" onClick={togglePlayPause} className="h-14 w-14">
{isPlaying ? <Pause className="h-8 w-8" /> : <Play className="h-8 w-8" />}
</Button>
<Button variant="ghost" size="lg" onClick={stop}>
<Square className="h-6 w-6" />
</Button>
<Button variant="ghost" size="lg" onClick={nextTrack}>
<SkipForward className="h-6 w-6" />
</Button>
</div>
{/* Secondary controls */}
<div className="flex items-center space-x-6">
<Button
variant="ghost"
size="sm"
onClick={handlePlayModeToggle}
>
{getPlayModeIcon()}
</Button>
{/* Volume control */}
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={toggleMute}
>
{isMuted || volume === 0 ? (
<VolumeX className="h-5 w-5" />
) : (
<Volume2 className="h-5 w-5" />
)}
</Button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-24 h-2 bg-muted rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
</div>
{/* Playlist sidebar */}
<div className="w-80 border-l bg-muted/30 flex flex-col">
<div className="p-4 border-b">
<h3 className="font-semibold">Playlist</h3>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{playlist.map((track, index) => (
<div
key={track.id}
className={`flex items-center space-x-3 p-3 rounded-lg cursor-pointer hover:bg-background/50 transition-colors ${
index === currentTrackIndex ? 'bg-background shadow-sm' : ''
}`}
onClick={() => playTrack(index)}
>
<div className="flex-1 min-w-0">
<p className="font-medium line-clamp-1">{track.title}</p>
{track.artist && (
<p className="text-sm text-muted-foreground line-clamp-1">
{track.artist}
</p>
)}
</div>
<span className="text-sm text-muted-foreground">
{formatTime(track.duration)}
</span>
</div>
))}
</div>
</div>
</div>
</Card>
)
}