244 lines
7.0 KiB
TypeScript
244 lines
7.0 KiB
TypeScript
"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 {
|
|
fetchProject,
|
|
isProjectNotFoundError,
|
|
patchProjectSceneData,
|
|
} 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<string, unknown>) => 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<ProjectSaveStatus>("idle");
|
|
const [usingLocalStorage, setUsingLocalStorage] = useState(false);
|
|
|
|
const skipSaveRef = useRef(true);
|
|
const lastSavedPayloadRef = useRef<string | null>(null);
|
|
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>;
|
|
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<string, unknown>;
|
|
await patchProjectSceneData(projectId, scene_data);
|
|
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,
|
|
};
|
|
}
|