nex_music/App.tsx

290 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 (http or relative/absolute local path)
// We treat anything starting with http, /, or ./ as a URL to fetch
if (track.lyricsSource.startsWith('http') || track.lyricsSource.startsWith('/') || track.lyricsSource.startsWith('./')) {
// 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) {
// Only try proxy if it's a remote HTTP URL
if (track.lyricsSource.startsWith('http')) {
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(track.lyricsSource)}`;
const res = await fetch(proxyUrl);
lrcContent = await res.text();
} else {
throw e;
}
}
} 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;