285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import Turntable from './components/Turntable';
|
|
import LyricsPanel from './components/LyricsPanel';
|
|
import Controls from './components/Controls';
|
|
import UploadOverlay from './components/UploadOverlay';
|
|
import { TrackData, PlaybackMode } from './types';
|
|
import { fetchPlaylist, parseLrc } from './utils/parsers';
|
|
import { Disc } from 'lucide-react';
|
|
|
|
// Use HTTP. The bucket likely does not support HTTPS.
|
|
// The proxy in utils/parsers.ts will handle the fetching securely.
|
|
const DEFAULT_PLAYLIST_URL = import.meta.env.VITE_DEFAULT_PLAYLIST_URL || "";
|
|
|
|
// Placeholder track while loading
|
|
const INITIAL_TRACK: TrackData = {
|
|
title: "Waiting for vinyl...",
|
|
artist: "Vinyl Vibes",
|
|
coverUrl: "",
|
|
audioUrl: "",
|
|
lyrics: []
|
|
};
|
|
|
|
const App: React.FC = () => {
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
|
|
// Player State
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
// Playlist State
|
|
const [playlist, setPlaylist] = useState<TrackData[]>([INITIAL_TRACK]);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [playbackMode, setPlaybackMode] = useState<PlaybackMode>('SEQUENCE');
|
|
const [playlistUrl, setPlaylistUrl] = useState(DEFAULT_PLAYLIST_URL);
|
|
const [isSingleMode, setIsSingleMode] = useState(false);
|
|
|
|
// UI State
|
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
|
|
|
// Initialize Default Playlist
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
const tracks = await fetchPlaylist(DEFAULT_PLAYLIST_URL);
|
|
if (tracks.length > 0) {
|
|
setPlaylist(tracks);
|
|
|
|
// Check for deep link track index
|
|
const params = new URLSearchParams(window.location.search);
|
|
const trackIndexParam = params.get('track');
|
|
let initialIndex = 0;
|
|
|
|
if (trackIndexParam) {
|
|
const parsedIndex = parseInt(trackIndexParam, 10);
|
|
// Validate index is within bounds (User provides 1-based index)
|
|
if (!isNaN(parsedIndex) && parsedIndex >= 1 && parsedIndex <= tracks.length) {
|
|
initialIndex = parsedIndex - 1; // Convert to 0-based
|
|
setIsSingleMode(true);
|
|
}
|
|
}
|
|
|
|
setCurrentIndex(initialIndex);
|
|
}
|
|
};
|
|
init();
|
|
}, []);
|
|
|
|
// Derived current track
|
|
const currentTrack = playlist[currentIndex] || INITIAL_TRACK;
|
|
|
|
// Effect: Handle Track Change (Load Lyrics if needed)
|
|
useEffect(() => {
|
|
const loadTrackDetails = async () => {
|
|
const track = playlist[currentIndex];
|
|
if (!track) return;
|
|
|
|
// If lyrics are missing but source exists, fetch them
|
|
if (track.lyrics.length === 0 && track.lyricsSource) {
|
|
try {
|
|
let lrcContent = "";
|
|
// Check if it's a URL
|
|
if (track.lyricsSource.startsWith('http')) {
|
|
// Try to fetch lyrics, might need proxy if CORS fails
|
|
try {
|
|
const res = await fetch(track.lyricsSource);
|
|
if (!res.ok) throw new Error("Direct lyrics fetch failed");
|
|
lrcContent = await res.text();
|
|
} catch (e) {
|
|
// Simple proxy fallback for lyrics text
|
|
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(track.lyricsSource)}`;
|
|
const res = await fetch(proxyUrl);
|
|
lrcContent = await res.text();
|
|
}
|
|
} else {
|
|
// Assume it's raw text
|
|
lrcContent = track.lyricsSource;
|
|
}
|
|
|
|
const parsedLyrics = parseLrc(lrcContent);
|
|
|
|
// Update playlist with parsed lyrics to avoid re-fetching
|
|
setPlaylist(prev => {
|
|
const newPlaylist = [...prev];
|
|
newPlaylist[currentIndex] = { ...track, lyrics: parsedLyrics };
|
|
return newPlaylist;
|
|
});
|
|
} catch (e) {
|
|
console.error("Failed to load lyrics", e);
|
|
}
|
|
}
|
|
|
|
// Reset audio if URL changed (implicitly handled by audio tag src prop, but explicit load is safer)
|
|
if (audioRef.current) {
|
|
// Only auto-play if we have a valid audio URL and it's not the initial placeholder
|
|
if (track.audioUrl) {
|
|
audioRef.current.load();
|
|
// If we were playing, keep playing. If this is the very first load, maybe wait.
|
|
// Let's auto-play if the user has interacted (isPlaying was true) OR if it's a playlist navigation.
|
|
// For simplicity, we try to play.
|
|
audioRef.current.play()
|
|
.then(() => setIsPlaying(true))
|
|
.catch(() => setIsPlaying(false)); // Autoplay might be blocked
|
|
}
|
|
}
|
|
};
|
|
|
|
loadTrackDetails();
|
|
}, [currentIndex, playlist.length]); // Depend on index and playlist length (initial load)
|
|
|
|
const togglePlay = () => {
|
|
if (audioRef.current && currentTrack.audioUrl) {
|
|
if (isPlaying) {
|
|
audioRef.current.pause();
|
|
} else {
|
|
audioRef.current.play().catch(console.error);
|
|
}
|
|
setIsPlaying(!isPlaying);
|
|
}
|
|
};
|
|
|
|
const playNext = () => {
|
|
if (isSingleMode || playlist.length <= 1) return;
|
|
|
|
let nextIndex = 0;
|
|
if (playbackMode === 'SHUFFLE') {
|
|
// Simple random excluding current if possible
|
|
do {
|
|
nextIndex = Math.floor(Math.random() * playlist.length);
|
|
} while (nextIndex === currentIndex && playlist.length > 1);
|
|
} else {
|
|
nextIndex = (currentIndex + 1) % playlist.length;
|
|
}
|
|
setCurrentIndex(nextIndex);
|
|
};
|
|
|
|
const playPrev = () => {
|
|
if (isSingleMode || playlist.length <= 1) return;
|
|
let prevIndex = currentIndex - 1;
|
|
if (prevIndex < 0) prevIndex = playlist.length - 1;
|
|
setCurrentIndex(prevIndex);
|
|
};
|
|
|
|
const handleTrackEnded = () => {
|
|
if (isSingleMode) {
|
|
setIsPlaying(false);
|
|
} else {
|
|
playNext();
|
|
}
|
|
};
|
|
|
|
const handleAudioError = (e: any) => {
|
|
console.error("Audio playback error", e);
|
|
// Fallback logic could go here, but for now we just log it.
|
|
// E.g., if HTTPS upgrade failed, maybe try proxy?
|
|
// Hard to do without state loop, so we stick to logging.
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
// Handle single local track load
|
|
const handleLocalTrackLoaded = (track: TrackData) => {
|
|
// Replace playlist with single track
|
|
setPlaylist([track]);
|
|
setCurrentIndex(0);
|
|
};
|
|
|
|
// Handle playlist load
|
|
const handlePlaylistLoaded = (tracks: TrackData[]) => {
|
|
setPlaylist(tracks);
|
|
setCurrentIndex(0);
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-full h-screen bg-neutral-950 flex flex-col overflow-hidden">
|
|
{/* Dynamic Background */}
|
|
<div
|
|
className="absolute inset-0 z-0 bg-cover bg-center transition-all duration-1000 blur-[80px] opacity-40 scale-110"
|
|
style={{ backgroundImage: `url(${currentTrack.coverUrl || INITIAL_TRACK.coverUrl})` }}
|
|
></div>
|
|
<div className="absolute inset-0 z-0 bg-neutral-950/40 backdrop-blur-sm"></div>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="relative z-10 flex-1 flex flex-col md:flex-row overflow-hidden">
|
|
|
|
{/* Left/Top: Turntable Area */}
|
|
<div className="flex-1 flex items-center justify-center p-8 md:p-12 lg:p-16 relative">
|
|
{/* Header branding */}
|
|
<div className="absolute top-6 left-6 md:top-8 md:left-8 flex items-center gap-2 text-white/80">
|
|
<Disc className="animate-spin-slow" />
|
|
<span className="font-bold tracking-widest text-sm uppercase">Vinyl Vibes</span>
|
|
</div>
|
|
|
|
<Turntable
|
|
isPlaying={isPlaying}
|
|
coverUrl={currentTrack.coverUrl || INITIAL_TRACK.coverUrl}
|
|
onToggle={togglePlay}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right/Bottom: Lyrics Area */}
|
|
<div className="flex-1 md:h-full h-[30vh] border-t md:border-t-0 md:border-l border-white/10 relative overflow-hidden group">
|
|
{/* Internal Background Image */}
|
|
{(currentTrack.coverUrl || INITIAL_TRACK.coverUrl) && (
|
|
<img
|
|
src={currentTrack.coverUrl || INITIAL_TRACK.coverUrl}
|
|
alt="Background"
|
|
className="absolute inset-0 w-full h-full object-cover blur-2xl opacity-80 scale-110 transition-all duration-1000 group-hover:scale-105"
|
|
/>
|
|
)}
|
|
{/* Lighter overlay to let more color through */}
|
|
<div className="absolute inset-0 bg-neutral-900/30 z-0"></div>
|
|
|
|
<div className="absolute inset-0 z-10">
|
|
<LyricsPanel lyrics={currentTrack.lyrics} currentTime={currentTime} />
|
|
</div>
|
|
|
|
{/* Gradient masks for lyrics fade effect - adjusted to be more subtle */}
|
|
<div className="absolute top-0 left-0 right-0 h-12 md:h-32 bg-gradient-to-b from-neutral-950/80 to-transparent pointer-events-none z-20"></div>
|
|
<div className="absolute bottom-0 left-0 right-0 h-12 md:h-32 bg-gradient-to-t from-neutral-950/80 to-transparent pointer-events-none z-20"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<Controls
|
|
isPlaying={isPlaying}
|
|
currentTime={currentTime}
|
|
duration={duration}
|
|
onPlayPause={togglePlay}
|
|
onSeek={(time) => {
|
|
if (audioRef.current) {
|
|
audioRef.current.currentTime = time;
|
|
setCurrentTime(time);
|
|
}
|
|
}}
|
|
onNext={playNext}
|
|
onPrev={playPrev}
|
|
title={currentTrack.title}
|
|
artist={currentTrack.artist}
|
|
onUploadClick={() => setIsUploadOpen(true)}
|
|
playbackMode={playbackMode}
|
|
onToggleMode={() => setPlaybackMode(prev => prev === 'SEQUENCE' ? 'SHUFFLE' : 'SEQUENCE')}
|
|
/>
|
|
|
|
{/* Hidden Audio Element */}
|
|
<audio
|
|
ref={audioRef}
|
|
src={currentTrack.audioUrl || undefined}
|
|
onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)}
|
|
onLoadedMetadata={() => audioRef.current && setDuration(audioRef.current.duration)}
|
|
onEnded={handleTrackEnded}
|
|
onError={handleAudioError}
|
|
/>
|
|
|
|
{/* Upload/Settings Modal */}
|
|
<UploadOverlay
|
|
isOpen={isUploadOpen}
|
|
onClose={() => setIsUploadOpen(false)}
|
|
onTrackLoaded={handleLocalTrackLoaded}
|
|
onPlaylistLoaded={handlePlaylistLoaded}
|
|
currentPlaylistUrl={playlistUrl}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App; |