487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
import { useRef } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card } from '@/components/ui/card'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { useMusicPlayer } from '@/contexts/MusicPlayerContext'
|
|
import {
|
|
Play,
|
|
Pause,
|
|
Square,
|
|
SkipBack,
|
|
SkipForward,
|
|
Volume2,
|
|
VolumeX,
|
|
Repeat,
|
|
Repeat1,
|
|
Shuffle,
|
|
List,
|
|
Maximize2,
|
|
Minimize2,
|
|
Minus,
|
|
ArrowRightToLine,
|
|
ExternalLink,
|
|
Download,
|
|
Globe
|
|
} from 'lucide-react'
|
|
import { formatDuration } from '@/lib/format-duration'
|
|
|
|
export function MusicPlayer() {
|
|
const {
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
volume,
|
|
isMuted,
|
|
playMode,
|
|
currentTrack,
|
|
playlist,
|
|
currentTrackIndex,
|
|
isMinimized,
|
|
isUltraMinimized,
|
|
showPlaylist,
|
|
togglePlayPause,
|
|
stop,
|
|
previousTrack,
|
|
nextTrack,
|
|
seekTo,
|
|
setVolume,
|
|
toggleMute,
|
|
setPlayMode,
|
|
playTrack,
|
|
toggleMaximize,
|
|
toggleUltraMinimize,
|
|
togglePlaylistVisibility,
|
|
} = useMusicPlayer()
|
|
|
|
const progressBarRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Show player if there's a playlist, even if no current track is playing
|
|
if (playlist.length === 0) {
|
|
return null
|
|
}
|
|
|
|
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" />
|
|
case 'single':
|
|
return <ArrowRightToLine className="h-4 w-4" />
|
|
default:
|
|
return <Play className="h-4 w-4" />
|
|
}
|
|
}
|
|
|
|
const handlePlayModeToggle = () => {
|
|
const modes = ['continuous', 'loop-playlist', 'loop-one', 'random', 'single'] as const
|
|
const currentIndex = modes.indexOf(playMode)
|
|
const nextIndex = (currentIndex + 1) % modes.length
|
|
setPlayMode(modes[nextIndex])
|
|
}
|
|
|
|
const progressPercentage = (currentTime / duration) * 100
|
|
|
|
// Ultra-minimized view - only essential controls
|
|
if (isUltraMinimized) {
|
|
return (
|
|
<Card className="fixed bottom-4 right-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border shadow-lg z-50">
|
|
<div className="p-3 flex items-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={nextTrack}>
|
|
<SkipForward className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={toggleUltraMinimize}>
|
|
<Maximize2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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 w-full overflow-hidden rounded-t-lg bg-muted/50 flex items-center justify-center">
|
|
<img
|
|
src={currentTrack.thumbnail}
|
|
alt={currentTrack.title}
|
|
className="max-w-full max-h-45 object-contain"
|
|
/>
|
|
<div className="absolute top-2 right-2 flex space-x-1">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={toggleUltraMinimize}
|
|
className="h-8 w-8 p-0 bg-black/50 hover:bg-black/70"
|
|
title="Minimize to controls only"
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</Button>
|
|
<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">
|
|
<div className="flex items-center gap-2">
|
|
{(currentTrack?.file_url || currentTrack?.service_url) && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 shrink-0"
|
|
title="Open track links"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
{currentTrack?.file_url && (
|
|
<DropdownMenuItem onClick={() => window.open(currentTrack.file_url, '_blank')}>
|
|
<Download className="h-3 w-3" />
|
|
Open File
|
|
</DropdownMenuItem>
|
|
)}
|
|
{currentTrack?.service_url && (
|
|
<DropdownMenuItem onClick={() => window.open(currentTrack.service_url, '_blank')}>
|
|
<Globe className="h-3 w-3" />
|
|
Open Service
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
<h3 className="font-medium text-sm leading-tight line-clamp-1">
|
|
{currentTrack?.title || 'No track selected'}
|
|
</h3>
|
|
</div>
|
|
{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>{formatDuration(currentTime)}</span>
|
|
<span>{formatDuration(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="100"
|
|
step="1"
|
|
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">
|
|
{formatDuration(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="max-w-full rounded-lg overflow-hidden mb-6 shadow-lg bg-muted/50 flex items-center justify-center">
|
|
<img
|
|
src={currentTrack.thumbnail}
|
|
alt={currentTrack.title}
|
|
className="max-w-full max-h-full object-contain"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Track info */}
|
|
<div className="text-center mb-6">
|
|
<div className="flex items-center justify-center gap-3 mb-2">
|
|
{(currentTrack?.file_url || currentTrack?.service_url) && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 shrink-0"
|
|
title="Open track links"
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
{currentTrack?.file_url && (
|
|
<DropdownMenuItem onClick={() => window.open(currentTrack.file_url, '_blank')}>
|
|
<Download className="h-4 w-4" />
|
|
Open File
|
|
</DropdownMenuItem>
|
|
)}
|
|
{currentTrack?.service_url && (
|
|
<DropdownMenuItem onClick={() => window.open(currentTrack.service_url, '_blank')}>
|
|
<Globe className="h-4 w-4" />
|
|
Open Service
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
<h1 className="text-2xl font-bold">{currentTrack?.title || 'No track selected'}</h1>
|
|
</div>
|
|
{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>{formatDuration(currentTime)}</span>
|
|
<span>{formatDuration(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="100"
|
|
step="1"
|
|
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">
|
|
{formatDuration(track.duration)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)
|
|
} |