"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { isDevelopmentEnv, loadLocalProject, saveLocalProject, } from "@/lib/dev-project-storage"; import { contentValuesFromSceneData, fetchProject, isProjectNotFoundError, patchProjectColors, patchProjectContents, patchProjectSceneData, themeColorsFromSceneData, } from "@/lib/project-api"; import { isDevProjectId } from "@/lib/project-ids"; import { PROJECT_SAVE_DEBOUNCE_MS, PROJECT_SAVED_DISPLAY_MS, type ProjectSaveStatus, } from "@/lib/project-save-status"; import { isVideoSceneDataEmpty } from "@/lib/studio-scene-data"; import { useStudioStore } from "@/lib/studio-store"; export interface UseStudioProjectPersistenceResult { projectName: string; setProjectName: (name: string) => void; saveStatus: ProjectSaveStatus; usingLocalStorage: boolean; retrySave: () => void; } function applyLocalSnapshot( projectId: string, hydrateFromSceneData: (sceneData: Record) => boolean, setProjectName: (name: string) => void ): string | null { const local = loadLocalProject(projectId); if (!local) return null; if (local.name) { setProjectName(local.name); } if (!isVideoSceneDataEmpty(local.scene_data)) { hydrateFromSceneData(local.scene_data); } return JSON.stringify(local.scene_data); } export function useStudioProjectPersistence( projectId: string ): UseStudioProjectPersistenceResult { const scenes = useStudioStore((state) => state.scenes); const activeSceneId = useStudioStore((state) => state.activeSceneId); const currentTime = useStudioStore((state) => state.currentTime); const pxPerSecond = useStudioStore((state) => state.pxPerSecond); const audioFileName = useStudioStore((state) => state.audioFileName); const audioSrc = useStudioStore((state) => state.audioSrc); const audioVolume = useStudioStore((state) => state.audioVolume); const sceneBackgroundColor = useStudioStore( (state) => state.sceneBackgroundColor ); const sceneAccentColor = useStudioStore((state) => state.sceneAccentColor); const hydrateFromSceneData = useStudioStore( (state) => state.hydrateFromSceneData ); const getSceneDataForSave = useStudioStore((state) => state.getSceneDataForSave); const [projectName, setProjectName] = useState( () => `Project ${projectId.slice(0, 8)}` ); const [saveStatus, setSaveStatus] = useState("idle"); const [usingLocalStorage, setUsingLocalStorage] = useState(false); const skipSaveRef = useRef(true); const lastSavedPayloadRef = useRef(null); const savedTimerRef = useRef | null>(null); const projectNameRef = useRef(projectName); useEffect(() => { projectNameRef.current = projectName; }, [projectName]); const persistPayload = useMemo( () => JSON.stringify(getSceneDataForSave()), [ scenes, activeSceneId, currentTime, pxPerSecond, audioFileName, audioSrc, audioVolume, sceneBackgroundColor, sceneAccentColor, getSceneDataForSave, ] ); const debouncedPayload = useDebouncedValue( persistPayload, PROJECT_SAVE_DEBOUNCE_MS ); const clearSavedTimer = useCallback(() => { if (savedTimerRef.current) { clearTimeout(savedTimerRef.current); savedTimerRef.current = null; } }, []); const markSaved = useCallback( (payloadJson: string, local: boolean) => { lastSavedPayloadRef.current = payloadJson; setSaveStatus(local ? "local" : "saved"); clearSavedTimer(); savedTimerRef.current = setTimeout(() => { setSaveStatus((current) => current === "saved" || current === "local" ? "idle" : current ); }, PROJECT_SAVED_DISPLAY_MS); }, [clearSavedTimer] ); const saveToLocalStorage = useCallback( (payloadJson: string) => { const scene_data = JSON.parse(payloadJson) as Record; saveLocalProject(projectId, { scene_data, name: projectNameRef.current, }); setUsingLocalStorage(true); lastSavedPayloadRef.current = payloadJson; setSaveStatus("local"); clearSavedTimer(); savedTimerRef.current = setTimeout(() => { setSaveStatus((current) => (current === "local" ? "idle" : current)); }, PROJECT_SAVED_DISPLAY_MS); }, [projectId, clearSavedTimer] ); const performSave = useCallback( async (payloadJson: string) => { if (isDevProjectId(projectId)) return; setSaveStatus("saving"); try { const scene_data = JSON.parse(payloadJson) as Record; await patchProjectSceneData(projectId, scene_data); // Persist the per-input values to content elements so the render binder uses // them (best-effort — never block the main save on it). void patchProjectContents( projectId, contentValuesFromSceneData(scene_data) ).catch(() => {}); // Persist the theme colours to saved_shared_colors so the FlexStory render // reads the theme picker (best-effort). void patchProjectColors( projectId, themeColorsFromSceneData(scene_data) ).catch(() => {}); setUsingLocalStorage(false); markSaved(payloadJson, false); } catch (error) { if (isDevelopmentEnv() && isProjectNotFoundError(error)) { saveToLocalStorage(payloadJson); return; } setSaveStatus("error"); } }, [projectId, markSaved, saveToLocalStorage] ); const retrySave = useCallback(() => { void performSave(persistPayload); }, [performSave, persistPayload]); useEffect(() => { if (isDevProjectId(projectId)) return; let cancelled = false; skipSaveRef.current = true; void (async () => { try { const { project } = await fetchProject(projectId); if (cancelled) return; setUsingLocalStorage(false); setProjectName(project.name); if (!isVideoSceneDataEmpty(project.scene_data)) { hydrateFromSceneData(project.scene_data); } lastSavedPayloadRef.current = JSON.stringify( useStudioStore.getState().getSceneDataForSave() ); } catch (error) { if (cancelled) return; if (isDevelopmentEnv() && isProjectNotFoundError(error)) { const savedPayload = applyLocalSnapshot( projectId, hydrateFromSceneData, setProjectName ); if (savedPayload) { setUsingLocalStorage(true); lastSavedPayloadRef.current = JSON.stringify( useStudioStore.getState().getSceneDataForSave() ); } } else { setSaveStatus("error"); } } finally { if (!cancelled) { skipSaveRef.current = false; } } })(); return () => { cancelled = true; }; }, [projectId, hydrateFromSceneData]); useEffect(() => { if (isDevProjectId(projectId) || skipSaveRef.current) return; if (persistPayload === lastSavedPayloadRef.current) return; setSaveStatus("pending"); }, [projectId, persistPayload]); useEffect(() => { if (isDevProjectId(projectId) || skipSaveRef.current) return; if (debouncedPayload === lastSavedPayloadRef.current) return; void performSave(debouncedPayload); }, [projectId, debouncedPayload, performSave]); useEffect(() => clearSavedTimer, [clearSavedTimer]); return { projectName, setProjectName, saveStatus, usingLocalStorage, retrySave, }; }