404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import type { Queue, PlayHistory } from '../types/api';
|
|
import { API_PATHS } from '../types/api';
|
|
|
|
interface AudioPlayerProps {
|
|
currentSong?: {
|
|
id: string;
|
|
title: string;
|
|
artist: string;
|
|
duration: number;
|
|
url: string;
|
|
};
|
|
queue?: Queue;
|
|
isPlaying?: boolean;
|
|
volume?: number;
|
|
currentTime?: number;
|
|
onPlay?: () => void;
|
|
onPause?: () => void;
|
|
onNext?: () => void;
|
|
onPrevious?: () => void;
|
|
onSeek?: (time: number) => void;
|
|
onVolumeChange?: (volume: number) => void;
|
|
onQueueUpdate?: (queue: Queue) => void;
|
|
}
|
|
|
|
type RepeatMode = 'none' | 'one' | 'all';
|
|
|
|
export default function AudioPlayer({
|
|
currentSong,
|
|
queue,
|
|
isPlaying = false,
|
|
volume = 0.7,
|
|
currentTime = 0,
|
|
onPlay,
|
|
onPause,
|
|
onNext,
|
|
onPrevious,
|
|
onSeek,
|
|
onVolumeChange,
|
|
onQueueUpdate,
|
|
}: AudioPlayerProps) {
|
|
const [localVolume, setLocalVolume] = useState(volume);
|
|
const [localCurrentTime, setLocalCurrentTime] = useState(currentTime);
|
|
const [repeatMode, setRepeatMode] = useState<RepeatMode>('none');
|
|
const [isShuffled, setIsShuffled] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [showQueue, setShowQueue] = useState(false);
|
|
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Sync with props
|
|
useEffect(() => {
|
|
setLocalVolume(volume);
|
|
}, [volume]);
|
|
|
|
useEffect(() => {
|
|
setLocalCurrentTime(currentTime);
|
|
}, [currentTime]);
|
|
|
|
// Format time helper
|
|
const formatTime = (seconds: number): string => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// API calls with proper error handling
|
|
const handlePlay = async () => {
|
|
try {
|
|
const response = await fetch(API_PATHS.PLAYER_PLAY, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to play');
|
|
}
|
|
|
|
onPlay?.();
|
|
} catch (error) {
|
|
console.error('Play error:', error);
|
|
}
|
|
};
|
|
|
|
const handlePause = async () => {
|
|
try {
|
|
const response = await fetch(API_PATHS.PLAYER_PAUSE, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to pause');
|
|
}
|
|
|
|
onPause?.();
|
|
} catch (error) {
|
|
console.error('Pause error:', error);
|
|
}
|
|
};
|
|
|
|
const handleNext = async () => {
|
|
try {
|
|
const response = await fetch(API_PATHS.PLAYER_NEXT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to play next');
|
|
}
|
|
|
|
onNext?.();
|
|
} catch (error) {
|
|
console.error('Next error:', error);
|
|
}
|
|
};
|
|
|
|
const handlePrevious = async () => {
|
|
try {
|
|
const response = await fetch(API_PATHS.PLAYER_PREVIOUS, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to play previous');
|
|
}
|
|
|
|
onPrevious?.();
|
|
} catch (error) {
|
|
console.error('Previous error:', error);
|
|
}
|
|
};
|
|
|
|
const handleVolumeChange = async (newVolume: number) => {
|
|
try {
|
|
setLocalVolume(newVolume);
|
|
setIsMuted(newVolume === 0);
|
|
onVolumeChange?.(newVolume);
|
|
|
|
// Volume change would typically be handled client-side
|
|
// But we could persist it to user preferences
|
|
} catch (error) {
|
|
console.error('Volume change error:', error);
|
|
}
|
|
};
|
|
|
|
const handleSeek = (time: number) => {
|
|
setLocalCurrentTime(time);
|
|
onSeek?.(time);
|
|
|
|
if (audioRef.current) {
|
|
audioRef.current.currentTime = time;
|
|
}
|
|
};
|
|
|
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!progressBarRef.current || !currentSong) return;
|
|
|
|
const rect = progressBarRef.current.getBoundingClientRect();
|
|
const percent = (e.clientX - rect.left) / rect.width;
|
|
const newTime = percent * currentSong.duration;
|
|
|
|
handleSeek(newTime);
|
|
};
|
|
|
|
const toggleRepeat = () => {
|
|
const modes: RepeatMode[] = ['none', 'one', 'all'];
|
|
const currentIndex = modes.indexOf(repeatMode);
|
|
const nextMode = modes[(currentIndex + 1) % modes.length];
|
|
setRepeatMode(nextMode);
|
|
};
|
|
|
|
const toggleShuffle = () => {
|
|
setIsShuffled(!isShuffled);
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
if (isMuted) {
|
|
handleVolumeChange(volume);
|
|
} else {
|
|
handleVolumeChange(0);
|
|
}
|
|
};
|
|
|
|
const fetchQueue = async () => {
|
|
try {
|
|
const response = await fetch(API_PATHS.PLAYER_QUEUE);
|
|
if (response.ok) {
|
|
const queueData: Queue = await response.json();
|
|
onQueueUpdate?.(queueData);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch queue:', error);
|
|
}
|
|
};
|
|
|
|
// Progress percentage
|
|
const progressPercent = currentSong
|
|
? (localCurrentTime / currentSong.duration) * 100
|
|
: 0;
|
|
|
|
return (
|
|
<div className="bg-gray-900 text-white p-4 rounded-lg shadow-xl">
|
|
{/* Audio element for actual playback */}
|
|
{currentSong && (
|
|
<audio
|
|
ref={audioRef}
|
|
src={currentSong.url}
|
|
onTimeUpdate={(e) => setLocalCurrentTime(e.currentTarget.currentTime)}
|
|
onEnded={handleNext}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
{/* Song Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold truncate">
|
|
{currentSong?.title || 'No song playing'}
|
|
</h3>
|
|
<p className="text-sm text-gray-400 truncate">
|
|
{currentSong?.artist || ''}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Queue button */}
|
|
<button
|
|
onClick={() => {
|
|
setShowQueue(!showQueue);
|
|
if (!showQueue) fetchQueue();
|
|
}}
|
|
className="ml-4 p-2 hover:bg-gray-800 rounded-full transition-colors"
|
|
aria-label="Toggle queue"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
d="M4 6h16M4 12h16M4 18h7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="mb-4">
|
|
<div
|
|
ref={progressBarRef}
|
|
className="relative h-2 bg-gray-700 rounded-full cursor-pointer group"
|
|
onClick={handleProgressClick}
|
|
>
|
|
<div
|
|
className="absolute left-0 top-0 h-full bg-blue-500 rounded-full transition-all"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
<div
|
|
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow"
|
|
style={{ left: `${progressPercent}%`, transform: 'translate(-50%, -50%)' }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
|
<span>{formatTime(localCurrentTime)}</span>
|
|
<span>{currentSong ? formatTime(currentSong.duration) : '0:00'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex items-center justify-center gap-4 mb-4">
|
|
{/* Shuffle */}
|
|
<button
|
|
onClick={toggleShuffle}
|
|
className={`p-2 rounded-full transition-colors ${
|
|
isShuffled ? 'bg-blue-600 text-white' : 'hover:bg-gray-800'
|
|
}`}
|
|
aria-label="Toggle shuffle"
|
|
>
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Previous */}
|
|
<button
|
|
onClick={handlePrevious}
|
|
className="p-3 hover:bg-gray-800 rounded-full transition-colors"
|
|
aria-label="Previous song"
|
|
>
|
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Play/Pause */}
|
|
<button
|
|
onClick={isPlaying ? handlePause : handlePlay}
|
|
className="p-4 bg-blue-600 hover:bg-blue-700 rounded-full transition-colors"
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? (
|
|
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
|
</svg>
|
|
) : (
|
|
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* Next */}
|
|
<button
|
|
onClick={handleNext}
|
|
className="p-3 hover:bg-gray-800 rounded-full transition-colors"
|
|
aria-label="Next song"
|
|
>
|
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Repeat */}
|
|
<button
|
|
onClick={toggleRepeat}
|
|
className={`p-2 rounded-full transition-colors ${
|
|
repeatMode !== 'none' ? 'bg-blue-600 text-white' : 'hover:bg-gray-800'
|
|
}`}
|
|
aria-label={`Repeat mode: ${repeatMode}`}
|
|
>
|
|
{repeatMode === 'one' ? (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>
|
|
<circle cx="12" cy="12" r="1"/>
|
|
</svg>
|
|
) : (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Volume Control */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={toggleMute}
|
|
className="p-2 hover:bg-gray-800 rounded-full transition-colors"
|
|
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
|
>
|
|
{isMuted || localVolume === 0 ? (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
|
</svg>
|
|
) : (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={isMuted ? 0 : localVolume}
|
|
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
|
className="flex-1 h-1 bg-gray-700 rounded-full appearance-none cursor-pointer"
|
|
style={{
|
|
background: `linear-gradient(to right, #3B82F6 0%, #3B82F6 ${isMuted ? 0 : localVolume * 100}%, #374151 ${isMuted ? 0 : localVolume * 100}%, #374151 100%)`
|
|
}}
|
|
/>
|
|
|
|
<span className="text-xs text-gray-400 w-8">
|
|
{Math.round(isMuted ? 0 : localVolume * 100)}%
|
|
</span>
|
|
</div>
|
|
|
|
{/* Queue Modal */}
|
|
{showQueue && queue && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full max-h-96 overflow-y-auto">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-semibold">Queue</h2>
|
|
<button
|
|
onClick={() => setShowQueue(false)}
|
|
className="p-2 hover:bg-gray-700 rounded-full"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Queue items would go here */}
|
|
<p className="text-gray-400 text-center py-8">
|
|
Queue functionality coming soon
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |