diff options
Diffstat (limited to 'apps/web/src/stores')
| -rw-r--r-- | apps/web/src/stores/editor-store.ts | 210 | ||||
| -rw-r--r-- | apps/web/src/stores/keybindings-store.ts | 360 | ||||
| -rw-r--r-- | apps/web/src/stores/media-store.ts | 438 | ||||
| -rw-r--r-- | apps/web/src/stores/panel-store.ts | 90 | ||||
| -rw-r--r-- | apps/web/src/stores/playback-store.ts | 206 | ||||
| -rw-r--r-- | apps/web/src/stores/project-store.ts | 722 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 2438 |
7 files changed, 2235 insertions, 2229 deletions
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index c24929a2..2b7e6181 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -1,105 +1,105 @@ -import { create } from "zustand";
-import { CanvasSize, CanvasPreset } from "@/types/editor";
-
-type CanvasMode = "preset" | "original" | "custom";
-
-interface EditorState {
- // Loading states
- isInitializing: boolean;
- isPanelsReady: boolean;
-
- // Canvas/Project settings
- canvasSize: CanvasSize;
- canvasMode: CanvasMode;
- canvasPresets: CanvasPreset[];
-
- // Actions
- setInitializing: (loading: boolean) => void;
- setPanelsReady: (ready: boolean) => void;
- initializeApp: () => Promise<void>;
- setCanvasSize: (size: CanvasSize) => void;
- setCanvasSizeToOriginal: (aspectRatio: number) => void;
- setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
-}
-
-const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
- { name: "16:9", width: 1920, height: 1080 },
- { name: "9:16", width: 1080, height: 1920 },
- { name: "1:1", width: 1080, height: 1080 },
- { name: "4:3", width: 1440, height: 1080 },
-];
-
-// Helper function to find the best matching canvas preset for an aspect ratio
-const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
- // Calculate aspect ratio for each preset and find the closest match
- let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
- let smallestDifference = Math.abs(
- aspectRatio - bestMatch.width / bestMatch.height
- );
-
- for (const preset of DEFAULT_CANVAS_PRESETS) {
- const presetAspectRatio = preset.width / preset.height;
- const difference = Math.abs(aspectRatio - presetAspectRatio);
-
- if (difference < smallestDifference) {
- smallestDifference = difference;
- bestMatch = preset;
- }
- }
-
- // If the difference is still significant (> 0.1), create a custom size
- // based on the media aspect ratio with a reasonable resolution
- const bestAspectRatio = bestMatch.width / bestMatch.height;
- if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
- // Create custom dimensions based on the aspect ratio
- if (aspectRatio > 1) {
- // Landscape - use 1920 width
- return { width: 1920, height: Math.round(1920 / aspectRatio) };
- } else {
- // Portrait or square - use 1080 height
- return { width: Math.round(1080 * aspectRatio), height: 1080 };
- }
- }
-
- return { width: bestMatch.width, height: bestMatch.height };
-};
-
-export const useEditorStore = create<EditorState>((set, get) => ({
- // Initial states
- isInitializing: true,
- isPanelsReady: false,
- canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
- canvasMode: "preset" as CanvasMode,
- canvasPresets: DEFAULT_CANVAS_PRESETS,
-
- // Actions
- setInitializing: (loading) => {
- set({ isInitializing: loading });
- },
-
- setPanelsReady: (ready) => {
- set({ isPanelsReady: ready });
- },
-
- initializeApp: async () => {
- console.log("Initializing video editor...");
- set({ isInitializing: true, isPanelsReady: false });
-
- set({ isPanelsReady: true, isInitializing: false });
- console.log("Video editor ready");
- },
-
- setCanvasSize: (size) => {
- set({ canvasSize: size, canvasMode: "preset" });
- },
-
- setCanvasSizeToOriginal: (aspectRatio) => {
- const newCanvasSize = findBestCanvasPreset(aspectRatio);
- set({ canvasSize: newCanvasSize, canvasMode: "original" });
- },
-
- setCanvasSizeFromAspectRatio: (aspectRatio) => {
- const newCanvasSize = findBestCanvasPreset(aspectRatio);
- set({ canvasSize: newCanvasSize, canvasMode: "custom" });
- },
-}));
+import { create } from "zustand"; +import { CanvasSize, CanvasPreset } from "@/types/editor"; + +type CanvasMode = "preset" | "original" | "custom"; + +interface EditorState { + // Loading states + isInitializing: boolean; + isPanelsReady: boolean; + + // Canvas/Project settings + canvasSize: CanvasSize; + canvasMode: CanvasMode; + canvasPresets: CanvasPreset[]; + + // Actions + setInitializing: (loading: boolean) => void; + setPanelsReady: (ready: boolean) => void; + initializeApp: () => Promise<void>; + setCanvasSize: (size: CanvasSize) => void; + setCanvasSizeToOriginal: (aspectRatio: number) => void; + setCanvasSizeFromAspectRatio: (aspectRatio: number) => void; +} + +const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [ + { name: "16:9", width: 1920, height: 1080 }, + { name: "9:16", width: 1080, height: 1920 }, + { name: "1:1", width: 1080, height: 1080 }, + { name: "4:3", width: 1440, height: 1080 }, +]; + +// Helper function to find the best matching canvas preset for an aspect ratio +const findBestCanvasPreset = (aspectRatio: number): CanvasSize => { + // Calculate aspect ratio for each preset and find the closest match + let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD + let smallestDifference = Math.abs( + aspectRatio - bestMatch.width / bestMatch.height, + ); + + for (const preset of DEFAULT_CANVAS_PRESETS) { + const presetAspectRatio = preset.width / preset.height; + const difference = Math.abs(aspectRatio - presetAspectRatio); + + if (difference < smallestDifference) { + smallestDifference = difference; + bestMatch = preset; + } + } + + // If the difference is still significant (> 0.1), create a custom size + // based on the media aspect ratio with a reasonable resolution + const bestAspectRatio = bestMatch.width / bestMatch.height; + if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) { + // Create custom dimensions based on the aspect ratio + if (aspectRatio > 1) { + // Landscape - use 1920 width + return { width: 1920, height: Math.round(1920 / aspectRatio) }; + } else { + // Portrait or square - use 1080 height + return { width: Math.round(1080 * aspectRatio), height: 1080 }; + } + } + + return { width: bestMatch.width, height: bestMatch.height }; +}; + +export const useEditorStore = create<EditorState>((set, get) => ({ + // Initial states + isInitializing: true, + isPanelsReady: false, + canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD + canvasMode: "preset" as CanvasMode, + canvasPresets: DEFAULT_CANVAS_PRESETS, + + // Actions + setInitializing: (loading) => { + set({ isInitializing: loading }); + }, + + setPanelsReady: (ready) => { + set({ isPanelsReady: ready }); + }, + + initializeApp: async () => { + console.log("Initializing video editor..."); + set({ isInitializing: true, isPanelsReady: false }); + + set({ isPanelsReady: true, isInitializing: false }); + console.log("Video editor ready"); + }, + + setCanvasSize: (size) => { + set({ canvasSize: size, canvasMode: "preset" }); + }, + + setCanvasSizeToOriginal: (aspectRatio) => { + const newCanvasSize = findBestCanvasPreset(aspectRatio); + set({ canvasSize: newCanvasSize, canvasMode: "original" }); + }, + + setCanvasSizeFromAspectRatio: (aspectRatio) => { + const newCanvasSize = findBestCanvasPreset(aspectRatio); + set({ canvasSize: newCanvasSize, canvasMode: "custom" }); + }, +})); diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 60c71afd..b12c83ad 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -8,234 +8,234 @@ import { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; // Default keybindings configuration export const defaultKeybindings: KeybindingConfig = { - space: "toggle-play", - j: "seek-backward", - k: "toggle-play", - l: "seek-forward", - left: "frame-step-backward", - right: "frame-step-forward", - "shift+left": "jump-backward", - "shift+right": "jump-forward", - home: "goto-start", - end: "goto-end", - s: "split-element", - n: "toggle-snapping", - "ctrl+a": "select-all", - "ctrl+d": "duplicate-selected", - "ctrl+z": "undo", - "ctrl+shift+z": "redo", - "ctrl+y": "redo", - delete: "delete-selected", - backspace: "delete-selected", + space: "toggle-play", + j: "seek-backward", + k: "toggle-play", + l: "seek-forward", + left: "frame-step-backward", + right: "frame-step-forward", + "shift+left": "jump-backward", + "shift+right": "jump-forward", + home: "goto-start", + end: "goto-end", + s: "split-element", + n: "toggle-snapping", + "ctrl+a": "select-all", + "ctrl+d": "duplicate-selected", + "ctrl+z": "undo", + "ctrl+shift+z": "redo", + "ctrl+y": "redo", + delete: "delete-selected", + backspace: "delete-selected", }; export interface KeybindingConflict { - key: ShortcutKey; - existingAction: ActionWithOptionalArgs; - newAction: ActionWithOptionalArgs; + key: ShortcutKey; + existingAction: ActionWithOptionalArgs; + newAction: ActionWithOptionalArgs; } interface KeybindingsState { - keybindings: KeybindingConfig; - isCustomized: boolean; - keybindingsEnabled: boolean; + keybindings: KeybindingConfig; + isCustomized: boolean; + keybindingsEnabled: boolean; - // Actions - updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void; - removeKeybinding: (key: ShortcutKey) => void; - resetToDefaults: () => void; - importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; - enableKeybindings: () => void; - disableKeybindings: () => void; + // Actions + updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void; + removeKeybinding: (key: ShortcutKey) => void; + resetToDefaults: () => void; + importKeybindings: (config: KeybindingConfig) => void; + exportKeybindings: () => KeybindingConfig; + enableKeybindings: () => void; + disableKeybindings: () => void; - // Validation - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs - ) => KeybindingConflict | null; - getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[]; + // Validation + validateKeybinding: ( + key: ShortcutKey, + action: ActionWithOptionalArgs, + ) => KeybindingConflict | null; + getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[]; - // Utility - getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; + // Utility + getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } export const useKeybindingsStore = create<KeybindingsState>()( - persist( - (set, get) => ({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - keybindingsEnabled: true, + persist( + (set, get) => ({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + keybindingsEnabled: true, - updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; + updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + newKeybindings[key] = action; - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, - removeKeybinding: (key: ShortcutKey) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; + removeKeybinding: (key: ShortcutKey) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + delete newKeybindings[key]; - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, - resetToDefaults: () => { - set({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - }); - }, + resetToDefaults: () => { + set({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + }); + }, - enableKeybindings: () => { - set({ keybindingsEnabled: true }); - }, + enableKeybindings: () => { + set({ keybindingsEnabled: true }); + }, - disableKeybindings: () => { - set({ keybindingsEnabled: false }); - }, + disableKeybindings: () => { + set({ keybindingsEnabled: false }); + }, - importKeybindings: (config: KeybindingConfig) => { - // Validate all keys and actions - for (const [key, action] of Object.entries(config)) { - // Validate the key format - if (typeof key !== "string" || key.length === 0) { - throw new Error(`Invalid key format: ${key}`); - } - } - set({ - keybindings: { ...config }, - isCustomized: true, - }); - }, + importKeybindings: (config: KeybindingConfig) => { + // Validate all keys and actions + for (const [key, action] of Object.entries(config)) { + // Validate the key format + if (typeof key !== "string" || key.length === 0) { + throw new Error(`Invalid key format: ${key}`); + } + } + set({ + keybindings: { ...config }, + isCustomized: true, + }); + }, - exportKeybindings: () => { - return get().keybindings; - }, + exportKeybindings: () => { + return get().keybindings; + }, - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; + validateKeybinding: ( + key: ShortcutKey, + action: ActionWithOptionalArgs, + ) => { + const { keybindings } = get(); + const existingAction = keybindings[key]; - if (existingAction && existingAction !== action) { - return { - key, - existingAction, - newAction: action, - }; - } + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } - return null; - }, + return null; + }, - getKeybindingsForAction: (action: ActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action - ) as ShortcutKey[]; - }, + getKeybindingsForAction: (action: ActionWithOptionalArgs) => { + const { keybindings } = get(); + return Object.keys(keybindings).filter( + (key) => keybindings[key as ShortcutKey] === action, + ) as ShortcutKey[]; + }, - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, - }), - { - name: "opencut-keybindings", - version: 1, - } - ) + getKeybindingString: (ev: KeyboardEvent) => { + return generateKeybindingString(ev) as ShortcutKey | null; + }, + }), + { + name: "opencut-keybindings", + version: 1, + }, + ), ); // Utility functions function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { - const target = ev.target; + const target = ev.target; - // We may or may not have a modifier key - const modifierKey = getActiveModifier(ev); + // We may or may not have a modifier key + const modifierKey = getActiveModifier(ev); - // We will always have a non-modifier key - const key = getPressedKey(ev); - if (!key) return null; + // We will always have a non-modifier key + const key = getPressedKey(ev); + if (!key) return null; - // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) - if (modifierKey) { - // If the modifier is shift and the target is an input, we ignore - if ( - modifierKey === "shift" && - isDOMElement(target) && - isTypableElement(target) - ) { - return null; - } + // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) + if (modifierKey) { + // If the modifier is shift and the target is an input, we ignore + if ( + modifierKey === "shift" && + isDOMElement(target) && + isTypableElement(target) + ) { + return null; + } - return `${modifierKey}+${key}` as ShortcutKey; - } + return `${modifierKey}+${key}` as ShortcutKey; + } - // no modifier key here then we do not do anything while on input - if (isDOMElement(target) && isTypableElement(target)) return null; + // no modifier key here then we do not do anything while on input + if (isDOMElement(target) && isTypableElement(target)) return null; - // single key while not input - return `${key}` as ShortcutKey; + // single key while not input + return `${key}` as ShortcutKey; } function getPressedKey(ev: KeyboardEvent): string | null { - // Sometimes the property code is not available on the KeyboardEvent object - const key = (ev.key ?? "").toLowerCase(); + // Sometimes the property code is not available on the KeyboardEvent object + const key = (ev.key ?? "").toLowerCase(); - // Check arrow keys - if (key.startsWith("arrow")) { - return key.slice(5); - } + // Check arrow keys + if (key.startsWith("arrow")) { + return key.slice(5); + } - // 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"; - if (key === "backspace") return "backspace"; + // 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"; + if (key === "backspace") return "backspace"; - // Check letter keys - const isLetter = key.length === 1 && key >= "a" && key <= "z"; - if (isLetter) return key; + // Check letter keys + const isLetter = key.length === 1 && key >= "a" && key <= "z"; + if (isLetter) return key; - // Check if number keys - const isDigit = key.length === 1 && key >= "0" && key <= "9"; - if (isDigit) return key; + // Check if number keys + const isDigit = key.length === 1 && key >= "0" && key <= "9"; + if (isDigit) return key; - // Check if slash, period or enter - if (key === "/" || key === "." || key === "enter") return key; + // Check if slash, period or enter + if (key === "/" || key === "." || key === "enter") return key; - // If no other cases match, this is not a valid key - return null; + // If no other cases match, this is not a valid key + return null; } function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; + const modifierKeys = { + ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey, + }; - // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift - // modiferKeys object's keys are sorted to match the above order - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); + // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift + // modiferKeys object's keys are sorted to match the above order + const activeModifier = Object.keys(modifierKeys) + .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) + .join("+"); - return activeModifier === "" ? null : activeModifier; + return activeModifier === "" ? null : activeModifier; } diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index c4ee5f3e..305316c8 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -6,284 +6,288 @@ import { generateUUID } from "@/lib/utils"; export type MediaType = "image" | "video" | "audio"; export interface MediaItem { - id: string; - name: string; - type: MediaType; - file: File; - url?: string; // Object URL for preview - thumbnailUrl?: string; // For video thumbnails - duration?: number; // For video/audio duration - width?: number; // For video/image width - height?: number; // For video/image height - fps?: number; // For video frame rate - // Text-specific properties - content?: string; // Text content - fontSize?: number; // Font size - fontFamily?: string; // Font family - color?: string; // Text color - backgroundColor?: string; // Background color - textAlign?: "left" | "center" | "right"; // Text alignment + id: string; + name: string; + type: MediaType; + file: File; + url?: string; // Object URL for preview + thumbnailUrl?: string; // For video thumbnails + duration?: number; // For video/audio duration + width?: number; // For video/image width + height?: number; // For video/image height + fps?: number; // For video frame rate + // Text-specific properties + content?: string; // Text content + fontSize?: number; // Font size + fontFamily?: string; // Font family + color?: string; // Text color + backgroundColor?: string; // Background color + textAlign?: "left" | "center" | "right"; // Text alignment } interface MediaStore { - mediaItems: MediaItem[]; - isLoading: boolean; + mediaItems: MediaItem[]; + isLoading: boolean; - // Actions - now require projectId - addMediaItem: ( - projectId: string, - item: Omit<MediaItem, "id"> - ) => Promise<void>; - removeMediaItem: (projectId: string, id: string) => Promise<void>; - loadProjectMedia: (projectId: string) => Promise<void>; - clearProjectMedia: (projectId: string) => Promise<void>; - clearAllMedia: () => void; // Clear local state only + // Actions - now require projectId + addMediaItem: ( + projectId: string, + item: Omit<MediaItem, "id">, + ) => Promise<void>; + removeMediaItem: (projectId: string, id: string) => Promise<void>; + loadProjectMedia: (projectId: string) => Promise<void>; + clearProjectMedia: (projectId: string) => Promise<void>; + clearAllMedia: () => void; // Clear local state only } // Helper function to determine file type export const getFileType = (file: File): MediaType | null => { - const { type } = file; + const { type } = file; - if (type.startsWith("image/")) { - return "image"; - } - if (type.startsWith("video/")) { - return "video"; - } - if (type.startsWith("audio/")) { - return "audio"; - } + if (type.startsWith("image/")) { + return "image"; + } + if (type.startsWith("video/")) { + return "video"; + } + if (type.startsWith("audio/")) { + return "audio"; + } - return null; + return null; }; // Helper function to get image dimensions export const getImageDimensions = ( - file: File + file: File, ): Promise<{ width: number; height: number }> => { - return new Promise((resolve, reject) => { - const img = new window.Image(); + return new Promise((resolve, reject) => { + const img = new window.Image(); - img.addEventListener("load", () => { - const width = img.naturalWidth; - const height = img.naturalHeight; - resolve({ width, height }); - img.remove(); - }); + img.addEventListener("load", () => { + const width = img.naturalWidth; + const height = img.naturalHeight; + resolve({ width, height }); + img.remove(); + }); - img.addEventListener("error", () => { - reject(new Error("Could not load image")); - img.remove(); - }); + img.addEventListener("error", () => { + reject(new Error("Could not load image")); + img.remove(); + }); - img.src = URL.createObjectURL(file); - }); + img.src = URL.createObjectURL(file); + }); }; // Helper function to generate video thumbnail and get dimensions export const generateVideoThumbnail = ( - file: File + file: File, ): Promise<{ thumbnailUrl: string; width: number; height: number }> => { - return new Promise((resolve, reject) => { - const video = document.createElement("video") as HTMLVideoElement; - const canvas = document.createElement("canvas") as HTMLCanvasElement; - const ctx = canvas.getContext("2d"); + return new Promise((resolve, reject) => { + const video = document.createElement("video") as HTMLVideoElement; + const canvas = document.createElement("canvas") as HTMLCanvasElement; + const ctx = canvas.getContext("2d"); - if (!ctx) { - reject(new Error("Could not get canvas context")); - return; - } + if (!ctx) { + reject(new Error("Could not get canvas context")); + return; + } - video.addEventListener("loadedmetadata", () => { - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; + video.addEventListener("loadedmetadata", () => { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; - // Seek to 1 second or 10% of duration, whichever is smaller - video.currentTime = Math.min(1, video.duration * 0.1); - }); + // Seek to 1 second or 10% of duration, whichever is smaller + video.currentTime = Math.min(1, video.duration * 0.1); + }); - video.addEventListener("seeked", () => { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8); - const width = video.videoWidth; - const height = video.videoHeight; + video.addEventListener("seeked", () => { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8); + const width = video.videoWidth; + const height = video.videoHeight; - resolve({ thumbnailUrl, width, height }); + resolve({ thumbnailUrl, width, height }); - // Cleanup - video.remove(); - canvas.remove(); - }); + // Cleanup + video.remove(); + canvas.remove(); + }); - video.addEventListener("error", () => { - reject(new Error("Could not load video")); - video.remove(); - canvas.remove(); - }); + video.addEventListener("error", () => { + reject(new Error("Could not load video")); + video.remove(); + canvas.remove(); + }); - video.src = URL.createObjectURL(file); - video.load(); - }); + video.src = URL.createObjectURL(file); + video.load(); + }); }; // Helper function to get media duration export const getMediaDuration = (file: File): Promise<number> => { - return new Promise((resolve, reject) => { - const element = document.createElement( - file.type.startsWith("video/") ? "video" : "audio" - ) as HTMLVideoElement; + return new Promise((resolve, reject) => { + const element = document.createElement( + file.type.startsWith("video/") ? "video" : "audio", + ) as HTMLVideoElement; - element.addEventListener("loadedmetadata", () => { - resolve(element.duration); - element.remove(); - }); + element.addEventListener("loadedmetadata", () => { + resolve(element.duration); + element.remove(); + }); - element.addEventListener("error", () => { - reject(new Error("Could not load media")); - element.remove(); - }); + element.addEventListener("error", () => { + reject(new Error("Could not load media")); + element.remove(); + }); - element.src = URL.createObjectURL(file); - element.load(); - }); + element.src = URL.createObjectURL(file); + element.load(); + }); }; // Helper to get aspect ratio from MediaItem export const getMediaAspectRatio = (item: MediaItem): number => { - if (item.width && item.height) { - return item.width / item.height; - } - return 16 / 9; // Default aspect ratio + if (item.width && item.height) { + return item.width / item.height; + } + return 16 / 9; // Default aspect ratio }; export const useMediaStore = create<MediaStore>((set, get) => ({ - mediaItems: [], - isLoading: false, + mediaItems: [], + isLoading: false, - addMediaItem: async (projectId, item) => { - const newItem: MediaItem = { - ...item, - id: generateUUID(), - }; + addMediaItem: async (projectId, item) => { + const newItem: MediaItem = { + ...item, + id: generateUUID(), + }; - // Add to local state immediately for UI responsiveness - set((state) => ({ - mediaItems: [...state.mediaItems, newItem], - })); + // Add to local state immediately for UI responsiveness + set((state) => ({ + mediaItems: [...state.mediaItems, newItem], + })); - // Save to persistent storage in background - try { - await storageService.saveMediaItem(projectId, newItem); - } catch (error) { - console.error("Failed to save media item:", error); - // Remove from local state if save failed - set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), - })); - } - }, + // Save to persistent storage in background + try { + await storageService.saveMediaItem(projectId, newItem); + } catch (error) { + console.error("Failed to save media item:", error); + // Remove from local state if save failed + set((state) => ({ + mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), + })); + } + }, - removeMediaItem: async (projectId, id: string) => { - const state = get(); - const item = state.mediaItems.find((media) => media.id === id); + removeMediaItem: async (projectId, id: string) => { + const state = get(); + const item = state.mediaItems.find((media) => media.id === id); - // Cleanup object URLs to prevent memory leaks - if (item && item.url) { - URL.revokeObjectURL(item.url); - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - } + // Cleanup object URLs to prevent memory leaks + if (item && item.url) { + URL.revokeObjectURL(item.url); + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + } - // Remove from local state immediately - set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== id), - })); + // Remove from local state immediately + set((state) => ({ + mediaItems: state.mediaItems.filter((media) => media.id !== id), + })); - // Remove from persistent storage - try { - await storageService.deleteMediaItem(projectId, id); - } catch (error) { - console.error("Failed to delete media item:", error); - } - }, + // Remove from persistent storage + try { + await storageService.deleteMediaItem(projectId, id); + } catch (error) { + console.error("Failed to delete media item:", error); + } + }, - loadProjectMedia: async (projectId) => { - set({ isLoading: true }); + loadProjectMedia: async (projectId) => { + set({ isLoading: true }); - try { - const mediaItems = await storageService.loadAllMediaItems(projectId); + try { + const mediaItems = await storageService.loadAllMediaItems(projectId); - // Regenerate thumbnails for video items - const updatedMediaItems = await Promise.all( - mediaItems.map(async (item) => { - if (item.type === "video" && item.file) { - try { - const { thumbnailUrl, width, height } = await generateVideoThumbnail(item.file); - return { - ...item, - thumbnailUrl, - width: width || item.width, - height: height || item.height - }; - } catch (error) { - console.error(`Failed to regenerate thumbnail for video ${item.id}:`, error); - return item; - } - } - return item; - }) - ); + // Regenerate thumbnails for video items + const updatedMediaItems = await Promise.all( + mediaItems.map(async (item) => { + if (item.type === "video" && item.file) { + try { + const { thumbnailUrl, width, height } = + await generateVideoThumbnail(item.file); + return { + ...item, + thumbnailUrl, + width: width || item.width, + height: height || item.height, + }; + } catch (error) { + console.error( + `Failed to regenerate thumbnail for video ${item.id}:`, + error, + ); + return item; + } + } + return item; + }), + ); - set({ mediaItems: updatedMediaItems }); - } catch (error) { - console.error("Failed to load media items:", error); - } finally { - set({ isLoading: false }); - } - }, + set({ mediaItems: updatedMediaItems }); + } catch (error) { + console.error("Failed to load media items:", error); + } finally { + set({ isLoading: false }); + } + }, - clearProjectMedia: async (projectId) => { - const state = get(); + clearProjectMedia: async (projectId) => { + const state = get(); - // Cleanup all object URLs - state.mediaItems.forEach((item) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - }); + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); - // Clear local state - set({ mediaItems: [] }); + // Clear local state + set({ mediaItems: [] }); - // Clear persistent storage - try { - const mediaIds = state.mediaItems.map((item) => item.id); - await Promise.all( - mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)) - ); - } catch (error) { - console.error("Failed to clear media items from storage:", error); - } - }, + // Clear persistent storage + try { + const mediaIds = state.mediaItems.map((item) => item.id); + await Promise.all( + mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)), + ); + } catch (error) { + console.error("Failed to clear media items from storage:", error); + } + }, - clearAllMedia: () => { - const state = get(); + clearAllMedia: () => { + const state = get(); - // Cleanup all object URLs - state.mediaItems.forEach((item) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - }); + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); - // Clear local state - set({ mediaItems: [] }); - }, + // Clear local state + set({ mediaItems: [] }); + }, })); diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts index 450b6a6e..8360ad57 100644 --- a/apps/web/src/stores/panel-store.ts +++ b/apps/web/src/stores/panel-store.ts @@ -1,45 +1,45 @@ -import { create } from "zustand";
-import { persist } from "zustand/middleware";
-
-const DEFAULT_PANEL_SIZES = {
- toolsPanel: 45,
- previewPanel: 75,
- propertiesPanel: 20,
- mainContent: 70,
- timeline: 30,
-} as const;
-
-interface PanelState {
- // Panel sizes as percentages
- toolsPanel: number;
- previewPanel: number;
- propertiesPanel: number;
- mainContent: number;
- timeline: number;
-
- // Actions
- setToolsPanel: (size: number) => void;
- setPreviewPanel: (size: number) => void;
- setPropertiesPanel: (size: number) => void;
- setMainContent: (size: number) => void;
- setTimeline: (size: number) => void;
-}
-
-export const usePanelStore = create<PanelState>()(
- persist(
- (set) => ({
- // Default sizes - optimized for responsiveness
- ...DEFAULT_PANEL_SIZES,
-
- // Actions
- setToolsPanel: (size) => set({ toolsPanel: size }),
- setPreviewPanel: (size) => set({ previewPanel: size }),
- setPropertiesPanel: (size) => set({ propertiesPanel: size }),
- setMainContent: (size) => set({ mainContent: size }),
- setTimeline: (size) => set({ timeline: size }),
- }),
- {
- name: "panel-sizes",
- }
- )
-);
+import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +const DEFAULT_PANEL_SIZES = { + toolsPanel: 45, + previewPanel: 75, + propertiesPanel: 20, + mainContent: 70, + timeline: 30, +} as const; + +interface PanelState { + // Panel sizes as percentages + toolsPanel: number; + previewPanel: number; + propertiesPanel: number; + mainContent: number; + timeline: number; + + // Actions + setToolsPanel: (size: number) => void; + setPreviewPanel: (size: number) => void; + setPropertiesPanel: (size: number) => void; + setMainContent: (size: number) => void; + setTimeline: (size: number) => void; +} + +export const usePanelStore = create<PanelState>()( + persist( + (set) => ({ + // Default sizes - optimized for responsiveness + ...DEFAULT_PANEL_SIZES, + + // Actions + setToolsPanel: (size) => set({ toolsPanel: size }), + setPreviewPanel: (size) => set({ previewPanel: size }), + setPropertiesPanel: (size) => set({ propertiesPanel: size }), + setMainContent: (size) => set({ mainContent: size }), + setTimeline: (size) => set({ timeline: size }), + }), + { + name: "panel-sizes", + }, + ), +); diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts index 543865f9..3ef1d95c 100644 --- a/apps/web/src/stores/playback-store.ts +++ b/apps/web/src/stores/playback-store.ts @@ -2,133 +2,133 @@ import { create } from "zustand"; import type { PlaybackState, PlaybackControls } from "@/types/playback"; interface PlaybackStore extends PlaybackState, PlaybackControls { - setDuration: (duration: number) => void; - setCurrentTime: (time: number) => void; + setDuration: (duration: number) => void; + setCurrentTime: (time: number) => void; } let playbackTimer: number | null = null; const startTimer = (store: () => PlaybackStore) => { - if (playbackTimer) cancelAnimationFrame(playbackTimer); + if (playbackTimer) cancelAnimationFrame(playbackTimer); - // Use requestAnimationFrame for smoother updates - const updateTime = () => { - const state = store(); - if (state.isPlaying && state.currentTime < state.duration) { - const now = performance.now(); - const delta = (now - lastUpdate) / 1000; // Convert to seconds - lastUpdate = now; + // Use requestAnimationFrame for smoother updates + const updateTime = () => { + const state = store(); + if (state.isPlaying && state.currentTime < state.duration) { + const now = performance.now(); + const delta = (now - lastUpdate) / 1000; // Convert to seconds + lastUpdate = now; - const newTime = state.currentTime + delta * state.speed; - if (newTime >= state.duration) { - // When video completes, pause and reset playhead to start - state.pause(); - state.setCurrentTime(0); - // Notify video elements to sync with reset - window.dispatchEvent( - new CustomEvent("playback-seek", { detail: { time: 0 } }) - ); - } else { - state.setCurrentTime(newTime); - // Notify video elements to sync - window.dispatchEvent( - new CustomEvent("playback-update", { detail: { time: newTime } }) - ); - } - } - playbackTimer = requestAnimationFrame(updateTime); - }; + const newTime = state.currentTime + delta * state.speed; + if (newTime >= state.duration) { + // When video completes, pause and reset playhead to start + state.pause(); + state.setCurrentTime(0); + // Notify video elements to sync with reset + window.dispatchEvent( + new CustomEvent("playback-seek", { detail: { time: 0 } }), + ); + } else { + state.setCurrentTime(newTime); + // Notify video elements to sync + window.dispatchEvent( + new CustomEvent("playback-update", { detail: { time: newTime } }), + ); + } + } + playbackTimer = requestAnimationFrame(updateTime); + }; - let lastUpdate = performance.now(); - playbackTimer = requestAnimationFrame(updateTime); + let lastUpdate = performance.now(); + playbackTimer = requestAnimationFrame(updateTime); }; const stopTimer = () => { - if (playbackTimer) { - cancelAnimationFrame(playbackTimer); - playbackTimer = null; - } + if (playbackTimer) { + cancelAnimationFrame(playbackTimer); + playbackTimer = null; + } }; export const usePlaybackStore = create<PlaybackStore>((set, get) => ({ - isPlaying: false, - currentTime: 0, - duration: 0, - volume: 1, - muted: false, - previousVolume: 1, - speed: 1.0, + isPlaying: false, + currentTime: 0, + duration: 0, + volume: 1, + muted: false, + previousVolume: 1, + speed: 1.0, - play: () => { - set({ isPlaying: true }); - startTimer(get); - }, + play: () => { + set({ isPlaying: true }); + startTimer(get); + }, - pause: () => { - set({ isPlaying: false }); - stopTimer(); - }, + pause: () => { + set({ isPlaying: false }); + stopTimer(); + }, - toggle: () => { - const { isPlaying } = get(); - if (isPlaying) { - get().pause(); - } else { - get().play(); - } - }, + toggle: () => { + const { isPlaying } = get(); + if (isPlaying) { + get().pause(); + } else { + get().play(); + } + }, - seek: (time: number) => { - const { duration } = get(); - const clampedTime = Math.max(0, Math.min(duration, time)); - set({ currentTime: clampedTime }); + seek: (time: number) => { + const { duration } = get(); + const clampedTime = Math.max(0, Math.min(duration, time)); + set({ currentTime: clampedTime }); - const event = new CustomEvent("playback-seek", { - detail: { time: clampedTime }, - }); - window.dispatchEvent(event); - }, + const event = new CustomEvent("playback-seek", { + detail: { time: clampedTime }, + }); + window.dispatchEvent(event); + }, - setVolume: (volume: number) => - set((state) => ({ - volume: Math.max(0, Math.min(1, volume)), - muted: volume === 0, - previousVolume: volume > 0 ? volume : state.previousVolume, - })), + setVolume: (volume: number) => + set((state) => ({ + volume: Math.max(0, Math.min(1, volume)), + muted: volume === 0, + previousVolume: volume > 0 ? volume : state.previousVolume, + })), - setSpeed: (speed: number) => { - const newSpeed = Math.max(0.1, Math.min(2.0, speed)); - set({ speed: newSpeed }); + setSpeed: (speed: number) => { + const newSpeed = Math.max(0.1, Math.min(2.0, speed)); + set({ speed: newSpeed }); - const event = new CustomEvent("playback-speed", { - detail: { speed: newSpeed }, - }); - window.dispatchEvent(event); - }, + const event = new CustomEvent("playback-speed", { + detail: { speed: newSpeed }, + }); + window.dispatchEvent(event); + }, - setDuration: (duration: number) => set({ duration }), - setCurrentTime: (time: number) => set({ currentTime: time }), + setDuration: (duration: number) => set({ duration }), + setCurrentTime: (time: number) => set({ currentTime: time }), - mute: () => { - const { volume, previousVolume } = get(); - set({ - muted: true, - previousVolume: volume > 0 ? volume : previousVolume, - volume: 0, - }); - }, + mute: () => { + const { volume, previousVolume } = get(); + set({ + muted: true, + previousVolume: volume > 0 ? volume : previousVolume, + volume: 0, + }); + }, - unmute: () => { - const { previousVolume } = get(); - set({ muted: false, volume: previousVolume ?? 1 }); - }, + unmute: () => { + const { previousVolume } = get(); + set({ muted: false, volume: previousVolume ?? 1 }); + }, - toggleMute: () => { - const { muted } = get(); - if (muted) { - get().unmute(); - } else { - get().mute(); - } - }, + toggleMute: () => { + const { muted } = get(); + if (muted) { + get().unmute(); + } else { + get().mute(); + } + }, })); diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index a8833982..36ab3503 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,361 +1,361 @@ -import { TProject } 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 { generateUUID } from "@/lib/utils";
-
-interface ProjectStore {
- activeProject: TProject | null;
- savedProjects: TProject[];
- isLoading: boolean;
- isInitialized: boolean;
-
- // Actions
- createNewProject: (name: string) => Promise<string>;
- loadProject: (id: string) => Promise<void>;
- saveCurrentProject: () => Promise<void>;
- loadAllProjects: () => Promise<void>;
- deleteProject: (id: string) => Promise<void>;
- closeProject: () => void;
- renameProject: (projectId: string, name: string) => Promise<void>;
- duplicateProject: (projectId: string) => Promise<string>;
- updateProjectBackground: (backgroundColor: string) => Promise<void>;
- updateBackgroundType: (
- type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number }
- ) => Promise<void>;
- updateProjectFps: (fps: number) => Promise<void>;
-
- getFilteredAndSortedProjects: (
- searchQuery: string,
- sortOption: string
- ) => TProject[];
-}
-
-export const useProjectStore = create<ProjectStore>((set, get) => ({
- activeProject: null,
- savedProjects: [],
- isLoading: true,
- isInitialized: false,
-
- createNewProject: async (name: string) => {
- const newProject: TProject = {
- id: generateUUID(),
- name,
- thumbnail: "",
- createdAt: new Date(),
- updatedAt: new Date(),
- backgroundColor: "#000000",
- backgroundType: "color",
- blurIntensity: 8,
- };
-
- set({ activeProject: newProject });
-
- try {
- await storageService.saveProject(newProject);
- // Reload all projects to update the list
- await get().loadAllProjects();
- return newProject.id;
- } catch (error) {
- toast.error("Failed to save new project");
- throw error;
- }
- },
-
- loadProject: async (id: string) => {
- if (!get().isInitialized) {
- set({ isLoading: true });
- }
-
- // Clear media and timeline immediately to prevent flickering when switching projects
- const mediaStore = useMediaStore.getState();
- const timelineStore = useTimelineStore.getState();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
-
- try {
- const project = await storageService.loadProject(id);
- if (project) {
- set({ activeProject: project });
-
- // Load project-specific data in parallel
- await Promise.all([
- mediaStore.loadProjectMedia(id),
- timelineStore.loadProjectTimeline(id),
- ]);
- } else {
- throw new Error(`Project with id ${id} not found`);
- }
- } catch (error) {
- console.error("Failed to load project:", error);
- throw error; // Re-throw so the editor page can handle it
- } finally {
- set({ isLoading: false });
- }
- },
-
- saveCurrentProject: async () => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- try {
- // Save project metadata and timeline data in parallel
- const timelineStore = useTimelineStore.getState();
- await Promise.all([
- storageService.saveProject(activeProject),
- timelineStore.saveProjectTimeline(activeProject.id),
- ]);
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to save project:", error);
- }
- },
-
- loadAllProjects: async () => {
- if (!get().isInitialized) {
- set({ isLoading: true });
- }
-
- try {
- const projects = await storageService.loadAllProjects();
- set({ savedProjects: projects });
- } catch (error) {
- console.error("Failed to load projects:", error);
- } finally {
- set({ isLoading: false, isInitialized: true });
- }
- },
-
- deleteProject: async (id: string) => {
- try {
- // Delete project data in parallel
- await Promise.all([
- storageService.deleteProjectMedia(id),
- storageService.deleteProjectTimeline(id),
- storageService.deleteProject(id),
- ]);
- await get().loadAllProjects(); // Refresh the list
-
- // If we deleted the 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();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
- }
- } catch (error) {
- console.error("Failed to delete project:", error);
- }
- },
-
- closeProject: () => {
- set({ activeProject: null });
-
- // Clear data from stores when closing project
- const mediaStore = useMediaStore.getState();
- const timelineStore = useTimelineStore.getState();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
- },
-
- renameProject: async (id: string, name: string) => {
- const { savedProjects } = get();
-
- // Find the project to rename
- const projectToRename = savedProjects.find((p) => p.id === id);
- if (!projectToRename) {
- toast.error("Project not found", {
- description: "Please try again",
- });
- return;
- }
-
- const updatedProject = {
- ...projectToRename,
- name,
- updatedAt: new Date(),
- };
-
- try {
- // Save to storage
- await storageService.saveProject(updatedProject);
-
- await get().loadAllProjects();
-
- // Update activeProject if it's the same project
- const { activeProject } = get();
- if (activeProject?.id === id) {
- set({ activeProject: updatedProject });
- }
- } catch (error) {
- console.error("Failed to rename project:", error);
- toast.error("Failed to rename project", {
- description:
- error instanceof Error ? error.message : "Please try again",
- });
- }
- },
-
- duplicateProject: async (projectId: string) => {
- try {
- const project = await storageService.loadProject(projectId);
- if (!project) {
- toast.error("Project not found", {
- description: "Please try again",
- });
- throw new Error("Project not found");
- }
-
- const { savedProjects } = get();
-
- // Extract the base name (remove any existing numbering)
- const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
- const baseName = numberMatch ? numberMatch[2] : project.name;
- const existingNumbers: number[] = [];
-
- // Check for pattern "(number) baseName" in existing projects
- savedProjects.forEach((p) => {
- const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
- if (match && match[2] === baseName) {
- existingNumbers.push(parseInt(match[1], 10));
- }
- });
-
- const nextNumber =
- existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
-
- const newProject: TProject = {
- id: generateUUID(),
- name: `(${nextNumber}) ${baseName}`,
- thumbnail: project.thumbnail,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- await storageService.saveProject(newProject);
- await get().loadAllProjects();
- return newProject.id;
- } catch (error) {
- console.error("Failed to duplicate project:", error);
- toast.error("Failed to duplicate project", {
- description:
- error instanceof Error ? error.message : "Please try again",
- });
- throw error;
- }
- },
-
- updateProjectBackground: async (backgroundColor: string) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- backgroundColor,
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update project background:", error);
- toast.error("Failed to update background", {
- description: "Please try again",
- });
- }
- },
-
- updateBackgroundType: async (
- type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number }
- ) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- backgroundType: type,
- ...(options?.backgroundColor && {
- backgroundColor: options.backgroundColor,
- }),
- ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update background type:", error);
- toast.error("Failed to update background", {
- description: "Please try again",
- });
- }
- },
-
- updateProjectFps: async (fps: number) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- fps,
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update project FPS:", error);
- toast.error("Failed to update project FPS", {
- description: "Please try again",
- });
- }
- },
-
- 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("-");
-
- if (key !== "createdAt" && key !== "name") {
- console.warn(`Invalid sort key: ${key}`);
- return 0;
- }
-
- const aValue = a[key];
- const bValue = b[key];
-
- if (aValue === undefined || bValue === undefined) return 0;
-
- if (order === "asc") {
- if (aValue < bValue) return -1;
- if (aValue > bValue) return 1;
- return 0;
- } else {
- if (aValue > bValue) return -1;
- if (aValue < bValue) return 1;
- return 0;
- }
- });
-
- return sortedProjects;
- },
-}));
+import { TProject } 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 { generateUUID } from "@/lib/utils"; + +interface ProjectStore { + activeProject: TProject | null; + savedProjects: TProject[]; + isLoading: boolean; + isInitialized: boolean; + + // Actions + createNewProject: (name: string) => Promise<string>; + loadProject: (id: string) => Promise<void>; + saveCurrentProject: () => Promise<void>; + loadAllProjects: () => Promise<void>; + deleteProject: (id: string) => Promise<void>; + closeProject: () => void; + renameProject: (projectId: string, name: string) => Promise<void>; + duplicateProject: (projectId: string) => Promise<string>; + updateProjectBackground: (backgroundColor: string) => Promise<void>; + updateBackgroundType: ( + type: "color" | "blur", + options?: { backgroundColor?: string; blurIntensity?: number }, + ) => Promise<void>; + updateProjectFps: (fps: number) => Promise<void>; + + getFilteredAndSortedProjects: ( + searchQuery: string, + sortOption: string, + ) => TProject[]; +} + +export const useProjectStore = create<ProjectStore>((set, get) => ({ + activeProject: null, + savedProjects: [], + isLoading: true, + isInitialized: false, + + createNewProject: async (name: string) => { + const newProject: TProject = { + id: generateUUID(), + name, + thumbnail: "", + createdAt: new Date(), + updatedAt: new Date(), + backgroundColor: "#000000", + backgroundType: "color", + blurIntensity: 8, + }; + + set({ activeProject: newProject }); + + try { + await storageService.saveProject(newProject); + // Reload all projects to update the list + await get().loadAllProjects(); + return newProject.id; + } catch (error) { + toast.error("Failed to save new project"); + throw error; + } + }, + + loadProject: async (id: string) => { + if (!get().isInitialized) { + set({ isLoading: true }); + } + + // Clear media and timeline immediately to prevent flickering when switching projects + const mediaStore = useMediaStore.getState(); + const timelineStore = useTimelineStore.getState(); + mediaStore.clearAllMedia(); + timelineStore.clearTimeline(); + + try { + const project = await storageService.loadProject(id); + if (project) { + set({ activeProject: project }); + + // Load project-specific data in parallel + await Promise.all([ + mediaStore.loadProjectMedia(id), + timelineStore.loadProjectTimeline(id), + ]); + } else { + throw new Error(`Project with id ${id} not found`); + } + } catch (error) { + console.error("Failed to load project:", error); + throw error; // Re-throw so the editor page can handle it + } finally { + set({ isLoading: false }); + } + }, + + saveCurrentProject: async () => { + const { activeProject } = get(); + if (!activeProject) return; + + try { + // Save project metadata and timeline data in parallel + const timelineStore = useTimelineStore.getState(); + await Promise.all([ + storageService.saveProject(activeProject), + timelineStore.saveProjectTimeline(activeProject.id), + ]); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to save project:", error); + } + }, + + loadAllProjects: async () => { + if (!get().isInitialized) { + set({ isLoading: true }); + } + + try { + const projects = await storageService.loadAllProjects(); + set({ savedProjects: projects }); + } catch (error) { + console.error("Failed to load projects:", error); + } finally { + set({ isLoading: false, isInitialized: true }); + } + }, + + deleteProject: async (id: string) => { + try { + // Delete project data in parallel + await Promise.all([ + storageService.deleteProjectMedia(id), + storageService.deleteProjectTimeline(id), + storageService.deleteProject(id), + ]); + await get().loadAllProjects(); // Refresh the list + + // If we deleted the 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(); + mediaStore.clearAllMedia(); + timelineStore.clearTimeline(); + } + } catch (error) { + console.error("Failed to delete project:", error); + } + }, + + closeProject: () => { + set({ activeProject: null }); + + // Clear data from stores when closing project + const mediaStore = useMediaStore.getState(); + const timelineStore = useTimelineStore.getState(); + mediaStore.clearAllMedia(); + timelineStore.clearTimeline(); + }, + + renameProject: async (id: string, name: string) => { + const { savedProjects } = get(); + + // Find the project to rename + const projectToRename = savedProjects.find((p) => p.id === id); + if (!projectToRename) { + toast.error("Project not found", { + description: "Please try again", + }); + return; + } + + const updatedProject = { + ...projectToRename, + name, + updatedAt: new Date(), + }; + + try { + // Save to storage + await storageService.saveProject(updatedProject); + + await get().loadAllProjects(); + + // Update activeProject if it's the same project + const { activeProject } = get(); + if (activeProject?.id === id) { + set({ activeProject: updatedProject }); + } + } catch (error) { + console.error("Failed to rename project:", error); + toast.error("Failed to rename project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } + }, + + duplicateProject: async (projectId: string) => { + try { + const project = await storageService.loadProject(projectId); + if (!project) { + toast.error("Project not found", { + description: "Please try again", + }); + throw new Error("Project not found"); + } + + const { savedProjects } = get(); + + // Extract the base name (remove any existing numbering) + const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/); + const baseName = numberMatch ? numberMatch[2] : project.name; + const existingNumbers: number[] = []; + + // Check for pattern "(number) baseName" in existing projects + savedProjects.forEach((p) => { + const match = p.name.match(/^\((\d+)\)\s+(.+)$/); + if (match && match[2] === baseName) { + existingNumbers.push(parseInt(match[1], 10)); + } + }); + + const nextNumber = + existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; + + const newProject: TProject = { + id: generateUUID(), + name: `(${nextNumber}) ${baseName}`, + thumbnail: project.thumbnail, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await storageService.saveProject(newProject); + await get().loadAllProjects(); + return newProject.id; + } catch (error) { + console.error("Failed to duplicate project:", error); + toast.error("Failed to duplicate project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + throw error; + } + }, + + updateProjectBackground: async (backgroundColor: string) => { + const { activeProject } = get(); + if (!activeProject) return; + + const updatedProject = { + ...activeProject, + backgroundColor, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update project background:", error); + toast.error("Failed to update background", { + description: "Please try again", + }); + } + }, + + updateBackgroundType: async ( + type: "color" | "blur", + options?: { backgroundColor?: string; blurIntensity?: number }, + ) => { + const { activeProject } = get(); + if (!activeProject) return; + + const updatedProject = { + ...activeProject, + backgroundType: type, + ...(options?.backgroundColor && { + backgroundColor: options.backgroundColor, + }), + ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }), + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update background type:", error); + toast.error("Failed to update background", { + description: "Please try again", + }); + } + }, + + updateProjectFps: async (fps: number) => { + const { activeProject } = get(); + if (!activeProject) return; + + const updatedProject = { + ...activeProject, + fps, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update project FPS:", error); + toast.error("Failed to update project FPS", { + description: "Please try again", + }); + } + }, + + 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("-"); + + if (key !== "createdAt" && key !== "name") { + console.warn(`Invalid sort key: ${key}`); + return 0; + } + + const aValue = a[key]; + const bValue = b[key]; + + if (aValue === undefined || bValue === undefined) return 0; + + if (order === "asc") { + if (aValue < bValue) return -1; + if (aValue > bValue) return 1; + return 0; + } else { + if (aValue > bValue) return -1; + if (aValue < bValue) return 1; + return 0; + } + }); + + return sortedProjects; + }, +})); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 1fcfc60e..6e48f7cd 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,20 +1,20 @@ import { create } from "zustand"; import { - TrackType, - TimelineElement, - CreateTimelineElement, - TimelineTrack, - TextElement, - DragData, - sortTracksByOrder, - ensureMainTrack, - validateElementTrackCompatibility, + TrackType, + TimelineElement, + CreateTimelineElement, + TimelineTrack, + TextElement, + DragData, + sortTracksByOrder, + ensureMainTrack, + validateElementTrackCompatibility, } from "@/types/timeline"; import { useEditorStore } from "./editor-store"; import { - useMediaStore, - getMediaAspectRatio, - type MediaItem, + useMediaStore, + getMediaAspectRatio, + type MediaItem, } from "./media-store"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; @@ -25,1394 +25,1396 @@ import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( - originalName: string, - suffix: string + originalName: string, + suffix: string, ): string => { - // Remove existing suffixes to prevent accumulation - const baseName = originalName - .replace(/ \(left\)$/, "") - .replace(/ \(right\)$/, "") - .replace(/ \(audio\)$/, "") - .replace(/ \(split \d+\)$/, ""); + // Remove existing suffixes to prevent accumulation + const baseName = originalName + .replace(/ \(left\)$/, "") + .replace(/ \(right\)$/, "") + .replace(/ \(audio\)$/, "") + .replace(/ \(split \d+\)$/, ""); - return `${baseName} (${suffix})`; + return `${baseName} (${suffix})`; }; interface TimelineStore { - // Private track storage - _tracks: TimelineTrack[]; - history: TimelineTrack[][]; - redoStack: TimelineTrack[][]; + // Private track storage + _tracks: TimelineTrack[]; + history: TimelineTrack[][]; + redoStack: TimelineTrack[][]; - // Always returns properly ordered tracks with main track ensured - tracks: TimelineTrack[]; + // Always returns properly ordered tracks with main track ensured + tracks: TimelineTrack[]; - // Manual method if you need to force recomputation - getSortedTracks: () => TimelineTrack[]; + // Manual method if you need to force recomputation + getSortedTracks: () => TimelineTrack[]; - // Snapping settings - snappingEnabled: boolean; + // Snapping settings + snappingEnabled: boolean; - // Snapping actions - toggleSnapping: () => void; + // Snapping actions + toggleSnapping: () => void; - // Ripple editing mode - rippleEditingEnabled: boolean; - toggleRippleEditing: () => void; + // Ripple editing mode + rippleEditingEnabled: boolean; + toggleRippleEditing: () => void; - // Multi-selection - selectedElements: { trackId: string; elementId: string }[]; - selectElement: (trackId: string, elementId: string, multi?: boolean) => void; - deselectElement: (trackId: string, elementId: string) => void; - clearSelectedElements: () => void; - setSelectedElements: ( - elements: { trackId: string; elementId: string }[] - ) => void; + // Multi-selection + selectedElements: { trackId: string; elementId: string }[]; + selectElement: (trackId: string, elementId: string, multi?: boolean) => void; + deselectElement: (trackId: string, elementId: string) => void; + clearSelectedElements: () => void; + setSelectedElements: ( + elements: { trackId: string; elementId: string }[], + ) => void; - // Drag state - dragState: { - isDragging: boolean; - elementId: string | null; - trackId: string | null; - startMouseX: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; - }; - setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void; - startDrag: ( - elementId: string, - trackId: string, - startMouseX: number, - startElementTime: number, - clickOffsetTime: number - ) => void; - updateDragTime: (currentTime: number) => void; - endDrag: () => void; + // Drag state + dragState: { + isDragging: boolean; + elementId: string | null; + trackId: string | null; + startMouseX: number; + startElementTime: number; + clickOffsetTime: number; + currentTime: number; + }; + setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void; + startDrag: ( + elementId: string, + trackId: string, + startMouseX: number, + startElementTime: number, + clickOffsetTime: number, + ) => void; + updateDragTime: (currentTime: number) => void; + endDrag: () => void; - // Actions - addTrack: (type: TrackType) => string; - insertTrackAt: (type: TrackType, index: number) => string; - removeTrack: (trackId: string) => void; - removeTrackWithRipple: (trackId: string) => void; - addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; - removeElementFromTrack: (trackId: string, elementId: string) => void; - moveElementToTrack: ( - fromTrackId: string, - toTrackId: string, - elementId: string - ) => void; - updateElementTrim: ( - trackId: string, - elementId: string, - trimStart: number, - trimEnd: number - ) => void; - updateElementDuration: ( - trackId: string, - elementId: string, - duration: number - ) => void; - updateElementStartTime: ( - trackId: string, - elementId: string, - startTime: number - ) => void; - toggleTrackMute: (trackId: string) => void; + // Actions + addTrack: (type: TrackType) => string; + insertTrackAt: (type: TrackType, index: number) => string; + removeTrack: (trackId: string) => void; + removeTrackWithRipple: (trackId: string) => void; + addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; + removeElementFromTrack: (trackId: string, elementId: string) => void; + moveElementToTrack: ( + fromTrackId: string, + toTrackId: string, + elementId: string, + ) => void; + updateElementTrim: ( + trackId: string, + elementId: string, + trimStart: number, + trimEnd: number, + ) => void; + updateElementDuration: ( + trackId: string, + elementId: string, + duration: number, + ) => void; + updateElementStartTime: ( + trackId: string, + elementId: string, + startTime: number, + ) => void; + toggleTrackMute: (trackId: string) => void; - // Split operations for elements - splitElement: ( - trackId: string, - elementId: string, - splitTime: number - ) => string | null; - splitAndKeepLeft: ( - trackId: string, - elementId: string, - splitTime: number - ) => void; - splitAndKeepRight: ( - trackId: string, - elementId: string, - splitTime: number - ) => void; - separateAudio: (trackId: string, elementId: string) => string | null; + // Split operations for elements + splitElement: ( + trackId: string, + elementId: string, + splitTime: number, + ) => string | null; + splitAndKeepLeft: ( + trackId: string, + elementId: string, + splitTime: number, + ) => void; + splitAndKeepRight: ( + trackId: string, + elementId: string, + splitTime: number, + ) => void; + separateAudio: (trackId: string, elementId: string) => string | null; - // Replace media for an element - replaceElementMedia: ( - trackId: string, - elementId: string, - newFile: File - ) => Promise<boolean>; + // Replace media for an element + replaceElementMedia: ( + trackId: string, + elementId: string, + newFile: File, + ) => Promise<boolean>; - // Ripple editing functions - updateElementStartTimeWithRipple: ( - trackId: string, - elementId: string, - newStartTime: number - ) => void; - removeElementFromTrackWithRipple: ( - trackId: string, - elementId: string - ) => void; + // Ripple editing functions + updateElementStartTimeWithRipple: ( + trackId: string, + elementId: string, + newStartTime: number, + ) => void; + removeElementFromTrackWithRipple: ( + trackId: string, + elementId: string, + ) => void; - // Computed values - getTotalDuration: () => number; + // Computed values + getTotalDuration: () => number; - // History actions - undo: () => void; - redo: () => void; - pushHistory: () => void; + // History actions + undo: () => void; + redo: () => void; + pushHistory: () => void; - // Persistence actions - loadProjectTimeline: (projectId: string) => Promise<void>; - saveProjectTimeline: (projectId: string) => Promise<void>; - clearTimeline: () => void; - updateTextElement: ( - trackId: string, - elementId: string, - updates: Partial< - Pick< - TextElement, - | "content" - | "fontSize" - | "fontFamily" - | "color" - | "backgroundColor" - | "textAlign" - | "fontWeight" - | "fontStyle" - | "textDecoration" - | "x" - | "y" - | "rotation" - | "opacity" - > - > - ) => void; - checkElementOverlap: ( - trackId: string, - startTime: number, - duration: number, - excludeElementId?: string - ) => boolean; - findOrCreateTrack: (trackType: TrackType) => string; - addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean; - addTextAtTime: (item: TextElement, currentTime?: number) => boolean; - addMediaToNewTrack: (item: MediaItem) => boolean; - addTextToNewTrack: (item: TextElement | DragData) => boolean; + // Persistence actions + loadProjectTimeline: (projectId: string) => Promise<void>; + saveProjectTimeline: (projectId: string) => Promise<void>; + clearTimeline: () => void; + updateTextElement: ( + trackId: string, + elementId: string, + updates: Partial< + Pick< + TextElement, + | "content" + | "fontSize" + | "fontFamily" + | "color" + | "backgroundColor" + | "textAlign" + | "fontWeight" + | "fontStyle" + | "textDecoration" + | "x" + | "y" + | "rotation" + | "opacity" + > + >, + ) => void; + checkElementOverlap: ( + trackId: string, + startTime: number, + duration: number, + excludeElementId?: string, + ) => boolean; + findOrCreateTrack: (trackType: TrackType) => string; + addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean; + addTextAtTime: (item: TextElement, currentTime?: number) => boolean; + addMediaToNewTrack: (item: MediaItem) => boolean; + addTextToNewTrack: (item: TextElement | DragData) => boolean; } export const useTimelineStore = create<TimelineStore>((set, get) => { - // Helper to update tracks and maintain ordering - const updateTracks = (newTracks: TimelineTrack[]) => { - const tracksWithMain = ensureMainTrack(newTracks); - const sortedTracks = sortTracksByOrder(tracksWithMain); - set({ - _tracks: tracksWithMain, - tracks: sortedTracks, - }); - }; + // Helper to update tracks and maintain ordering + const updateTracks = (newTracks: TimelineTrack[]) => { + const tracksWithMain = ensureMainTrack(newTracks); + const sortedTracks = sortTracksByOrder(tracksWithMain); + set({ + _tracks: tracksWithMain, + tracks: sortedTracks, + }); + }; - // Helper to auto-save timeline changes - const autoSaveTimeline = async () => { - const activeProject = useProjectStore.getState().activeProject; - if (activeProject) { - try { - await storageService.saveTimeline(activeProject.id, get()._tracks); - } catch (error) { - console.error("Failed to auto-save timeline:", error); - } - } - }; + // Helper to auto-save timeline changes + const autoSaveTimeline = async () => { + const activeProject = useProjectStore.getState().activeProject; + if (activeProject) { + try { + await storageService.saveTimeline(activeProject.id, get()._tracks); + } catch (error) { + console.error("Failed to auto-save timeline:", error); + } + } + }; - // Helper to update tracks and auto-save - const updateTracksAndSave = (newTracks: TimelineTrack[]) => { - updateTracks(newTracks); - // Auto-save in background - setTimeout(autoSaveTimeline, 100); - }; + // Helper to update tracks and auto-save + const updateTracksAndSave = (newTracks: TimelineTrack[]) => { + updateTracks(newTracks); + // Auto-save in background + setTimeout(autoSaveTimeline, 100); + }; - // Initialize with proper track ordering - const initialTracks = ensureMainTrack([]); - const sortedInitialTracks = sortTracksByOrder(initialTracks); + // Initialize with proper track ordering + const initialTracks = ensureMainTrack([]); + const sortedInitialTracks = sortTracksByOrder(initialTracks); - return { - _tracks: initialTracks, - tracks: sortedInitialTracks, - history: [], - redoStack: [], - selectedElements: [], - rippleEditingEnabled: false, + return { + _tracks: initialTracks, + tracks: sortedInitialTracks, + history: [], + redoStack: [], + selectedElements: [], + rippleEditingEnabled: false, - // Snapping settings defaults - snappingEnabled: true, + // Snapping settings defaults + snappingEnabled: true, - getSortedTracks: () => { - const { _tracks } = get(); - const tracksWithMain = ensureMainTrack(_tracks); - return sortTracksByOrder(tracksWithMain); - }, + getSortedTracks: () => { + const { _tracks } = get(); + const tracksWithMain = ensureMainTrack(_tracks); + return sortTracksByOrder(tracksWithMain); + }, - pushHistory: () => { - const { _tracks, history } = get(); - set({ - history: [...history, JSON.parse(JSON.stringify(_tracks))], - redoStack: [], - }); - }, + pushHistory: () => { + const { _tracks, history } = get(); + set({ + history: [...history, JSON.parse(JSON.stringify(_tracks))], + redoStack: [], + }); + }, - undo: () => { - const { history, redoStack, _tracks } = get(); - if (history.length === 0) return; - const prev = history[history.length - 1]; - updateTracksAndSave(prev); - set({ - history: history.slice(0, -1), - redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))], - }); - }, + undo: () => { + const { history, redoStack, _tracks } = get(); + if (history.length === 0) return; + const prev = history[history.length - 1]; + updateTracksAndSave(prev); + set({ + history: history.slice(0, -1), + redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))], + }); + }, - selectElement: (trackId, elementId, multi = false) => { - set((state) => { - const exists = state.selectedElements.some( - (c) => c.trackId === trackId && c.elementId === elementId - ); - if (multi) { - return exists - ? { - selectedElements: state.selectedElements.filter( - (c) => !(c.trackId === trackId && c.elementId === elementId) - ), - } - : { - selectedElements: [ - ...state.selectedElements, - { trackId, elementId }, - ], - }; - } else { - return { selectedElements: [{ trackId, elementId }] }; - } - }); - }, + selectElement: (trackId, elementId, multi = false) => { + set((state) => { + const exists = state.selectedElements.some( + (c) => c.trackId === trackId && c.elementId === elementId, + ); + if (multi) { + return exists + ? { + selectedElements: state.selectedElements.filter( + (c) => !(c.trackId === trackId && c.elementId === elementId), + ), + } + : { + selectedElements: [ + ...state.selectedElements, + { trackId, elementId }, + ], + }; + } else { + return { selectedElements: [{ trackId, elementId }] }; + } + }); + }, - deselectElement: (trackId, elementId) => { - set((state) => ({ - selectedElements: state.selectedElements.filter( - (c) => !(c.trackId === trackId && c.elementId === elementId) - ), - })); - }, + deselectElement: (trackId, elementId) => { + set((state) => ({ + selectedElements: state.selectedElements.filter( + (c) => !(c.trackId === trackId && c.elementId === elementId), + ), + })); + }, - clearSelectedElements: () => { - set({ selectedElements: [] }); - }, + clearSelectedElements: () => { + set({ selectedElements: [] }); + }, - setSelectedElements: (elements) => set({ selectedElements: elements }), + setSelectedElements: (elements) => set({ selectedElements: elements }), - addTrack: (type) => { - get().pushHistory(); + addTrack: (type) => { + get().pushHistory(); - // Generate proper track name based on type - const trackName = - type === "media" - ? "Media Track" - : type === "text" - ? "Text Track" - : type === "audio" - ? "Audio Track" - : "Track"; + // Generate proper track name based on type + const trackName = + type === "media" + ? "Media Track" + : type === "text" + ? "Text Track" + : type === "audio" + ? "Audio Track" + : "Track"; - const newTrack: TimelineTrack = { - id: generateUUID(), - name: trackName, - type, - elements: [], - muted: false, - }; + const newTrack: TimelineTrack = { + id: generateUUID(), + name: trackName, + type, + elements: [], + muted: false, + }; - updateTracksAndSave([...get()._tracks, newTrack]); - return newTrack.id; - }, + updateTracksAndSave([...get()._tracks, newTrack]); + return newTrack.id; + }, - insertTrackAt: (type, index) => { - get().pushHistory(); + insertTrackAt: (type, index) => { + get().pushHistory(); - // Generate proper track name based on type - const trackName = - type === "media" - ? "Media Track" - : type === "text" - ? "Text Track" - : type === "audio" - ? "Audio Track" - : "Track"; + // Generate proper track name based on type + const trackName = + type === "media" + ? "Media Track" + : type === "text" + ? "Text Track" + : type === "audio" + ? "Audio Track" + : "Track"; - const newTrack: TimelineTrack = { - id: generateUUID(), - name: trackName, - type, - elements: [], - muted: false, - }; + const newTrack: TimelineTrack = { + id: generateUUID(), + name: trackName, + type, + elements: [], + muted: false, + }; - const newTracks = [...get()._tracks]; - newTracks.splice(index, 0, newTrack); - updateTracksAndSave(newTracks); - return newTrack.id; - }, + const newTracks = [...get()._tracks]; + newTracks.splice(index, 0, newTrack); + updateTracksAndSave(newTracks); + return newTrack.id; + }, - removeTrack: (trackId) => { - const { rippleEditingEnabled } = get(); + removeTrack: (trackId) => { + const { rippleEditingEnabled } = get(); - if (rippleEditingEnabled) { - get().removeTrackWithRipple(trackId); - } else { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.filter((track) => track.id !== trackId) - ); - } - }, + if (rippleEditingEnabled) { + get().removeTrackWithRipple(trackId); + } else { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.filter((track) => track.id !== trackId), + ); + } + }, - removeTrackWithRipple: (trackId) => { - const { _tracks } = get(); - const trackToRemove = _tracks.find((t) => t.id === trackId); + removeTrackWithRipple: (trackId) => { + const { _tracks } = get(); + const trackToRemove = _tracks.find((t) => t.id === trackId); - if (!trackToRemove) return; + if (!trackToRemove) return; - get().pushHistory(); + get().pushHistory(); - // If track has no elements, just remove it normally - if (trackToRemove.elements.length === 0) { - updateTracksAndSave(_tracks.filter((track) => track.id !== trackId)); - return; - } + // If track has no elements, just remove it normally + if (trackToRemove.elements.length === 0) { + updateTracksAndSave(_tracks.filter((track) => track.id !== trackId)); + return; + } - // Find all the time ranges occupied by elements in the track being removed - const occupiedRanges = trackToRemove.elements.map((element) => ({ - startTime: element.startTime, - endTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd), - })); + // Find all the time ranges occupied by elements in the track being removed + const occupiedRanges = trackToRemove.elements.map((element) => ({ + startTime: element.startTime, + endTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd), + })); - // Sort ranges by start time - occupiedRanges.sort((a, b) => a.startTime - b.startTime); + // Sort ranges by start time + occupiedRanges.sort((a, b) => a.startTime - b.startTime); - // Merge overlapping ranges to get consolidated gaps - const mergedRanges: Array<{ - startTime: number; - endTime: number; - duration: number; - }> = []; + // Merge overlapping ranges to get consolidated gaps + const mergedRanges: Array<{ + startTime: number; + endTime: number; + duration: number; + }> = []; - for (const range of occupiedRanges) { - if (mergedRanges.length === 0) { - mergedRanges.push({ - startTime: range.startTime, - endTime: range.endTime, - duration: range.endTime - range.startTime, - }); - } else { - const lastRange = mergedRanges[mergedRanges.length - 1]; - if (range.startTime <= lastRange.endTime) { - // Overlapping or adjacent ranges, merge them - lastRange.endTime = Math.max(lastRange.endTime, range.endTime); - lastRange.duration = lastRange.endTime - lastRange.startTime; - } else { - // Non-overlapping range, add as new - mergedRanges.push({ - startTime: range.startTime, - endTime: range.endTime, - duration: range.endTime - range.startTime, - }); - } - } - } + for (const range of occupiedRanges) { + if (mergedRanges.length === 0) { + mergedRanges.push({ + startTime: range.startTime, + endTime: range.endTime, + duration: range.endTime - range.startTime, + }); + } else { + const lastRange = mergedRanges[mergedRanges.length - 1]; + if (range.startTime <= lastRange.endTime) { + // Overlapping or adjacent ranges, merge them + lastRange.endTime = Math.max(lastRange.endTime, range.endTime); + lastRange.duration = lastRange.endTime - lastRange.startTime; + } else { + // Non-overlapping range, add as new + mergedRanges.push({ + startTime: range.startTime, + endTime: range.endTime, + duration: range.endTime - range.startTime, + }); + } + } + } - // Remove the track and apply ripple effects to remaining tracks - const updatedTracks = _tracks - .filter((track) => track.id !== trackId) - .map((track) => { - const updatedElements = track.elements.map((element) => { - let newStartTime = element.startTime; + // Remove the track and apply ripple effects to remaining tracks + const updatedTracks = _tracks + .filter((track) => track.id !== trackId) + .map((track) => { + const updatedElements = track.elements.map((element) => { + let newStartTime = element.startTime; - // Process gaps from right to left (latest to earliest) to avoid cumulative shifts - for (let i = mergedRanges.length - 1; i >= 0; i--) { - const gap = mergedRanges[i]; - // If this element starts after the gap, shift it left by the gap duration - if (newStartTime >= gap.endTime) { - newStartTime -= gap.duration; - } - } + // Process gaps from right to left (latest to earliest) to avoid cumulative shifts + for (let i = mergedRanges.length - 1; i >= 0; i--) { + const gap = mergedRanges[i]; + // If this element starts after the gap, shift it left by the gap duration + if (newStartTime >= gap.endTime) { + newStartTime -= gap.duration; + } + } - return { - ...element, - startTime: Math.max(0, newStartTime), - }; - }); + return { + ...element, + startTime: Math.max(0, newStartTime), + }; + }); - // Check for overlaps and resolve them if necessary - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...track, elements: resolvedElements }; - } + // Check for overlaps and resolve them if necessary + const hasOverlaps = checkElementOverlaps(updatedElements); + if (hasOverlaps) { + const resolvedElements = resolveElementOverlaps(updatedElements); + return { ...track, elements: resolvedElements }; + } - return { ...track, elements: updatedElements }; - }); + return { ...track, elements: updatedElements }; + }); - updateTracksAndSave(updatedTracks); - }, + updateTracksAndSave(updatedTracks); + }, - addElementToTrack: (trackId, elementData) => { - get().pushHistory(); + addElementToTrack: (trackId, elementData) => { + get().pushHistory(); - // Validate element type matches track type - const track = get()._tracks.find((t) => t.id === trackId); - if (!track) { - console.error("Track not found:", trackId); - return; - } + // Validate element type matches track type + const track = get()._tracks.find((t) => t.id === trackId); + if (!track) { + console.error("Track not found:", trackId); + return; + } - // Use utility function for validation - const validation = validateElementTrackCompatibility(elementData, track); - if (!validation.isValid) { - console.error(validation.errorMessage); - return; - } + // Use utility function for validation + const validation = validateElementTrackCompatibility(elementData, track); + if (!validation.isValid) { + console.error(validation.errorMessage); + return; + } - // For media elements, validate mediaId exists - if (elementData.type === "media" && !elementData.mediaId) { - console.error("Media element must have mediaId"); - return; - } + // For media elements, validate mediaId exists + if (elementData.type === "media" && !elementData.mediaId) { + console.error("Media element must have mediaId"); + return; + } - // For text elements, validate required text properties - if (elementData.type === "text" && !elementData.content) { - console.error("Text element must have content"); - return; - } + // For text elements, validate required text properties + if (elementData.type === "text" && !elementData.content) { + console.error("Text element must have content"); + return; + } - // Check if this is the first element being added to the timeline - const currentState = get(); - const totalElementsInTimeline = currentState._tracks.reduce( - (total, track) => total + track.elements.length, - 0 - ); - const isFirstElement = totalElementsInTimeline === 0; + // Check if this is the first element being added to the timeline + const currentState = get(); + const totalElementsInTimeline = currentState._tracks.reduce( + (total, track) => total + track.elements.length, + 0, + ); + const isFirstElement = totalElementsInTimeline === 0; - const newElement: TimelineElement = { - ...elementData, - id: generateUUID(), - startTime: elementData.startTime || 0, - trimStart: 0, - trimEnd: 0, - } as TimelineElement; // Type assertion since we trust the caller passes valid data + const newElement: TimelineElement = { + ...elementData, + id: generateUUID(), + startTime: elementData.startTime || 0, + trimStart: 0, + trimEnd: 0, + } as TimelineElement; // Type assertion since we trust the caller passes valid data - // If this is the first element and it's a media element, automatically set the project canvas size - // to match the media's aspect ratio and FPS (for videos) - if (isFirstElement && newElement.type === "media") { - const mediaStore = useMediaStore.getState(); - const mediaItem = mediaStore.mediaItems.find( - (item) => item.id === newElement.mediaId - ); + // If this is the first element and it's a media element, automatically set the project canvas size + // to match the media's aspect ratio and FPS (for videos) + if (isFirstElement && newElement.type === "media") { + const mediaStore = useMediaStore.getState(); + const mediaItem = mediaStore.mediaItems.find( + (item) => item.id === newElement.mediaId, + ); - if ( - mediaItem && - (mediaItem.type === "image" || mediaItem.type === "video") - ) { - const editorStore = useEditorStore.getState(); - editorStore.setCanvasSizeFromAspectRatio( - getMediaAspectRatio(mediaItem) - ); - } + if ( + mediaItem && + (mediaItem.type === "image" || mediaItem.type === "video") + ) { + const editorStore = useEditorStore.getState(); + editorStore.setCanvasSizeFromAspectRatio( + getMediaAspectRatio(mediaItem), + ); + } - // Set project FPS from the first video element - if (mediaItem && mediaItem.type === "video" && mediaItem.fps) { - const projectStore = useProjectStore.getState(); - if (projectStore.activeProject) { - projectStore.updateProjectFps(mediaItem.fps); - } - } - } + // Set project FPS from the first video element + if (mediaItem && mediaItem.type === "video" && mediaItem.fps) { + const projectStore = useProjectStore.getState(); + if (projectStore.activeProject) { + projectStore.updateProjectFps(mediaItem.fps); + } + } + } - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { ...track, elements: [...track.elements, newElement] } - : track - ) - ); - }, + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { ...track, elements: [...track.elements, newElement] } + : track, + ), + ); + }, - removeElementFromTrack: (trackId, elementId) => { - const { rippleEditingEnabled } = get(); + removeElementFromTrack: (trackId, elementId) => { + const { rippleEditingEnabled } = get(); - if (rippleEditingEnabled) { - get().removeElementFromTrackWithRipple(trackId, elementId); - } else { - get().pushHistory(); - updateTracksAndSave( - get() - ._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.filter( - (element) => element.id !== elementId - ), - } - : track - ) - .filter((track) => track.elements.length > 0) - ); - } - }, + if (rippleEditingEnabled) { + get().removeElementFromTrackWithRipple(trackId, elementId); + } else { + get().pushHistory(); + updateTracksAndSave( + get() + ._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.filter( + (element) => element.id !== elementId, + ), + } + : track, + ) + .filter((track) => track.elements.length > 0), + ); + } + }, - removeElementFromTrackWithRipple: (trackId, elementId) => { - const { _tracks, rippleEditingEnabled } = get(); + removeElementFromTrackWithRipple: (trackId, elementId) => { + const { _tracks, rippleEditingEnabled } = get(); - if (!rippleEditingEnabled) { - // If ripple editing is disabled, use regular removal - get().removeElementFromTrack(trackId, elementId); - return; - } + if (!rippleEditingEnabled) { + // If ripple editing is disabled, use regular removal + get().removeElementFromTrack(trackId, elementId); + return; + } - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - if (!element || !track) return; + if (!element || !track) return; - get().pushHistory(); + get().pushHistory(); - const elementStartTime = element.startTime; - const elementDuration = - element.duration - element.trimStart - element.trimEnd; - const elementEndTime = elementStartTime + elementDuration; + const elementStartTime = element.startTime; + const elementDuration = + element.duration - element.trimStart - element.trimEnd; + const elementEndTime = elementStartTime + elementDuration; - // Remove the element and shift all elements that come after it - const updatedTracks = _tracks - .map((currentTrack) => { - // Only apply ripple effects to the same track unless multi-track ripple is enabled - const shouldApplyRipple = currentTrack.id === trackId; + // Remove the element and shift all elements that come after it + const updatedTracks = _tracks + .map((currentTrack) => { + // Only apply ripple effects to the same track unless multi-track ripple is enabled + const shouldApplyRipple = currentTrack.id === trackId; - const updatedElements = currentTrack.elements - .filter((currentElement) => { - // Remove the target element - if ( - currentElement.id === elementId && - currentTrack.id === trackId - ) { - return false; - } - return true; - }) - .map((currentElement) => { - // Only apply ripple effects if we should process this track - if (!shouldApplyRipple) { - return currentElement; - } + const updatedElements = currentTrack.elements + .filter((currentElement) => { + // Remove the target element + if ( + currentElement.id === elementId && + currentTrack.id === trackId + ) { + return false; + } + return true; + }) + .map((currentElement) => { + // Only apply ripple effects if we should process this track + if (!shouldApplyRipple) { + return currentElement; + } - // Shift elements that start after the removed element - if (currentElement.startTime >= elementEndTime) { - return { - ...currentElement, - startTime: Math.max( - 0, - currentElement.startTime - elementDuration - ), - }; - } - return currentElement; - }); + // Shift elements that start after the removed element + if (currentElement.startTime >= elementEndTime) { + return { + ...currentElement, + startTime: Math.max( + 0, + currentElement.startTime - elementDuration, + ), + }; + } + return currentElement; + }); - // Check for overlaps and resolve them if necessary - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - // Resolve overlaps by adjusting element positions - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...currentTrack, elements: resolvedElements }; - } + // Check for overlaps and resolve them if necessary + const hasOverlaps = checkElementOverlaps(updatedElements); + if (hasOverlaps) { + // Resolve overlaps by adjusting element positions + const resolvedElements = resolveElementOverlaps(updatedElements); + return { ...currentTrack, elements: resolvedElements }; + } - return { ...currentTrack, elements: updatedElements }; - }) - .filter((track) => track.elements.length > 0 || track.isMain); + return { ...currentTrack, elements: updatedElements }; + }) + .filter((track) => track.elements.length > 0 || track.isMain); - updateTracksAndSave(updatedTracks); - }, + updateTracksAndSave(updatedTracks); + }, - moveElementToTrack: (fromTrackId, toTrackId, elementId) => { - get().pushHistory(); + moveElementToTrack: (fromTrackId, toTrackId, elementId) => { + get().pushHistory(); - const fromTrack = get()._tracks.find((track) => track.id === fromTrackId); - const toTrack = get()._tracks.find((track) => track.id === toTrackId); - const elementToMove = fromTrack?.elements.find( - (element) => element.id === elementId - ); + const fromTrack = get()._tracks.find((track) => track.id === fromTrackId); + const toTrack = get()._tracks.find((track) => track.id === toTrackId); + const elementToMove = fromTrack?.elements.find( + (element) => element.id === elementId, + ); - if (!elementToMove || !toTrack) return; + if (!elementToMove || !toTrack) return; - // Validate element type compatibility with target track - const validation = validateElementTrackCompatibility( - elementToMove, - toTrack - ); - if (!validation.isValid) { - console.error(validation.errorMessage); - return; - } + // Validate element type compatibility with target track + const validation = validateElementTrackCompatibility( + elementToMove, + toTrack, + ); + if (!validation.isValid) { + console.error(validation.errorMessage); + return; + } - const newTracks = get() - ._tracks.map((track) => { - if (track.id === fromTrackId) { - return { - ...track, - elements: track.elements.filter( - (element) => element.id !== elementId - ), - }; - } else if (track.id === toTrackId) { - return { - ...track, - elements: [...track.elements, elementToMove], - }; - } - return track; - }) - .filter((track) => track.elements.length > 0); + const newTracks = get() + ._tracks.map((track) => { + if (track.id === fromTrackId) { + return { + ...track, + elements: track.elements.filter( + (element) => element.id !== elementId, + ), + }; + } else if (track.id === toTrackId) { + return { + ...track, + elements: [...track.elements, elementToMove], + }; + } + return track; + }) + .filter((track) => track.elements.length > 0); - updateTracksAndSave(newTracks); - }, + updateTracksAndSave(newTracks); + }, - updateElementTrim: (trackId, elementId, trimStart, trimEnd) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId - ? { ...element, trimStart, trimEnd } - : element - ), - } - : track - ) - ); - }, + updateElementTrim: (trackId, elementId, trimStart, trimEnd) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId + ? { ...element, trimStart, trimEnd } + : element, + ), + } + : track, + ), + ); + }, - updateElementDuration: (trackId, elementId, duration) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId ? { ...element, duration } : element - ), - } - : track - ) - ); - }, + updateElementDuration: (trackId, elementId, duration) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId ? { ...element, duration } : element, + ), + } + : track, + ), + ); + }, - updateElementStartTime: (trackId, elementId, startTime) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId ? { ...element, startTime } : element - ), - } - : track - ) - ); - }, + updateElementStartTime: (trackId, elementId, startTime) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId + ? { ...element, startTime } + : element, + ), + } + : track, + ), + ); + }, - updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => { - const { _tracks, rippleEditingEnabled } = get(); + updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => { + const { _tracks, rippleEditingEnabled } = get(); - if (!rippleEditingEnabled) { - // If ripple editing is disabled, use regular update - get().updateElementStartTime(trackId, elementId, newStartTime); - return; - } + if (!rippleEditingEnabled) { + // If ripple editing is disabled, use regular update + get().updateElementStartTime(trackId, elementId, newStartTime); + return; + } - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - if (!element || !track) return; + if (!element || !track) return; - get().pushHistory(); + get().pushHistory(); - const oldStartTime = element.startTime; - const oldEndTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - const newEndTime = - newStartTime + (element.duration - element.trimStart - element.trimEnd); - const timeDelta = newStartTime - oldStartTime; + const oldStartTime = element.startTime; + const oldEndTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + const newEndTime = + newStartTime + (element.duration - element.trimStart - element.trimEnd); + const timeDelta = newStartTime - oldStartTime; - // Update tracks based on multi-track ripple setting - const updatedTracks = _tracks.map((currentTrack) => { - // Only apply ripple effects to the same track unless multi-track ripple is enabled - const shouldApplyRipple = currentTrack.id === trackId; + // Update tracks based on multi-track ripple setting + const updatedTracks = _tracks.map((currentTrack) => { + // Only apply ripple effects to the same track unless multi-track ripple is enabled + const shouldApplyRipple = currentTrack.id === trackId; - const updatedElements = currentTrack.elements.map((currentElement) => { - if (currentElement.id === elementId && currentTrack.id === trackId) { - // Update the moved element - return { ...currentElement, startTime: newStartTime }; - } + const updatedElements = currentTrack.elements.map((currentElement) => { + if (currentElement.id === elementId && currentTrack.id === trackId) { + // Update the moved element + return { ...currentElement, startTime: newStartTime }; + } - // Only apply ripple effects if we should process this track - if (!shouldApplyRipple) { - return currentElement; - } + // Only apply ripple effects if we should process this track + if (!shouldApplyRipple) { + return currentElement; + } - // For ripple editing, we need to move elements that come after the moved element - const currentElementStart = currentElement.startTime; - const currentElementEnd = - currentElement.startTime + - (currentElement.duration - - currentElement.trimStart - - currentElement.trimEnd); + // For ripple editing, we need to move elements that come after the moved element + const currentElementStart = currentElement.startTime; + const currentElementEnd = + currentElement.startTime + + (currentElement.duration - + currentElement.trimStart - + currentElement.trimEnd); - // If moving element to the right (positive delta) - if (timeDelta > 0) { - // Move elements that start after the original position of the moved element - if (currentElementStart >= oldEndTime) { - return { - ...currentElement, - startTime: currentElementStart + timeDelta, - }; - } - } - // If moving element to the left (negative delta) - else if (timeDelta < 0) { - // Move elements that start after the new position of the moved element - if ( - currentElementStart >= newEndTime && - currentElementStart >= oldStartTime - ) { - return { - ...currentElement, - startTime: Math.max(0, currentElementStart + timeDelta), - }; - } - } + // If moving element to the right (positive delta) + if (timeDelta > 0) { + // Move elements that start after the original position of the moved element + if (currentElementStart >= oldEndTime) { + return { + ...currentElement, + startTime: currentElementStart + timeDelta, + }; + } + } + // If moving element to the left (negative delta) + else if (timeDelta < 0) { + // Move elements that start after the new position of the moved element + if ( + currentElementStart >= newEndTime && + currentElementStart >= oldStartTime + ) { + return { + ...currentElement, + startTime: Math.max(0, currentElementStart + timeDelta), + }; + } + } - return currentElement; - }); + return currentElement; + }); - // Check for overlaps and resolve them if necessary - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - // Resolve overlaps by adjusting element positions - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...currentTrack, elements: resolvedElements }; - } + // Check for overlaps and resolve them if necessary + const hasOverlaps = checkElementOverlaps(updatedElements); + if (hasOverlaps) { + // Resolve overlaps by adjusting element positions + const resolvedElements = resolveElementOverlaps(updatedElements); + return { ...currentTrack, elements: resolvedElements }; + } - return { ...currentTrack, elements: updatedElements }; - }); + return { ...currentTrack, elements: updatedElements }; + }); - updateTracksAndSave(updatedTracks); - }, + updateTracksAndSave(updatedTracks); + }, - toggleTrackMute: (trackId) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId ? { ...track, muted: !track.muted } : track - ) - ); - }, + toggleTrackMute: (trackId) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId ? { ...track, muted: !track.muted } : track, + ), + ); + }, - updateTextElement: (trackId, elementId, updates) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId && element.type === "text" - ? { ...element, ...updates } - : element - ), - } - : track - ) - ); - }, + updateTextElement: (trackId, elementId, updates) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId && element.type === "text" + ? { ...element, ...updates } + : element, + ), + } + : track, + ), + ); + }, - splitElement: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + 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; + if (!element) return null; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; - get().pushHistory(); + get().pushHistory(); - const relativeTime = splitTime - element.startTime; - const firstDuration = relativeTime; - const secondDuration = - element.duration - element.trimStart - element.trimEnd - relativeTime; + const relativeTime = splitTime - element.startTime; + const firstDuration = relativeTime; + const secondDuration = + element.duration - element.trimStart - element.trimEnd - relativeTime; - const secondElementId = generateUUID(); + 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 - ) - ); + 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; - }, + return secondElementId; + }, - // Split element and keep only the left portion - splitAndKeepLeft: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + // Split element and keep only the left portion + splitAndKeepLeft: (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; + if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; - get().pushHistory(); + get().pushHistory(); - const relativeTime = splitTime - element.startTime; - const durationToRemove = - element.duration - element.trimStart - element.trimEnd - relativeTime; + const relativeTime = splitTime - element.startTime; + const durationToRemove = + element.duration - element.trimStart - element.trimEnd - relativeTime; - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((c) => - c.id === elementId - ? { - ...c, - trimEnd: c.trimEnd + durationToRemove, - name: getElementNameWithSuffix(c.name, "left"), - } - : c - ), - } - : track - ) - ); - }, + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((c) => + c.id === elementId + ? { + ...c, + trimEnd: c.trimEnd + durationToRemove, + name: getElementNameWithSuffix(c.name, "left"), + } + : c, + ), + } + : track, + ), + ); + }, - // Split element and keep only the right portion - splitAndKeepRight: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + // Split element and keep only the right portion + splitAndKeepRight: (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; + if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; - get().pushHistory(); + get().pushHistory(); - const relativeTime = splitTime - element.startTime; + const relativeTime = splitTime - element.startTime; - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((c) => - c.id === elementId - ? { - ...c, - startTime: splitTime, - trimStart: c.trimStart + relativeTime, - name: getElementNameWithSuffix(c.name, "right"), - } - : c - ), - } - : track - ) - ); - }, + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((c) => + c.id === elementId + ? { + ...c, + startTime: splitTime, + trimStart: c.trimStart + relativeTime, + name: getElementNameWithSuffix(c.name, "right"), + } + : c, + ), + } + : track, + ), + ); + }, - // Extract audio from video element to an audio track - separateAudio: (trackId, elementId) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + // Extract audio from video element to an audio track + separateAudio: (trackId, elementId) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); - if (!element || track?.type !== "media") return null; + if (!element || track?.type !== "media") return null; - get().pushHistory(); + get().pushHistory(); - // Find existing audio track or prepare to create one - const existingAudioTrack = _tracks.find((t) => t.type === "audio"); - const audioElementId = generateUUID(); + // Find existing audio track or prepare to create one + const existingAudioTrack = _tracks.find((t) => t.type === "audio"); + const audioElementId = generateUUID(); - if (existingAudioTrack) { - // Add audio element to existing audio track - updateTracksAndSave( - get()._tracks.map((track) => - track.id === existingAudioTrack.id - ? { - ...track, - elements: [ - ...track.elements, - { - ...element, - id: audioElementId, - name: getElementNameWithSuffix(element.name, "audio"), - }, - ], - } - : track - ) - ); - } else { - // Create new audio track with the audio element in a single atomic update - const newAudioTrack: TimelineTrack = { - id: generateUUID(), - name: "Audio Track", - type: "audio", - elements: [ - { - ...element, - id: audioElementId, - name: getElementNameWithSuffix(element.name, "audio"), - }, - ], - muted: false, - }; + if (existingAudioTrack) { + // Add audio element to existing audio track + updateTracksAndSave( + get()._tracks.map((track) => + track.id === existingAudioTrack.id + ? { + ...track, + elements: [ + ...track.elements, + { + ...element, + id: audioElementId, + name: getElementNameWithSuffix(element.name, "audio"), + }, + ], + } + : track, + ), + ); + } else { + // Create new audio track with the audio element in a single atomic update + const newAudioTrack: TimelineTrack = { + id: generateUUID(), + name: "Audio Track", + type: "audio", + elements: [ + { + ...element, + id: audioElementId, + name: getElementNameWithSuffix(element.name, "audio"), + }, + ], + muted: false, + }; - updateTracksAndSave([...get()._tracks, newAudioTrack]); - } + updateTracksAndSave([...get()._tracks, newAudioTrack]); + } - return audioElementId; - }, + return audioElementId; + }, - // Replace media for an element - replaceElementMedia: async (trackId, elementId, newFile) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + // Replace media for an element + replaceElementMedia: async (trackId, elementId, newFile) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); - if (!element || element.type !== "media") return false; + if (!element || element.type !== "media") return false; - try { - const mediaStore = useMediaStore.getState(); - const projectStore = useProjectStore.getState(); + try { + const mediaStore = useMediaStore.getState(); + const projectStore = useProjectStore.getState(); - if (!projectStore.activeProject) return false; + if (!projectStore.activeProject) return false; - // Import required media processing functions - const { - getFileType, - getImageDimensions, - generateVideoThumbnail, - getMediaDuration, - } = await import("./media-store"); + // Import required media processing functions + const { + getFileType, + getImageDimensions, + generateVideoThumbnail, + getMediaDuration, + } = await import("./media-store"); - const fileType = getFileType(newFile); - if (!fileType) return false; + const fileType = getFileType(newFile); + if (!fileType) return false; - // Process the new media file - let mediaData: any = { - name: newFile.name, - type: fileType, - file: newFile, - url: URL.createObjectURL(newFile), - }; + // Process the new media file + let mediaData: any = { + name: newFile.name, + type: fileType, + file: newFile, + url: URL.createObjectURL(newFile), + }; - // Get media-specific metadata - if (fileType === "image") { - const { width, height } = await getImageDimensions(newFile); - mediaData.width = width; - mediaData.height = height; - } else if (fileType === "video") { - const [duration, { thumbnailUrl, width, height }] = await Promise.all( - [getMediaDuration(newFile), generateVideoThumbnail(newFile)] - ); - mediaData.duration = duration; - mediaData.thumbnailUrl = thumbnailUrl; - mediaData.width = width; - mediaData.height = height; - } else if (fileType === "audio") { - mediaData.duration = await getMediaDuration(newFile); - } + // Get media-specific metadata + if (fileType === "image") { + const { width, height } = await getImageDimensions(newFile); + mediaData.width = width; + mediaData.height = height; + } else if (fileType === "video") { + const [duration, { thumbnailUrl, width, height }] = await Promise.all( + [getMediaDuration(newFile), generateVideoThumbnail(newFile)], + ); + mediaData.duration = duration; + mediaData.thumbnailUrl = thumbnailUrl; + mediaData.width = width; + mediaData.height = height; + } else if (fileType === "audio") { + mediaData.duration = await getMediaDuration(newFile); + } - // Add new media item to store - await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData); + // Add new media item to store + await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData); - // Find the newly created media item - const newMediaItem = mediaStore.mediaItems.find( - (item) => item.file === newFile - ); + // Find the newly created media item + const newMediaItem = mediaStore.mediaItems.find( + (item) => item.file === newFile, + ); - if (!newMediaItem) return false; + if (!newMediaItem) return false; - get().pushHistory(); + get().pushHistory(); - // Update the timeline element to reference the new media - updateTracksAndSave( - _tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((c) => - c.id === elementId - ? { - ...c, - mediaId: newMediaItem.id, - name: newMediaItem.name, - // Update duration if the new media has a different duration - duration: newMediaItem.duration || c.duration, - } - : c - ), - } - : track - ) - ); + // Update the timeline element to reference the new media + updateTracksAndSave( + _tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((c) => + c.id === elementId + ? { + ...c, + mediaId: newMediaItem.id, + name: newMediaItem.name, + // Update duration if the new media has a different duration + duration: newMediaItem.duration || c.duration, + } + : c, + ), + } + : track, + ), + ); - return true; - } catch (error) { - console.log( - JSON.stringify({ - error: "Failed to replace element media", - details: error, - }) - ); - return false; - } - }, + return true; + } catch (error) { + console.log( + JSON.stringify({ + error: "Failed to replace element media", + details: error, + }), + ); + return false; + } + }, - getTotalDuration: () => { - const { _tracks } = get(); - if (_tracks.length === 0) return 0; + getTotalDuration: () => { + const { _tracks } = get(); + if (_tracks.length === 0) return 0; - const trackEndTimes = _tracks.map((track) => - track.elements.reduce((maxEnd, element) => { - const elementEnd = - element.startTime + - element.duration - - element.trimStart - - element.trimEnd; - return Math.max(maxEnd, elementEnd); - }, 0) - ); + const trackEndTimes = _tracks.map((track) => + track.elements.reduce((maxEnd, element) => { + const elementEnd = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; + return Math.max(maxEnd, elementEnd); + }, 0), + ); - return Math.max(...trackEndTimes, 0); - }, + return Math.max(...trackEndTimes, 0); + }, - redo: () => { - const { redoStack } = get(); - if (redoStack.length === 0) return; - const next = redoStack[redoStack.length - 1]; - updateTracksAndSave(next); - set({ redoStack: redoStack.slice(0, -1) }); - }, + redo: () => { + const { redoStack } = get(); + if (redoStack.length === 0) return; + const next = redoStack[redoStack.length - 1]; + updateTracksAndSave(next); + set({ redoStack: redoStack.slice(0, -1) }); + }, - dragState: { - isDragging: false, - elementId: null, - trackId: null, - startMouseX: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }, + dragState: { + isDragging: false, + elementId: null, + trackId: null, + startMouseX: 0, + startElementTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, - setDragState: (dragState) => - set((state) => ({ - dragState: { ...state.dragState, ...dragState }, - })), + setDragState: (dragState) => + set((state) => ({ + dragState: { ...state.dragState, ...dragState }, + })), - startDrag: ( - elementId, - trackId, - startMouseX, - startElementTime, - clickOffsetTime - ) => { - set({ - dragState: { - isDragging: true, - elementId, - trackId, - startMouseX, - startElementTime, - clickOffsetTime, - currentTime: startElementTime, - }, - }); - }, + startDrag: ( + elementId, + trackId, + startMouseX, + startElementTime, + clickOffsetTime, + ) => { + set({ + dragState: { + isDragging: true, + elementId, + trackId, + startMouseX, + startElementTime, + clickOffsetTime, + currentTime: startElementTime, + }, + }); + }, - updateDragTime: (currentTime) => { - set((state) => ({ - dragState: { - ...state.dragState, - currentTime, - }, - })); - }, + updateDragTime: (currentTime) => { + set((state) => ({ + dragState: { + ...state.dragState, + currentTime, + }, + })); + }, - endDrag: () => { - set({ - dragState: { - isDragging: false, - elementId: null, - trackId: null, - startMouseX: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }, - }); - }, + endDrag: () => { + set({ + dragState: { + isDragging: false, + elementId: null, + trackId: null, + startMouseX: 0, + startElementTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, + }); + }, - // Persistence methods - loadProjectTimeline: async (projectId) => { - try { - const tracks = await storageService.loadTimeline(projectId); - if (tracks) { - updateTracks(tracks); - } else { - // No timeline saved yet, initialize with default - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - } - // Clear history when loading a project - set({ history: [], redoStack: [] }); - } catch (error) { - console.error("Failed to load timeline:", error); - // Initialize with default on error - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - set({ history: [], redoStack: [] }); - } - }, + // Persistence methods + loadProjectTimeline: async (projectId) => { + try { + const tracks = await storageService.loadTimeline(projectId); + if (tracks) { + updateTracks(tracks); + } else { + // No timeline saved yet, initialize with default + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + } + // Clear history when loading a project + set({ history: [], redoStack: [] }); + } catch (error) { + console.error("Failed to load timeline:", error); + // Initialize with default on error + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + set({ history: [], redoStack: [] }); + } + }, - saveProjectTimeline: async (projectId) => { - try { - await storageService.saveTimeline(projectId, get()._tracks); - } catch (error) { - console.error("Failed to save timeline:", error); - } - }, + saveProjectTimeline: async (projectId) => { + try { + await storageService.saveTimeline(projectId, get()._tracks); + } catch (error) { + console.error("Failed to save timeline:", error); + } + }, - clearTimeline: () => { - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - set({ history: [], redoStack: [], selectedElements: [] }); - }, + clearTimeline: () => { + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + set({ history: [], redoStack: [], selectedElements: [] }); + }, - // Snapping actions - toggleSnapping: () => { - set((state) => ({ snappingEnabled: !state.snappingEnabled })); - }, + // Snapping actions + toggleSnapping: () => { + set((state) => ({ snappingEnabled: !state.snappingEnabled })); + }, - // Ripple editing functions - toggleRippleEditing: () => { - set((state) => ({ - rippleEditingEnabled: !state.rippleEditingEnabled, - })); - }, + // Ripple editing functions + toggleRippleEditing: () => { + set((state) => ({ + rippleEditingEnabled: !state.rippleEditingEnabled, + })); + }, - checkElementOverlap: (trackId, startTime, duration, excludeElementId) => { - const track = get()._tracks.find((t) => t.id === trackId); - if (!track) return false; + checkElementOverlap: (trackId, startTime, duration, excludeElementId) => { + const track = get()._tracks.find((t) => t.id === trackId); + if (!track) return false; - const overlap = track.elements.some((element) => { - const elementEnd = - element.startTime + - element.duration - - element.trimStart - - element.trimEnd; + const overlap = track.elements.some((element) => { + const elementEnd = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; - if (element.id === excludeElementId) { - return false; - } + if (element.id === excludeElementId) { + return false; + } - return ( - (startTime >= element.startTime && startTime < elementEnd) || - (startTime + duration > element.startTime && - startTime + duration <= elementEnd) || - (startTime < element.startTime && startTime + duration > elementEnd) - ); - }); - return overlap; - }, + return ( + (startTime >= element.startTime && startTime < elementEnd) || + (startTime + duration > element.startTime && + startTime + duration <= elementEnd) || + (startTime < element.startTime && startTime + duration > elementEnd) + ); + }); + return overlap; + }, - findOrCreateTrack: (trackType) => { - // Always create new text track to allow multiple text elements - if (trackType === "text") { - return get().addTrack(trackType); - } + findOrCreateTrack: (trackType) => { + // Always create new text track to allow multiple text elements + if (trackType === "text") { + return get().addTrack(trackType); + } - const existingTrack = get()._tracks.find((t) => t.type === trackType); - if (existingTrack) { - return existingTrack.id; - } + const existingTrack = get()._tracks.find((t) => t.type === trackType); + if (existingTrack) { + return existingTrack.id; + } - return get().addTrack(trackType); - }, + return get().addTrack(trackType); + }, - addMediaAtTime: (item, currentTime = 0) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = get().findOrCreateTrack(trackType); + addMediaAtTime: (item, currentTime = 0) => { + const trackType = item.type === "audio" ? "audio" : "media"; + const targetTrackId = get().findOrCreateTrack(trackType); - const duration = - item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; + const duration = + item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; - if (get().checkElementOverlap(targetTrackId, currentTime, duration)) { - toast.error( - "Cannot place element here - it would overlap with existing elements" - ); - return false; - } + if (get().checkElementOverlap(targetTrackId, currentTime, duration)) { + toast.error( + "Cannot place element here - it would overlap with existing elements", + ); + return false; + } - get().addElementToTrack(targetTrackId, { - type: "media", - mediaId: item.id, - name: item.name, - duration, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - }); - return true; - }, + get().addElementToTrack(targetTrackId, { + type: "media", + mediaId: item.id, + name: item.name, + duration, + startTime: currentTime, + trimStart: 0, + trimEnd: 0, + }); + return true; + }, - addTextAtTime: (item, currentTime = 0) => { - const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements + addTextAtTime: (item, currentTime = 0) => { + const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements - get().addElementToTrack(targetTrackId, { - type: "text", - name: item.name || "Text", - content: item.content || "Default Text", - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - fontSize: item.fontSize || 48, - fontFamily: item.fontFamily || "Arial", - color: item.color || "#ffffff", - backgroundColor: item.backgroundColor || "transparent", - textAlign: item.textAlign || "center", - fontWeight: item.fontWeight || "normal", - fontStyle: item.fontStyle || "normal", - textDecoration: item.textDecoration || "none", - x: item.x || 0, - y: item.y || 0, - rotation: item.rotation || 0, - opacity: item.opacity !== undefined ? item.opacity : 1, - }); - return true; - }, + get().addElementToTrack(targetTrackId, { + type: "text", + name: item.name || "Text", + content: item.content || "Default Text", + duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: currentTime, + trimStart: 0, + trimEnd: 0, + fontSize: item.fontSize || 48, + fontFamily: item.fontFamily || "Arial", + color: item.color || "#ffffff", + backgroundColor: item.backgroundColor || "transparent", + textAlign: item.textAlign || "center", + fontWeight: item.fontWeight || "normal", + fontStyle: item.fontStyle || "normal", + textDecoration: item.textDecoration || "none", + x: item.x || 0, + y: item.y || 0, + rotation: item.rotation || 0, + opacity: item.opacity !== undefined ? item.opacity : 1, + }); + return true; + }, - addMediaToNewTrack: (item) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = get().findOrCreateTrack(trackType); + addMediaToNewTrack: (item) => { + const trackType = item.type === "audio" ? "audio" : "media"; + const targetTrackId = get().findOrCreateTrack(trackType); - get().addElementToTrack(targetTrackId, { - type: "media", - mediaId: item.id, - name: item.name, - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); - return true; - }, + get().addElementToTrack(targetTrackId, { + type: "media", + mediaId: item.id, + name: item.name, + duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + return true; + }, - addTextToNewTrack: (item) => { - const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements + addTextToNewTrack: (item) => { + const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements - get().addElementToTrack(targetTrackId, { - type: "text", - name: item.name || "Text", - content: - ("content" in item ? item.content : "Default Text") || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - fontSize: ("fontSize" in item ? item.fontSize : 48) || 48, - fontFamily: - ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial", - color: ("color" in item ? item.color : "#ffffff") || "#ffffff", - backgroundColor: - ("backgroundColor" in item ? item.backgroundColor : "transparent") || - "transparent", - textAlign: - ("textAlign" in item ? item.textAlign : "center") || "center", - fontWeight: - ("fontWeight" in item ? item.fontWeight : "normal") || "normal", - fontStyle: - ("fontStyle" in item ? item.fontStyle : "normal") || "normal", - textDecoration: - ("textDecoration" in item ? item.textDecoration : "none") || "none", - x: ("x" in item ? item.x : 0) || 0, - y: ("y" in item ? item.y : 0) || 0, - rotation: ("rotation" in item ? item.rotation : 0) || 0, - opacity: - "opacity" in item && item.opacity !== undefined ? item.opacity : 1, - }); - return true; - }, - }; + get().addElementToTrack(targetTrackId, { + type: "text", + name: item.name || "Text", + content: + ("content" in item ? item.content : "Default Text") || "Default Text", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + fontSize: ("fontSize" in item ? item.fontSize : 48) || 48, + fontFamily: + ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial", + color: ("color" in item ? item.color : "#ffffff") || "#ffffff", + backgroundColor: + ("backgroundColor" in item ? item.backgroundColor : "transparent") || + "transparent", + textAlign: + ("textAlign" in item ? item.textAlign : "center") || "center", + fontWeight: + ("fontWeight" in item ? item.fontWeight : "normal") || "normal", + fontStyle: + ("fontStyle" in item ? item.fontStyle : "normal") || "normal", + textDecoration: + ("textDecoration" in item ? item.textDecoration : "none") || "none", + x: ("x" in item ? item.x : 0) || 0, + y: ("y" in item ? item.y : 0) || 0, + rotation: ("rotation" in item ? item.rotation : 0) || 0, + opacity: + "opacity" in item && item.opacity !== undefined ? item.opacity : 1, + }); + return true; + }, + }; }); |
