feat: MUSIC PLAYER!
This commit is contained in:
parent
19a0fb4306
commit
5725623e5f
73
app/api/music/tracks/route.ts
Normal file
73
app/api/music/tracks/route.ts
Normal file
@ -0,0 +1,73 @@
|
||||
// app/api/music/tracks/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { readdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface Track {
|
||||
title: string;
|
||||
fileName: string;
|
||||
artist: string;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
function parseFileName(fileName: string): Track {
|
||||
// Remove file extension
|
||||
const nameWithoutExt = fileName.replace(/\.(mp4|mp3|wav|ogg)$/i, '');
|
||||
|
||||
// Parse format: [music_name]_[theme/genre].[format]
|
||||
const parts = nameWithoutExt.split('_');
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const theme = parts[parts.length - 1].toLowerCase();
|
||||
const title = parts.slice(0, -1).join(' ');
|
||||
|
||||
return {
|
||||
title: title.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||
fileName: fileName,
|
||||
artist: theme.charAt(0).toUpperCase() + theme.slice(1) + ' Collection',
|
||||
theme: theme
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for files that don't follow the naming convention
|
||||
return {
|
||||
title: nameWithoutExt.replace(/-|_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||
fileName: fileName,
|
||||
artist: 'Unknown Artist',
|
||||
theme: 'general'
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to your music directory
|
||||
const musicDir = join(process.cwd(), 'public', 'files', 'music');
|
||||
|
||||
// Read all files in the directory
|
||||
const files = await readdir(musicDir);
|
||||
|
||||
// Filter for audio/video files and parse them
|
||||
const tracks = files
|
||||
.filter(file => /\.(mp4|mp3|wav|ogg)$/i.test(file))
|
||||
.map(file => parseFileName(file))
|
||||
.sort((a, b) => {
|
||||
// Sort by theme first, then by title
|
||||
if (a.theme !== b.theme) {
|
||||
return a.theme.localeCompare(b.theme);
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
return NextResponse.json({ tracks });
|
||||
} catch (error) {
|
||||
console.error('Error reading music directory:', error);
|
||||
|
||||
// Return fallback tracks if directory reading fails
|
||||
return NextResponse.json({
|
||||
tracks: [
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
103
app/layout.tsx
103
app/layout.tsx
@ -7,64 +7,69 @@ import InteractiveTopo from "@/components/interactiveTopo";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenWheels Racing",
|
||||
description: "Free racing community - Live dashboard and rankings",
|
||||
title: "OpenWheels Racing",
|
||||
description: "Free racing community - Live dashboard and rankings",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex flex-col relative">
|
||||
{/* Interactive topology effect */}
|
||||
<InteractiveTopo />
|
||||
|
||||
{/* Static topology background */}
|
||||
<div className="fixed inset-0 topo-lines pointer-events-none z-0" />
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex flex-col relative">
|
||||
{/* Interactive topology effect */}
|
||||
<InteractiveTopo />
|
||||
|
||||
{/* Content wrapper */}
|
||||
<div className="relative z-10 flex flex-col min-h-screen">
|
||||
{/* Navbar - appears on all pages */}
|
||||
<Navbar />
|
||||
{/* Static topology background */}
|
||||
<div className="fixed inset-0 topo-lines pointer-events-none z-0" />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
{/* Content wrapper */}
|
||||
<div className="relative z-10 flex flex-col min-h-screen">
|
||||
{/* Navbar - appears on all pages */}
|
||||
<Navbar />
|
||||
|
||||
{/* Footer - appears on all pages */}
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
{/* Page content */}
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Footer - appears on all pages */}
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-white/10 mt-20">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-white/40 tracking-wider">
|
||||
© 2025 OPENWHEELS.RACING
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 text-white/40">
|
||||
<a href="https://discord.gg/nvuB8EvT9P" className="hover:text-white transition-colors">
|
||||
DISCORD
|
||||
</a>
|
||||
<a href="https://openwheels.racing" className="hover:text-white transition-colors">
|
||||
WEBSITE
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
return (
|
||||
<footer className="border-t border-white/10 mt-20">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-white/40 tracking-wider">
|
||||
© 2025 OPENWHEELS.RACING
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 text-white/40">
|
||||
<a href="https://openwheels.racing" className="hover:text-white transition-colors">
|
||||
WEBSITE
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/nvuB8EvT9P"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block px-8 py-3 border border-white/20 hover:border-white hover:bg-white/5 transition-all text-sm font-medium tracking-wider"
|
||||
>
|
||||
JOIN OUR DISCORD
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer >
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,581 @@
|
||||
export default function MusicPage() {
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
// Configuration
|
||||
const BASE_URL = "https://openwheels.racing/files/music/";
|
||||
|
||||
// Icons
|
||||
function PlayIcon({ className = "w-6 h-6" }) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: '42px' }}>Music Stream</h1>
|
||||
<p>This is still a work in progress. Stay tuned for updates!</p>
|
||||
<button style={{ marginTop: '20px', padding: '10px 20px', fontSize: '16px' }}>
|
||||
<a href="https://discord.com/channels/1375797466066325596/1461402729661464576">Click here to find more!</a>
|
||||
</button>
|
||||
<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" }) {
|
||||
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" }) {
|
||||
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" }) {
|
||||
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" }) {
|
||||
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" }) {
|
||||
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" }) {
|
||||
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([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(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(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) {
|
||||
console.error('Error fetching tracks:', err);
|
||||
setError(err.message);
|
||||
// Fallback to hardcoded tracks if API fails
|
||||
setTracks([
|
||||
{ title: "Alone Again", fileName: "AloneAgain.mp4", artist: "Unknown Artist", theme: "general" },
|
||||
{ title: "House Music VOL1", fileName: "deephousetherapy.mp4", artist: "Deep House Therapy", theme: "house" },
|
||||
{ title: "House Music VOL2", fileName: "videoplayback.mp4", artist: "Various Artists", theme: "house" },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTracks();
|
||||
}, []);
|
||||
|
||||
// Group tracks by theme
|
||||
const tracksByTheme = tracks.reduce((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]);
|
||||
|
||||
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) => {
|
||||
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) => {
|
||||
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) => {
|
||||
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}
|
||||
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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user