project-standalo-sonic-cloud/app/components/AudioPlayer.tsx

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>
);
}