import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { HistoryPanel } from "../components/HistoryPanel"; import { Footer } from "../components/Footer"; import { EqualizerIcon } from "../components/EqualizerIcon"; 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"; // localStorage key for the in-progress input form. Lets the user survive // an accidental refresh without losing what they were typing. 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; } } export function HomePage() { const toast = useToast(); // Hydrate the input from any previously-saved draft so the user can recover // an accidental refresh. const [input, setInput] = useState( () => loadDraft() ?? DEFAULT_INPUT, ); const [assets, setAssets] = useState(null); // The "last generated" snapshot. The cards compare their current editable // values against these to decide whether to show the Revert button. const [originalAssets, setOriginalAssets] = useState(null); const [loading, setLoading] = useState(false); const [regenerating, setRegenerating] = useState({}); const [selectedTitleIndex, setSelectedTitleIndex] = useState(0); const [serverOk, setServerOk] = useState(null); // AbortController for the *current* network request. Refs are used so the // cancel handler always sees the latest value without re-binding. const abortRef = useRef(null); const anyRegenerating = isAnyBusy(loading, regenerating); // Debounced auto-save: persist the input form ~400ms after the last edit so // a refresh doesn't wipe what the user was typing. useEffect(() => { const id = window.setTimeout(() => { try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); } catch { // Quota or disabled — fine, history is the more durable store. } }, DRAFT_DEBOUNCE_MS); return () => window.clearTimeout(id); }, [input]); // Check the server on mount. If unreachable, surface a quiet header hint. useEffect(() => { const ctrl = new AbortController(); (async () => { try { const s = await getServerStatus(ctrl.signal); setServerOk(Boolean(s.ok)); } catch { setServerOk(false); } })(); return () => ctrl.abort(); }, []); // Clean up any in-flight request on unmount. useEffect(() => () => abortRef.current?.abort(), []); const selectedTitle = useMemo(() => { if (!assets || assets.titles.length === 0) return undefined; return assets.titles[ Math.min(selectedTitleIndex, assets.titles.length - 1) ]; }, [assets, selectedTitleIndex]); const cancel = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; }, []); // Esc cancels any in-flight generation. 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]); // Build the request payload from the current form values. Kept as a // function so all three call sites (full / regenerate all / per-section) // stay in sync. const buildRequest = useCallback( ( section: GenerateCall["section"], extra: Partial = {}, ): 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, }), [input], ); const runGeneration = useCallback( async ( build: () => GenerateCall, onSuccess: (result: Partial) => void, busySetter: (b: boolean) => void, successMsg: string, ) => { // Cancel any in-flight request before starting a new one. 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("Generation cancelled"); } else { toast.error( err instanceof Error ? err.message : "Generation failed — please try again", ); } } finally { busySetter(false); if (abortRef.current === ctrl) abortRef.current = null; } }, [toast], ); const handleGenerate = useCallback(async () => { if (!input.idea.trim()) return; // Clear the old assets so the skeleton state shows during generation // instead of the previous song's cards. setAssets(null); setOriginalAssets(null); await runGeneration( () => buildRequest("all"), (result) => { const next = result as SongAssets; setAssets(next); setOriginalAssets(next); setSelectedTitleIndex(0); addToHistory(input, next); }, setLoading, "Song assets generated", ); }, [input, buildRequest, runGeneration]); const handleRegenerateAll = useCallback(async () => { if (!input.idea.trim()) return; setAssets(null); setOriginalAssets(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 })); }, "Regenerated all sections", ); }, [input, buildRequest, runGeneration]); const handleRegenerateSection = useCallback( async (section: SectionKey) => { if (!input.idea.trim() || !assets) return; await runGeneration( () => buildRequest(section, { context: assets }), (result) => { // Merge partial into current assets, and update the original // snapshot for the keys that came back. Any keys the user has // hand-edited that the model *didn't* return stay as-is. const merged: SongAssets = { ...assets, ...result }; setAssets(merged); setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged)); }, (b) => setRegenerating((m) => ({ ...m, [section]: b })), `Regenerated ${SECTION_LABELS[section]}`, ); }, [input, assets, buildRequest, runGeneration], ); 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]); // Asset editing handlers 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 }; }); }, [], ); // Restore a previous generation from history. const handleLoadHistory = useCallback( (entry: HistoryEntry) => { if (anyRegenerating) cancel(); setInput(entry.input); setAssets(entry.assets); setOriginalAssets(entry.assets); setSelectedTitleIndex(0); toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`); }, [anyRegenerating, cancel, toast], ); return (
MelodyMuse
{serverOk === false && ( Server offline )}
Cancel generation } />
{assets && ( )}
); } 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];