import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faYoutube } from "@fortawesome/free-brands-svg-icons"; import { faLightbulb, faMoon, faHistory, faCog, faRobot } from "@fortawesome/free-solid-svg-icons"; import { Link } from "react-router-dom"; import { InputPanel } from "../components/InputPanel"; import type { InputValues } from "../components/InputPanel"; import { ResultsPanel, type RegeneratingMap, type SectionKey, } from "../components/ResultsPanel"; import { StickyZipBar } from "../components/StickyZipBar"; import { HistoryDrawer } from "../components/HistoryPanel"; import { Footer } from "../components/Footer"; import { useToast } from "../lib/toast"; import { generateSong, getServerStatus } from "../lib/llm"; import { addToHistory, type HistoryEntry } from "../lib/history"; import { buildSongZip, downloadBlob, sanitizeFilename } from "../lib/zip"; import { SECTION_LABELS, type SongAssets, type VideoPrompt, } from "../lib/types"; import { useI18n } from "../lib/i18n"; import { useTheme } from "../lib/theme"; const DRAFT_KEY = "melodymuse-draft"; const DRAFT_DEBOUNCE_MS = 400; const DEFAULT_INPUT: InputValues = { idea: "", language: "English", customLanguage: "", style_hint: "", mood: "", vocals: "vocals", }; function resolveLanguage(v: InputValues): string { return v.language === "Other" ? v.customLanguage.trim() || "English" : v.language; } function isAnyBusy(loading: boolean, regenerating: RegeneratingMap): boolean { if (loading) return true; for (const v of Object.values(regenerating)) if (v) return true; return false; } function loadDraft(): InputValues | null { if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(DRAFT_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Partial | null; if (!parsed || typeof parsed !== "object") return null; return { ...DEFAULT_INPUT, ...parsed }; } catch { return null; } } function FlagDE() { return ( ); } function FlagEN() { return ( ); } /** Animated equalizer bars for the logo */ function MusicBarsLogo() { return (
); } export function HomePage() { const toast = useToast(); const { t, lang, setLang } = useI18n(); const { theme, toggleTheme } = useTheme(); const [input, setInput] = useState( () => loadDraft() ?? DEFAULT_INPUT, ); const [assets, setAssets] = useState(null); const [originalAssets, setOriginalAssets] = useState(null); const [loading, setLoading] = useState(false); const [regenerating, setRegenerating] = useState({}); const [selectedTitleIndex, setSelectedTitleIndex] = useState(0); const [customTitle, setCustomTitle] = useState(null); const [serverOk, setServerOk] = useState(null); const [historyOpen, setHistoryOpen] = useState(false); const [chainRegenerate, setChainRegenerate] = useState(null); const abortRef = useRef(null); const anyRegenerating = isAnyBusy(loading, regenerating); // Save draft useEffect(() => { const id = window.setTimeout(() => { try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); } catch { /* ignore */ } }, DRAFT_DEBOUNCE_MS); return () => window.clearTimeout(id); }, [input]); // Server health check useEffect(() => { let cancelled = false; const check = async () => { try { const s = await getServerStatus(); if (!cancelled) setServerOk(Boolean(s.ok)); } catch { if (!cancelled) setServerOk(false); } }; check(); const id = window.setInterval(check, 30_000); return () => { cancelled = true; window.clearInterval(id); }; }, []); useEffect(() => () => abortRef.current?.abort(), []); // Resolved title for ZIP: custom override > chip selection const selectedTitle = useMemo(() => { if (customTitle !== null) return customTitle; if (!assets || assets.titles.length === 0) return undefined; return assets.titles[Math.min(selectedTitleIndex, assets.titles.length - 1)]; }, [assets, selectedTitleIndex, customTitle]); const cancel = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; }, []); useEffect(() => { if (!anyRegenerating) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); cancel(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [anyRegenerating, cancel]); const buildRequest = useCallback( (section: string, extra: Record = {}): GenerateCall => ({ input: input.idea.trim(), language: resolveLanguage(input), ...(input.mood.trim() ? { mood: input.mood.trim() } : {}), ...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}), vocals: input.vocals, section, ...extra, } as unknown as GenerateCall), [input], ); const runGeneration = useCallback( async ( build: () => GenerateCall, onSuccess: (result: Partial) => void, busySetter: (b: boolean) => void, successMsg: string, ) => { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; busySetter(true); try { const req = build(); const result = await generateSong(req, { signal: ctrl.signal }); onSuccess(result); toast.success(successMsg); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { toast.info(t.generationCancelled); } else { toast.error(err instanceof Error ? err.message : t.generationFailed); } } finally { busySetter(false); if (abortRef.current === ctrl) abortRef.current = null; } }, [toast, t], ); const handleGenerate = useCallback(async () => { if (!input.idea.trim()) return; setAssets(null); setOriginalAssets(null); setCustomTitle(null); await runGeneration( () => buildRequest("all"), (result) => { const next = result as SongAssets; setAssets(next); setOriginalAssets(next); setSelectedTitleIndex(0); addToHistory(input, next); }, setLoading, t.assetsGenerated, ); }, [input, buildRequest, runGeneration, t]); const handleRegenerateAll = useCallback(async () => { if (!input.idea.trim()) return; setAssets(null); setOriginalAssets(null); setCustomTitle(null); await runGeneration( () => buildRequest("all"), (result) => { const next = result as SongAssets; setAssets(next); setOriginalAssets(next); setSelectedTitleIndex(0); addToHistory(input, next); }, (b) => { setLoading(b); setRegenerating((m) => ({ ...m, all: b })); }, t.regeneratedAll, ); }, [input, buildRequest, runGeneration, t]); const handleRegenerateSection = useCallback( async (section: SectionKey, selectedText?: string) => { if (!input.idea.trim() || !assets) return; const isPartialLyrics = section === "lyrics" && !!selectedText; const endpointSection = isPartialLyrics ? "lyrics_partial" : section; const reqExtras = { context: assets }; if (isPartialLyrics) { (reqExtras as any).selected_text = selectedText; } await runGeneration( () => buildRequest(endpointSection, reqExtras), (result) => { let merged: SongAssets; if (isPartialLyrics) { // Replace only the selected text in the existing lyrics let newPartial = (result as any).lyrics || (result as any).text || String(result); // Cleanup quotes or markdown if the LLM leaked them newPartial = newPartial.replace(/^```[a-z]*\n/gi, '').replace(/\n```$/g, '').trim(); if (newPartial.startsWith('"') && newPartial.endsWith('"')) { newPartial = newPartial.slice(1, -1).trim(); } // Normalize newlines to prevent replace() from failing due to \r\n vs \n const normalizedAssetsLyrics = assets.lyrics.replace(/\r\n/g, '\n'); const normalizedSelected = selectedText.replace(/\r\n/g, '\n'); const replaced = normalizedAssetsLyrics.replace(normalizedSelected, newPartial); merged = { ...assets, lyrics: replaced }; } else { merged = { ...assets, ...result }; } setAssets(merged); setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged)); if (section === "titles") setCustomTitle(null); // Chain YouTube description regeneration if lyrics changed if (section === "lyrics") { setChainRegenerate("youtube_description"); } }, (b) => setRegenerating((m) => ({ ...m, [section]: b })), `${t.regeneratedSection} ${SECTION_LABELS[section]}`, ); }, [input, assets, buildRequest, runGeneration, t], ); // Execute chained regeneration useEffect(() => { if (chainRegenerate && assets && !anyRegenerating) { const section = chainRegenerate; setChainRegenerate(null); handleRegenerateSection(section); } }, [chainRegenerate, assets, anyRegenerating, handleRegenerateSection]); const handleDownload = useCallback(async () => { if (!assets || !selectedTitle) return; try { const blob = await buildSongZip(assets, selectedTitle); const safe = sanitizeFilename(selectedTitle); downloadBlob(blob, `${safe || "song"}.zip`); } catch { toast.error("Could not generate ZIP — please try again"); } }, [assets, selectedTitle, toast]); const handleAssetChange = useCallback( (key: K, value: SongAssets[K]) => { setAssets((curr) => (curr ? { ...curr, [key]: value } : curr)); }, [], ); const handleVideoPromptChange = useCallback( (index: number, value: string) => { setAssets((curr) => { if (!curr) return curr; const next: VideoPrompt[] = curr.video_prompts.map((p, i) => i === index ? { ...p, prompt: value } : p, ); return { ...curr, video_prompts: next }; }); }, [], ); const handleLoadHistory = useCallback( (entry: HistoryEntry) => { if (anyRegenerating) cancel(); setInput(entry.input); setAssets(entry.assets); setOriginalAssets(entry.assets); setSelectedTitleIndex(0); setCustomTitle(null); toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`); }, [anyRegenerating, cancel, toast], ); return (
{/* Liquid background blobs */}
{/* Header */}
{/* Logo */}
MelodyMuse
{/* Right controls */}
{/* External Links */} {/* Server offline indicator */} {serverOk === false && ( {t.serverOffline} )} {/* Divider */}
{/* Language */}
{/* Divider */}
{/* History toggle */} {/* Settings link */} {/* Theme toggle switch */}
{/* Main content: 1/3 left input + 2/3 right results */}
{/* LEFT PANEL — 1/3 — Input */}
{t.cancelGeneration} } />
{/* RIGHT PANEL — 2/3 — Results */}
{/* Download bar inside results column, always visible */} {assets && (
)}
{/* Footer — always visible */}
{/* History Drawer (right-side slide-in) */} setHistoryOpen(false)} onLoad={handleLoadHistory} />
); } function truncate(s: string, n: number): string { const t = s.trim(); if (t.length <= n) return t || "previous generation"; return t.slice(0, n - 1) + "…"; } type GenerateCall = Parameters[0];