583 lines
28 KiB
TypeScript

'use client';
import { useState, useRef, useEffect, MouseEvent } from 'react';
// Configuration
const BASE_URL = "https://openwheels.racing/files/music/";
// Types
interface Track {
title: string;
artist: string;
fileName: string;
theme?: string;
}
type IconProps = { className?: string };
// Icons
function PlayIcon({ className = "w-6 h-6" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
);
}
function PauseIcon({ className = "w-6 h-6" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" />
<rect x="14" y="4" width="4" height="16" />
</svg>
);
}
function SkipNextIcon({ className = "w-6 h-6" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="5 4 15 12 5 20 5 4" fill="currentColor" />
<line x1="19" y1="5" x2="19" y2="19" strokeLinecap="square" />
</svg>
);
}
function SkipPrevIcon({ className = "w-6 h-6" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="19 20 9 12 19 4 19 20" fill="currentColor" />
<line x1="5" y1="5" x2="5" y2="19" strokeLinecap="square" />
</svg>
);
}
function VideoIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="7" width="20" height="15" strokeLinecap="square" />
<polygon points="10 12 16 15 10 18 10 12" fill="currentColor" stroke="none" />
</svg>
);
}
function MusicIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" strokeLinecap="square" />
<circle cx="6" cy="18" r="3" strokeLinecap="square" />
<circle cx="18" cy="16" r="3" strokeLinecap="square" />
</svg>
);
}
function RefreshIcon({ className = "w-5 h-5" }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10" strokeLinecap="square" />
<polyline points="1 20 1 14 7 14" strokeLinecap="square" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" strokeLinecap="square" />
</svg>
);
}
export default function MusicPage() {
const [tracks, setTracks] = useState<Track[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentTrackIndex, setCurrentTrackIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [selectedTheme, setSelectedTheme] = useState('all');
const mediaRef = useRef<HTMLMediaElement | null>(null);
// Fetch available tracks from the server
const fetchTracks = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/music/tracks');
if (!response.ok) {
throw new Error('Failed to fetch tracks');
}
const data = await response.json();
setTracks(data.tracks || []);
} catch (err: unknown) {
console.error('Error fetching tracks:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setTracks([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTracks();
}, []);
// Group tracks by theme
const tracksByTheme = tracks.reduce<Record<string, Track[]>>((acc, track) => {
const theme = track?.theme || 'general';
if (!acc[theme]) {
acc[theme] = [];
}
acc[theme].push(track);
return acc;
}, {});
const themes = Object.keys(tracksByTheme).sort();
// Get filtered tracks based on selected theme
const filteredTracks = selectedTheme === 'all'
? tracks
: tracksByTheme[selectedTheme] || [];
const currentTrack = filteredTracks[currentTrackIndex];
const isVideo = currentTrack?.fileName.toLowerCase().endsWith('.mp4');
useEffect(() => {
if (mediaRef.current && currentTrack) {
mediaRef.current.load();
if (isPlaying) {
mediaRef.current.play().catch(err => {
console.error('Playback error:', err);
setIsPlaying(false);
});
}
}
}, [currentTrackIndex, currentTrack]); // Removed isPlaying from deps to prevent loop
const togglePlayPause = () => {
if (mediaRef.current) {
if (isPlaying) {
mediaRef.current.pause();
} else {
mediaRef.current.play().catch(err => {
console.error('Playback error:', err);
});
}
setIsPlaying(!isPlaying);
}
};
const nextTrack = () => {
if (filteredTracks.length > 0) {
setCurrentTrackIndex((prev) => (prev + 1) % filteredTracks.length);
setIsPlaying(true);
}
};
const prevTrack = () => {
if (filteredTracks.length > 0) {
setCurrentTrackIndex((prev) => (prev - 1 + filteredTracks.length) % filteredTracks.length);
setIsPlaying(true);
}
};
const handleTimeUpdate = () => {
if (mediaRef.current) {
setCurrentTime(mediaRef.current.currentTime);
setDuration(mediaRef.current.duration || 0);
}
};
const handleSeek = (e: MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
if (mediaRef.current) {
mediaRef.current.currentTime = percentage * duration;
}
};
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')}`;
};
const handleThemeChange = (theme: string) => {
setSelectedTheme(theme);
setCurrentTrackIndex(0);
setIsPlaying(false);
};
if (loading) {
return (
<div className="min-h-screen text-white">
<div className="relative border-b border-white/10 grid-overlay">
<div className="max-w-7xl mx-auto px-6 py-16">
<div className="inline-flex items-center space-x-2 px-3 py-1 border border-white/20 bg-black">
<MusicIcon className="w-4 h-4" />
<span className="text-xs font-medium tracking-wider">MUSIC STREAM</span>
</div>
<h1 className="text-6xl font-bold tracking-tight mb-4">
AUDIO SYSTEM
</h1>
<p className="text-white/60 text-lg">
High-fidelity streaming for the racing community
</p>
</div>
</div>
<div className="max-w-6xl mx-auto px-6 py-16">
<div className="border border-white/10 bg-black p-16 text-center">
<div className="w-16 h-16 border-2 border-white/20 mx-auto mb-6 animate-pulse"></div>
<p className="text-white/40 text-base tracking-wider">LOADING TRACKS...</p>
</div>
</div>
</div>
);
}
if (!currentTrack) {
return (
<div className="min-h-screen text-white">
<div className="relative border-b border-white/10 grid-overlay">
<div className="max-w-7xl mx-auto px-6 py-16">
<div className="inline-flex items-center space-x-2 px-3 py-1 border border-white/20 bg-black">
<MusicIcon className="w-4 h-4" />
<span className="text-xs font-medium tracking-wider">MUSIC STREAM</span>
</div>
<h1 className="text-6xl font-bold tracking-tight mb-4">
AUDIO SYSTEM
</h1>
<p className="text-white/60 text-lg">
High-fidelity streaming for the racing community
</p>
</div>
</div>
<div className="max-w-6xl mx-auto px-6 py-16">
<div className="border border-white/10 bg-black p-16 text-center">
<div className="w-16 h-16 border-2 border-white/20 mx-auto mb-6"></div>
<p className="text-white/40 text-base tracking-wider mb-4">NO TRACKS AVAILABLE</p>
{error && <p className="text-red-400 text-sm mb-4">Error: {error}</p>}
<button
onClick={fetchTracks}
className="inline-flex items-center space-x-2 px-6 py-3 border border-white/20 hover:border-white transition-all"
>
<RefreshIcon className="w-4 h-4" />
<span>RETRY</span>
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen text-white">
{/* Animated Background */}
<div className="fixed inset-0 opacity-20 pointer-events-none">
<div
className="absolute inset-0"
style={{
backgroundImage: `
repeating-linear-gradient(0deg, transparent, transparent 50px, rgba(255, 255, 255, 0.02) 50px, rgba(255, 255, 255, 0.02) 51px),
repeating-linear-gradient(90deg, transparent, transparent 50px, rgba(255, 255, 255, 0.02) 50px, rgba(255, 255, 255, 0.02) 51px)
`
}}
/>
</div>
<div className="relative border-b border-white/10 grid-overlay">
<div className="max-w-7xl mx-auto px-6 py-16">
<div className="inline-flex items-center space-x-2 px-3 py-1 border border-white/20 bg-black">
<MusicIcon className="w-4 h-4" />
<span className="text-xs font-medium tracking-wider">MUSIC STREAM</span>
</div>
<h1 className="text-6xl font-bold tracking-tight mb-4">
AUDIO SYSTEM
</h1>
<p className="text-white/60 text-lg mb-6">
High-fidelity streaming for the racing community
</p>
{/* Theme Filter */}
<div className="flex items-center space-x-2 flex-wrap gap-2">
<span className="text-xs text-white/40 tracking-wider">FILTER:</span>
<button
onClick={() => handleThemeChange('all')}
className={`px-4 py-2 border text-xs tracking-wider transition-all ${selectedTheme === 'all'
? 'border-white bg-white text-black'
: 'border-white/20 hover:border-white/40'
}`}
>
ALL ({tracks.length})
</button>
{themes.map(theme => (
<button
key={theme}
onClick={() => handleThemeChange(theme)}
className={`px-4 py-2 border text-xs tracking-wider transition-all ${selectedTheme === theme
? 'border-white bg-white text-black'
: 'border-white/20 hover:border-white/40'
}`}
>
{theme.toUpperCase()} ({tracksByTheme[theme].length})
</button>
))}
<button
onClick={fetchTracks}
className="ml-auto px-4 py-2 border border-white/20 hover:border-white transition-all inline-flex items-center space-x-2 text-xs"
>
<RefreshIcon className="w-3 h-3" />
<span>REFRESH</span>
</button>
</div>
</div>
</div>
<div className="relative max-w-6xl mx-auto px-6 py-16">
{/* Main Player Container */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Now Playing */}
<div className="lg:col-span-2 space-y-6">
{/* Video/Audio Display */}
<div className="border border-white/10 bg-black overflow-hidden">
{isVideo ? (
<video
ref={mediaRef as any}
onTimeUpdate={handleTimeUpdate}
onEnded={nextTrack}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
className="w-full aspect-video"
preload="metadata"
>
<source src={`${BASE_URL}${currentTrack.fileName}`} type="video/mp4" />
</video>
) : (
<div className="aspect-video bg-gradient-to-br from-black via-zinc-900 to-black relative overflow-hidden">
{/* Audio Visualizer */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative">
{/* Pulsing circles */}
{[...Array(3)].map((_, i) => (
<div
key={i}
className="absolute inset-0 border-2 border-white/20 rounded-full"
style={{
width: `${120 + i * 40}px`,
height: `${120 + i * 40}px`,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
animation: isPlaying ? `pulse ${2 + i * 0.5}s infinite ease-in-out` : 'none',
animationDelay: `${i * 0.2}s`
}}
/>
))}
{/* Center icon */}
<div className="relative z-10 w-32 h-32 border-2 border-white/40 flex items-center justify-center bg-black">
<MusicIcon className="w-16 h-16 text-white/60" />
</div>
</div>
</div>
{/* Audio element (hidden) */}
<audio
ref={mediaRef as any}
onTimeUpdate={handleTimeUpdate}
onEnded={nextTrack}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
preload="metadata"
>
<source src={`${BASE_URL}${currentTrack.fileName}`} />
</audio>
</div>
)}
</div>
{/* Track Info & Controls */}
<div className="border border-white/10 bg-black p-6">
{/* Track Info */}
<div className="mb-6">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h2 className="text-2xl font-bold tracking-tight mb-1">
{currentTrack.title}
</h2>
<p className="text-white/60 text-sm tracking-wider">
{currentTrack.artist}
</p>
</div>
<div className="flex flex-col items-end space-y-2">
<div className="flex items-center space-x-2 px-3 py-1 border border-white/20 text-xs">
{isVideo ? <VideoIcon className="w-4 h-4" /> : <MusicIcon className="w-4 h-4" />}
<span className="tracking-wider">{isVideo ? 'VIDEO' : 'AUDIO'}</span>
</div>
<div className="px-3 py-1 border border-white/20 bg-white/5 text-xs tracking-wider">
{currentTrack.theme?.toUpperCase() || 'GENERAL'}
</div>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="mb-6">
<div
className="h-1 bg-white/10 cursor-pointer relative group"
onClick={handleSeek}
>
<div
className="h-full bg-white transition-all"
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
/>
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white opacity-0 group-hover:opacity-100 transition-opacity"
style={{ left: `${(currentTime / duration) * 100 || 0}%`, transform: 'translate(-50%, -50%)' }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-white/60 font-mono">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-center space-x-4">
<button
onClick={prevTrack}
className="p-3 border border-white/20 hover:border-white hover:bg-white/5 transition-all"
disabled={filteredTracks.length === 0}
>
<SkipPrevIcon className="w-5 h-5" />
</button>
<button
onClick={togglePlayPause}
className="p-4 border-2 border-white hover:bg-white hover:text-black transition-all"
disabled={!currentTrack}
>
{isPlaying ? <PauseIcon className="w-6 h-6" /> : <PlayIcon className="w-6 h-6" />}
</button>
<button
onClick={nextTrack}
className="p-3 border border-white/20 hover:border-white hover:bg-white/5 transition-all"
disabled={filteredTracks.length === 0}
>
<SkipNextIcon className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Right Column - Playlist */}
<div className="border border-white/10 bg-black">
<div className="border-b border-white/10 p-4 bg-white/5">
<h3 className="text-xs font-bold tracking-wider text-white/60">
PLAYLIST
{selectedTheme !== 'all' && ` - ${selectedTheme.toUpperCase()}`}
</h3>
</div>
<div className="divide-y divide-white/5 max-h-[600px] overflow-y-auto">
{filteredTracks.length === 0 ? (
<div className="p-8 text-center text-white/40 text-sm">
No tracks in this category
</div>
) : (
filteredTracks.map((track, index) => (
<button
key={index}
onClick={() => {
setCurrentTrackIndex(index);
setIsPlaying(true);
}}
className={`w-full p-4 text-left transition-all hover:bg-white/5 ${currentTrackIndex === index ? 'bg-white/10' : ''
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
{track.fileName.endsWith('.mp4') ? (
<VideoIcon className="w-4 h-4 text-white/40 flex-shrink-0" />
) : (
<MusicIcon className="w-4 h-4 text-white/40 flex-shrink-0" />
)}
<span className={`text-sm font-semibold truncate ${currentTrackIndex === index ? 'text-white' : 'text-white/80'
}`}>
{track.title}
</span>
</div>
<p className="text-xs text-white/50 truncate">
{track.artist}
</p>
</div>
{currentTrackIndex === index && isPlaying && (
<div className="flex items-center space-x-0.5 ml-2">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="w-0.5 bg-white"
style={{
height: '12px',
animation: `wave 0.8s infinite ease-in-out`,
animationDelay: `${i * 0.15}s`
}}
/>
))}
</div>
)}
</div>
<div className="text-xs text-white/40 font-mono">
TRACK {String(index + 1).padStart(2, '0')}
</div>
</button>
))
)}
</div>
</div>
</div>
{/* Info Box */}
<div className="mt-8 border border-white/10 p-6 bg-black">
<h3 className="text-sm font-bold tracking-wider text-white/60 mb-4">
SYSTEM INFO
</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-white/40 mb-1">FORMAT</p>
<p className="text-white/80">MP4 / AUDIO</p>
</div>
<div>
<p className="text-white/40 mb-1">QUALITY</p>
<p className="text-white/80">HIGH FIDELITY</p>
</div>
<div>
<p className="text-white/40 mb-1">TRACKS</p>
<p className="text-white/80">{tracks.length} AVAILABLE</p>
</div>
<div>
<p className="text-white/40 mb-1">STREAMING</p>
<p className="text-white/80">PROGRESSIVE LOAD</p>
</div>
</div>
</div>
</div>
{/* Animations */}
<style jsx global>{`
@keyframes pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(0.95);
opacity: 0.3;
}
50% {
transform: translate(-50%, -50%) scale(1.05);
opacity: 0.6;
}
}
@keyframes wave {
0%, 100% { height: 4px; }
50% { height: 16px; }
}
`}</style>
</div>
);
}