feat: MUSIC PLAYER!

This commit is contained in:
AfonsoCMSousa 2026-01-16 16:21:28 +00:00
parent 19a0fb4306
commit 5725623e5f
3 changed files with 704 additions and 56 deletions

View 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: [
]
});
}
}

View File

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

View File

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