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: [
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/layout.tsx
101
app/layout.tsx
@ -7,64 +7,69 @@ import InteractiveTopo from "@/components/interactiveTopo";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "OpenWheels Racing",
|
title: "OpenWheels Racing",
|
||||||
description: "Free racing community - Live dashboard and rankings",
|
description: "Free racing community - Live dashboard and rankings",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex flex-col relative">
|
<div className="min-h-screen bg-[#0a0a0a] flex flex-col relative">
|
||||||
{/* Interactive topology effect */}
|
{/* Interactive topology effect */}
|
||||||
<InteractiveTopo />
|
<InteractiveTopo />
|
||||||
|
|
||||||
{/* Static topology background */}
|
{/* Static topology background */}
|
||||||
<div className="fixed inset-0 topo-lines pointer-events-none z-0" />
|
<div className="fixed inset-0 topo-lines pointer-events-none z-0" />
|
||||||
|
|
||||||
{/* Content wrapper */}
|
{/* Content wrapper */}
|
||||||
<div className="relative z-10 flex flex-col min-h-screen">
|
<div className="relative z-10 flex flex-col min-h-screen">
|
||||||
{/* Navbar - appears on all pages */}
|
{/* Navbar - appears on all pages */}
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer - appears on all pages */}
|
{/* Footer - appears on all pages */}
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-white/10 mt-20">
|
<footer className="border-t border-white/10 mt-20">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
<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 justify-between text-sm">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<span className="text-white/40 tracking-wider">
|
<span className="text-white/40 tracking-wider">
|
||||||
© 2025 OPENWHEELS.RACING
|
© 2025 OPENWHEELS.RACING
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-6 text-white/40">
|
<div className="flex items-center space-x-6 text-white/40">
|
||||||
<a href="https://discord.gg/nvuB8EvT9P" className="hover:text-white transition-colors">
|
<a href="https://openwheels.racing" className="hover:text-white transition-colors">
|
||||||
DISCORD
|
WEBSITE
|
||||||
</a>
|
</a>
|
||||||
<a href="https://openwheels.racing" className="hover:text-white transition-colors">
|
<a
|
||||||
WEBSITE
|
href="https://discord.gg/nvuB8EvT9P"
|
||||||
</a>
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
</div>
|
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"
|
||||||
</div>
|
>
|
||||||
</footer>
|
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 (
|
return (
|
||||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
<h1 style={{ fontSize: '42px' }}>Music Stream</h1>
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
<p>This is still a work in progress. Stay tuned for updates!</p>
|
</svg>
|
||||||
<button style={{ marginTop: '20px', padding: '10px 20px', fontSize: '16px' }}>
|
);
|
||||||
<a href="https://discord.com/channels/1375797466066325596/1461402729661464576">Click here to find more!</a>
|
}
|
||||||
</button>
|
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user