Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useState, useEffect, useCallback, Suspense, useRef } from "react"; | |
| import { useUser, useClerk } from "@clerk/nextjs"; | |
| import { useSearchParams } from "next/navigation"; | |
| import { UploadCloud, Music, AudioLines, Settings2, ShieldCheck, Zap, Lock, Sliders, Activity, Mic2, Search, LogOut, History, Copy, Check, FileMusic, AlignLeft, Users, CheckCircle2, XCircle, List, Plus } from 'lucide-react'; | |
| import { db } from "../lib/firebase"; | |
| import { collection, addDoc, getDocs, query, where, deleteDoc, doc, serverTimestamp, orderBy } from "firebase/firestore"; | |
| import ExtractionFlowDiagram from "../components/ExtractionFlowDiagram"; | |
| import SmartCompareStudio from "../components/SmartCompareStudio"; | |
| import QueueWaitingRoom from "../components/QueueWaitingRoom"; | |
| import SyncedTranscriptPlayer from "../components/SyncedTranscriptPlayer"; | |
| import RawTranscriptViewer from "../components/RawTranscriptViewer"; | |
| import FAQAccordion from "../components/FAQAccordion"; | |
| import { faqs } from "../data/faqs"; | |
| import Link from "next/link"; | |
| function HomeContent() { | |
| const [file, setFile] = useState<File | null>(null); | |
| const [loading, setLoading] = useState<boolean>(false); | |
| const [progress, setProgress] = useState({ step: "", percent: 0, message: "", chunks_total: 0, chunks_completed: 0, chunks_pending: 0, start_time: 0, eta_seconds: 0, completed_time: 0, queue_position: 0 }); | |
| const [resultZip, setResultZip] = useState<string | null>(null); | |
| const [taskId, setTaskId] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const vocalsAudioRef = useRef<HTMLAudioElement>(null); | |
| const { user, isLoaded, isSignedIn } = useUser(); | |
| const { openSignIn } = useClerk(); | |
| const searchParams = useSearchParams(); | |
| const [userEmail, setUserEmail] = useState<string | null>(null); | |
| const [loginInput, setLoginInput] = useState(""); | |
| const [searchTaskId, setSearchTaskId] = useState(""); | |
| const baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000"; | |
| const eventSourceRef = useRef<EventSource | null>(null); | |
| const pollProgress = useCallback((id: string) => { | |
| if (eventSourceRef.current) { | |
| eventSourceRef.current.close(); | |
| } | |
| const eventSource = new EventSource(`${baseUrl}/api/events/status/${id}`); | |
| eventSourceRef.current = eventSource; | |
| eventSource.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| if (data.error) { | |
| eventSource.close(); | |
| setError(data.error === "Task not found" ? "This task is no longer available. The server restarts periodically to free up resources, which clears old files. Please upload your audio again." : data.error); | |
| setLoading(false); | |
| setTaskId(null); | |
| window.history.replaceState({}, '', '/'); | |
| return; | |
| } | |
| if (data.status === "processing" || data.status === "pending" || data.status === "queued") { | |
| setProgress({ | |
| step: data.step || (data.status === "queued" ? "Queued" : "Processing..."), | |
| percent: data.progress || 5, | |
| message: data.message || "Working...", | |
| chunks_total: data.chunks_total || 0, | |
| chunks_completed: data.chunks_completed || 0, | |
| chunks_pending: data.chunks_pending || 0, | |
| start_time: data.start_time || 0, | |
| eta_seconds: data.eta_seconds || 0, | |
| completed_time: data.completed_time || 0, | |
| queue_position: data.queue_position || 0, | |
| status: data.status | |
| } as any); | |
| } else if (data.status === "completed") { | |
| eventSource.close(); | |
| setResultZip(data.result_path || true); | |
| setLoading(false); | |
| setProgress({ step: "Complete", percent: 100, message: "Ready to download!", chunks_total: 0, chunks_completed: 0, chunks_pending: 0, start_time: 0, eta_seconds: 0, completed_time: 0, queue_position: 0 }); | |
| } else if (data.status === "failed") { | |
| eventSource.close(); | |
| setError(data.message || data.error || "An unknown error occurred on the server."); | |
| setLoading(false); | |
| } else if (data.status === "cancelled" || data.status === "expired") { | |
| eventSource.close(); | |
| setError(data.status === "cancelled" ? "Processing was cancelled." : "This task is no longer available. The server restarts periodically to free up resources, which clears old files. Please upload your audio again."); | |
| setLoading(false); | |
| setTaskId(null); | |
| window.history.replaceState({}, '', '/'); | |
| } | |
| }; | |
| eventSource.onerror = () => { | |
| // SSE auto-reconnects on network drops. | |
| // If the backend strictly sends data.error on 404, we handle it above. | |
| }; | |
| }, [baseUrl]); | |
| // Clean up SSE on component unmount | |
| useEffect(() => { | |
| return () => { | |
| if (eventSourceRef.current) { | |
| eventSourceRef.current.close(); | |
| } | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| const queryTaskId = searchParams.get('taskId'); | |
| if (queryTaskId && !taskId && !loading && !resultZip) { | |
| setTimeout(() => { | |
| setTaskId(queryTaskId); | |
| setLoading(true); | |
| setProgress({ step: "Looking up task...", percent: 10, message: "Connecting to server...", chunks_total: 0, chunks_completed: 0, chunks_pending: 0, start_time: 0, eta_seconds: 0, completed_time: 0, queue_position: 0 }); | |
| pollProgress(queryTaskId); | |
| }, 0); | |
| } | |
| }, [searchParams, taskId, loading, resultZip, pollProgress]); | |
| useEffect(() => { | |
| const savedEmail = localStorage.getItem("user_email"); | |
| if (savedEmail) { | |
| setTimeout(() => setUserEmail(savedEmail), 0); | |
| } | |
| }, []); | |
| const selectTask = (task: any) => { | |
| setTaskId(task.task_id); | |
| if (task.status === "completed") { | |
| if (eventSourceRef.current) eventSourceRef.current.close(); | |
| setResultZip(task.result_path || true); | |
| setLoading(false); | |
| setProgress({ step: "Complete", percent: 100, message: "Ready to download!", chunks_total: 0, chunks_completed: 0, chunks_pending: 0, start_time: 0, eta_seconds: 0, completed_time: 0, queue_position: 0 } as any); | |
| setError(null); | |
| } else if (task.status === "failed" || task.status === "cancelled") { | |
| if (eventSourceRef.current) eventSourceRef.current.close(); | |
| setError(task.message || "Task failed or cancelled."); | |
| setResultZip(null); | |
| setLoading(false); | |
| } else { | |
| setResultZip(null); | |
| setError(null); | |
| setLoading(true); | |
| pollProgress(task.task_id); | |
| } | |
| }; | |
| const startNewUpload = () => { | |
| if (eventSourceRef.current) eventSourceRef.current.close(); | |
| setFile(null); | |
| setTaskId(null); | |
| setResultZip(null); | |
| setError(null); | |
| setLoading(false); | |
| setProgress({ step: "", percent: 0, message: "", chunks_total: 0, chunks_completed: 0, chunks_pending: 0, start_time: 0, eta_seconds: 0, completed_time: 0, queue_position: 0 } as any); | |
| window.history.replaceState({}, '', '/'); | |
| }; | |
| // Feature Toggles | |
| const [isolateVocals, setIsolateVocals] = useState<boolean>(false); | |
| const [enhance, setEnhance] = useState<boolean>(false); | |
| const [lyricSync, setLyricSync] = useState<boolean>(false); | |
| // Download Customization Options | |
| const [dlFormat, setDlFormat] = useState<string>("mp3"); | |
| const [dlChunked, setDlChunked] = useState<boolean>(true); | |
| const [dlFolderName, setDlFolderName] = useState<string>("My_Song_Stems"); | |
| const [dlStems, setDlStems] = useState<Record<string, boolean>>({ | |
| vocals: true | |
| }); | |
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files[0]) { | |
| setFile(e.target.files[0]); | |
| } | |
| }; | |
| const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { | |
| e.preventDefault(); | |
| if (e.dataTransfer.files && e.dataTransfer.files[0]) { | |
| setFile(e.dataTransfer.files[0]); | |
| } | |
| }; | |
| const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { | |
| e.preventDefault(); | |
| }; | |
| const handleUpload = async () => { | |
| if (!file) return; | |
| if (!isSignedIn) { | |
| openSignIn(); | |
| return; | |
| } | |
| setLoading(true); | |
| setError(null); | |
| setResultZip(null); | |
| setProgress({ step: "Uploading...", percent: 5, message: "Sending file to cloud...", chunks_total: 0, chunks_completed: 0, chunks_pending: 0, start_time: 0, eta_seconds: 0, completed_time: 0, queue_position: 0 }); | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| formData.append("isolate_vocals", isolateVocals.toString()); | |
| formData.append("enhance_speech", enhance.toString()); | |
| formData.append("lyric_sync", lyricSync.toString()); | |
| formData.append("email", user?.primaryEmailAddress?.emailAddress || ""); | |
| try { | |
| const data = await new Promise<any>((resolve, reject) => { | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open("POST", `${baseUrl}/api/process`, true); | |
| // Track real-time upload progress | |
| xhr.upload.onprogress = (event) => { | |
| if (event.lengthComputable) { | |
| const uploadPercent = Math.round((event.loaded / event.total) * 100); | |
| // Cap the UI progress bar at 10% during upload phase | |
| const uiPercent = Math.max(1, Math.min(10, Math.floor(uploadPercent / 10))); | |
| setProgress({ | |
| step: "Uploading...", | |
| percent: uiPercent, | |
| message: `Sending file to cloud... ${uploadPercent}%`, | |
| chunks_total: 0, chunks_completed: 0, chunks_pending: 0, start_time: 0, eta_seconds: 0, completed_time: 0, queue_position: 0 | |
| }); | |
| } | |
| }; | |
| xhr.onload = () => { | |
| if (xhr.status >= 200 && xhr.status < 300) { | |
| try { | |
| resolve(JSON.parse(xhr.responseText)); | |
| } catch (e) { | |
| reject(new Error("Invalid response from server")); | |
| } | |
| } else { | |
| // Check if it's a payload too large error (HTTP 413) | |
| if (xhr.status === 413) { | |
| reject(new Error("File is too large! The Hugging Face free tier cannot accept files this big.")); | |
| } else { | |
| reject(new Error("Failed to process file")); | |
| } | |
| } | |
| }; | |
| xhr.onerror = () => reject(new Error("Network error occurred during upload. Check your internet connection.")); | |
| xhr.send(formData); | |
| }); | |
| setTaskId(data.task_id); | |
| // Save to Firebase History | |
| if (user?.primaryEmailAddress?.emailAddress) { | |
| try { | |
| await addDoc(collection(db, "extractions"), { | |
| taskId: data.task_id, | |
| email: user.primaryEmailAddress.emailAddress, | |
| filename: file.name, | |
| createdAt: serverTimestamp(), | |
| options: { | |
| isolateVocals, | |
| enhance, | |
| lyricSync | |
| } | |
| }); | |
| } catch (fbErr) { | |
| console.error("Failed to save to Firebase history:", fbErr); | |
| } | |
| } | |
| pollProgress(data.task_id); | |
| } catch (err: any) { | |
| setError(err.message || "An error occurred"); | |
| setLoading(false); | |
| } | |
| }; | |
| // pollProgress moved to top to satisfy ESLint | |
| return ( | |
| <div className="min-h-screen pb-20"> | |
| {/* Massive Hero Section */} | |
| <div className="pt-16 pb-10 px-4"> | |
| <div className="max-w-4xl mx-auto text-center"> | |
| <h1 className="text-3xl md:text-5xl lg:text-6xl font-bold text-white tracking-tight mb-4"> | |
| Extract <span className="text-[#1877F2]">Vocals & Stems</span> instantly. | |
| </h1> | |
| <p className="text-lg md:text-xl text-gray-400 mb-8 max-w-2xl mx-auto"> | |
| The professional AI vocal remover and music source separation engine. | |
| </p> | |
| </div> | |
| </div> | |
| {/* Main Split-Screen Dashboard UI */} | |
| <div className="max-w-7xl mx-auto px-4"> | |
| <div className="grid grid-cols-1 lg:grid-cols-12 gap-6 relative"> | |
| {/* Subtle global background glow behind the entire dashboard */} | |
| <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-[1000px] aspect-square bg-[#1877F2]/5 rounded-full blur-[100px] pointer-events-none z-0" /> | |
| {/* LEFT SIDEBAR: TOOLS PANEL */} | |
| <div className="lg:col-span-4 bg-[#111] border border-[#27272a] rounded-3xl p-6 shadow-2xl relative z-10 flex flex-col h-full"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <h2 className="text-xl font-bold text-white flex items-center gap-3"> | |
| <Settings2 className="w-6 h-6 text-[#1877F2]" /> Extraction Tools | |
| </h2> | |
| </div> | |
| {/* Basic Extraction Tools */} | |
| <div className="space-y-4"> | |
| <button | |
| onClick={() => setIsolateVocals(!isolateVocals)} | |
| className={`w-full flex items-center justify-between p-4 rounded-xl border transition-all text-left ${ | |
| isolateVocals | |
| ? 'bg-[#1877F2]/10 border-[#1877F2] shadow-[0_0_15px_rgba(24,119,242,0.15)]' | |
| : 'bg-[#050505] border-[#27272a] hover:border-gray-500' | |
| }`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isolateVocals ? 'bg-[#1877F2] text-white' : 'bg-[#18191A] text-gray-400'}`}> | |
| <Mic2 className="w-5 h-5" /> | |
| </div> | |
| <div> | |
| <h3 className={`font-bold ${isolateVocals ? 'text-white' : 'text-gray-300'}`}>Isolate Vocals</h3> | |
| <p className="text-xs text-gray-500">Extract clean acapellas</p> | |
| </div> | |
| </div> | |
| <div className={`w-12 h-6 rounded-full p-1 transition-colors ${isolateVocals ? 'bg-[#1877F2]' : 'bg-[#27272a]'}`}> | |
| <div className={`w-4 h-4 bg-white rounded-full transition-transform ${isolateVocals ? 'translate-x-6' : 'translate-x-0'}`} /> | |
| </div> | |
| </button> | |
| <button | |
| onClick={() => setEnhance(!enhance)} | |
| className={`w-full flex items-center justify-between p-4 rounded-xl border transition-all text-left ${ | |
| enhance | |
| ? 'bg-purple-500/10 border-purple-500 shadow-[0_0_15px_rgba(168,85,247,0.15)]' | |
| : 'bg-[#050505] border-[#27272a] hover:border-gray-500' | |
| }`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div className={`w-10 h-10 rounded-lg flex items-center justify-center ${enhance ? 'bg-purple-500 text-white' : 'bg-[#18191A] text-gray-400'}`}> | |
| <Activity className="w-5 h-5" /> | |
| </div> | |
| <div> | |
| <h3 className={`font-bold ${enhance ? 'text-white' : 'text-gray-300'}`}>Studio Denoise</h3> | |
| <p className="text-xs text-gray-500">Clean up background noise</p> | |
| </div> | |
| </div> | |
| {/* Custom Toggle Switch UI */} | |
| <div className={`w-12 h-6 rounded-full p-1 transition-colors ${enhance ? 'bg-purple-500' : 'bg-[#27272a]'}`}> | |
| <div className={`w-4 h-4 bg-white rounded-full transition-transform ${enhance ? 'translate-x-6' : 'translate-x-0'}`} /> | |
| </div> | |
| </button> | |
| </div> | |
| <hr className="border-[#27272a] my-8" /> | |
| {/* Pro / Coming Soon Tools */} | |
| {/* Pro / Coming Soon Tools */} | |
| <div className="mb-4 flex items-center justify-between"> | |
| <h2 className="text-xs font-bold text-gray-500 uppercase tracking-widest">Advanced / Pro Features</h2> | |
| </div> | |
| <div className="space-y-3"> | |
| <button | |
| onClick={() => setLyricSync(!lyricSync)} | |
| className={`w-full flex items-center justify-between p-4 rounded-xl border transition-all text-left relative overflow-hidden group ${ | |
| lyricSync | |
| ? 'bg-emerald-500/10 border-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.15)]' | |
| : 'bg-[#050505] border-[#27272a] hover:border-gray-500' | |
| }`} | |
| > | |
| <div className={`absolute top-0 left-0 w-1 h-full ${lyricSync ? 'bg-emerald-500' : 'bg-transparent'}`}></div> | |
| <div className="flex items-center gap-3"> | |
| <AlignLeft className={`w-5 h-5 ${lyricSync ? 'text-emerald-500' : 'text-gray-500'}`} /> | |
| <div className="flex flex-col"> | |
| <span className={`font-bold ${lyricSync ? 'text-white' : 'text-gray-300'}`}>Whisper Lyric Sync <span className="ml-2 text-[9px] bg-emerald-500/20 text-emerald-500 px-2 py-0.5 rounded font-bold uppercase">Pro</span></span> | |
| <span className="text-[10px] text-gray-500">Auto-generate .srt subtitle files</span> | |
| </div> | |
| </div> | |
| <div className={`w-12 h-6 rounded-full p-1 transition-colors ${lyricSync ? 'bg-emerald-500' : 'bg-[#27272a]'}`}> | |
| <div className={`w-4 h-4 bg-white rounded-full transition-transform ${lyricSync ? 'translate-x-6' : 'translate-x-0'}`} /> | |
| </div> | |
| </button> | |
| </div> | |
| <div className="mt-auto pt-8"> | |
| <p className="text-xs text-center text-gray-600">Select your tools before dropping a file to process.</p> | |
| </div> | |
| </div> | |
| {/* RIGHT SIDE: FILE UPLOAD & RESULTS */} | |
| <div className="lg:col-span-8 bg-[#111] border border-[#27272a] rounded-3xl p-6 md:p-12 shadow-2xl relative z-10 flex flex-col min-h-[700px]"> | |
| {/* Dynamic Content Area */} | |
| <div className="flex-1 flex flex-col justify-center relative"> | |
| {/* Massive Dropzone */} | |
| {!resultZip && !loading && ( | |
| <div | |
| className="border-2 border-dashed border-[#27272a] hover:border-[#1877F2] bg-[#050505] rounded-3xl p-8 sm:p-12 md:p-24 text-center cursor-pointer transition-all group h-full flex flex-col items-center justify-center" | |
| onDrop={handleDrop} | |
| onDragOver={handleDragOver} | |
| onClick={() => document.getElementById("fileInput")?.click()} | |
| > | |
| <input id="fileInput" type="file" className="hidden" accept="audio/*,video/*" onChange={handleFileChange} /> | |
| <UploadCloud className="w-20 h-20 text-gray-500 group-hover:text-[#1877F2] mx-auto mb-6 transition-colors" /> | |
| {file ? ( | |
| <div className="flex flex-col items-center"> | |
| <h3 className="text-3xl text-emerald-400 font-bold mb-4 break-words mx-auto">{file.name}</h3> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); handleUpload(); }} | |
| className="bg-[#1877F2] text-white px-10 py-4 rounded-xl text-xl font-bold hover:bg-[#166FE5] transition-all shadow-[0_0_20px_rgba(24,119,242,0.3)] hover:-translate-y-1" | |
| > | |
| Extract Now | |
| </button> | |
| </div> | |
| ) : ( | |
| <> | |
| {!isLoaded ? ( | |
| <h3 className="text-3xl md:text-4xl text-white font-bold mb-4">Loading...</h3> | |
| ) : !isSignedIn ? ( | |
| <> | |
| <Lock className="w-16 h-16 text-gray-600 mx-auto mb-4" /> | |
| <h3 className="text-3xl md:text-4xl text-white font-bold mb-4">Login Required</h3> | |
| <p className="text-gray-400 text-lg">You must sign in via the top right to process files.</p> | |
| </> | |
| ) : ( | |
| <> | |
| <h3 className="text-3xl md:text-4xl text-white font-bold mb-4 group-hover:text-[#1877F2] transition-colors">Select Files</h3> | |
| <p className="text-gray-400 text-lg">or drag and drop them here</p> | |
| <p className="text-sm text-gray-600 mt-4">Supports MP3, WAV, FLAC, MP4, and more.</p> | |
| </> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| )} | |
| {/* Error Message */} | |
| {error && <div className="mt-6 p-6 bg-red-900/30 border border-red-500 text-red-200 rounded-2xl text-center font-medium absolute top-4 left-4 right-4">{error}</div>} | |
| {/* Loading State */} | |
| {loading && (progress as any).status !== "queued" && ( | |
| <div className="bg-[#050505] border border-[#27272a] rounded-3xl p-12 text-center h-full flex flex-col items-center justify-center"> | |
| <div className="inline-block animate-spin rounded-full h-16 w-16 border-t-4 border-[#1877F2] border-r-4 border-r-transparent border-b-4 border-b-[#1877F2]/30 border-l-4 border-l-transparent mb-8"></div> | |
| {/* Status Content */} | |
| <h3 className="text-4xl font-bold text-white mb-4">{progress.percent}%</h3> | |
| <h4 className="text-xl text-[#1877F2] font-medium mb-2">{progress.step || "Preparing audio..."}</h4> | |
| <p className="text-[#a1a1aa] mb-8 text-lg">{progress.message}</p> | |
| {/* TIMING METRICS */} | |
| <div className="mt-2 h-3 bg-[#27272a] rounded-full overflow-hidden w-full max-w-md mx-auto"> | |
| <div | |
| className="h-full bg-gradient-to-r from-[#166FE5] to-[#1877F2] relative transition-all duration-500" | |
| style={{ width: `${progress.percent}%` }} | |
| > | |
| <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/50 to-transparent animate-shimmer" /> | |
| </div> | |
| </div> | |
| <div className="mt-4 flex justify-between text-sm font-medium text-gray-500 w-full max-w-md mx-auto px-2"> | |
| <div>Started: {progress.start_time ? new Date(progress.start_time * 1000).toLocaleTimeString() : '...'}</div> | |
| {progress.eta_seconds > 0 && progress.eta_seconds < 3600 && ( | |
| <div className="text-[#1877F2]">ETA: {progress.eta_seconds}s</div> | |
| )} | |
| {progress.eta_seconds >= 3600 && ( | |
| <div className="text-[#1877F2]">ETA: {Math.floor(progress.eta_seconds / 60)}m</div> | |
| )} | |
| </div> | |
| {/* CHUNK MATRIX */} | |
| {progress.chunks_total > 0 && ( | |
| <div className="mt-8 pt-8 border-t border-[#27272a] w-full max-w-3xl mx-auto"> | |
| <h5 className="text-sm text-gray-400 font-bold mb-4 tracking-widest uppercase text-center">Live Chunk Matrix</h5> | |
| <div className="flex flex-wrap gap-2 justify-center max-h-60 overflow-y-auto pr-2"> | |
| {[...Array(progress.chunks_total)].map((_, i) => { | |
| const isCompleted = i < progress.chunks_completed; | |
| const isProcessing = !isCompleted && i < progress.chunks_completed + 5; // Represents max 5 parallel cloud workers | |
| return ( | |
| <div | |
| key={i} | |
| className={`w-10 h-10 rounded-xl flex items-center justify-center text-xs font-bold transition-all duration-300 ${ | |
| isCompleted | |
| ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/50 shadow-[0_0_10px_rgba(16,185,129,0.3)]' | |
| : isProcessing | |
| ? 'bg-orange-500/20 text-orange-400 border border-orange-500/80 shadow-[0_0_15px_rgba(249,115,22,0.5)] animate-pulse scale-110 z-10' | |
| : 'bg-[#0a0a0a] text-gray-600 border border-[#27272a]' | |
| }`} | |
| > | |
| {i + 1} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| <button | |
| onClick={async () => { | |
| if (!taskId) return; | |
| try { | |
| await fetch(`${baseUrl}/api/cancel/${taskId}`, { method: 'POST' }); | |
| setLoading(false); | |
| setResultZip(null); | |
| setTaskId(null); | |
| setFile(null); | |
| window.history.replaceState({}, '', '/'); | |
| setError("Processing was cancelled."); | |
| } catch (err) { | |
| console.error(err); | |
| } | |
| }} | |
| className="mt-8 px-6 py-2 border border-red-500/50 text-red-400 hover:bg-red-500/10 rounded-full text-sm font-medium transition-colors" | |
| > | |
| Cancel Processing | |
| </button> | |
| </div> | |
| )} | |
| {/* Audio Player and Download (Success State) */} | |
| {resultZip && taskId && ( | |
| <div className="bg-[#050505] border border-[#27272a] rounded-3xl p-8 md:p-12 h-full flex flex-col justify-center"> | |
| <div className="text-center mb-10"> | |
| <div className="w-16 h-16 bg-emerald-500/20 rounded-full flex items-center justify-center mx-auto mb-4"> | |
| <ShieldCheck className="w-8 h-8 text-emerald-400" /> | |
| </div> | |
| <h3 className="text-3xl font-bold text-white mb-2">Extraction Complete!</h3> | |
| <p className="text-gray-400 text-md mb-4">Your stems have been successfully separated.</p> | |
| <div className="flex flex-col sm:flex-row items-center justify-center gap-2 bg-[#1a1a1a] border border-[#27272a] rounded-xl px-4 py-3 w-full sm:w-max mx-auto"> | |
| <span className="text-gray-500 text-sm font-bold uppercase tracking-wider">Task ID:</span> | |
| <span className="text-white font-mono text-xs sm:text-sm tracking-wider break-all">{taskId}</span> | |
| <button | |
| onClick={() => { | |
| if(taskId) navigator.clipboard.writeText(taskId); | |
| alert("Task ID copied to clipboard!"); | |
| }} | |
| className="ml-2 text-gray-400 hover:text-[#1877F2] transition-colors p-1" | |
| title="Copy ID to Clipboard" | |
| > | |
| <Copy className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="mb-8 w-full max-w-2xl mx-auto"> | |
| <audio ref={vocalsAudioRef} src={`${baseUrl}/api/stream/${taskId}/vocals.wav`} preload="auto" className="hidden" /> | |
| <SmartCompareStudio | |
| originalUrl={`${baseUrl}/api/stream/${taskId}/original.wav`} | |
| cleanedUrl={`${baseUrl}/api/stream/${taskId}/vocals.wav`} | |
| audioRef={vocalsAudioRef} | |
| /> | |
| </div> | |
| {lyricSync && <SyncedTranscriptPlayer taskId={taskId} baseUrl={baseUrl} audioRef={vocalsAudioRef} />} | |
| {lyricSync && <RawTranscriptViewer taskId={taskId} baseUrl={baseUrl} />} | |
| <div className="bg-[#111] border border-[#27272a] rounded-2xl p-6 mb-8 w-full max-w-xl mx-auto"> | |
| <h4 className="text-lg font-bold text-white mb-4">Download Customization</h4> | |
| <div className="space-y-6"> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-400 mb-2">Select Stems to Include</label> | |
| <div className="flex flex-wrap gap-3"> | |
| <label className="flex items-center gap-2 bg-[#1a1a1a] px-4 py-2 rounded-lg border border-[#27272a] cursor-pointer hover:border-gray-500 transition-colors"> | |
| <input type="checkbox" checked={dlStems.vocals} onChange={(e) => setDlStems({...dlStems, vocals: e.target.checked})} className="accent-[#1877F2]" /> | |
| <span className="text-gray-300">Vocals</span> | |
| </label> | |
| </div> | |
| </div> | |
| <div className="flex flex-col md:flex-row gap-6"> | |
| <div className="flex-1"> | |
| <label className="block text-sm font-medium text-gray-400 mb-2">Audio Format</label> | |
| <select | |
| value={dlFormat} | |
| onChange={(e) => setDlFormat(e.target.value)} | |
| className="w-full bg-[#1a1a1a] text-white border border-[#27272a] rounded-lg px-4 py-3 outline-none focus:border-[#1877F2]" | |
| > | |
| <option value="mp3">MP3 (Fast, Small Size)</option> | |
| <option value="wav">WAV (Lossless Quality)</option> | |
| </select> | |
| </div> | |
| <div className="flex-1"> | |
| <label className="block text-sm font-medium text-gray-400 mb-2">Zip / Folder Name</label> | |
| <input | |
| type="text" | |
| value={dlFolderName} | |
| onChange={(e) => setDlFolderName(e.target.value)} | |
| placeholder="My_Song_Stems" | |
| className="w-full bg-[#1a1a1a] text-white border border-[#27272a] rounded-lg px-4 py-3 outline-none focus:border-[#1877F2]" | |
| /> | |
| </div> | |
| <div className="flex-1"> | |
| <label className="block text-sm font-medium text-gray-400 mb-2">Delivery Structure</label> | |
| <select | |
| value={dlChunked ? "chunked" : "single"} | |
| onChange={(e) => setDlChunked(e.target.value === "chunked")} | |
| className="w-full bg-[#1a1a1a] text-white border border-[#27272a] rounded-lg px-4 py-3 outline-none focus:border-[#1877F2]" | |
| > | |
| <option value="chunked">50s Chunks (ad0001...) in Folders</option> | |
| <option value="single">Single Full-Length Files</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex justify-center flex-col items-center gap-4"> | |
| <a | |
| href={`${baseUrl}/api/custom_download/${taskId}?stems=${Object.entries(dlStems).filter(([k,v]) => v).map(([k]) => k).join(',')}&format=${dlFormat}&chunked=${dlChunked}&folder_name=${encodeURIComponent(dlFolderName)}`} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="inline-flex items-center justify-center gap-3 py-4 px-6 md:px-8 bg-[#1877F2] text-white rounded-full text-base md:text-lg font-bold hover:bg-[#166FE5] hover:-translate-y-1 shadow-[0_10px_30px_-10px_rgba(24,119,242,0.5)] transition-all w-full sm:w-auto" | |
| > | |
| <UploadCloud className="w-6 h-6 rotate-180" /> | |
| Download Custom ZIP | |
| </a> | |
| <p className="text-gray-500 text-xs text-center max-w-sm"> | |
| Files will be dynamically converted and packaged on our servers. This takes a few seconds. | |
| </p> | |
| </div> | |
| <div className="text-center mt-6"> | |
| <button onClick={startNewUpload} className="text-gray-400 hover:text-white underline text-sm"> | |
| Process another file | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* MAIN FLOW DIAGRAM OR QUEUE */} | |
| {(progress as any).status === "queued" ? ( | |
| <QueueWaitingRoom | |
| position={progress.queue_position} | |
| etaSeconds={progress.eta_seconds} | |
| onCancel={async () => { | |
| if (!taskId) return; | |
| try { | |
| await fetch(`${baseUrl}/api/cancel/${taskId}`, { method: 'POST' }); | |
| setLoading(false); | |
| setResultZip(null); | |
| setTaskId(null); | |
| setFile(null); | |
| window.history.replaceState({}, '', '/'); | |
| setError("Processing was cancelled from the queue."); | |
| } catch (err) { | |
| console.error(err); | |
| } | |
| }} | |
| /> | |
| ) : ( | |
| <ExtractionFlowDiagram isProcessing={loading} progress={progress.percent} /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Trust / Features Section */} | |
| <div className="max-w-7xl mx-auto px-4 mt-24"> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center"> | |
| <div className="bg-[#111] border border-[#27272a] rounded-3xl p-8"> | |
| <Zap className="w-10 h-10 text-[#1877F2] mx-auto mb-4" /> | |
| <h3 className="text-xl font-bold text-white mb-2">Fast Processing</h3> | |
| <p className="text-gray-400 text-sm">Our next-gen AI splits songs in less than a minute. No waiting in queues for hours.</p> | |
| </div> | |
| <div className="bg-[#111] border border-[#27272a] rounded-3xl p-8"> | |
| <Settings2 className="w-10 h-10 text-[#1877F2] mx-auto mb-4" /> | |
| <h3 className="text-xl font-bold text-white mb-2">Studio Quality</h3> | |
| <p className="text-gray-400 text-sm">Extract completely clean vocals and stems with zero artifacting using Demucs v4.</p> | |
| </div> | |
| <div className="bg-[#111] border border-[#27272a] rounded-3xl p-8"> | |
| <ShieldCheck className="w-10 h-10 text-[#1877F2] mx-auto mb-4" /> | |
| <h3 className="text-xl font-bold text-white mb-2">100% Secure</h3> | |
| <p className="text-gray-400 text-sm">Your files are encrypted during upload and permanently deleted after processing.</p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* FAQ Section on Homepage */} | |
| <div className="max-w-4xl mx-auto px-4 mt-32 mb-10"> | |
| <div className="text-center mb-12"> | |
| <h2 className="text-3xl md:text-5xl font-bold text-white mb-4">Frequently Asked Questions</h2> | |
| <p className="text-gray-400 text-lg">Quick answers to common questions about VocalBee.</p> | |
| </div> | |
| <FAQAccordion faqs={faqs.slice(0, 10)} /> | |
| <div className="text-center mt-10"> | |
| <Link href="/faq" className="text-[#1877F2] hover:text-white transition-colors font-medium text-lg"> | |
| View all 100 FAQs → | |
| </Link> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default function Home() { | |
| return ( | |
| <Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-[#050505] text-white">Loading...</div>}> | |
| <HomeContent /> | |
| </Suspense> | |
| ); | |
| } | |