340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { FaPlay, FaPause, FaStepBackward, FaStepForward, FaTimes, FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
|
import { Album, Song } from '@/lib/types';
|
|
import { formatPrice } from '@/lib/utils';
|
|
|
|
interface MusicPlayerProps {
|
|
album: Album | null;
|
|
isPurchased: boolean;
|
|
onClose: () => void;
|
|
onPurchase: (album: Album) => void;
|
|
}
|
|
|
|
export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }: MusicPlayerProps) {
|
|
const [currentSongIndex, setCurrentSongIndex] = useState(0);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [showTrackList, setShowTrackList] = useState(false);
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
|
|
const currentSong = album?.songs[currentSongIndex];
|
|
|
|
useEffect(() => {
|
|
if (album) {
|
|
setCurrentSongIndex(0);
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
}
|
|
}, [album]);
|
|
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
console.log(audioRef.current)
|
|
if (!audio) return;
|
|
|
|
const updateTime = () => setCurrentTime(audio.currentTime);
|
|
const updateDuration = () => setDuration(audio.duration);
|
|
const handleEnded = () => {
|
|
if (!isPurchased) {
|
|
// Preview ended
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
} else {
|
|
// Move to next song
|
|
handleNext();
|
|
}
|
|
};
|
|
|
|
audio.addEventListener('timeupdate', updateTime);
|
|
audio.addEventListener('loadedmetadata', updateDuration);
|
|
audio.addEventListener('ended', handleEnded);
|
|
|
|
return () => {
|
|
audio.removeEventListener('timeupdate', updateTime);
|
|
audio.removeEventListener('loadedmetadata', updateDuration);
|
|
audio.removeEventListener('ended', handleEnded);
|
|
};
|
|
}, [isPurchased, currentSongIndex, album]);
|
|
|
|
const togglePlay = () => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
if (isPlaying) {
|
|
audio.pause();
|
|
} else {
|
|
audio.play();
|
|
}
|
|
setIsPlaying(!isPlaying);
|
|
};
|
|
|
|
const handleNext = () => {
|
|
if (!album) return;
|
|
const nextIndex = (currentSongIndex + 1) % album.songs.length;
|
|
setCurrentSongIndex(nextIndex);
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
};
|
|
|
|
const handlePrevious = () => {
|
|
if (!album) return;
|
|
const prevIndex = currentSongIndex === 0 ? album.songs.length - 1 : currentSongIndex - 1;
|
|
setCurrentSongIndex(prevIndex);
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
};
|
|
|
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const audio = audioRef.current;
|
|
if (!audio || !isPurchased) return; // Only allow seeking for purchased albums
|
|
|
|
const time = parseFloat(e.target.value);
|
|
audio.currentTime = time;
|
|
setCurrentTime(time);
|
|
};
|
|
|
|
const formatTime = (time: number) => {
|
|
if (isNaN(time)) return '0:00';
|
|
const minutes = Math.floor(time / 60);
|
|
const seconds = Math.floor(time % 60);
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
if (!album) return null;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{album && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
className="fixed inset-0 bg-paper-dark/90 z-50"
|
|
/>
|
|
|
|
{/* Player Modal */}
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9, y: 50 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9, y: 50 }}
|
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none overflow-y-auto"
|
|
>
|
|
<div className="w-full max-w-sm pointer-events-auto relative my-auto">
|
|
{/* Close Button - Top Right */}
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={onClose}
|
|
className="absolute -top-2 -right-2 z-10 p-2 bg-paper-light hover:bg-paper-sand border-2 border-paper-dark transition-colors shadow-paper-lg"
|
|
aria-label="Close player"
|
|
>
|
|
<FaTimes className="text-lg text-paper-dark" />
|
|
</motion.button>
|
|
|
|
{/* Album Artwork */}
|
|
<motion.div
|
|
initial={{ scale: 0.8, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
className="relative aspect-square overflow-hidden mb-3 shadow-paper-lg border-4 border-paper-dark"
|
|
>
|
|
<div className="absolute inset-0 bg-paper-brown"></div>
|
|
<div className="absolute inset-0 cardboard-texture"></div>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="text-4xl md:text-5xl font-bold text-paper-dark/30 text-center px-6">
|
|
{album.title}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview Badge */}
|
|
{!isPurchased && (
|
|
<div className="absolute top-2 right-2 bg-paper-dark text-paper-light px-2 py-1 border-2 border-paper-brown text-xs font-semibold shadow-paper">
|
|
Preview
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
|
|
{/* Song Info */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
className="text-center mb-3 p-3 paper-card"
|
|
>
|
|
<h2 className="text-lg font-bold text-paper-dark mb-0.5">
|
|
{currentSong?.title}
|
|
</h2>
|
|
<p className="text-sm text-paper-brown">
|
|
{album.title}
|
|
</p>
|
|
<p className="text-xs text-paper-gray mt-0.5">
|
|
Track {currentSongIndex + 1} of {album.songs.length}
|
|
</p>
|
|
</motion.div>
|
|
|
|
{/* Progress Bar */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.3 }}
|
|
className="mb-3 p-3 paper-card-light"
|
|
>
|
|
<div className="relative">
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max={duration || 0}
|
|
value={currentTime}
|
|
onChange={handleSeek}
|
|
disabled={!isPurchased}
|
|
className="w-full h-2 bg-paper-gray appearance-none cursor-pointer border-2 border-paper-brown"
|
|
style={{
|
|
background: `linear-gradient(to right, #5A554C ${(currentTime / (duration || 1)) * 100}%, #D9DACA ${(currentTime / (duration || 1)) * 100}%)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-paper-dark mt-1.5 font-mono">
|
|
<span>{formatTime(currentTime)}</span>
|
|
<span>{isPurchased ? formatTime(duration) : '0:30'}</span>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Controls */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.4 }}
|
|
className="flex items-center justify-center gap-4 mb-3 p-3 paper-card"
|
|
>
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={handlePrevious}
|
|
className="p-2 hover:bg-paper-brown hover:text-paper-light border-2 border-transparent hover:border-paper-dark transition-colors"
|
|
aria-label="Previous track"
|
|
>
|
|
<FaStepBackward className="text-lg text-paper-dark" />
|
|
</motion.button>
|
|
|
|
<motion.button
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
onClick={togglePlay}
|
|
className="p-4 bg-paper-dark hover:bg-paper-brown border-2 border-paper-brown transition-all shadow-paper-lg"
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? (
|
|
<FaPause className="text-xl text-paper-light" />
|
|
) : (
|
|
<FaPlay className="text-xl text-paper-light ml-0.5" />
|
|
)}
|
|
</motion.button>
|
|
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={handleNext}
|
|
className="p-2 hover:bg-paper-brown hover:text-paper-light border-2 border-transparent hover:border-paper-dark transition-colors"
|
|
aria-label="Next track"
|
|
>
|
|
<FaStepForward className="text-lg text-paper-dark" />
|
|
</motion.button>
|
|
</motion.div>
|
|
|
|
{/* Purchase Button */}
|
|
{!isPurchased && (
|
|
<motion.button
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.5 }}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
onClick={() => onPurchase(album)}
|
|
className="w-full mb-2 py-2.5 bg-paper-brown hover:bg-paper-dark border-2 border-paper-dark font-semibold text-paper-light text-sm transition-all shadow-paper-lg"
|
|
>
|
|
Purchase Full Album - {formatPrice(album.price)}
|
|
</motion.button>
|
|
)}
|
|
|
|
{/* Track List Toggle */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.6 }}
|
|
>
|
|
<button
|
|
onClick={() => setShowTrackList(!showTrackList)}
|
|
className="w-full py-2 bg-paper-sand hover:bg-paper-gray border-2 border-paper-brown transition-colors flex items-center justify-center gap-2 text-paper-dark text-xs font-medium"
|
|
>
|
|
{showTrackList ? <FaChevronUp className="text-xs" /> : <FaChevronDown className="text-xs" />}
|
|
<span className="font-medium">
|
|
{showTrackList ? 'Hide' : 'Show'} Track List
|
|
</span>
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{showTrackList && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="mt-2 max-h-40 overflow-y-auto bg-paper-light border-2 border-paper-brown p-1.5">
|
|
<div className="space-y-0.5">
|
|
{album.songs.map((song, index) => (
|
|
<button
|
|
key={song.id}
|
|
onClick={() => {
|
|
setCurrentSongIndex(index);
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
}}
|
|
className={`w-full text-left px-2 py-1.5 transition-all border ${
|
|
index === currentSongIndex
|
|
? 'bg-paper-brown text-paper-light border-paper-dark'
|
|
: 'hover:bg-paper-sand text-paper-dark border-transparent'
|
|
}`}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-paper-gray font-mono w-4">
|
|
{String(index + 1).padStart(2, '0')}
|
|
</span>
|
|
<span className="text-xs font-medium">{song.title}</span>
|
|
</div>
|
|
<span className="text-xs text-paper-gray font-mono">{song.duration}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
|
|
{/* Hidden Audio Element */}
|
|
<audio
|
|
ref={audioRef}
|
|
src={isPurchased ? currentSong?.fullUrl : currentSong?.previewUrl}
|
|
onPlay={() => setIsPlaying(true)}
|
|
onPause={() => setIsPlaying(false)}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|