301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import React, { useState, ChangeEvent, useEffect } from 'react';
|
|
import { Upload, Music, FileText, Image as ImageIcon, X, Link, ListMusic } from 'lucide-react';
|
|
import { TrackData } from '../types';
|
|
import { parseLrc, fetchPlaylist } from '../utils/parsers';
|
|
|
|
interface UploadOverlayProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onTrackLoaded: (track: TrackData) => void;
|
|
onPlaylistLoaded: (playlist: TrackData[]) => void;
|
|
currentPlaylistUrl: string;
|
|
}
|
|
|
|
const UploadOverlay: React.FC<UploadOverlayProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
onTrackLoaded,
|
|
onPlaylistLoaded,
|
|
currentPlaylistUrl
|
|
}) => {
|
|
// Playlist State
|
|
const [playlistUrl, setPlaylistUrl] = useState(currentPlaylistUrl);
|
|
const [playlistLoading, setPlaylistLoading] = useState(false);
|
|
|
|
// Local Upload State
|
|
const [audioFile, setAudioFile] = useState<File | null>(null);
|
|
const [coverFile, setCoverFile] = useState<File | null>(null);
|
|
const [lrcFile, setLrcFile] = useState<File | null>(null);
|
|
const [title, setTitle] = useState('Unknown Track');
|
|
const [artist, setArtist] = useState('Unknown Artist');
|
|
const [localLoading, setLocalLoading] = useState(false);
|
|
|
|
// Tab state
|
|
const [activeTab, setActiveTab] = useState<'playlist' | 'local'>('playlist');
|
|
|
|
useEffect(() => {
|
|
setPlaylistUrl(currentPlaylistUrl);
|
|
}, [currentPlaylistUrl]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>, setter: (f: File | null) => void) => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
const file = e.target.files[0];
|
|
setter(file);
|
|
|
|
// Auto-parse filename if it's the audio file
|
|
if (setter === setAudioFile) {
|
|
// Remove extension
|
|
const filename = file.name.replace(/\.[^/.]+$/, "");
|
|
|
|
// Try parsing "Artist - Title" format
|
|
const parts = filename.split(' - ');
|
|
if (parts.length >= 2) {
|
|
setArtist(parts[1].trim());
|
|
setTitle(parts.slice(0).join(' - ').trim());
|
|
} else {
|
|
setArtist("Unknown Artist");
|
|
setTitle(filename);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleLoadPlaylist = async () => {
|
|
if (!playlistUrl) return;
|
|
setPlaylistLoading(true);
|
|
try {
|
|
const tracks = await fetchPlaylist(playlistUrl);
|
|
if (tracks.length > 0) {
|
|
onPlaylistLoaded(tracks);
|
|
onClose();
|
|
} else {
|
|
alert("No tracks found in playlist or invalid format.");
|
|
}
|
|
} catch (e) {
|
|
alert("Failed to load playlist.");
|
|
} finally {
|
|
setPlaylistLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLocalLoad = async () => {
|
|
if (!audioFile) return;
|
|
setLocalLoading(true);
|
|
|
|
try {
|
|
const audioUrl = URL.createObjectURL(audioFile);
|
|
const coverUrl = coverFile
|
|
? URL.createObjectURL(coverFile)
|
|
: 'https://picsum.photos/400/400'; // Fallback
|
|
|
|
let lyrics: any[] = [];
|
|
if (lrcFile) {
|
|
const text = await lrcFile.text();
|
|
lyrics = parseLrc(text);
|
|
}
|
|
|
|
const track: TrackData = {
|
|
title: title || audioFile.name.replace(/\.[^/.]+$/, ""),
|
|
artist: artist || "Unknown Artist",
|
|
audioUrl,
|
|
coverUrl,
|
|
lyrics
|
|
};
|
|
|
|
onTrackLoaded(track);
|
|
onClose();
|
|
} catch (error) {
|
|
console.error("Error loading track", error);
|
|
} finally {
|
|
setLocalLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
|
<div className="bg-neutral-900 border border-neutral-700 rounded-2xl p-6 w-full max-w-lg shadow-2xl relative max-h-[90vh] overflow-y-auto">
|
|
<button
|
|
onClick={onClose}
|
|
className="absolute top-4 right-4 text-neutral-400 hover:text-white"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
|
|
<h2 className="text-2xl font-bold mb-6 text-white flex items-center gap-2">
|
|
<Music className="text-amber-500" /> Library & Source
|
|
</h2>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-4 mb-6 border-b border-neutral-700 pb-2">
|
|
<button
|
|
onClick={() => setActiveTab('playlist')}
|
|
className={`text-sm font-bold pb-2 transition-colors ${activeTab === 'playlist' ? 'text-white border-b-2 border-amber-500' : 'text-neutral-500 hover:text-neutral-300'}`}
|
|
>
|
|
Remote Playlist
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('local')}
|
|
className={`text-sm font-bold pb-2 transition-colors ${activeTab === 'local' ? 'text-white border-b-2 border-amber-500' : 'text-neutral-500 hover:text-neutral-300'}`}
|
|
>
|
|
Local File (Single)
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'playlist' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-400 mb-2">Playlist JSON URL</label>
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Link size={16} className="absolute left-3 top-3 text-neutral-500" />
|
|
<input
|
|
type="text"
|
|
value={playlistUrl}
|
|
onChange={(e) => setPlaylistUrl(e.target.value)}
|
|
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2.5 text-sm text-white focus:outline-none focus:border-amber-500"
|
|
placeholder="https://example.com/playlist.json"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-[10px] text-neutral-500 mt-2">
|
|
Supported format: JSON array with Title, Artist, Audio, Cover, Lyrics fields.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleLoadPlaylist}
|
|
disabled={playlistLoading || !playlistUrl}
|
|
className={`w-full mt-4 py-3 rounded-lg font-bold text-black transition-all ${
|
|
playlistLoading || !playlistUrl
|
|
? 'bg-neutral-700 cursor-not-allowed text-neutral-500'
|
|
: 'bg-amber-500 hover:bg-amber-400 shadow-[0_0_15px_rgba(245,158,11,0.4)]'
|
|
}`}
|
|
>
|
|
{playlistLoading ? 'Fetching Playlist...' : 'Load Playlist'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'local' && (
|
|
<div className="space-y-4">
|
|
{/* Metadata Inputs */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-400 mb-1">Title</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-amber-500"
|
|
placeholder="Song Title"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-400 mb-1">Artist</label>
|
|
<input
|
|
type="text"
|
|
value={artist}
|
|
onChange={(e) => setArtist(e.target.value)}
|
|
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-amber-500"
|
|
placeholder="Artist Name"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* File Inputs */}
|
|
<div className="space-y-3">
|
|
<div className="bg-neutral-800/50 border border-neutral-700 border-dashed rounded-lg p-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded bg-blue-500/20 flex items-center justify-center text-blue-400">
|
|
<Music size={16} />
|
|
</div>
|
|
<span className="text-sm text-neutral-300 truncate max-w-[150px]">
|
|
{audioFile ? audioFile.name : 'Select Audio (MP3/WAV)'}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
accept="audio/*"
|
|
className="hidden"
|
|
id="audio-upload"
|
|
onChange={(e) => handleFileChange(e, setAudioFile)}
|
|
/>
|
|
<label
|
|
htmlFor="audio-upload"
|
|
className="text-xs bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded cursor-pointer transition-colors"
|
|
>
|
|
Browse
|
|
</label>
|
|
</div>
|
|
|
|
<div className="bg-neutral-800/50 border border-neutral-700 border-dashed rounded-lg p-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded bg-purple-500/20 flex items-center justify-center text-purple-400">
|
|
<ImageIcon size={16} />
|
|
</div>
|
|
<span className="text-sm text-neutral-300 truncate max-w-[150px]">
|
|
{coverFile ? coverFile.name : 'Select Cover (Img)'}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
id="cover-upload"
|
|
onChange={(e) => handleFileChange(e, setCoverFile)}
|
|
/>
|
|
<label
|
|
htmlFor="cover-upload"
|
|
className="text-xs bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded cursor-pointer transition-colors"
|
|
>
|
|
Browse
|
|
</label>
|
|
</div>
|
|
|
|
<div className="bg-neutral-800/50 border border-neutral-700 border-dashed rounded-lg p-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded bg-green-500/20 flex items-center justify-center text-green-400">
|
|
<FileText size={16} />
|
|
</div>
|
|
<span className="text-sm text-neutral-300 truncate max-w-[150px]">
|
|
{lrcFile ? lrcFile.name : 'Select Lyrics (.lrc)'}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
accept=".lrc,.txt"
|
|
className="hidden"
|
|
id="lrc-upload"
|
|
onChange={(e) => handleFileChange(e, setLrcFile)}
|
|
/>
|
|
<label
|
|
htmlFor="lrc-upload"
|
|
className="text-xs bg-neutral-700 hover:bg-neutral-600 px-3 py-1.5 rounded cursor-pointer transition-colors"
|
|
>
|
|
Browse
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleLocalLoad}
|
|
disabled={!audioFile || localLoading}
|
|
className={`w-full mt-6 py-3 rounded-lg font-bold text-black transition-all ${
|
|
!audioFile
|
|
? 'bg-neutral-700 cursor-not-allowed text-neutral-500'
|
|
: 'bg-amber-500 hover:bg-amber-400 shadow-[0_0_15px_rgba(245,158,11,0.4)]'
|
|
}`}
|
|
>
|
|
{localLoading ? 'Processing...' : 'Load Into Player'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UploadOverlay; |