diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-09-03 00:02:07 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-09-03 00:02:07 +0200 |
| commit | 60debe2ce52a73dc554a0daa0a127fc836ca123a (patch) | |
| tree | 9c334f5de4211d0bb7893929ea63c86280679705 /apps/web/src/stores | |
| parent | 37d40148087fc7c3497fba31e82bc7c7496bde8d (diff) | |
| parent | 167e2b83dc072f5d80ab0bf0d3f786a993248c70 (diff) | |
| download | opencut-60debe2ce52a73dc554a0daa0a127fc836ca123a.tar.gz | |
Merge branch 'staging'
Diffstat (limited to 'apps/web/src/stores')
| -rw-r--r-- | apps/web/src/stores/keybindings-store.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/stores/media-store.ts | 19 | ||||
| -rw-r--r-- | apps/web/src/stores/project-store.ts | 158 | ||||
| -rw-r--r-- | apps/web/src/stores/scene-store.ts | 318 | ||||
| -rw-r--r-- | apps/web/src/stores/sounds-store.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/stores/stickers-store.ts | 53 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 580 |
7 files changed, 936 insertions, 200 deletions
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 7fa0da5e..febcaa30 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -206,6 +206,9 @@ function getPressedKey(ev: KeyboardEvent): string | null { const key = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; + if (code === "Space" || key === " " || key === "spacebar" || key === "space") + return "space"; + // Check arrow keys if (key.startsWith("arrow")) { return key.slice(5); @@ -213,7 +216,6 @@ function getPressedKey(ev: KeyboardEvent): string | null { // Check for special keys if (key === "tab") return "tab"; - if (key === " " || key === "space") return "space"; if (key === "home") return "home"; if (key === "end") return "end"; if (key === "delete") return "delete"; diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 2b7cb62e..61c771db 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -3,6 +3,7 @@ import { storageService } from "@/lib/storage/storage-service"; import { useTimelineStore } from "./timeline-store"; import { generateUUID } from "@/lib/utils"; import { MediaType, MediaFile } from "@/types/media"; +import { videoCache } from "@/lib/video-cache"; interface MediaStore { mediaFiles: MediaFile[]; @@ -151,7 +152,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ // Save to persistent storage in background try { - await storageService.saveMediaFile(projectId, newItem); + await storageService.saveMediaFile({ projectId, mediaItem: newItem }); } catch (error) { console.error("Failed to save media item:", error); // Remove from local state if save failed @@ -165,6 +166,8 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ const state = get(); const item = state.mediaFiles.find((media) => media.id === id); + videoCache.clearVideo(id); + // Cleanup object URLs to prevent memory leaks if (item?.url) { URL.revokeObjectURL(item.url); @@ -200,6 +203,12 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ // If there are elements to remove, push history once before batch removal if (elementsToRemove.length > 0) { + const { + removeElementFromTrack, + removeElementFromTrackWithRipple, + rippleEditingEnabled, + pushHistory, + } = useTimelineStore.getState(); pushHistory(); // Remove all elements without pushing additional history entries @@ -214,7 +223,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ // 3) Remove from persistent storage try { - await storageService.deleteMediaFile(projectId, id); + await storageService.deleteMediaFile({ projectId, id }); } catch (error) { console.error("Failed to delete media item:", error); } @@ -224,7 +233,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ set({ isLoading: true }); try { - const mediaItems = await storageService.loadAllMediaFiles(projectId); + const mediaItems = await storageService.loadAllMediaFiles({ projectId }); // Regenerate thumbnails for video items const updatedMediaItems = await Promise.all( @@ -279,7 +288,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ try { const mediaIds = state.mediaFiles.map((item) => item.id); await Promise.all( - mediaIds.map((id) => storageService.deleteMediaFile(projectId, id)) + mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })) ); } catch (error) { console.error("Failed to clear media items from storage:", error); @@ -289,6 +298,8 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ clearAllMedia: () => { const state = get(); + videoCache.clearAll(); + // Cleanup all object URLs state.mediaFiles.forEach((item) => { if (item.url) { diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index b924ea22..c58f7309 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,28 +1,45 @@ -import { TProject } from "@/types/project"; +import { TProject, BlurIntensity, Scene } from "@/types/project"; import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; import { toast } from "sonner"; import { useMediaStore } from "./media-store"; import { useTimelineStore } from "./timeline-store"; +import { useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { CanvasSize, CanvasMode } from "@/types/editor"; export const DEFAULT_CANVAS_SIZE: CanvasSize = { width: 1920, height: 1080 }; export const DEFAULT_FPS = 30; -const DEFAULT_PROJECT: TProject = { - id: generateUUID(), - name: "Untitled", - thumbnail: "", - createdAt: new Date(), - updatedAt: new Date(), - backgroundColor: "#000000", - backgroundType: "color", - blurIntensity: 8, - bookmarks: [], - fps: DEFAULT_FPS, - canvasSize: DEFAULT_CANVAS_SIZE, - canvasMode: "preset", +export function createMainScene(): Scene { + return { + id: generateUUID(), + name: "Main Scene", + isMain: true, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +const createDefaultProject = (name: string): TProject => { + const mainScene = createMainScene(); + + return { + id: generateUUID(), + name, + thumbnail: "", + createdAt: new Date(), + updatedAt: new Date(), + scenes: [mainScene], + currentSceneId: mainScene.id, + backgroundColor: "#000000", + backgroundType: "color", + blurIntensity: 8, + bookmarks: [], + fps: DEFAULT_FPS, + canvasSize: DEFAULT_CANVAS_SIZE, + canvasMode: "preset", + }; }; interface ProjectStore { @@ -44,7 +61,7 @@ interface ProjectStore { updateProjectBackground: (backgroundColor: string) => Promise<void>; updateBackgroundType: ( type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number } + options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } ) => Promise<void>; updateProjectFps: (fps: number) => Promise<void>; updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>; @@ -104,7 +121,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -152,7 +169,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -164,23 +181,24 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }, createNewProject: async (name: string) => { - const newProject: TProject = { - ...DEFAULT_PROJECT, - id: generateUUID(), - name, - createdAt: new Date(), - updatedAt: new Date(), - }; + const newProject = createDefaultProject(name); set({ activeProject: newProject }); const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.initializeScenes({ + scenes: newProject.scenes, + currentSceneId: newProject.currentSceneId, + }); + try { - await storageService.saveProject(newProject); + await storageService.saveProject({ project: newProject }); // Reload all projects to update the list await get().loadAllProjects(); return newProject.id; @@ -195,21 +213,39 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ set({ isLoading: true }); } - // Clear media and timeline immediately to prevent flickering when switching projects + // Prevent flicker when switching projects - clear all stores const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); try { - const project = await storageService.loadProject(id); + const project = await storageService.loadProject({ id }); if (project) { set({ activeProject: project }); - // Load project-specific data in parallel + let currentScene = null; + if (project.scenes && project.scenes.length > 0) { + sceneStore.initializeScenes({ + scenes: project.scenes, + currentSceneId: project.currentSceneId, + }); + // Get current scene directly from project data (don't rely on store state) + currentScene = + project.scenes.find((s) => s.id === project.currentSceneId) || + project.scenes.find((s) => s.isMain) || + project.scenes[0]; + } + await Promise.all([ mediaStore.loadProjectMedia(id), - timelineStore.loadProjectTimeline(id), + timelineStore.loadProjectTimeline({ + projectId: id, + sceneId: currentScene?.id, + }), ]); } else { throw new Error(`Project with id ${id} not found`); @@ -227,11 +263,16 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ if (!activeProject) return; try { - // Save project metadata and timeline data in parallel const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + const currentScene = sceneStore.currentScene; + await Promise.all([ - storageService.saveProject(activeProject), - timelineStore.saveProjectTimeline(activeProject.id), + storageService.saveProject({ project: activeProject }), + timelineStore.saveProjectTimeline({ + projectId: activeProject.id, + sceneId: currentScene?.id, + }), ]); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -256,22 +297,24 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ deleteProject: async (id: string) => { try { - // Delete project data in parallel await Promise.all([ - storageService.deleteProjectMedia(id), - storageService.deleteProjectTimeline(id), - storageService.deleteProject(id), + storageService.deleteProjectMedia({ projectId: id }), + storageService.deleteProjectTimeline({ projectId: id }), + storageService.deleteProject({ id }), ]); await get().loadAllProjects(); // Refresh the list - // If we deleted the active project, close it and clear data + // If deleted active project, close it and clear data const { activeProject } = get(); if (activeProject?.id === id) { set({ activeProject: null }); const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); } } catch (error) { console.error("Failed to delete project:", error); @@ -281,11 +324,13 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ closeProject: () => { set({ activeProject: null }); - // Clear data from stores when closing project const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); }, renameProject: async (id: string, name: string) => { @@ -307,12 +352,11 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - // Save to storage - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); await get().loadAllProjects(); - // Update activeProject if it's the same project + // Update activeProject if same project const { activeProject } = get(); if (activeProject?.id === id) { set({ activeProject: updatedProject }); @@ -328,7 +372,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ duplicateProject: async (projectId: string) => { try { - const project = await storageService.loadProject(projectId); + const project = await storageService.loadProject({ id: projectId }); if (!project) { toast.error("Project not found", { description: "Please try again", @@ -355,14 +399,14 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; const newProject: TProject = { - ...project, // Copy all properties from the original project + ...project, id: generateUUID(), name: `(${nextNumber}) ${baseName}`, createdAt: new Date(), updatedAt: new Date(), }; - await storageService.saveProject(newProject); + await storageService.saveProject({ project: newProject }); await get().loadAllProjects(); return newProject.id; } catch (error) { @@ -386,9 +430,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update project background:", error); toast.error("Failed to update background", { @@ -399,7 +443,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ updateBackgroundType: async ( type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number } + options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } ) => { const { activeProject } = get(); if (!activeProject) return; @@ -410,14 +454,16 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ ...(options?.backgroundColor && { backgroundColor: options.backgroundColor, }), - ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }), + ...(options?.blurIntensity !== undefined && { + blurIntensity: options.blurIntensity, + }), updatedAt: new Date(), }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update background type:", error); toast.error("Failed to update background", { @@ -437,9 +483,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update project FPS:", error); toast.error("Failed to update project FPS", { @@ -460,9 +506,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update canvas size:", error); toast.error("Failed to update canvas size", { @@ -474,12 +520,10 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => { const { savedProjects } = get(); - // Filter projects by search query const filteredProjects = savedProjects.filter((project) => project.name.toLowerCase().includes(searchQuery.toLowerCase()) ); - // Sort filtered projects const sortedProjects = [...filteredProjects].sort((a, b) => { const [key, order] = sortOption.split("-"); @@ -506,7 +550,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ return sortedProjects; }, - // Global invalid project ID tracking implementation + // Global invalid project ID tracking isInvalidProjectId: (id: string) => { const invalidIds = get().invalidProjectIds || new Set(); return invalidIds.has(id); diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts new file mode 100644 index 00000000..a9f65a49 --- /dev/null +++ b/apps/web/src/stores/scene-store.ts @@ -0,0 +1,318 @@ +import { create } from "zustand"; +import { Scene } from "@/types/project"; +import { useProjectStore } from "./project-store"; +import { useTimelineStore } from "./timeline-store"; +import { storageService } from "@/lib/storage/storage-service"; +import { generateUUID } from "@/lib/utils"; + +export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null { + return scenes.find((scene) => scene.isMain) || null; +} + +function ensureMainScene(scenes: Scene[]): Scene[] { + const hasMain = scenes.some((scene) => scene.isMain); + if (!hasMain) { + const mainScene: Scene = { + id: generateUUID(), + name: "Main scene", + isMain: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + return [mainScene, ...scenes]; + } + return scenes; +} + +interface SceneStore { + // Current scene state + currentScene: Scene | null; + scenes: Scene[]; + + // Scene management + createScene: ({ + name, + isMain, + }: { + name: string; + isMain: boolean; + }) => Promise<string>; + deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>; + renameScene: ({ + sceneId, + name, + }: { + sceneId: string; + name: string; + }) => Promise<void>; + switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>; + + // Scene utilities + getMainScene: () => Scene | null; + getCurrentScene: () => Scene | null; + + // Project integration + loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>; + initializeScenes: ({ + scenes, + currentSceneId, + }: { + scenes: Scene[]; + currentSceneId?: string; + }) => void; + clearScenes: () => void; +} + +export const useSceneStore = create<SceneStore>((set, get) => ({ + currentScene: null, + scenes: [], + + createScene: async ({ name, isMain = false }) => { + const { scenes } = get(); + + const newScene = { + id: generateUUID(), + name, + isMain, + isBackground: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + const updatedScenes = [...scenes, newScene]; + + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ scenes: updatedScenes }); + return newScene.id; + } catch (error) { + console.error("Failed to create scene:", error); + throw error; + } + }, + + deleteScene: async ({ sceneId }: { sceneId: string }) => { + const { scenes, currentScene } = get(); + const sceneToDelete = scenes.find((s) => s.id === sceneId); + + if (!sceneToDelete) { + throw new Error("Scene not found"); + } + + if (sceneToDelete.isMain) { + throw new Error("Cannot delete main scene"); + } + + const updatedScenes = scenes.filter((s) => s.id !== sceneId); + + // Determine new current scene if we're deleting the current one + let newCurrentScene = currentScene; + if (currentScene?.id === sceneId) { + newCurrentScene = getMainScene({ scenes: updatedScenes }); + } + + // Update project + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ + scenes: updatedScenes, + currentScene: newCurrentScene, + }); + + // If we switched scenes, load the new scene's timeline + if (newCurrentScene && newCurrentScene.id !== currentScene?.id) { + const timelineStore = useTimelineStore.getState(); + await timelineStore.loadProjectTimeline({ + projectId: activeProject.id, + sceneId: newCurrentScene.id, + }); + } + } catch (error) { + console.error("Failed to delete scene:", error); + throw error; + } + }, + + renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { + const { scenes } = get(); + const updatedScenes = scenes.map((scene) => + scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene + ); + + // Update project + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ + scenes: updatedScenes, + currentScene: updatedScenes.find((s) => s.id === sceneId) || null, + }); + } catch (error) { + console.error("Failed to rename scene:", error); + throw error; + } + }, + + switchToScene: async ({ sceneId }: { sceneId: string }) => { + const { scenes } = get(); + const targetScene = scenes.find((s) => s.id === sceneId); + + if (!targetScene) { + throw new Error("Scene not found"); + } + + const timelineStore = useTimelineStore.getState(); + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + const { currentScene } = get(); + + if (activeProject && currentScene) { + await timelineStore.saveProjectTimeline({ + projectId: activeProject.id, + sceneId: currentScene.id, + }); + } + + if (activeProject) { + await timelineStore.loadProjectTimeline({ + projectId: activeProject.id, + sceneId, + }); + + const updatedProject = { + ...activeProject, + currentSceneId: sceneId, + updatedAt: new Date(), + }; + + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + } + + set({ currentScene: targetScene }); + }, + + getMainScene: () => { + const { scenes } = get(); + return scenes.find((scene) => scene.isMain) || null; + }, + + getCurrentScene: () => { + return get().currentScene; + }, + + loadProjectScenes: async ({ projectId }: { projectId: string }) => { + try { + const project = await storageService.loadProject({ id: projectId }); + if (project?.scenes) { + const ensuredScenes = project.scenes.map((scene) => ({ + ...scene, + isMain: scene.isMain || false, + })); + const currentScene = + ensuredScenes.find((s) => s.id === project.currentSceneId) || + ensuredScenes[0]; + + set({ + scenes: ensuredScenes, + currentScene, + }); + } + } catch (error) { + console.error("Failed to load project scenes:", error); + set({ scenes: [], currentScene: null }); + } + }, + + initializeScenes: ({ + scenes, + currentSceneId, + }: { + scenes: Scene[]; + currentSceneId?: string; + }) => { + const ensuredScenes = ensureMainScene(scenes); + const currentScene = currentSceneId + ? ensuredScenes.find((s) => s.id === currentSceneId) + : null; + + const fallbackScene = getMainScene({ scenes: ensuredScenes }); + + set({ + scenes: ensuredScenes, + currentScene: currentScene || fallbackScene, + }); + + if (ensuredScenes.length > scenes.length) { + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (activeProject) { + const updatedProject = { + ...activeProject, + scenes: ensuredScenes, + updatedAt: new Date(), + }; + + storageService + .saveProject({ project: updatedProject }) + .then(() => { + useProjectStore.setState({ activeProject: updatedProject }); + }) + .catch((error) => { + console.error( + "Failed to save project with background scene:", + error + ); + }); + } + } + }, + + clearScenes: () => { + set({ + scenes: [], + currentScene: null, + }); + }, +})); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 6dffa198..579eee7a 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -162,7 +162,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({ saveSoundEffect: async (soundEffect: SoundEffect) => {
try {
- await storageService.saveSoundEffect(soundEffect);
+ await storageService.saveSoundEffect({ soundEffect });
// Refresh saved sounds
const savedSoundsData = await storageService.loadSavedSounds();
@@ -178,7 +178,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({ removeSavedSound: async (soundId: number) => {
try {
- await storageService.removeSavedSound(soundId);
+ await storageService.removeSavedSound({ soundId });
// Update local state immediately
set((state) => ({
diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts index da8b926b..c7c9ef98 100644 --- a/apps/web/src/stores/stickers-store.ts +++ b/apps/web/src/stores/stickers-store.ts @@ -9,6 +9,12 @@ import { type CollectionInfo, type IconSearchResult, } from "@/lib/iconify-api"; +import { useProjectStore } from "@/stores/project-store"; +import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { MediaFile } from "@/types/media"; export type StickerCategory = "all" | "general" | "brands" | "emoji"; @@ -26,6 +32,7 @@ interface StickersStore { isLoadingCollection: boolean; isSearching: boolean; isDownloading: boolean; + addingSticker: string | null; setSearchQuery: (query: string) => void; setSelectedCategory: (category: StickerCategory) => void; @@ -36,6 +43,7 @@ interface StickersStore { loadCollection: (prefix: string) => Promise<void>; searchStickers: (query: string) => Promise<void>; downloadSticker: (iconName: string) => Promise<File | null>; + addStickerToTimeline: (iconName: string) => Promise<void>; addToRecentStickers: (iconName: string) => void; clearRecentStickers: () => void; @@ -58,6 +66,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({ isLoadingCollection: false, isSearching: false, isDownloading: false, + addingSticker: null, setSearchQuery: (query) => set({ searchQuery: query }), @@ -162,6 +171,50 @@ export const useStickersStore = create<StickersStore>((set, get) => ({ } }, + addStickerToTimeline: async (iconName: string) => { + set({ addingSticker: iconName }); + try { + const { activeProject } = useProjectStore.getState(); + if (!activeProject) { + throw new Error("No active project"); + } + + const file = await get().downloadSticker(iconName); + if (!file) { + throw new Error("Failed to download sticker"); + } + + const mediaItem: Omit<MediaFile, "id"> = { + name: iconName.replace(":", "-"), + type: "image", + file, + url: URL.createObjectURL(file), + width: 200, + height: 200, + duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + ephemeral: false, + }; + + const { addMediaFile } = useMediaStore.getState(); + await addMediaFile(activeProject.id, mediaItem); + + const added = useMediaStore + .getState() + .mediaFiles.find( + (m) => m.url === mediaItem.url && m.name === mediaItem.name + ); + if (!added) { + throw new Error("Sticker not in media store"); + } + + const { currentTime } = usePlaybackStore.getState(); + const { addElementAtTime } = useTimelineStore.getState(); + addElementAtTime(added, currentTime); + } finally { + set({ addingSticker: null }); + } + }, + addToRecentStickers: (iconName: string) => { set((state) => { const recent = [ diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f0bae00c..aeeb65c5 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -6,19 +6,22 @@ import { TimelineTrack, TextElement, DragData, + MediaElement, sortTracksByOrder, ensureMainTrack, validateElementTrackCompatibility, } from "@/types/timeline"; import { useMediaStore, getMediaAspectRatio } from "./media-store"; -import { MediaFile } from "@/types/media"; +import { MediaFile, MediaType } from "@/types/media"; import { findBestCanvasPreset } from "@/lib/editor-utils"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; +import { useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { usePlaybackStore } from "./playback-store"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -98,11 +101,7 @@ interface TimelineStore { removeTrack: (trackId: string) => void; removeTrackWithRipple: (trackId: string) => void; addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; - removeElementFromTrack: ( - trackId: string, - elementId: string, - pushHistory?: boolean - ) => void; + moveElementToTrack: ( fromTrackId: string, toTrackId: string, @@ -128,15 +127,6 @@ interface TimelineStore { pushHistory?: boolean ) => void; toggleTrackMute: (trackId: string) => void; - toggleElementHidden: (trackId: string, elementId: string) => void; - toggleElementMuted: (trackId: string, elementId: string) => void; - - // Split operations for elements - splitElement: ( - trackId: string, - elementId: string, - splitTime: number - ) => string | null; splitAndKeepLeft: ( trackId: string, elementId: string, @@ -178,13 +168,52 @@ interface TimelineStore { pushHistory: () => void; // Persistence actions - loadProjectTimeline: (projectId: string) => Promise<void>; - saveProjectTimeline: (projectId: string) => Promise<void>; + loadProjectTimeline: ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => Promise<void>; + saveProjectTimeline: ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => Promise<void>; clearTimeline: () => void; // Clipboard actions copySelected: () => void; pasteAtTime: (time: number) => void; + + // Unified selection-aware actions + deleteSelected: (trackId?: string, elementId?: string) => void; + splitSelected: ( + splitTime: number, + trackId?: string, + elementId?: string + ) => void; + toggleSelectedHidden: (trackId?: string, elementId?: string) => void; + toggleSelectedMuted: (trackId?: string, elementId?: string) => void; + duplicateElement: (trackId: string, elementId: string) => void; + revealElementInMedia: (elementId: string) => void; + replaceElementWithFile: ( + trackId: string, + elementId: string, + file: File + ) => Promise<void>; + getContextMenuState: ( + trackId: string, + elementId: string + ) => { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + hasAudioElements: boolean; + canSplitSelected: boolean; + currentTime: number; + }; updateTextElement: ( trackId: string, elementId: string, @@ -235,12 +264,27 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { // Helper to auto-save timeline changes const autoSaveTimeline = async () => { const activeProject = useProjectStore.getState().activeProject; - if (activeProject) { + const currentScene = useSceneStore.getState().currentScene; + + if (activeProject && currentScene) { try { - await storageService.saveTimeline(activeProject.id, get()._tracks); + await storageService.saveTimeline({ + projectId: activeProject.id, + tracks: get()._tracks, + sceneId: currentScene.id, + }); } catch (error) { console.error("Failed to auto-save timeline:", error); } + } else { + console.warn( + "Auto-save skipped - missing activeProject or currentScene:", + { + hasProject: !!activeProject, + hasScene: !!currentScene, + sceneName: currentScene?.name, + } + ); } }; @@ -547,16 +591,19 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { get().selectElement(trackId, newElement.id); }, - removeElementFromTrack: (trackId, elementId, pushHistory = true) => { - const { rippleEditingEnabled } = get(); + removeElementFromTrackWithRipple: ( + trackId, + elementId, + pushHistory = true + ) => { + const { _tracks, rippleEditingEnabled } = get(); - if (rippleEditingEnabled) { - get().removeElementFromTrackWithRipple(trackId, elementId, pushHistory); - } else { + if (!rippleEditingEnabled) { + // Inline non-ripple removal logic if (pushHistory) get().pushHistory(); updateTracksAndSave( - get() - ._tracks.map((track) => + _tracks + .map((track) => track.id === trackId ? { ...track, @@ -568,18 +615,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ) .filter((track) => track.elements.length > 0) ); - } - }, - - removeElementFromTrackWithRipple: ( - trackId, - elementId, - pushHistory = true - ) => { - const { _tracks, rippleEditingEnabled } = get(); - - if (!rippleEditingEnabled) { - get().removeElementFromTrack(trackId, elementId, pushHistory); return; } @@ -836,42 +871,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ); }, - toggleElementHidden: (trackId, elementId) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId - ? { ...element, hidden: !element.hidden } - : element - ), - } - : track - ) - ); - }, - - toggleElementMuted: (trackId, elementId) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId && element.type === "media" - ? { ...element, muted: !element.muted } - : element - ), - } - : track - ) - ); - }, - updateTextElement: (trackId, elementId, updates) => { get().pushHistory(); updateTracksAndSave( @@ -890,60 +889,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ); }, - splitElement: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - - if (!element) return null; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; - - get().pushHistory(); - - const relativeTime = splitTime - element.startTime; - const firstDuration = relativeTime; - const secondDuration = - element.duration - element.trimStart - element.trimEnd - relativeTime; - - const secondElementId = generateUUID(); - - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.flatMap((c) => - c.id === elementId - ? [ - { - ...c, - trimEnd: c.trimEnd + secondDuration, - name: getElementNameWithSuffix(c.name, "left"), - }, - { - ...c, - id: secondElementId, - startTime: splitTime, - trimStart: c.trimStart + firstDuration, - name: getElementNameWithSuffix(c.name, "right"), - }, - ] - : [c] - ), - } - : track - ) - ); - - return secondElementId; - }, - // Split element and keep only the left portion splitAndKeepLeft: (trackId, elementId, splitTime) => { const { _tracks } = get(); @@ -1122,9 +1067,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }; } - const mediaData: any = { + const mediaData: Omit<MediaFile, "id"> = { name: newFile.name, - type: fileType, + type: fileType as MediaType, file: newFile, url: URL.createObjectURL(newFile), }; @@ -1229,8 +1174,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { getProjectThumbnail: async (projectId) => { try { - const tracks = await storageService.loadTimeline(projectId); - const mediaItems = await storageService.loadAllMediaFiles(projectId); + const project = await storageService.loadProject({ id: projectId }); + if (!project) return null; + + // For scene-based projects, use main scene timeline + // For legacy projects, use legacy timeline format + let sceneId: string | undefined; + if (project.scenes && project.scenes.length > 0) { + const mainScene = project.scenes.find((s) => s.isMain); + sceneId = mainScene?.id; + } + + const tracks = await storageService.loadTimeline({ + projectId, + sceneId, + }); + const mediaItems = await storageService.loadAllMediaFiles({ + projectId, + }); if (!tracks || !mediaItems.length) return null; @@ -1330,9 +1291,19 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }); }, - loadProjectTimeline: async (projectId) => { + loadProjectTimeline: async ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => { try { - const tracks = await storageService.loadTimeline(projectId); + const tracks = await storageService.loadTimeline({ + projectId, + sceneId, + }); + if (tracks) { updateTracks(tracks); } else { @@ -1348,9 +1319,20 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { } }, - saveProjectTimeline: async (projectId) => { + saveProjectTimeline: async ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => { + const { _tracks } = get(); try { - await storageService.saveTimeline(projectId, get()._tracks); + await storageService.saveTimeline({ + projectId, + tracks: _tracks, + sceneId, + }); } catch (error) { console.error("Failed to save timeline:", error); } @@ -1541,6 +1523,332 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { } } }, + + deleteSelected: (trackId?: string, elementId?: string) => { + const { selectedElements, rippleEditingEnabled } = get(); + + const elementsToDelete = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToDelete.length === 0) return; + + get().pushHistory(); + + if (rippleEditingEnabled) { + for (const { trackId: tId, elementId: eId } of elementsToDelete) { + get().removeElementFromTrackWithRipple(tId, eId, false); + } + } else { + updateTracksAndSave( + get() + ._tracks.map((track) => ({ + ...track, + elements: track.elements.filter( + (element) => + !elementsToDelete.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ) + ), + })) + .filter((track) => track.elements.length > 0) + ); + } + + get().clearSelectedElements(); + }, + + splitSelected: (splitTime, trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + const elementsToSplit: Array<{ + trackId: string; + elementId: string; + element: TimelineElement; + }> = []; + + for (const { trackId: tId, elementId: eId } of elementsToProcess) { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + if (!track || !element) continue; + + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + elementsToSplit.push({ trackId: tId, elementId: eId, element }); + } + } + + if (elementsToSplit.length === 0) { + const { toast } = require("sonner"); + const isMultiple = elementsToProcess.length > 1; + toast.error( + isMultiple + ? "Playhead must be within all selected elements to split" + : "Playhead must be within element to split" + ); + return; + } + + get().pushHistory(); + + updateTracksAndSave( + get()._tracks.map((track) => { + const elementsToSplitInTrack = elementsToSplit.filter( + ({ trackId: tId }) => tId === track.id + ); + + if (elementsToSplitInTrack.length === 0) return track; + + return { + ...track, + elements: track.elements.flatMap((c) => { + const elementToSplit = elementsToSplitInTrack.find( + ({ elementId: eId }) => eId === c.id + ); + + if (!elementToSplit) return [c]; + + const relativeTime = splitTime - elementToSplit.element.startTime; + const firstDuration = relativeTime; + const secondDuration = + elementToSplit.element.duration - + elementToSplit.element.trimStart - + elementToSplit.element.trimEnd - + relativeTime; + + const secondElementId = generateUUID(); + + return [ + { + ...c, + trimEnd: c.trimEnd + secondDuration, + name: getElementNameWithSuffix(c.name, "left"), + }, + { + ...c, + id: secondElementId, + startTime: splitTime, + trimStart: c.trimStart + firstDuration, + name: getElementNameWithSuffix(c.name, "right"), + }, + ]; + }), + }; + }) + ); + }, + + toggleSelectedHidden: (trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + get().pushHistory(); + + const shouldHide = elementsToProcess.some( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element && !element.hidden; + } + ); + + updateTracksAndSave( + _tracks.map((track) => ({ + ...track, + elements: track.elements.map((element) => { + const shouldUpdate = elementsToProcess.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ); + return shouldUpdate && element.hidden !== shouldHide + ? { ...element, hidden: shouldHide } + : element; + }), + })) + ); + }, + + toggleSelectedMuted: (trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + get().pushHistory(); + + const audioElements = elementsToProcess.filter( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element?.type === "media"; + } + ); + + if (audioElements.length === 0) return; + + const shouldMute = audioElements.some( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element?.type === "media" && !element.muted; + } + ); + + updateTracksAndSave( + _tracks.map((track) => ({ + ...track, + elements: track.elements.map((element) => { + const shouldUpdate = audioElements.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ); + return shouldUpdate && + element.type === "media" && + element.muted !== shouldMute + ? { ...element, muted: shouldMute } + : element; + }), + })) + ); + }, + + duplicateElement: (trackId, elementId) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); + if (!track || !element) return; + + const { id, ...elementWithoutId } = element; + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + + get().addElementToTrack(trackId, { + ...elementWithoutId, + name: `${element.name} (copy)`, + startTime: element.startTime + effectiveDuration + 0.1, + } as CreateTimelineElement); + }, + + revealElementInMedia: (elementId) => { + const { + useMediaPanelStore, + } = require("../components/editor/media-panel/store"); + const { requestRevealMedia } = useMediaPanelStore.getState(); + + const { _tracks } = get(); + const element = _tracks + .flatMap((track) => track.elements) + .find((el) => el.id === elementId); + + if (element?.type === "media") { + requestRevealMedia(element.mediaId); + } + }, + + replaceElementWithFile: async (trackId, elementId, file) => { + try { + const result = await get().replaceElementMedia( + trackId, + elementId, + file + ); + if (result.success) { + const { toast } = await import("sonner"); + toast.success("Clip replaced successfully"); + } else { + const { toast } = await import("sonner"); + toast.error(result.error || "Failed to replace clip"); + } + } catch (error) { + console.error("Unexpected error replacing clip:", error); + const { toast } = await import("sonner"); + toast.error( + `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + }, + + getContextMenuState: (trackId, elementId) => { + const { selectedElements, _tracks } = get(); + const { currentTime } = usePlaybackStore.getState(); + const { mediaFiles } = useMediaStore.getState(); + + const isMultipleSelected = selectedElements.length > 1; + const isCurrentElementSelected = selectedElements.some( + (sel) => sel.trackId === trackId && sel.elementId === elementId + ); + + const hasAudioElements = selectedElements.some( + ({ trackId: tId, elementId: eId }) => { + const selectedTrack = _tracks.find((t) => t.id === tId); + const selectedElement = selectedTrack?.elements.find( + (e) => e.id === eId + ); + if (selectedElement?.type !== "media") return false; + const mediaElement = selectedElement as MediaElement; + const mediaItem = mediaFiles.find( + (file: MediaFile) => file.id === mediaElement.mediaId + ); + return mediaItem?.type === "audio" || mediaItem?.type === "video"; + } + ); + + const canSplitSelected = selectedElements.every( + ({ trackId: tId, elementId: eId }) => { + const selectedTrack = _tracks.find((t) => t.id === tId); + const selectedElement = selectedTrack?.elements.find( + (e) => e.id === eId + ); + if (!selectedElement) return false; + const effectiveStart = selectedElement.startTime; + const effectiveEnd = + selectedElement.startTime + + (selectedElement.duration - + selectedElement.trimStart - + selectedElement.trimEnd); + return currentTime > effectiveStart && currentTime < effectiveEnd; + } + ); + + return { + isMultipleSelected, + isCurrentElementSelected, + hasAudioElements, + canSplitSelected, + currentTime, + }; + }, }; }); |
