From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/stores/assets-panel-store.tsx | 107 ++ apps/web/src/stores/editor-store.ts | 129 +- apps/web/src/stores/keybindings-store.ts | 475 +++-- .../web/src/stores/keybindings/migrations/index.ts | 28 + .../src/stores/keybindings/migrations/v2-to-v3.ts | 26 + apps/web/src/stores/media-store.ts | 296 --- apps/web/src/stores/panel-store.ts | 300 +-- apps/web/src/stores/playback-store.ts | 174 -- apps/web/src/stores/project-store.ts | 571 ------ apps/web/src/stores/scene-store.ts | 318 ---- apps/web/src/stores/sounds-store.ts | 525 +++--- apps/web/src/stores/stickers-store.ts | 383 ++-- apps/web/src/stores/text-properties-store.ts | 30 +- apps/web/src/stores/timeline-store.ts | 1913 +------------------- 14 files changed, 997 insertions(+), 4278 deletions(-) create mode 100644 apps/web/src/stores/assets-panel-store.tsx create mode 100644 apps/web/src/stores/keybindings/migrations/index.ts create mode 100644 apps/web/src/stores/keybindings/migrations/v2-to-v3.ts delete mode 100644 apps/web/src/stores/media-store.ts delete mode 100644 apps/web/src/stores/playback-store.ts delete mode 100644 apps/web/src/stores/project-store.ts delete mode 100644 apps/web/src/stores/scene-store.ts (limited to 'apps/web/src/stores') diff --git a/apps/web/src/stores/assets-panel-store.tsx b/apps/web/src/stores/assets-panel-store.tsx new file mode 100644 index 00000000..21ceb6a3 --- /dev/null +++ b/apps/web/src/stores/assets-panel-store.tsx @@ -0,0 +1,107 @@ +import type { ElementType } from "react"; +import { create } from "zustand"; +import { + ArrowRightDoubleIcon, + ClosedCaptionIcon, + Folder03Icon, + Happy01Icon, + HeadphonesIcon, + MagicWand05Icon, + TextIcon, + Settings01Icon, + SlidersHorizontalIcon, + ColorsIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; + +export const TAB_KEYS = [ + "media", + "sounds", + "text", + "stickers", + "effects", + "transitions", + "captions", + "filters", + "adjustment", + "settings", +] as const; + +export type Tab = (typeof TAB_KEYS)[number]; + +const createHugeiconsIcon = + ({ icon }: { icon: IconSvgElement }) => + ({ className }: { className?: string }) => ( + + ); + +export const tabs = { + media: { + icon: createHugeiconsIcon({ icon: Folder03Icon }), + label: "Media", + }, + sounds: { + icon: createHugeiconsIcon({ icon: HeadphonesIcon }), + label: "Sounds", + }, + text: { + icon: createHugeiconsIcon({ icon: TextIcon }), + label: "Text", + }, + stickers: { + icon: createHugeiconsIcon({ icon: Happy01Icon }), + label: "Stickers", + }, + effects: { + icon: createHugeiconsIcon({ icon: MagicWand05Icon }), + label: "Effects", + }, + transitions: { + icon: createHugeiconsIcon({ icon: ArrowRightDoubleIcon }), + label: "Transitions", + }, + captions: { + icon: createHugeiconsIcon({ icon: ClosedCaptionIcon }), + label: "Captions", + }, + filters: { + icon: createHugeiconsIcon({ icon: ColorsIcon }), + label: "Filters", + }, + adjustment: { + icon: createHugeiconsIcon({ icon: SlidersHorizontalIcon }), + label: "Adjustment", + }, + settings: { + icon: createHugeiconsIcon({ icon: Settings01Icon }), + label: "Settings", + }, +} satisfies Record< + Tab, + { icon: ElementType<{ className?: string }>; label: string } +>; + +type MediaViewMode = "grid" | "list"; + +interface AssetsPanelStore { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; + highlightMediaId: string | null; + requestRevealMedia: (mediaId: string) => void; + clearHighlight: () => void; + + /* Media */ + mediaViewMode: MediaViewMode; + setMediaViewMode: (mode: MediaViewMode) => void; +} + +export const useAssetsPanelStore = create((set) => ({ + activeTab: "media", + setActiveTab: (tab) => set({ activeTab: tab }), + highlightMediaId: null, + requestRevealMedia: (mediaId) => + set({ activeTab: "media", highlightMediaId: mediaId }), + clearHighlight: () => set({ highlightMediaId: null }), + mediaViewMode: "grid", + setMediaViewMode: (mode) => set({ mediaViewMode: mode }), +})); diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index 9923aa87..5c4856d3 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -1,91 +1,70 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { CanvasPreset } from "@/types/editor"; - -export type PlatformLayout = "tiktok"; - -export const PLATFORM_LAYOUTS: Record = { - tiktok: "TikTok", -}; +import type { TPlatformLayout } from "@/types/editor"; +import { DEFAULT_CANVAS_PRESETS } from "@/constants/project-constants"; +import type { TCanvasSize } from "@/types/project"; interface LayoutGuideSettings { - platform: PlatformLayout | null; + platform: TPlatformLayout | null; } interface EditorState { - // Loading states - isInitializing: boolean; - isPanelsReady: boolean; - - // Editor UI settings - canvasPresets: CanvasPreset[]; - layoutGuide: LayoutGuideSettings; - - // Actions - setInitializing: (loading: boolean) => void; - setPanelsReady: (ready: boolean) => void; - initializeApp: () => Promise; - setLayoutGuide: (settings: Partial) => void; - toggleLayoutGuide: (platform: PlatformLayout) => void; + isInitializing: boolean; + isPanelsReady: boolean; + canvasPresets: TCanvasSize[]; + layoutGuide: LayoutGuideSettings; + setInitializing: (loading: boolean) => void; + setPanelsReady: (ready: boolean) => void; + initializeApp: () => Promise; + setLayoutGuide: (settings: Partial) => void; + toggleLayoutGuide: (platform: TPlatformLayout) => 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 }, -]; - export const useEditorStore = create()( - persist( - (set) => ({ - // Initial states - isInitializing: true, - isPanelsReady: false, - canvasPresets: DEFAULT_CANVAS_PRESETS, - layoutGuide: { - platform: null, - }, - - // Actions - setInitializing: (loading) => { - set({ isInitializing: loading }); - }, + persist( + (set) => ({ + isInitializing: true, + isPanelsReady: false, + canvasPresets: DEFAULT_CANVAS_PRESETS, + layoutGuide: { + platform: null, + }, + setInitializing: (loading) => { + set({ isInitializing: loading }); + }, - setPanelsReady: (ready) => { - set({ isPanelsReady: ready }); - }, + setPanelsReady: (ready) => { + set({ isPanelsReady: ready }); + }, - initializeApp: async () => { - console.log("Initializing video editor..."); - set({ isInitializing: true, isPanelsReady: false }); + initializeApp: async () => { + set({ isInitializing: true, isPanelsReady: false }); - set({ isPanelsReady: true, isInitializing: false }); - console.log("Video editor ready"); - }, + set({ isPanelsReady: true, isInitializing: false }); + }, - setLayoutGuide: (settings) => { - set((state) => ({ - layoutGuide: { - ...state.layoutGuide, - ...settings, - }, - })); - }, + setLayoutGuide: (settings) => { + set((state) => ({ + layoutGuide: { + ...state.layoutGuide, + ...settings, + }, + })); + }, - toggleLayoutGuide: (platform) => { - set((state) => ({ - layoutGuide: { - platform: state.layoutGuide.platform === platform ? null : platform, - }, - })); - }, - }), - { - name: "editor-settings", - partialize: (state) => ({ - layoutGuide: state.layoutGuide, - }), - } - ) + toggleLayoutGuide: (platform) => { + set((state) => ({ + layoutGuide: { + platform: state.layoutGuide.platform === platform ? null : platform, + }, + })); + }, + }), + { + name: "editor-settings", + partialize: (state) => ({ + layoutGuide: state.layoutGuide, + }), + }, + ), ); diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index febcaa30..5ff48ffc 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -2,264 +2,255 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { ActionWithOptionalArgs } from "@/constants/actions"; -import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils"; -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", - enter: "goto-start", - end: "goto-end", - s: "split-element", - n: "toggle-snapping", - "ctrl+a": "select-all", - "ctrl+d": "duplicate-selected", - "ctrl+c": "copy-selected", - "ctrl+v": "paste-selected", - "ctrl+z": "undo", - "ctrl+shift+z": "redo", - "ctrl+y": "redo", - delete: "delete-selected", - backspace: "delete-selected", -}; +import type { TActionWithOptionalArgs } from "@/lib/actions"; +import { getDefaultShortcuts } from "@/lib/actions"; +import { isTypableDOMElement } from "@/utils/browser"; +import { isAppleDevice } from "@/utils/platform"; +import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; +import { + runMigrations, + CURRENT_VERSION, +} from "./keybindings/migrations"; + +export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); export interface KeybindingConflict { - key: ShortcutKey; - existingAction: ActionWithOptionalArgs; - newAction: ActionWithOptionalArgs; + key: ShortcutKey; + existingAction: TActionWithOptionalArgs; + newAction: TActionWithOptionalArgs; } interface KeybindingsState { - keybindings: KeybindingConfig; - isCustomized: boolean; - keybindingsEnabled: boolean; - isRecording: boolean; - - // Actions - updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void; - removeKeybinding: (key: ShortcutKey) => void; - resetToDefaults: () => void; - importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; - enableKeybindings: () => void; - disableKeybindings: () => void; - setIsRecording: (isRecording: boolean) => void; - - // Validation - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs - ) => KeybindingConflict | null; - getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[]; - - // Utility - getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; + keybindings: KeybindingConfig; + isCustomized: boolean; + keybindingsEnabled: boolean; + isRecording: boolean; + + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + removeKeybinding: (key: ShortcutKey) => void; + resetToDefaults: () => void; + importKeybindings: (config: KeybindingConfig) => void; + exportKeybindings: () => KeybindingConfig; + enableKeybindings: () => void; + disableKeybindings: () => void; + setIsRecording: (isRecording: boolean) => void; + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => KeybindingConflict | null; + getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; + getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; +} + +function isDOMElement(el: EventTarget | null): el is HTMLElement { + return !!el && (el instanceof Element || el instanceof HTMLElement); } export const useKeybindingsStore = create()( - persist( - (set, get) => ({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - keybindingsEnabled: true, - isRecording: false, - - updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; - - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, - - removeKeybinding: (key: ShortcutKey) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; - - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, - - resetToDefaults: () => { - set({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - }); - }, - - enableKeybindings: () => { - set({ keybindingsEnabled: true }); - }, - - 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, - }); - }, - - exportKeybindings: () => { - return get().keybindings; - }, - - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; - - if (existingAction && existingAction !== action) { - return { - key, - existingAction, - newAction: action, - }; - } - - return null; - }, - setIsRecording: (isRecording: boolean) => { - set({ isRecording }); - }, - - 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: 2, - } - ) + persist( + (set, get) => ({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + keybindingsEnabled: true, + isRecording: false, + + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + newKeybindings[key] = action; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + removeKeybinding: (key: ShortcutKey) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + delete newKeybindings[key]; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + resetToDefaults: () => { + set({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + }); + }, + + enableKeybindings: () => { + set({ keybindingsEnabled: true }); + }, + + disableKeybindings: () => { + set({ keybindingsEnabled: false }); + }, + + importKeybindings: (config: KeybindingConfig) => { + // Validate all keys and actions + for (const [key] 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; + }, + + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => { + const { keybindings } = get(); + const existingAction = keybindings[key]; + + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } + + return null; + }, + setIsRecording: (isRecording: boolean) => { + set({ isRecording }); + }, + + getKeybindingsForAction: (action: TActionWithOptionalArgs) => { + 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: CURRENT_VERSION, + partialize: (state) => ({ + keybindings: state.keybindings, + isCustomized: state.isCustomized, + }), + migrate: (persisted, version) => + runMigrations({ state: persisted, fromVersion: version }), + }, + ), ); // Utility functions function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { - const target = ev.target; - - // 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; - - // 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; - } - - // 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; + const target = ev.target; + + // 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; + + // 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) && + isTypableDOMElement({ element: target as HTMLElement }) + ) { + return null; + } + + return `${modifierKey}+${key}` as ShortcutKey; + } + + // no modifier key here then we do not do anything while on input + if ( + isDOMElement(target) && + isTypableDOMElement({ element: target as HTMLElement }) + ) + return null; + + // 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(); - const code = ev.code ?? ""; - - if (code === "Space" || key === " " || key === "spacebar" || key === "space") - return "space"; - - // Check arrow keys - if (key.startsWith("arrow")) { - return key.slice(5); - } - - // Check for special keys - if (key === "tab") return "tab"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; - - // Check letter keys - if (code.startsWith("Key")) { - const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") { - return letter; - } - } - - // Check number keys using physical position for AZERTY support - if (code.startsWith("Digit")) { - const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") { - return digit; - } - } - - // Fallback for other layouts - 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; - - // If no other cases match, this is not a valid key - return null; + // Sometimes the property code is not available on the KeyboardEvent object + const key = (ev.key ?? "").toLowerCase(); + const code = ev.code ?? ""; + + if (code === "Space" || key === " " || key === "spacebar" || key === "space") + return "space"; + + // Check arrow keys + if (key.startsWith("arrow")) { + return key.slice(5); + } + + // Check for special keys + if (key === "tab") return "tab"; + if (key === "home") return "home"; + if (key === "end") return "end"; + if (key === "delete") return "delete"; + if (key === "backspace") return "backspace"; + + // Check letter keys + if (code.startsWith("Key")) { + const letter = code.slice(3).toLowerCase(); + if (letter.length === 1 && letter >= "a" && letter <= "z") { + return letter; + } + } + + // Check number keys using physical position for AZERTY support + if (code.startsWith("Digit")) { + const digit = code.slice(5); + if (digit.length === 1 && digit >= "0" && digit <= "9") { + return digit; + } + } + + // Fallback for other layouts + 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; + + // 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, - }; - - // 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; + 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("+"); + + return activeModifier === "" ? null : activeModifier; } diff --git a/apps/web/src/stores/keybindings/migrations/index.ts b/apps/web/src/stores/keybindings/migrations/index.ts new file mode 100644 index 00000000..1689802e --- /dev/null +++ b/apps/web/src/stores/keybindings/migrations/index.ts @@ -0,0 +1,28 @@ +import { v2ToV3 } from './v2-to-v3'; + +type MigrationFn = ({ state }: { state: unknown }) => unknown; + +/** + * key = version we're migrating from + * value = migration function + */ +const migrations: Record = { + 2: v2ToV3, +}; + +export const CURRENT_VERSION = 3; + +export function runMigrations({ + state, + fromVersion, +}: { + state: unknown; + fromVersion: number; +}): unknown { + let current = state; + for (let version = fromVersion; version < CURRENT_VERSION; version++) { + const migrate = migrations[version]; + if (migrate) current = migrate({ state: current }); + } + return current; +} diff --git a/apps/web/src/stores/keybindings/migrations/v2-to-v3.ts b/apps/web/src/stores/keybindings/migrations/v2-to-v3.ts new file mode 100644 index 00000000..1517b52b --- /dev/null +++ b/apps/web/src/stores/keybindings/migrations/v2-to-v3.ts @@ -0,0 +1,26 @@ +import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; +import type { TActionWithOptionalArgs } from "@/lib/actions"; + +interface V2State { + keybindings: KeybindingConfig; + isCustomized: boolean; +} + +export function v2ToV3({ state }: { state: unknown }): unknown { + const v2 = state as V2State; + + const renames: Record = { + "split-selected": "split", + "split-selected-left": "split-left", + "split-selected-right": "split-right", + }; + + const migrated = { ...v2.keybindings }; + for (const [key, action] of Object.entries(migrated)) { + if (action && renames[action]) { + migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + } + } + + return { ...v2, keybindings: migrated }; +} diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts deleted file mode 100644 index fcbd6575..00000000 --- a/apps/web/src/stores/media-store.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { create } from "zustand"; -import { storageService } from "@/lib/storage/storage-service"; -import { useTimelineStore } from "./timeline-store"; -import { generateUUID } from "@/lib/utils"; -import { MediaType, MediaFile } from "@/types/media"; -import { videoCache } from "@/lib/video-cache"; - -interface MediaStore { - mediaFiles: MediaFile[]; - isLoading: boolean; - - // Actions - addMediaFile: ( - projectId: string, - file: Omit - ) => Promise; - removeMediaFile: (projectId: string, id: string) => Promise; - loadProjectMedia: (projectId: string) => Promise; - clearProjectMedia: (projectId: string) => Promise; - clearAllMedia: () => void; -} - -// Helper function to determine file type -export const getFileType = (file: File): MediaType | null => { - const { type } = file; - - if (type.startsWith("image/")) { - return "image"; - } - if (type.startsWith("video/")) { - return "video"; - } - if (type.startsWith("audio/")) { - return "audio"; - } - - return null; -}; - -// Helper function to get image dimensions -export const getImageDimensions = ( - file: File -): Promise<{ width: number; height: number }> => { - 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("error", () => { - reject(new Error("Could not load image")); - img.remove(); - }); - - img.src = URL.createObjectURL(file); - }); -}; - -// Helper function to generate video thumbnail and get dimensions -export const generateVideoThumbnail = ( - 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"); - - if (!ctx) { - reject(new Error("Could not get canvas context")); - return; - } - - 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); - }); - - 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 }); - - // Cleanup - 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(); - }); -}; - -// Helper function to get media duration -export const getMediaDuration = (file: File): Promise => { - 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("error", () => { - reject(new Error("Could not load media")); - element.remove(); - }); - - element.src = URL.createObjectURL(file); - element.load(); - }); -}; - -export const getMediaAspectRatio = (item: MediaFile): number => { - if (item.width && item.height) { - return item.width / item.height; - } - return 16 / 9; // Default aspect ratio -}; - -export const useMediaStore = create((set, get) => ({ - mediaFiles: [], - isLoading: false, - - addMediaFile: async (projectId, file) => { - const newItem: MediaFile = { - ...file, - id: generateUUID(), - }; - - // Add to local state immediately for UI responsiveness - set((state) => ({ - mediaFiles: [...state.mediaFiles, newItem], - })); - - // Save to persistent storage in background - try { - await storageService.saveMediaFile({ projectId, mediaItem: newItem }); - } catch (error) { - console.error("Failed to save media item:", error); - // Remove from local state if save failed - set((state) => ({ - mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id), - })); - } - }, - - removeMediaFile: async (projectId: string, id: string) => { - const state = get(); - const item = state.mediaFiles.find((media) => media.id === id); - - videoCache.clearVideo(id); - - // Cleanup object URLs to prevent memory leaks - if (item?.url) { - URL.revokeObjectURL(item.url); - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - } - - // 1) Remove from local state immediately - set((state) => ({ - mediaFiles: state.mediaFiles.filter((media) => media.id !== id), - })); - - // 2) Cascade into the timeline: remove any elements using this media ID - const timeline = useTimelineStore.getState(); - const { tracks, deleteSelected, setSelectedElements } = timeline; - - // Find all elements that reference this media - const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; - for (const track of tracks) { - for (const el of track.elements) { - if (el.type === "media" && el.mediaId === id) { - elementsToRemove.push({ trackId: track.id, elementId: el.id }); - } - } - } - - // If there are elements to remove, use unified delete function - if (elementsToRemove.length > 0) { - setSelectedElements(elementsToRemove); - deleteSelected(); - } - - // 3) Remove from persistent storage - try { - await storageService.deleteMediaFile({ projectId, id }); - } catch (error) { - console.error("Failed to delete media item:", error); - } - }, - - loadProjectMedia: async (projectId) => { - set({ isLoading: true }); - - try { - const mediaItems = await storageService.loadAllMediaFiles({ 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; - }) - ); - - set({ mediaFiles: updatedMediaItems }); - } catch (error) { - console.error("Failed to load media items:", error); - } finally { - set({ isLoading: false }); - } - }, - - clearProjectMedia: async (projectId) => { - const state = get(); - - // Cleanup all object URLs - state.mediaFiles.forEach((item) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - }); - - // Clear local state - set({ mediaFiles: [] }); - - // Clear persistent storage - try { - const mediaIds = state.mediaFiles.map((item) => item.id); - await Promise.all( - mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })) - ); - } catch (error) { - console.error("Failed to clear media items from storage:", error); - } - }, - - clearAllMedia: () => { - const state = get(); - - videoCache.clearAll(); - - // Cleanup all object URLs - state.mediaFiles.forEach((item) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - }); - - // Clear local state - set({ mediaFiles: [] }); - }, -})); diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts index ae09c13b..5d2621a4 100644 --- a/apps/web/src/stores/panel-store.ts +++ b/apps/web/src/stores/panel-store.ts @@ -1,225 +1,93 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; - -export type PanelPreset = - | "default" - | "media" - | "inspector" - | "vertical-preview"; - -interface PanelSizes { - toolsPanel: number; - previewPanel: number; - propertiesPanel: number; - mainContent: number; - timeline: number; +import { PANEL_CONFIG } from "@/constants/editor-constants"; + +export interface PanelSizes { + tools: number; + preview: number; + properties: number; + mainContent: number; + timeline: number; } -export const PRESET_CONFIGS: Record = { - default: { - toolsPanel: 25, - previewPanel: 50, - propertiesPanel: 25, - mainContent: 70, - timeline: 30, - }, - media: { - toolsPanel: 30, - previewPanel: 45, - propertiesPanel: 25, - mainContent: 100, - timeline: 25, - }, - inspector: { - toolsPanel: 30, - previewPanel: 70, - propertiesPanel: 30, - mainContent: 75, - timeline: 25, - }, - "vertical-preview": { - toolsPanel: 30, - previewPanel: 40, - propertiesPanel: 30, - mainContent: 75, - timeline: 25, - }, -}; - -interface PanelState extends PanelSizes { - activePreset: PanelPreset; - presetCustomSizes: Record>; - resetCounter: number; - - mediaViewMode: "grid" | "list"; - - setToolsPanel: (size: number) => void; - setPreviewPanel: (size: number) => void; - setPropertiesPanel: (size: number) => void; - setMainContent: (size: number) => void; - setTimeline: (size: number) => void; - setMediaViewMode: (mode: "grid" | "list") => void; +export type PanelId = keyof PanelSizes; - setActivePreset: (preset: PanelPreset) => void; - resetPreset: (preset: PanelPreset) => void; - getCurrentPresetSizes: () => PanelSizes; +interface PanelState { + panels: PanelSizes; + setPanel: (panel: PanelId, size: number) => void; + setPanels: (sizes: Partial) => void; + resetPanels: () => void; } export const usePanelStore = create()( - persist( - (set, get) => ({ - ...PRESET_CONFIGS.default, - activePreset: "default" as PanelPreset, - presetCustomSizes: { - default: {}, - media: {}, - inspector: {}, - "vertical-preview": {}, - }, - resetCounter: 0, - - mediaViewMode: "grid" as const, - - setToolsPanel: (size) => { - const { activePreset, presetCustomSizes } = get(); - set({ - toolsPanel: size, - presetCustomSizes: { - ...presetCustomSizes, - [activePreset]: { - ...presetCustomSizes[activePreset], - toolsPanel: size, - }, - }, - }); - }, - setPreviewPanel: (size) => { - const { activePreset, presetCustomSizes } = get(); - set({ - previewPanel: size, - presetCustomSizes: { - ...presetCustomSizes, - [activePreset]: { - ...presetCustomSizes[activePreset], - previewPanel: size, - }, - }, - }); - }, - setPropertiesPanel: (size) => { - const { activePreset, presetCustomSizes } = get(); - set({ - propertiesPanel: size, - presetCustomSizes: { - ...presetCustomSizes, - [activePreset]: { - ...presetCustomSizes[activePreset], - propertiesPanel: size, - }, - }, - }); - }, - setMainContent: (size) => { - const { activePreset, presetCustomSizes } = get(); - set({ - mainContent: size, - presetCustomSizes: { - ...presetCustomSizes, - [activePreset]: { - ...presetCustomSizes[activePreset], - mainContent: size, - }, - }, - }); - }, - setTimeline: (size) => { - const { activePreset, presetCustomSizes } = get(); - set({ - timeline: size, - presetCustomSizes: { - ...presetCustomSizes, - [activePreset]: { - ...presetCustomSizes[activePreset], - timeline: size, - }, - }, - }); - }, - setMediaViewMode: (mode) => set({ mediaViewMode: mode }), - - setActivePreset: (preset) => { - const { - activePreset: currentPreset, - presetCustomSizes, - toolsPanel, - previewPanel, - propertiesPanel, - mainContent, - timeline, - } = get(); - - const updatedPresetCustomSizes = { - ...presetCustomSizes, - [currentPreset]: { - toolsPanel, - previewPanel, - propertiesPanel, - mainContent, - timeline, - }, - }; - - const defaultSizes = PRESET_CONFIGS[preset]; - const customSizes = updatedPresetCustomSizes[preset] || {}; - const finalSizes = { ...defaultSizes, ...customSizes } as PanelSizes; - - set({ - activePreset: preset, - presetCustomSizes: updatedPresetCustomSizes, - ...finalSizes, - }); - }, - - resetPreset: (preset) => { - const { presetCustomSizes, activePreset, resetCounter } = get(); - const defaultSizes = PRESET_CONFIGS[preset]; - - const newPresetCustomSizes = { - ...presetCustomSizes, - [preset]: {}, - }; - - const updates: Partial = { - presetCustomSizes: newPresetCustomSizes, - resetCounter: resetCounter + 1, - }; - - if (preset === activePreset) { - Object.assign(updates, defaultSizes); - } - - set(updates); - }, - - getCurrentPresetSizes: () => { - const { - toolsPanel, - previewPanel, - propertiesPanel, - mainContent, - timeline, - } = get(); - return { - toolsPanel, - previewPanel, - propertiesPanel, - mainContent, - timeline, - }; - }, - }), - { - name: "panel-sizes", - } - ) + persist( + (set) => ({ + ...PANEL_CONFIG, + setPanel: (panel, size) => + set((state) => ({ + panels: { + ...state.panels, + [panel]: size, + }, + })), + setPanels: (sizes) => + set((state) => ({ + panels: { + ...state.panels, + ...sizes, + }, + })), + resetPanels: () => set({ ...PANEL_CONFIG }), + }), + { + name: "panel-sizes", + version: 2, + migrate: (persistedState) => { + const state = persistedState as + | { + panels?: Partial | null; + toolsPanel?: number; + previewPanel?: number; + propertiesPanel?: number; + mainContent?: number; + timeline?: number; + tools?: number; + preview?: number; + properties?: number; + } + | undefined + | null; + + if (!state) return { panels: { ...PANEL_CONFIG.panels } }; + + if (state.panels && typeof state.panels === "object") { + return { + panels: { + ...PANEL_CONFIG.panels, + ...state.panels, + }, + }; + } + + return { + panels: { + tools: state.tools ?? state.toolsPanel ?? PANEL_CONFIG.panels.tools, + preview: + state.preview ?? + state.previewPanel ?? + PANEL_CONFIG.panels.preview, + properties: + state.properties ?? + state.propertiesPanel ?? + PANEL_CONFIG.panels.properties, + mainContent: state.mainContent ?? PANEL_CONFIG.panels.mainContent, + timeline: state.timeline ?? PANEL_CONFIG.panels.timeline, + }, + }; + }, + partialize: (state) => ({ + panels: state.panels, + }), + }, + ), ); diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts deleted file mode 100644 index c2b9cf4a..00000000 --- a/apps/web/src/stores/playback-store.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { create } from "zustand"; -import type { PlaybackState, PlaybackControls } from "@/types/playback"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { DEFAULT_FPS, useProjectStore } from "./project-store"; - -interface PlaybackStore extends PlaybackState, PlaybackControls { - setDuration: (duration: number) => void; - setCurrentTime: (time: number) => void; -} - -let playbackTimer: number | null = null; - -const startTimer = (store: () => PlaybackStore) => { - 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; - - const newTime = state.currentTime + delta * state.speed; - - // Get actual content duration from timeline store - const actualContentDuration = useTimelineStore - .getState() - .getTotalDuration(); - - // Stop at actual content end, not timeline duration (which has 10s minimum) - // It was either this or reducing default min timeline to 1 second - const effectiveDuration = - actualContentDuration > 0 ? actualContentDuration : state.duration; - - if (newTime >= effectiveDuration) { - // When content completes, pause just before the end so we can see the last frame - const projectFps = useProjectStore.getState().activeProject?.fps; - if (!projectFps) - console.error("Project FPS is not set, assuming " + DEFAULT_FPS + "fps"); - - const frameOffset = 1 / (projectFps ?? DEFAULT_FPS); // Stop 1 frame before end based on project FPS - const stopTime = Math.max(0, effectiveDuration - frameOffset); - - state.pause(); - state.setCurrentTime(stopTime); - // Notify video elements to sync with end position - window.dispatchEvent( - new CustomEvent("playback-seek", { - detail: { time: stopTime }, - }) - ); - } 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); -}; - -const stopTimer = () => { - if (playbackTimer) { - cancelAnimationFrame(playbackTimer); - playbackTimer = null; - } -}; - -export const usePlaybackStore = create((set, get) => ({ - isPlaying: false, - currentTime: 0, - duration: 0, - volume: 1, - muted: false, - previousVolume: 1, - speed: 1.0, - - play: () => { - const state = get(); - - const actualContentDuration = useTimelineStore - .getState() - .getTotalDuration(); - const effectiveDuration = - actualContentDuration > 0 ? actualContentDuration : state.duration; - - if (effectiveDuration > 0) { - const fps = useProjectStore.getState().activeProject?.fps ?? DEFAULT_FPS; - const frameOffset = 1 / fps; - const endThreshold = Math.max(0, effectiveDuration - frameOffset); - - if (state.currentTime >= endThreshold) { - get().seek(0); - } - } - - set({ isPlaying: true }); - startTimer(get); - }, - - pause: () => { - set({ isPlaying: false }); - stopTimer(); - }, - - 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 }); - - 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, - })), - - 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); - }, - - 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, - }); - }, - - unmute: () => { - const { previousVolume } = get(); - set({ muted: false, volume: previousVolume ?? 1 }); - }, - - 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 deleted file mode 100644 index c58f7309..00000000 --- a/apps/web/src/stores/project-store.ts +++ /dev/null @@ -1,571 +0,0 @@ -import { TProject, BlurIntensity, Scene } from "@/types/project"; -import { create } from "zustand"; -import { storageService } from "@/lib/storage/storage-service"; -import { toast } from "sonner"; -import { useMediaStore } from "./media-store"; -import { useTimelineStore } from "./timeline-store"; -import { useSceneStore } from "./scene-store"; -import { generateUUID } from "@/lib/utils"; -import { CanvasSize, CanvasMode } from "@/types/editor"; - -export const DEFAULT_CANVAS_SIZE: CanvasSize = { width: 1920, height: 1080 }; -export const DEFAULT_FPS = 30; - -export function createMainScene(): Scene { - return { - id: generateUUID(), - name: "Main Scene", - isMain: true, - createdAt: new Date(), - updatedAt: new Date(), - }; -} - -const createDefaultProject = (name: string): TProject => { - const mainScene = createMainScene(); - - return { - id: generateUUID(), - name, - thumbnail: "", - createdAt: new Date(), - updatedAt: new Date(), - scenes: [mainScene], - currentSceneId: mainScene.id, - backgroundColor: "#000000", - backgroundType: "color", - blurIntensity: 8, - bookmarks: [], - fps: DEFAULT_FPS, - canvasSize: DEFAULT_CANVAS_SIZE, - canvasMode: "preset", - }; -}; - -interface ProjectStore { - activeProject: TProject | null; - savedProjects: TProject[]; - isLoading: boolean; - isInitialized: boolean; - invalidProjectIds?: Set; - - // Actions - createNewProject: (name: string) => Promise; - loadProject: (id: string) => Promise; - saveCurrentProject: () => Promise; - loadAllProjects: () => Promise; - deleteProject: (id: string) => Promise; - closeProject: () => void; - renameProject: (projectId: string, name: string) => Promise; - duplicateProject: (projectId: string) => Promise; - updateProjectBackground: (backgroundColor: string) => Promise; - updateBackgroundType: ( - type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } - ) => Promise; - updateProjectFps: (fps: number) => Promise; - updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise; - - // Bookmark methods - toggleBookmark: (time: number) => Promise; - isBookmarked: (time: number) => boolean; - removeBookmark: (time: number) => Promise; - - getFilteredAndSortedProjects: ( - searchQuery: string, - sortOption: string - ) => TProject[]; - - // Global invalid project ID tracking - isInvalidProjectId: (id: string) => boolean; - markProjectIdAsInvalid: (id: string) => void; - clearInvalidProjectIds: () => void; -} - -export const useProjectStore = create((set, get) => ({ - activeProject: null, - savedProjects: [], - isLoading: true, - isInitialized: false, - invalidProjectIds: new Set(), - - // Implementation of bookmark methods - toggleBookmark: async (time: number) => { - const { activeProject } = get(); - if (!activeProject) return; - - // Round time to the nearest frame - const fps = activeProject.fps || DEFAULT_FPS; - const frameTime = Math.round(time * fps) / fps; - - const bookmarks = activeProject.bookmarks || []; - let updatedBookmarks: number[]; - - // Check if already bookmarked - const bookmarkIndex = bookmarks.findIndex( - (bookmark) => Math.abs(bookmark - frameTime) < 0.001 - ); - - if (bookmarkIndex !== -1) { - // Remove bookmark - updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex); - } else { - // Add bookmark - updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b); - } - - const updatedProject = { - ...activeProject, - bookmarks: updatedBookmarks, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list - } catch (error) { - console.error("Failed to update project bookmarks:", error); - toast.error("Failed to update bookmarks", { - description: "Please try again", - }); - } - }, - - isBookmarked: (time: number) => { - const { activeProject } = get(); - if (!activeProject || !activeProject.bookmarks) return false; - - // Round time to the nearest frame - const fps = activeProject.fps || DEFAULT_FPS; - const frameTime = Math.round(time * fps) / fps; - - return activeProject.bookmarks.some( - (bookmark) => Math.abs(bookmark - frameTime) < 0.001 - ); - }, - - removeBookmark: async (time: number) => { - const { activeProject } = get(); - if (!activeProject || !activeProject.bookmarks) return; - - // Round time to the nearest frame - const fps = activeProject.fps || DEFAULT_FPS; - const frameTime = Math.round(time * fps) / fps; - - const updatedBookmarks = activeProject.bookmarks.filter( - (bookmark) => Math.abs(bookmark - frameTime) >= 0.001 - ); - - if (updatedBookmarks.length === activeProject.bookmarks.length) { - // No bookmark found to remove - return; - } - - const updatedProject = { - ...activeProject, - bookmarks: updatedBookmarks, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list - } catch (error) { - console.error("Failed to update project bookmarks:", error); - toast.error("Failed to remove bookmark", { - description: "Please try again", - }); - } - }, - - createNewProject: async (name: string) => { - const newProject = createDefaultProject(name); - - set({ activeProject: newProject }); - - const mediaStore = useMediaStore.getState(); - const timelineStore = useTimelineStore.getState(); - const sceneStore = useSceneStore.getState(); - - mediaStore.clearAllMedia(); - timelineStore.clearTimeline(); - - sceneStore.initializeScenes({ - scenes: newProject.scenes, - currentSceneId: newProject.currentSceneId, - }); - - try { - await storageService.saveProject({ project: 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 }); - } - - // Prevent flicker when switching projects - clear all stores - const mediaStore = useMediaStore.getState(); - const timelineStore = useTimelineStore.getState(); - const sceneStore = useSceneStore.getState(); - - mediaStore.clearAllMedia(); - timelineStore.clearTimeline(); - sceneStore.clearScenes(); - - try { - const project = await storageService.loadProject({ id }); - if (project) { - set({ activeProject: project }); - - let currentScene = null; - if (project.scenes && project.scenes.length > 0) { - sceneStore.initializeScenes({ - scenes: project.scenes, - currentSceneId: project.currentSceneId, - }); - // Get current scene directly from project data (don't rely on store state) - currentScene = - project.scenes.find((s) => s.id === project.currentSceneId) || - project.scenes.find((s) => s.isMain) || - project.scenes[0]; - } - - await Promise.all([ - mediaStore.loadProjectMedia(id), - timelineStore.loadProjectTimeline({ - projectId: id, - sceneId: currentScene?.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 { - const timelineStore = useTimelineStore.getState(); - const sceneStore = useSceneStore.getState(); - const currentScene = sceneStore.currentScene; - - await Promise.all([ - storageService.saveProject({ project: activeProject }), - timelineStore.saveProjectTimeline({ - projectId: activeProject.id, - sceneId: currentScene?.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 { - await Promise.all([ - storageService.deleteProjectMedia({ projectId: id }), - storageService.deleteProjectTimeline({ projectId: id }), - storageService.deleteProject({ id }), - ]); - await get().loadAllProjects(); // Refresh the list - - // If deleted active project, close it and clear data - const { activeProject } = get(); - if (activeProject?.id === id) { - set({ activeProject: null }); - const mediaStore = useMediaStore.getState(); - const timelineStore = useTimelineStore.getState(); - const sceneStore = useSceneStore.getState(); - - mediaStore.clearAllMedia(); - timelineStore.clearTimeline(); - sceneStore.clearScenes(); - } - } catch (error) { - console.error("Failed to delete project:", error); - } - }, - - closeProject: () => { - set({ activeProject: null }); - - const mediaStore = useMediaStore.getState(); - const timelineStore = useTimelineStore.getState(); - const sceneStore = useSceneStore.getState(); - - mediaStore.clearAllMedia(); - timelineStore.clearTimeline(); - sceneStore.clearScenes(); - }, - - 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 { - await storageService.saveProject({ project: updatedProject }); - - await get().loadAllProjects(); - - // Update activeProject if 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({ id: 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 = { - ...project, - id: generateUUID(), - name: `(${nextNumber}) ${baseName}`, - createdAt: new Date(), - updatedAt: new Date(), - }; - - await storageService.saveProject({ project: 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({ project: updatedProject }); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); - } 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?: BlurIntensity } - ) => { - const { activeProject } = get(); - if (!activeProject) return; - - const updatedProject = { - ...activeProject, - backgroundType: type, - ...(options?.backgroundColor && { - backgroundColor: options.backgroundColor, - }), - ...(options?.blurIntensity !== undefined && { - blurIntensity: options.blurIntensity, - }), - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); - } 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({ project: updatedProject }); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); - } catch (error) { - console.error("Failed to update project FPS:", error); - toast.error("Failed to update project FPS", { - description: "Please try again", - }); - } - }, - - updateCanvasSize: async (size: CanvasSize, mode: CanvasMode) => { - const { activeProject } = get(); - if (!activeProject) return; - - const updatedProject = { - ...activeProject, - canvasSize: size, - canvasMode: mode, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); - } catch (error) { - console.error("Failed to update canvas size:", error); - toast.error("Failed to update canvas size", { - description: "Please try again", - }); - } - }, - - getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => { - const { savedProjects } = get(); - - const filteredProjects = savedProjects.filter((project) => - project.name.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - 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; - } - if (aValue > bValue) return -1; - if (aValue < bValue) return 1; - return 0; - }); - - return sortedProjects; - }, - - // Global invalid project ID tracking - isInvalidProjectId: (id: string) => { - const invalidIds = get().invalidProjectIds || new Set(); - return invalidIds.has(id); - }, - - markProjectIdAsInvalid: (id: string) => { - set((state) => ({ - invalidProjectIds: new Set([ - ...(state.invalidProjectIds || new Set()), - id, - ]), - })); - }, - - clearInvalidProjectIds: () => { - set({ invalidProjectIds: new Set() }); - }, -})); diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts deleted file mode 100644 index a9f65a49..00000000 --- a/apps/web/src/stores/scene-store.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { create } from "zustand"; -import { Scene } from "@/types/project"; -import { useProjectStore } from "./project-store"; -import { useTimelineStore } from "./timeline-store"; -import { storageService } from "@/lib/storage/storage-service"; -import { generateUUID } from "@/lib/utils"; - -export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null { - return scenes.find((scene) => scene.isMain) || null; -} - -function ensureMainScene(scenes: Scene[]): Scene[] { - const hasMain = scenes.some((scene) => scene.isMain); - if (!hasMain) { - const mainScene: Scene = { - id: generateUUID(), - name: "Main scene", - isMain: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - return [mainScene, ...scenes]; - } - return scenes; -} - -interface SceneStore { - // Current scene state - currentScene: Scene | null; - scenes: Scene[]; - - // Scene management - createScene: ({ - name, - isMain, - }: { - name: string; - isMain: boolean; - }) => Promise; - deleteScene: ({ sceneId }: { sceneId: string }) => Promise; - renameScene: ({ - sceneId, - name, - }: { - sceneId: string; - name: string; - }) => Promise; - switchToScene: ({ sceneId }: { sceneId: string }) => Promise; - - // Scene utilities - getMainScene: () => Scene | null; - getCurrentScene: () => Scene | null; - - // Project integration - loadProjectScenes: ({ projectId }: { projectId: string }) => Promise; - initializeScenes: ({ - scenes, - currentSceneId, - }: { - scenes: Scene[]; - currentSceneId?: string; - }) => void; - clearScenes: () => void; -} - -export const useSceneStore = create((set, get) => ({ - currentScene: null, - scenes: [], - - createScene: async ({ name, isMain = false }) => { - const { scenes } = get(); - - const newScene = { - id: generateUUID(), - name, - isMain, - isBackground: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - const updatedScenes = [...scenes, newScene]; - - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (!activeProject) { - throw new Error("No active project"); - } - - const updatedProject = { - ...activeProject, - scenes: updatedScenes, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - set({ scenes: updatedScenes }); - return newScene.id; - } catch (error) { - console.error("Failed to create scene:", error); - throw error; - } - }, - - deleteScene: async ({ sceneId }: { sceneId: string }) => { - const { scenes, currentScene } = get(); - const sceneToDelete = scenes.find((s) => s.id === sceneId); - - if (!sceneToDelete) { - throw new Error("Scene not found"); - } - - if (sceneToDelete.isMain) { - throw new Error("Cannot delete main scene"); - } - - const updatedScenes = scenes.filter((s) => s.id !== sceneId); - - // Determine new current scene if we're deleting the current one - let newCurrentScene = currentScene; - if (currentScene?.id === sceneId) { - newCurrentScene = getMainScene({ scenes: updatedScenes }); - } - - // Update project - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (!activeProject) { - throw new Error("No active project"); - } - - const updatedProject = { - ...activeProject, - scenes: updatedScenes, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - set({ - scenes: updatedScenes, - currentScene: newCurrentScene, - }); - - // If we switched scenes, load the new scene's timeline - if (newCurrentScene && newCurrentScene.id !== currentScene?.id) { - const timelineStore = useTimelineStore.getState(); - await timelineStore.loadProjectTimeline({ - projectId: activeProject.id, - sceneId: newCurrentScene.id, - }); - } - } catch (error) { - console.error("Failed to delete scene:", error); - throw error; - } - }, - - renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { - const { scenes } = get(); - const updatedScenes = scenes.map((scene) => - scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene - ); - - // Update project - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (!activeProject) { - throw new Error("No active project"); - } - - const updatedProject = { - ...activeProject, - scenes: updatedScenes, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - set({ - scenes: updatedScenes, - currentScene: updatedScenes.find((s) => s.id === sceneId) || null, - }); - } catch (error) { - console.error("Failed to rename scene:", error); - throw error; - } - }, - - switchToScene: async ({ sceneId }: { sceneId: string }) => { - const { scenes } = get(); - const targetScene = scenes.find((s) => s.id === sceneId); - - if (!targetScene) { - throw new Error("Scene not found"); - } - - const timelineStore = useTimelineStore.getState(); - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - const { currentScene } = get(); - - if (activeProject && currentScene) { - await timelineStore.saveProjectTimeline({ - projectId: activeProject.id, - sceneId: currentScene.id, - }); - } - - if (activeProject) { - await timelineStore.loadProjectTimeline({ - projectId: activeProject.id, - sceneId, - }); - - const updatedProject = { - ...activeProject, - currentSceneId: sceneId, - updatedAt: new Date(), - }; - - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - } - - set({ currentScene: targetScene }); - }, - - getMainScene: () => { - const { scenes } = get(); - return scenes.find((scene) => scene.isMain) || null; - }, - - getCurrentScene: () => { - return get().currentScene; - }, - - loadProjectScenes: async ({ projectId }: { projectId: string }) => { - try { - const project = await storageService.loadProject({ id: projectId }); - if (project?.scenes) { - const ensuredScenes = project.scenes.map((scene) => ({ - ...scene, - isMain: scene.isMain || false, - })); - const currentScene = - ensuredScenes.find((s) => s.id === project.currentSceneId) || - ensuredScenes[0]; - - set({ - scenes: ensuredScenes, - currentScene, - }); - } - } catch (error) { - console.error("Failed to load project scenes:", error); - set({ scenes: [], currentScene: null }); - } - }, - - initializeScenes: ({ - scenes, - currentSceneId, - }: { - scenes: Scene[]; - currentSceneId?: string; - }) => { - const ensuredScenes = ensureMainScene(scenes); - const currentScene = currentSceneId - ? ensuredScenes.find((s) => s.id === currentSceneId) - : null; - - const fallbackScene = getMainScene({ scenes: ensuredScenes }); - - set({ - scenes: ensuredScenes, - currentScene: currentScene || fallbackScene, - }); - - if (ensuredScenes.length > scenes.length) { - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (activeProject) { - const updatedProject = { - ...activeProject, - scenes: ensuredScenes, - updatedAt: new Date(), - }; - - storageService - .saveProject({ project: updatedProject }) - .then(() => { - useProjectStore.setState({ activeProject: updatedProject }); - }) - .catch((error) => { - console.error( - "Failed to save project with background scene:", - error - ); - }); - } - } - }, - - clearScenes: () => { - set({ - scenes: [], - currentScene: null, - }); - }, -})); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 579eee7a..bea1a4bc 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -1,282 +1,261 @@ import { create } from "zustand"; import type { SoundEffect, SavedSound } from "@/types/sounds"; -import { storageService } from "@/lib/storage/storage-service"; +import { storageService } from "@/services/storage/storage-service"; import { toast } from "sonner"; -import { useMediaStore } from "./media-store"; -import { useTimelineStore } from "./timeline-store"; -import { useProjectStore } from "./project-store"; -import { usePlaybackStore } from "./playback-store"; +import { EditorCore } from "@/core"; +import { buildLibraryAudioElement } from "@/lib/timeline/element-utils"; interface SoundsStore { - topSoundEffects: SoundEffect[]; - isLoading: boolean; - error: string | null; - hasLoaded: boolean; - - // Filter state - showCommercialOnly: boolean; - toggleCommercialFilter: () => void; - - // Search state - searchQuery: string; - searchResults: SoundEffect[]; - isSearching: boolean; - searchError: string | null; - lastSearchQuery: string; - scrollPosition: number; - - // Pagination state - currentPage: number; - hasNextPage: boolean; - totalCount: number; - isLoadingMore: boolean; - - // Saved sounds state - savedSounds: SavedSound[]; - isSavedSoundsLoaded: boolean; - isLoadingSavedSounds: boolean; - savedSoundsError: string | null; - - // Timeline integration - addSoundToTimeline: (sound: SoundEffect) => Promise; - - setTopSoundEffects: (sounds: SoundEffect[]) => void; - setLoading: (loading: boolean) => void; - setError: (error: string | null) => void; - setHasLoaded: (loaded: boolean) => void; - - // Search actions - setSearchQuery: (query: string) => void; - setSearchResults: (results: SoundEffect[]) => void; - setSearching: (searching: boolean) => void; - setSearchError: (error: string | null) => void; - setLastSearchQuery: (query: string) => void; - setScrollPosition: (position: number) => void; - - // Pagination actions - setCurrentPage: (page: number) => void; - setHasNextPage: (hasNext: boolean) => void; - setTotalCount: (count: number) => void; - setLoadingMore: (loading: boolean) => void; - appendSearchResults: (results: SoundEffect[]) => void; - appendTopSounds: (results: SoundEffect[]) => void; - resetPagination: () => void; - - // Saved sounds actions - loadSavedSounds: () => Promise; - saveSoundEffect: (soundEffect: SoundEffect) => Promise; - removeSavedSound: (soundId: number) => Promise; - isSoundSaved: (soundId: number) => boolean; - toggleSavedSound: (soundEffect: SoundEffect) => Promise; - clearSavedSounds: () => Promise; + topSoundEffects: SoundEffect[]; + isLoading: boolean; + error: string | null; + hasLoaded: boolean; + showCommercialOnly: boolean; + toggleCommercialFilter: () => void; + searchQuery: string; + searchResults: SoundEffect[]; + isSearching: boolean; + searchError: string | null; + lastSearchQuery: string; + scrollPosition: number; + currentPage: number; + hasNextPage: boolean; + totalCount: number; + isLoadingMore: boolean; + savedSounds: SavedSound[]; + isSavedSoundsLoaded: boolean; + isLoadingSavedSounds: boolean; + savedSoundsError: string | null; + + addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise; + setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void; + setLoading: ({ loading }: { loading: boolean }) => void; + setError: ({ error }: { error: string | null }) => void; + setHasLoaded: ({ loaded }: { loaded: boolean }) => void; + setSearchQuery: ({ query }: { query: string }) => void; + setSearchResults: ({ results }: { results: SoundEffect[] }) => void; + setSearching: ({ searching }: { searching: boolean }) => void; + setSearchError: ({ error }: { error: string | null }) => void; + setLastSearchQuery: ({ query }: { query: string }) => void; + setScrollPosition: ({ position }: { position: number }) => void; + setCurrentPage: ({ page }: { page: number }) => void; + setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void; + setTotalCount: ({ count }: { count: number }) => void; + setLoadingMore: ({ loading }: { loading: boolean }) => void; + appendSearchResults: ({ results }: { results: SoundEffect[] }) => void; + appendTopSounds: ({ results }: { results: SoundEffect[] }) => void; + resetPagination: () => void; + loadSavedSounds: () => Promise; + saveSoundEffect: ({ + soundEffect, + }: { + soundEffect: SoundEffect; + }) => Promise; + removeSavedSound: ({ soundId }: { soundId: number }) => Promise; + isSoundSaved: ({ soundId }: { soundId: number }) => boolean; + toggleSavedSound: ({ + soundEffect, + }: { + soundEffect: SoundEffect; + }) => Promise; + clearSavedSounds: () => Promise; } export const useSoundsStore = create((set, get) => ({ - topSoundEffects: [], - isLoading: false, - error: null, - hasLoaded: false, - showCommercialOnly: true, - - toggleCommercialFilter: () => { - set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); - }, - - // Search state - searchQuery: "", - searchResults: [], - isSearching: false, - searchError: null, - lastSearchQuery: "", - scrollPosition: 0, - - // Pagination state - currentPage: 1, - hasNextPage: false, - totalCount: 0, - isLoadingMore: false, - - // Saved sounds state - savedSounds: [], - isSavedSoundsLoaded: false, - isLoadingSavedSounds: false, - savedSoundsError: null, - - setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }), - setLoading: (loading) => set({ isLoading: loading }), - setError: (error) => set({ error }), - setHasLoaded: (loaded) => set({ hasLoaded: loaded }), - - // Search actions - setSearchQuery: (query) => set({ searchQuery: query }), - setSearchResults: (results) => - set({ searchResults: results, currentPage: 1 }), - setSearching: (searching) => set({ isSearching: searching }), - setSearchError: (error) => set({ searchError: error }), - setLastSearchQuery: (query) => set({ lastSearchQuery: query }), - setScrollPosition: (position) => set({ scrollPosition: position }), - - // Pagination actions - setCurrentPage: (page) => set({ currentPage: page }), - setHasNextPage: (hasNext) => set({ hasNextPage: hasNext }), - setTotalCount: (count) => set({ totalCount: count }), - setLoadingMore: (loading) => set({ isLoadingMore: loading }), - appendSearchResults: (results) => - set((state) => ({ - searchResults: [...state.searchResults, ...results], - })), - appendTopSounds: (results) => - set((state) => ({ - topSoundEffects: [...state.topSoundEffects, ...results], - })), - resetPagination: () => - set({ - currentPage: 1, - hasNextPage: false, - totalCount: 0, - isLoadingMore: false, - }), - - // Saved sounds actions - loadSavedSounds: async () => { - if (get().isSavedSoundsLoaded) return; - - try { - set({ isLoadingSavedSounds: true, savedSoundsError: null }); - const savedSoundsData = await storageService.loadSavedSounds(); - set({ - savedSounds: savedSoundsData.sounds, - isSavedSoundsLoaded: true, - isLoadingSavedSounds: false, - }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to load saved sounds"; - set({ - savedSoundsError: errorMessage, - isLoadingSavedSounds: false, - }); - console.error("Failed to load saved sounds:", error); - } - }, - - saveSoundEffect: async (soundEffect: SoundEffect) => { - try { - await storageService.saveSoundEffect({ soundEffect }); - - // Refresh saved sounds - const savedSoundsData = await storageService.loadSavedSounds(); - set({ savedSounds: savedSoundsData.sounds }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to save sound"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to save sound"); - console.error("Failed to save sound:", error); - } - }, - - removeSavedSound: async (soundId: number) => { - try { - await storageService.removeSavedSound({ soundId }); - - // Update local state immediately - set((state) => ({ - savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId), - })); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to remove sound"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to remove sound"); - console.error("Failed to remove sound:", error); - } - }, - - isSoundSaved: (soundId: number) => { - const { savedSounds } = get(); - return savedSounds.some((sound) => sound.id === soundId); - }, - - toggleSavedSound: async (soundEffect: SoundEffect) => { - const { isSoundSaved, saveSoundEffect, removeSavedSound } = get(); - - if (isSoundSaved(soundEffect.id)) { - await removeSavedSound(soundEffect.id); - } else { - await saveSoundEffect(soundEffect); - } - }, - - clearSavedSounds: async () => { - try { - await storageService.clearSavedSounds(); - set({ - savedSounds: [], - savedSoundsError: null, - }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to clear saved sounds"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to clear saved sounds"); - console.error("Failed to clear saved sounds:", error); - } - }, - - addSoundToTimeline: async (sound) => { - const activeProject = useProjectStore.getState().activeProject; - if (!activeProject) { - toast.error("No active project"); - return false; - } - - const audioUrl = sound.previewUrl; - if (!audioUrl) { - toast.error("Sound file not available"); - return false; - } - - try { - const response = await fetch(audioUrl); - if (!response.ok) - throw new Error(`Failed to download audio: ${response.statusText}`); - - const blob = await response.blob(); - const file = new File([blob], `${sound.name}.mp3`, { - type: "audio/mpeg", - }); - - await useMediaStore.getState().addMediaFile(activeProject.id, { - name: sound.name, - type: "audio", - file, - duration: sound.duration, - url: URL.createObjectURL(file), - }); - - const mediaItem = useMediaStore - .getState() - .mediaFiles.find((item) => item.file === file); - if (!mediaItem) throw new Error("Failed to create media item"); - - const success = useTimelineStore - .getState() - .addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime); - - if (success) { - return true; - } - throw new Error("Failed to add to timeline - check for overlaps"); - } catch (error) { - console.error("Failed to add sound to timeline:", error); - toast.error( - error instanceof Error - ? error.message - : "Failed to add sound to timeline", - { id: `sound-${sound.id}` } - ); - return false; - } - }, + topSoundEffects: [], + isLoading: false, + error: null, + hasLoaded: false, + showCommercialOnly: true, + + toggleCommercialFilter: () => { + set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); + }, + + searchQuery: "", + searchResults: [], + isSearching: false, + searchError: null, + lastSearchQuery: "", + scrollPosition: 0, + currentPage: 1, + hasNextPage: false, + totalCount: 0, + isLoadingMore: false, + savedSounds: [], + isSavedSoundsLoaded: false, + isLoadingSavedSounds: false, + savedSoundsError: null, + + setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }), + setLoading: ({ loading }) => set({ isLoading: loading }), + setError: ({ error }) => set({ error }), + setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }), + setSearchQuery: ({ query }) => set({ searchQuery: query }), + setSearchResults: ({ results }) => + set({ searchResults: results, currentPage: 1 }), + setSearching: ({ searching }) => set({ isSearching: searching }), + setSearchError: ({ error }) => set({ searchError: error }), + setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }), + setScrollPosition: ({ position }) => set({ scrollPosition: position }), + setCurrentPage: ({ page }) => set({ currentPage: page }), + setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }), + setTotalCount: ({ count }) => set({ totalCount: count }), + setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }), + + appendSearchResults: ({ results }) => + set((state) => ({ + searchResults: [...state.searchResults, ...results], + })), + + appendTopSounds: ({ results }) => + set((state) => ({ + topSoundEffects: [...state.topSoundEffects, ...results], + })), + + resetPagination: () => + set({ + currentPage: 1, + hasNextPage: false, + totalCount: 0, + isLoadingMore: false, + }), + + loadSavedSounds: async () => { + if (get().isSavedSoundsLoaded) return; + + try { + set({ isLoadingSavedSounds: true, savedSoundsError: null }); + const savedSoundsData = await storageService.loadSavedSounds(); + set({ + savedSounds: savedSoundsData.sounds, + isSavedSoundsLoaded: true, + isLoadingSavedSounds: false, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to load saved sounds"; + set({ + savedSoundsError: errorMessage, + isLoadingSavedSounds: false, + }); + console.error("Failed to load saved sounds:", error); + } + }, + + saveSoundEffect: async ({ soundEffect }) => { + try { + await storageService.saveSoundEffect({ soundEffect }); + + const savedSoundsData = await storageService.loadSavedSounds(); + set({ savedSounds: savedSoundsData.sounds }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to save sound"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to save sound"); + console.error("Failed to save sound:", error); + } + }, + + removeSavedSound: async ({ soundId }) => { + try { + await storageService.removeSavedSound({ soundId }); + + set((state) => ({ + savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId), + })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to remove sound"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to remove sound"); + console.error("Failed to remove sound:", error); + } + }, + + isSoundSaved: ({ soundId }) => { + const { savedSounds } = get(); + return savedSounds.some((sound) => sound.id === soundId); + }, + + toggleSavedSound: async ({ soundEffect }) => { + const { isSoundSaved, saveSoundEffect, removeSavedSound } = get(); + + if (isSoundSaved({ soundId: soundEffect.id })) { + await removeSavedSound({ soundId: soundEffect.id }); + } else { + await saveSoundEffect({ soundEffect }); + } + }, + + clearSavedSounds: async () => { + try { + await storageService.clearSavedSounds(); + set({ + savedSounds: [], + savedSoundsError: null, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to clear saved sounds"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to clear saved sounds"); + console.error("Failed to clear saved sounds:", error); + } + }, + + addSoundToTimeline: async ({ sound }) => { + const audioUrl = sound.previewUrl; + if (!audioUrl) { + toast.error("Sound file not available"); + return false; + } + + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); + + const response = await fetch(audioUrl); + if (!response.ok) + throw new Error(`Failed to download audio: ${response.statusText}`); + + const arrayBuffer = await response.arrayBuffer(); + const audioContext = new AudioContext(); + const buffer = await audioContext.decodeAudioData(arrayBuffer); + + const audioTrack = tracks.find((t) => t.type === "audio"); + let trackId: string; + + if (audioTrack) { + trackId = audioTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "audio" }); + } + + const element = buildLibraryAudioElement({ + sourceUrl: audioUrl, + name: sound.name, + duration: sound.duration, + startTime: currentTime, + buffer, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + return true; + } catch (error) { + console.error("Failed to add sound to timeline:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to add sound to timeline", + { id: `sound-${sound.id}` }, + ); + return false; + } + }, })); diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts index c7c9ef98..edc78220 100644 --- a/apps/web/src/stores/stickers-store.ts +++ b/apps/web/src/stores/stickers-store.ts @@ -1,231 +1,176 @@ import { create } from "zustand"; import { - getCollections, - getCollection, - searchIcons, - downloadSvgAsText, - svgToFile, - type IconSet, - type CollectionInfo, - type IconSearchResult, + getCollections, + getCollection, + searchIcons, + type IconSet, + type CollectionInfo, + type IconSearchResult, } from "@/lib/iconify-api"; -import { useProjectStore } from "@/stores/project-store"; -import { useMediaStore } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import type { MediaFile } from "@/types/media"; +import { EditorCore } from "@/core"; +import { buildStickerElement } from "@/lib/timeline/element-utils"; +import { STICKER_CATEGORY_CONFIG } from "@/constants/stickers-constants"; +import type { StickerCategory } from "@/types/stickers"; -export type StickerCategory = "all" | "general" | "brands" | "emoji"; +type ViewMode = "search" | "browse" | "collection"; interface StickersStore { - searchQuery: string; - selectedCategory: StickerCategory; - selectedCollection: string | null; - viewMode: "search" | "browse" | "collection"; - - collections: Record; - currentCollection: CollectionInfo | null; - searchResults: IconSearchResult | null; - recentStickers: string[]; - isLoadingCollections: boolean; - isLoadingCollection: boolean; - isSearching: boolean; - isDownloading: boolean; - addingSticker: string | null; - - setSearchQuery: (query: string) => void; - setSelectedCategory: (category: StickerCategory) => void; - setSelectedCollection: (collection: string | null) => void; - setViewMode: (mode: "search" | "browse" | "collection") => void; - - loadCollections: () => Promise; - loadCollection: (prefix: string) => Promise; - searchStickers: (query: string) => Promise; - downloadSticker: (iconName: string) => Promise; - addStickerToTimeline: (iconName: string) => Promise; - - addToRecentStickers: (iconName: string) => void; - clearRecentStickers: () => void; + searchQuery: string; + selectedCategory: StickerCategory; + selectedCollection: string | null; + viewMode: ViewMode; + collections: Record; + currentCollection: CollectionInfo | null; + searchResults: IconSearchResult | null; + recentStickers: string[]; + isLoadingCollections: boolean; + isLoadingCollection: boolean; + isSearching: boolean; + addingSticker: string | null; + + setSearchQuery: ({ query }: { query: string }) => void; + setSelectedCategory: ({ category }: { category: StickerCategory }) => void; + setSelectedCollection: ({ + collection, + }: { + collection: string | null; + }) => void; + setViewMode: ({ mode }: { mode: ViewMode }) => void; + loadCollections: () => Promise; + loadCollection: ({ prefix }: { prefix: string }) => Promise; + searchStickers: ({ query }: { query: string }) => Promise; + addStickerToTimeline: ({ iconName }: { iconName: string }) => void; + addToRecentStickers: ({ iconName }: { iconName: string }) => void; + clearRecentStickers: () => void; } const MAX_RECENT_STICKERS = 50; export const useStickersStore = create((set, get) => ({ - searchQuery: "", - selectedCategory: "all", - selectedCollection: null, - viewMode: "browse", - - collections: {}, - currentCollection: null, - searchResults: null, - recentStickers: [], - - isLoadingCollections: false, - isLoadingCollection: false, - isSearching: false, - isDownloading: false, - addingSticker: null, - - setSearchQuery: (query) => set({ searchQuery: query }), - - setSelectedCategory: (category) => - set({ - selectedCategory: category, - viewMode: "browse", - selectedCollection: null, - currentCollection: null, - }), - - setSelectedCollection: (collection) => { - set({ - selectedCollection: collection, - viewMode: collection ? "collection" : "browse", - currentCollection: null, - }); - - if (collection) { - get().loadCollection(collection); - } - }, - - setViewMode: (mode) => set({ viewMode: mode }), - - loadCollections: async () => { - set({ isLoadingCollections: true }); - try { - const collections = await getCollections(); - set({ collections }); - } catch (error) { - console.error("Failed to load collections:", error); - } finally { - set({ isLoadingCollections: false }); - } - }, - - loadCollection: async (prefix: string) => { - set({ isLoadingCollection: true }); - try { - const collection = await getCollection(prefix); - set({ currentCollection: collection }); - } catch (error) { - console.error(`Failed to load collection ${prefix}:`, error); - set({ currentCollection: null }); - } finally { - set({ isLoadingCollection: false }); - } - }, - - searchStickers: async (query: string) => { - if (!query.trim()) { - set({ searchResults: null, viewMode: "browse" }); - return; - } - - const { selectedCategory } = get(); - - set({ isSearching: true, viewMode: "search" }); - try { - let category: string | undefined; - - if (selectedCategory !== "all") { - if (selectedCategory === "general") { - category = "General"; - } else if (selectedCategory === "brands") { - category = "Brands / Social"; - } else if (selectedCategory === "emoji") { - category = "Emoji"; - } - } - - const results = await searchIcons(query, 100, undefined, category); - set({ searchResults: results }); - } catch (error) { - console.error("Search failed:", error); - set({ searchResults: null }); - } finally { - set({ isSearching: false }); - } - }, - - downloadSticker: async (iconName: string) => { - set({ isDownloading: true }); - try { - const svgText = await downloadSvgAsText(iconName, { - width: 200, - height: 200, - }); - - const fileName = `${iconName.replace(":", "-")}.svg`; - const file = svgToFile(svgText, fileName); - - get().addToRecentStickers(iconName); - - return file; - } catch (error) { - console.error(`Failed to download sticker ${iconName}:`, error); - return null; - } finally { - set({ isDownloading: false }); - } - }, - - addStickerToTimeline: async (iconName: string) => { - set({ addingSticker: iconName }); - try { - const { activeProject } = useProjectStore.getState(); - if (!activeProject) { - throw new Error("No active project"); - } - - const file = await get().downloadSticker(iconName); - if (!file) { - throw new Error("Failed to download sticker"); - } - - const mediaItem: Omit = { - name: iconName.replace(":", "-"), - type: "image", - file, - url: URL.createObjectURL(file), - width: 200, - height: 200, - duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, - ephemeral: false, - }; - - const { addMediaFile } = useMediaStore.getState(); - await addMediaFile(activeProject.id, mediaItem); - - const added = useMediaStore - .getState() - .mediaFiles.find( - (m) => m.url === mediaItem.url && m.name === mediaItem.name - ); - if (!added) { - throw new Error("Sticker not in media store"); - } - - const { currentTime } = usePlaybackStore.getState(); - const { addElementAtTime } = useTimelineStore.getState(); - addElementAtTime(added, currentTime); - } finally { - set({ addingSticker: null }); - } - }, - - addToRecentStickers: (iconName: string) => { - set((state) => { - const recent = [ - iconName, - ...state.recentStickers.filter((s) => s !== iconName), - ]; - return { - recentStickers: recent.slice(0, MAX_RECENT_STICKERS), - }; - }); - }, - - clearRecentStickers: () => set({ recentStickers: [] }), + searchQuery: "", + selectedCategory: "all", + selectedCollection: null, + viewMode: "browse", + + collections: {}, + currentCollection: null, + searchResults: null, + recentStickers: [], + + isLoadingCollections: false, + isLoadingCollection: false, + isSearching: false, + addingSticker: null, + + setSearchQuery: ({ query }) => set({ searchQuery: query }), + + setSelectedCategory: ({ category }) => + set({ + selectedCategory: category, + viewMode: "browse", + selectedCollection: null, + currentCollection: null, + }), + + setSelectedCollection: ({ collection }) => { + set({ + selectedCollection: collection, + viewMode: collection ? "collection" : "browse", + currentCollection: null, + }); + + if (collection) { + get().loadCollection({ prefix: collection }); + } + }, + + setViewMode: ({ mode }) => set({ viewMode: mode }), + + loadCollections: async () => { + set({ isLoadingCollections: true }); + try { + const collections = await getCollections(); + set({ collections }); + } catch (error) { + console.error("Failed to load collections:", error); + } finally { + set({ isLoadingCollections: false }); + } + }, + + loadCollection: async ({ prefix }: { prefix: string }) => { + set({ isLoadingCollection: true }); + try { + const collection = await getCollection(prefix); + set({ currentCollection: collection }); + } catch (error) { + console.error(`Failed to load collection ${prefix}:`, error); + set({ currentCollection: null }); + } finally { + set({ isLoadingCollection: false }); + } + }, + + searchStickers: async ({ query }: { query: string }) => { + if (!query.trim()) { + set({ searchResults: null, viewMode: "browse" }); + return; + } + + const { selectedCategory } = get(); + + set({ isSearching: true, viewMode: "search" }); + try { + const category = STICKER_CATEGORY_CONFIG[selectedCategory]; + const results = await searchIcons(query, 100, undefined, category); + set({ searchResults: results }); + } catch (error) { + console.error("Search failed:", error); + set({ searchResults: null }); + } finally { + set({ isSearching: false }); + } + }, + + addStickerToTimeline: ({ iconName }: { iconName: string }) => { + set({ addingSticker: iconName }); + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); + + const stickerTrack = tracks.find((t) => t.type === "sticker"); + let trackId: string; + + if (stickerTrack) { + trackId = stickerTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "sticker" }); + } + + const element = buildStickerElement({ iconName, startTime: currentTime }); + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + + get().addToRecentStickers({ iconName }); + } finally { + set({ addingSticker: null }); + } + }, + + addToRecentStickers: ({ iconName }: { iconName: string }) => { + set((state) => { + const recent = [ + iconName, + ...state.recentStickers.filter((s) => s !== iconName), + ]; + return { + recentStickers: recent.slice(0, MAX_RECENT_STICKERS), + }; + }); + }, + + clearRecentStickers: () => set({ recentStickers: [] }), })); diff --git a/apps/web/src/stores/text-properties-store.ts b/apps/web/src/stores/text-properties-store.ts index b3ce3689..c7476b46 100644 --- a/apps/web/src/stores/text-properties-store.ts +++ b/apps/web/src/stores/text-properties-store.ts @@ -1,33 +1,33 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -export type TextPropertiesTab = "transform" | "style"; +export type TextPropertiesTab = "text" | "transform"; export interface TextPropertiesTabMeta { - value: TextPropertiesTab; - label: string; + value: TextPropertiesTab; + label: string; } export const TEXT_PROPERTIES_TABS: ReadonlyArray = [ - { value: "transform", label: "Transform" }, - { value: "style", label: "Style" }, + { value: "text", label: "Text" }, + { value: "transform", label: "Transform" }, ] as const; export function isTextPropertiesTab(value: string): value is TextPropertiesTab { - return TEXT_PROPERTIES_TABS.some((t) => t.value === value); + return TEXT_PROPERTIES_TABS.some((t) => t.value === value); } interface TextPropertiesState { - activeTab: TextPropertiesTab; - setActiveTab: (tab: TextPropertiesTab) => void; + activeTab: TextPropertiesTab; + setActiveTab: (tab: TextPropertiesTab) => void; } export const useTextPropertiesStore = create()( - persist( - (set) => ({ - activeTab: "transform", - setActiveTab: (tab) => set({ activeTab: tab }), - }), - { name: "text-properties" } - ) + persist( + (set) => ({ + activeTab: "text", + setActiveTab: (tab) => set({ activeTab: tab }), + }), + { name: "text-properties" }, + ), ); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index aeeb65c5..7b555cd9 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,1889 +1,44 @@ -import { create } from "zustand"; -import { - TrackType, - TimelineElement, - CreateTimelineElement, - TimelineTrack, - TextElement, - DragData, - MediaElement, - sortTracksByOrder, - ensureMainTrack, - validateElementTrackCompatibility, -} from "@/types/timeline"; -import { useMediaStore, getMediaAspectRatio } from "./media-store"; -import { MediaFile, MediaType } from "@/types/media"; -import { findBestCanvasPreset } from "@/lib/editor-utils"; -import { storageService } from "@/lib/storage/storage-service"; -import { useProjectStore } from "./project-store"; -import { useSceneStore } from "./scene-store"; -import { generateUUID } from "@/lib/utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; -import { usePlaybackStore } from "./playback-store"; - -// Helper function to manage element naming with suffixes -const getElementNameWithSuffix = ( - originalName: string, - suffix: string -): string => { - // Remove existing suffixes to prevent accumulation - const baseName = originalName - .replace(/ \(left\)$/i, "") - .replace(/ \(right\)$/i, "") - .replace(/ \(audio\)$/i, "") - .replace(/ \(split \d+\)$/i, ""); +/** + * UI state for the timeline + * For core logic, use EditorCore instead. + */ - return `${baseName} (${suffix})`; -}; +import { create } from "zustand"; +import type { ClipboardItem } from "@/types/timeline"; interface TimelineStore { - // Private track storage - _tracks: TimelineTrack[]; - history: TimelineTrack[][]; - redoStack: TimelineTrack[][]; - - // Clipboard buffer - clipboard: { - items: Array<{ trackType: TrackType; element: CreateTimelineElement }>; - } | null; - - // Always returns properly ordered tracks with main track ensured - tracks: TimelineTrack[]; - - // Manual method if you need to force recomputation - getSortedTracks: () => TimelineTrack[]; - - // Snapping settings - snappingEnabled: boolean; - - // Snapping actions - toggleSnapping: () => 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; - - // Drag state - dragState: { - isDragging: boolean; - elementId: string | null; - trackId: string | null; - startMouseX: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; - }; - setDragState: (dragState: Partial) => 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; - - moveElementToTrack: ( - fromTrackId: string, - toTrackId: string, - elementId: string - ) => void; - updateElementTrim: ( - trackId: string, - elementId: string, - trimStart: number, - trimEnd: number, - pushHistory?: boolean - ) => void; - updateElementDuration: ( - trackId: string, - elementId: string, - duration: number, - pushHistory?: boolean - ) => void; - updateElementStartTime: ( - trackId: string, - elementId: string, - startTime: number, - pushHistory?: boolean - ) => void; - toggleTrackMute: (trackId: string) => void; - 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<{ success: boolean; error?: string }>; - - // Ripple editing functions - updateElementStartTimeWithRipple: ( - trackId: string, - elementId: string, - newStartTime: number - ) => void; - removeElementFromTrackWithRipple: ( - trackId: string, - elementId: string, - pushHistory?: boolean - ) => void; - - // Computed values - getTotalDuration: () => number; - getProjectThumbnail: (projectId: string) => Promise; - - // History actions - undo: () => void; - redo: () => void; - pushHistory: () => void; - - // Persistence actions - loadProjectTimeline: ({ - projectId, - sceneId, - }: { - projectId: string; - sceneId?: string; - }) => Promise; - saveProjectTimeline: ({ - projectId, - sceneId, - }: { - projectId: string; - sceneId?: string; - }) => Promise; - clearTimeline: () => void; - - // Clipboard actions - copySelected: () => void; - pasteAtTime: (time: number) => void; - - // Unified selection-aware actions - deleteSelected: (trackId?: string, elementId?: string) => void; - splitSelected: ( - splitTime: number, - trackId?: string, - elementId?: string - ) => void; - toggleSelectedHidden: (trackId?: string, elementId?: string) => void; - toggleSelectedMuted: (trackId?: string, elementId?: string) => void; - duplicateElement: (trackId: string, elementId: string) => void; - revealElementInMedia: (elementId: string) => void; - replaceElementWithFile: ( - trackId: string, - elementId: string, - file: File - ) => Promise; - getContextMenuState: ( - trackId: string, - elementId: string - ) => { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - hasAudioElements: boolean; - canSplitSelected: boolean; - currentTime: number; - }; - 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; - addElementAtTime: ( - item: MediaFile | TextElement, - currentTime?: number - ) => boolean; - addElementToNewTrack: (item: MediaFile | TextElement | DragData) => boolean; + snappingEnabled: boolean; + toggleSnapping: () => void; + rippleEditingEnabled: boolean; + toggleRippleEditing: () => void; + clipboard: { + items: ClipboardItem[]; + } | null; + setClipboard: ( + clipboard: { + items: ClipboardItem[]; + } | null, + ) => void; } -export const useTimelineStore = create((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 auto-save timeline changes - const autoSaveTimeline = async () => { - const activeProject = useProjectStore.getState().activeProject; - const currentScene = useSceneStore.getState().currentScene; - - if (activeProject && currentScene) { - try { - await storageService.saveTimeline({ - projectId: activeProject.id, - tracks: get()._tracks, - sceneId: currentScene.id, - }); - } catch (error) { - console.error("Failed to auto-save timeline:", error); - } - } else { - console.warn( - "Auto-save skipped - missing activeProject or currentScene:", - { - hasProject: !!activeProject, - hasScene: !!currentScene, - sceneName: currentScene?.name, - } - ); - } - }; - - // 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); - - return { - _tracks: initialTracks, - tracks: sortedInitialTracks, - history: [], - redoStack: [], - selectedElements: [], - rippleEditingEnabled: false, - clipboard: null, - - // Snapping settings defaults - snappingEnabled: true, - - 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: [], - }); - }, - - 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 }, - ], - }; - } - return { selectedElements: [{ trackId, elementId }] }; - }); - }, - - deselectElement: (trackId, elementId) => { - set((state) => ({ - selectedElements: state.selectedElements.filter( - (c) => !(c.trackId === trackId && c.elementId === elementId) - ), - })); - }, - - clearSelectedElements: () => { - set({ selectedElements: [] }); - }, - - setSelectedElements: (elements) => set({ selectedElements: elements }), - - addTrack: (type) => { - get().pushHistory(); - - 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, - }; - - updateTracksAndSave([...get()._tracks, newTrack]); - return newTrack.id; - }, - - insertTrackAt: (type, index) => { - get().pushHistory(); - - 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 newTracks = [...get()._tracks]; - newTracks.splice(index, 0, newTrack); - updateTracksAndSave(newTracks); - return newTrack.id; - }, - - removeTrack: (trackId) => { - const { rippleEditingEnabled } = get(); - - 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); - - if (!trackToRemove) return; - - get().pushHistory(); - - const occupiedRanges = trackToRemove.elements.map((element) => ({ - startTime: element.startTime, - endTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd), - })); - - occupiedRanges.sort((a, b) => a.startTime - b.startTime); - - 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) { - lastRange.endTime = Math.max(lastRange.endTime, range.endTime); - lastRange.duration = lastRange.endTime - lastRange.startTime; - } else { - mergedRanges.push({ - startTime: range.startTime, - endTime: range.endTime, - duration: range.endTime - range.startTime, - }); - } - } - } - - const updatedTracks = _tracks - .filter((track) => track.id !== trackId) - .map((track) => { - const updatedElements = track.elements.map((element) => { - let newStartTime = element.startTime; - - for (let i = mergedRanges.length - 1; i >= 0; i--) { - const gap = mergedRanges[i]; - if (newStartTime >= gap.endTime) { - newStartTime -= gap.duration; - } - } - - return { - ...element, - startTime: Math.max(0, newStartTime), - }; - }); - - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...track, elements: resolvedElements }; - } - - return { ...track, elements: updatedElements }; - }); - - updateTracksAndSave(updatedTracks); - }, - - addElementToTrack: (trackId, elementData) => { - get().pushHistory(); - - const track = get()._tracks.find((t) => t.id === trackId); - if (!track) { - console.error("Track not found:", trackId); - return; - } - - const validation = validateElementTrackCompatibility(elementData, track); - if (!validation.isValid) { - console.error(validation.errorMessage); - return; - } - - if (elementData.type === "media" && !elementData.mediaId) { - console.error("Media element must have mediaId"); - return; - } - - if (elementData.type === "text" && !elementData.content) { - console.error("Text element must have content"); - return; - } - - 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, - trimStart: elementData.trimStart ?? 0, - trimEnd: elementData.trimEnd ?? 0, - ...(elementData.type === "media" - ? { muted: elementData.muted ?? false } - : {}), - } as TimelineElement; - - if (isFirstElement && newElement.type === "media") { - const mediaStore = useMediaStore.getState(); - const mediaItem = mediaStore.mediaFiles.find( - (item) => item.id === newElement.mediaId - ); - - if ( - mediaItem && - (mediaItem.type === "image" || mediaItem.type === "video") - ) { - const projectStore = useProjectStore.getState(); - projectStore.updateCanvasSize( - findBestCanvasPreset(getMediaAspectRatio(mediaItem)), - "original" - ); - } - - 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 - ) - ); - - get().selectElement(trackId, newElement.id); - }, - - removeElementFromTrackWithRipple: ( - trackId, - elementId, - pushHistory = true - ) => { - const { _tracks, rippleEditingEnabled } = get(); - - if (!rippleEditingEnabled) { - // Inline non-ripple removal logic - if (pushHistory) get().pushHistory(); - updateTracksAndSave( - _tracks - .map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.filter( - (element) => element.id !== elementId - ), - } - : track - ) - .filter((track) => track.elements.length > 0) - ); - return; - } - - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - - if (!element || !track) return; - - if (pushHistory) get().pushHistory(); - - const elementStartTime = element.startTime; - const elementDuration = - element.duration - element.trimStart - element.trimEnd; - const elementEndTime = elementStartTime + elementDuration; - - const updatedTracks = _tracks - .map((currentTrack) => { - const shouldApplyRipple = currentTrack.id === trackId; - - const updatedElements = currentTrack.elements - .filter((currentElement) => { - if ( - currentElement.id === elementId && - currentTrack.id === trackId - ) { - return false; - } - return true; - }) - .map((currentElement) => { - if (!shouldApplyRipple) { - return currentElement; - } - - if (currentElement.startTime >= elementEndTime) { - return { - ...currentElement, - startTime: Math.max( - 0, - currentElement.startTime - elementDuration - ), - }; - } - return currentElement; - }); - - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...currentTrack, elements: resolvedElements }; - } - - return { ...currentTrack, elements: updatedElements }; - }) - .filter((track) => track.elements.length > 0 || track.isMain); - - updateTracksAndSave(updatedTracks); - }, - - 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 - ); - - if (!elementToMove || !toTrack) return; - - 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 - ), - }; - } - if (track.id === toTrackId) { - return { - ...track, - elements: [...track.elements, elementToMove], - }; - } - return track; - }) - .filter((track) => track.elements.length > 0); - - updateTracksAndSave(newTracks); - }, - - updateElementTrim: ( - trackId, - elementId, - trimStart, - trimEnd, - pushHistory = true - ) => { - if (pushHistory) 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, - pushHistory = true - ) => { - if (pushHistory) 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, - pushHistory = true - ) => { - if (pushHistory) get().pushHistory(); - const clampedStartTime = Math.max(0, startTime); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId - ? { ...element, startTime: clampedStartTime } - : element - ), - } - : track - ) - ); - }, - - updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => { - const { _tracks, rippleEditingEnabled } = get(); - - if (!rippleEditingEnabled) { - get().updateElementStartTime(trackId, elementId, newStartTime); - return; - } - - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - - if (!element || !track) return; - - 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 updatedTracks = _tracks.map((currentTrack) => { - const shouldApplyRipple = currentTrack.id === trackId; - - const updatedElements = currentTrack.elements.map((currentElement) => { - if (currentElement.id === elementId && currentTrack.id === trackId) { - return { ...currentElement, startTime: Math.max(0, newStartTime) }; - } - - if (!shouldApplyRipple) { - return currentElement; - } - - const currentElementStart = currentElement.startTime; - const currentElementEnd = - currentElement.startTime + - (currentElement.duration - - currentElement.trimStart - - currentElement.trimEnd); - - if (timeDelta > 0) { - if (currentElementStart >= oldEndTime) { - return { - ...currentElement, - startTime: currentElementStart + timeDelta, - }; - } - } else if (timeDelta < 0) { - if ( - currentElementStart >= newEndTime && - currentElementStart >= oldStartTime - ) { - return { - ...currentElement, - startTime: Math.max(0, currentElementStart + timeDelta), - }; - } - } - - return currentElement; - }); - - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...currentTrack, elements: resolvedElements }; - } - - return { ...currentTrack, elements: updatedElements }; - }); - - updateTracksAndSave(updatedTracks); - }, - - 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 - ) - ); - }, - - // 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; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; - - get().pushHistory(); - - 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 - ) - ); - }, - - // 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; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; +export const useTimelineStore = create((set) => ({ + snappingEnabled: true, - get().pushHistory(); + toggleSnapping: () => { + set((state) => ({ snappingEnabled: !state.snappingEnabled })); + }, - const relativeTime = splitTime - element.startTime; + rippleEditingEnabled: false, - 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 - ) - ); - }, + toggleRippleEditing: () => { + set((state) => ({ + rippleEditingEnabled: !state.rippleEditingEnabled, + })); + }, - // 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); + clipboard: null, - if (!element || track?.type !== "media") return null; - - get().pushHistory(); - - const existingAudioTrack = _tracks.find((t) => t.type === "audio"); - const audioElementId = generateUUID(); - - if (existingAudioTrack) { - updateTracksAndSave( - get()._tracks.map((track) => - track.id === existingAudioTrack.id - ? { - ...track, - elements: [ - ...track.elements, - { - ...element, - id: audioElementId, - name: getElementNameWithSuffix(element.name, "audio"), - }, - ], - } - : track - ) - ); - } else { - 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]); - } - - return audioElementId; - }, - - // Replace media for an element - replaceElementMedia: async ( - trackId: string, - elementId: string, - newFile: File - ): Promise<{ success: boolean; error?: string }> => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - - if (!element) { - return { success: false, error: "Timeline element not found" }; - } - - if (element.type !== "media") { - return { - success: false, - error: "Replace is only available for media clips", - }; - } - - try { - const mediaStore = useMediaStore.getState(); - const projectStore = useProjectStore.getState(); - - if (!projectStore.activeProject) { - return { success: false, error: "No active project found" }; - } - - const { - getFileType, - getImageDimensions, - generateVideoThumbnail, - getMediaDuration, - } = await import("./media-store"); - - const fileType = getFileType(newFile); - if (!fileType) { - return { - success: false, - error: - "Unsupported file type. Please select a video, audio, or image file.", - }; - } - - const mediaData: Omit = { - name: newFile.name, - type: fileType as MediaType, - file: newFile, - url: URL.createObjectURL(newFile), - }; - - try { - 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); - } - } catch (error) { - return { - success: false, - error: `Failed to process ${fileType} file: ${error instanceof Error ? error.message : "Unknown error"}`, - }; - } - - try { - await mediaStore.addMediaFile( - projectStore.activeProject.id, - mediaData - ); - } catch (error) { - return { - success: false, - error: `Failed to add media to project: ${error instanceof Error ? error.message : "Unknown error"}`, - }; - } - - const newMediaItem = mediaStore.mediaFiles.find( - (item) => item.file === newFile - ); - - if (!newMediaItem) { - return { - success: false, - error: "Failed to create media item in project. Please try again.", - }; - } - - get().pushHistory(); - - updateTracksAndSave( - _tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((c) => - c.id === elementId - ? { - ...c, - mediaId: newMediaItem.id, - name: newMediaItem.name, - duration: newMediaItem.duration || c.duration, - } - : c - ), - } - : track - ) - ); - - return { success: true }; - } catch (error) { - console.error("Failed to replace element media:", error); - return { - success: false, - error: `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`, - }; - } - }, - - 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) - ); - - return Math.max(...trackEndTimes, 0); - }, - - getProjectThumbnail: async (projectId) => { - try { - const project = await storageService.loadProject({ id: projectId }); - if (!project) return null; - - // For scene-based projects, use main scene timeline - // For legacy projects, use legacy timeline format - let sceneId: string | undefined; - if (project.scenes && project.scenes.length > 0) { - const mainScene = project.scenes.find((s) => s.isMain); - sceneId = mainScene?.id; - } - - const tracks = await storageService.loadTimeline({ - projectId, - sceneId, - }); - const mediaItems = await storageService.loadAllMediaFiles({ - projectId, - }); - - if (!tracks || !mediaItems.length) return null; - - const firstMediaElement = tracks - .flatMap((track) => track.elements) - .filter((element) => element.type === "media") - .sort((a, b) => a.startTime - b.startTime)[0]; - - if (!firstMediaElement) return null; - - const mediaFile = mediaItems.find( - (item) => item.id === firstMediaElement.mediaId - ); - if (!mediaFile) return null; - - if (mediaFile.type === "video" && mediaFile.file) { - const { generateVideoThumbnail } = await import( - "@/stores/media-store" - ); - const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file); - return thumbnailUrl; - } - if (mediaFile.type === "image" && mediaFile.url) { - return mediaFile.url; - } - - return null; - } catch (error) { - console.error("Failed to get project thumbnail:", error); - return null; - } - }, - - 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, - }, - - 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, - }, - }); - }, - - 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, - }, - }); - }, - - loadProjectTimeline: async ({ - projectId, - sceneId, - }: { - projectId: string; - sceneId?: string; - }) => { - try { - const tracks = await storageService.loadTimeline({ - projectId, - sceneId, - }); - - if (tracks) { - updateTracks(tracks); - } else { - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - } - set({ history: [], redoStack: [] }); - } catch (error) { - console.error("Failed to load timeline:", error); - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - set({ history: [], redoStack: [] }); - } - }, - - saveProjectTimeline: async ({ - projectId, - sceneId, - }: { - projectId: string; - sceneId?: string; - }) => { - const { _tracks } = get(); - try { - await storageService.saveTimeline({ - projectId, - tracks: _tracks, - sceneId, - }); - } catch (error) { - console.error("Failed to save timeline:", error); - } - }, - - clearTimeline: () => { - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - set({ - history: [], - redoStack: [], - selectedElements: [], - clipboard: null, - }); - }, - - // Snapping actions - toggleSnapping: () => { - set((state) => ({ snappingEnabled: !state.snappingEnabled })); - }, - - // 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; - - const overlap = track.elements.some((element) => { - const elementEnd = - element.startTime + - element.duration - - element.trimStart - - element.trimEnd; - - 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; - }, - - findOrCreateTrack: (trackType) => { - if (trackType === "text") { - return get().insertTrackAt(trackType, 0); - } - - const existingTrack = get()._tracks.find((t) => t.type === trackType); - if (existingTrack) { - return existingTrack.id; - } - - return get().addTrack(trackType); - }, - - addElementAtTime: (item: MediaFile | TextElement, currentTime = 0) => { - if (item.type === "text") { - const targetTrackId = get().insertTrackAt("text", 0); - get().addElementToTrack( - targetTrackId, - buildTextElement(item, currentTime) - ); - return true; - } - - const media = item as MediaFile; - const trackType = media.type === "audio" ? "audio" : "media"; - const targetTrackId = get().insertTrackAt(trackType, 0); - get().addElementToTrack(targetTrackId, { - type: "media", - mediaId: media.id, - name: media.name, - duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - muted: false, - }); - return true; - }, - - addElementToNewTrack: (item) => { - if (item.type === "text") { - const targetTrackId = get().insertTrackAt("text", 0); - get().addElementToTrack( - targetTrackId, - buildTextElement(item as TextElement | DragData, 0) - ); - return true; - } - - const media = item as MediaFile; - const trackType = media.type === "audio" ? "audio" : "media"; - const targetTrackId = get().insertTrackAt(trackType, 0); - get().addElementToTrack(targetTrackId, { - type: "media", - mediaId: media.id, - name: media.name, - duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - muted: false, - }); - return true; - }, - - copySelected: () => { - const { selectedElements, _tracks } = get(); - if (selectedElements.length === 0) return; - - const items: Array<{ - trackType: TrackType; - element: CreateTimelineElement; - }> = []; - - for (const { trackId, elementId } of selectedElements) { - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - if (!track || !element) continue; - - // Prepare a creation-friendly copy without id - const { id: _id, ...rest } = element as TimelineElement; - items.push({ - trackType: track.type, - element: rest as CreateTimelineElement, - }); - } - - set({ clipboard: { items } }); - }, - - pasteAtTime: (time) => { - const { clipboard } = get(); - if (!clipboard || clipboard.items.length === 0) return; - - // Determine reference start time offset based on earliest element in clipboard - const minStart = Math.min( - ...clipboard.items.map((x) => x.element.startTime) - ); - - get().pushHistory(); - - for (const item of clipboard.items) { - const targetTrackId = get().findOrCreateTrack(item.trackType); - const relativeOffset = item.element.startTime - minStart; - const startTime = Math.max(0, time + relativeOffset); - - // Ensure no overlap on target track - const duration = - item.element.duration - item.element.trimStart - item.element.trimEnd; - const hasOverlap = get().checkElementOverlap( - targetTrackId, - startTime, - duration - ); - if (hasOverlap) { - // If overlap, nudge forward slightly until free (simple resolve) - let candidate = startTime; - let safety = 0; - while ( - get().checkElementOverlap(targetTrackId, candidate, duration) && - safety < 1000 - ) { - candidate += 0.01; - safety += 1; - } - get().addElementToTrack(targetTrackId, { - ...item.element, - startTime: candidate, - }); - } else { - get().addElementToTrack(targetTrackId, { - ...item.element, - startTime, - }); - } - } - }, - - deleteSelected: (trackId?: string, elementId?: string) => { - const { selectedElements, rippleEditingEnabled } = get(); - - const elementsToDelete = - trackId && elementId - ? [{ trackId, elementId }] - : selectedElements.length > 0 - ? selectedElements - : []; - - if (elementsToDelete.length === 0) return; - - get().pushHistory(); - - if (rippleEditingEnabled) { - for (const { trackId: tId, elementId: eId } of elementsToDelete) { - get().removeElementFromTrackWithRipple(tId, eId, false); - } - } else { - updateTracksAndSave( - get() - ._tracks.map((track) => ({ - ...track, - elements: track.elements.filter( - (element) => - !elementsToDelete.some( - ({ trackId: tId, elementId: eId }) => - track.id === tId && element.id === eId - ) - ), - })) - .filter((track) => track.elements.length > 0) - ); - } - - get().clearSelectedElements(); - }, - - splitSelected: (splitTime, trackId?: string, elementId?: string) => { - const { selectedElements, _tracks } = get(); - - const elementsToProcess = - trackId && elementId - ? [{ trackId, elementId }] - : selectedElements.length > 0 - ? selectedElements - : []; - - if (elementsToProcess.length === 0) return; - - const elementsToSplit: Array<{ - trackId: string; - elementId: string; - element: TimelineElement; - }> = []; - - for (const { trackId: tId, elementId: eId } of elementsToProcess) { - const track = _tracks.find((t) => t.id === tId); - const element = track?.elements.find((e) => e.id === eId); - if (!track || !element) continue; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime > effectiveStart && splitTime < effectiveEnd) { - elementsToSplit.push({ trackId: tId, elementId: eId, element }); - } - } - - if (elementsToSplit.length === 0) { - const { toast } = require("sonner"); - const isMultiple = elementsToProcess.length > 1; - toast.error( - isMultiple - ? "Playhead must be within all selected elements to split" - : "Playhead must be within element to split" - ); - return; - } - - get().pushHistory(); - - updateTracksAndSave( - get()._tracks.map((track) => { - const elementsToSplitInTrack = elementsToSplit.filter( - ({ trackId: tId }) => tId === track.id - ); - - if (elementsToSplitInTrack.length === 0) return track; - - return { - ...track, - elements: track.elements.flatMap((c) => { - const elementToSplit = elementsToSplitInTrack.find( - ({ elementId: eId }) => eId === c.id - ); - - if (!elementToSplit) return [c]; - - const relativeTime = splitTime - elementToSplit.element.startTime; - const firstDuration = relativeTime; - const secondDuration = - elementToSplit.element.duration - - elementToSplit.element.trimStart - - elementToSplit.element.trimEnd - - relativeTime; - - const secondElementId = generateUUID(); - - return [ - { - ...c, - trimEnd: c.trimEnd + secondDuration, - name: getElementNameWithSuffix(c.name, "left"), - }, - { - ...c, - id: secondElementId, - startTime: splitTime, - trimStart: c.trimStart + firstDuration, - name: getElementNameWithSuffix(c.name, "right"), - }, - ]; - }), - }; - }) - ); - }, - - toggleSelectedHidden: (trackId?: string, elementId?: string) => { - const { selectedElements, _tracks } = get(); - - const elementsToProcess = - trackId && elementId - ? [{ trackId, elementId }] - : selectedElements.length > 0 - ? selectedElements - : []; - - if (elementsToProcess.length === 0) return; - - get().pushHistory(); - - const shouldHide = elementsToProcess.some( - ({ trackId: tId, elementId: eId }) => { - const track = _tracks.find((t) => t.id === tId); - const element = track?.elements.find((e) => e.id === eId); - return element && !element.hidden; - } - ); - - updateTracksAndSave( - _tracks.map((track) => ({ - ...track, - elements: track.elements.map((element) => { - const shouldUpdate = elementsToProcess.some( - ({ trackId: tId, elementId: eId }) => - track.id === tId && element.id === eId - ); - return shouldUpdate && element.hidden !== shouldHide - ? { ...element, hidden: shouldHide } - : element; - }), - })) - ); - }, - - toggleSelectedMuted: (trackId?: string, elementId?: string) => { - const { selectedElements, _tracks } = get(); - - const elementsToProcess = - trackId && elementId - ? [{ trackId, elementId }] - : selectedElements.length > 0 - ? selectedElements - : []; - - if (elementsToProcess.length === 0) return; - - get().pushHistory(); - - const audioElements = elementsToProcess.filter( - ({ trackId: tId, elementId: eId }) => { - const track = _tracks.find((t) => t.id === tId); - const element = track?.elements.find((e) => e.id === eId); - return element?.type === "media"; - } - ); - - if (audioElements.length === 0) return; - - const shouldMute = audioElements.some( - ({ trackId: tId, elementId: eId }) => { - const track = _tracks.find((t) => t.id === tId); - const element = track?.elements.find((e) => e.id === eId); - return element?.type === "media" && !element.muted; - } - ); - - updateTracksAndSave( - _tracks.map((track) => ({ - ...track, - elements: track.elements.map((element) => { - const shouldUpdate = audioElements.some( - ({ trackId: tId, elementId: eId }) => - track.id === tId && element.id === eId - ); - return shouldUpdate && - element.type === "media" && - element.muted !== shouldMute - ? { ...element, muted: shouldMute } - : element; - }), - })) - ); - }, - - duplicateElement: (trackId, elementId) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - if (!track || !element) return; - - const { id, ...elementWithoutId } = element; - const effectiveDuration = - element.duration - element.trimStart - element.trimEnd; - - get().addElementToTrack(trackId, { - ...elementWithoutId, - name: `${element.name} (copy)`, - startTime: element.startTime + effectiveDuration + 0.1, - } as CreateTimelineElement); - }, - - revealElementInMedia: (elementId) => { - const { - useMediaPanelStore, - } = require("../components/editor/media-panel/store"); - const { requestRevealMedia } = useMediaPanelStore.getState(); - - const { _tracks } = get(); - const element = _tracks - .flatMap((track) => track.elements) - .find((el) => el.id === elementId); - - if (element?.type === "media") { - requestRevealMedia(element.mediaId); - } - }, - - replaceElementWithFile: async (trackId, elementId, file) => { - try { - const result = await get().replaceElementMedia( - trackId, - elementId, - file - ); - if (result.success) { - const { toast } = await import("sonner"); - toast.success("Clip replaced successfully"); - } else { - const { toast } = await import("sonner"); - toast.error(result.error || "Failed to replace clip"); - } - } catch (error) { - console.error("Unexpected error replacing clip:", error); - const { toast } = await import("sonner"); - toast.error( - `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` - ); - } - }, - - getContextMenuState: (trackId, elementId) => { - const { selectedElements, _tracks } = get(); - const { currentTime } = usePlaybackStore.getState(); - const { mediaFiles } = useMediaStore.getState(); - - const isMultipleSelected = selectedElements.length > 1; - const isCurrentElementSelected = selectedElements.some( - (sel) => sel.trackId === trackId && sel.elementId === elementId - ); - - const hasAudioElements = selectedElements.some( - ({ trackId: tId, elementId: eId }) => { - const selectedTrack = _tracks.find((t) => t.id === tId); - const selectedElement = selectedTrack?.elements.find( - (e) => e.id === eId - ); - if (selectedElement?.type !== "media") return false; - const mediaElement = selectedElement as MediaElement; - const mediaItem = mediaFiles.find( - (file: MediaFile) => file.id === mediaElement.mediaId - ); - return mediaItem?.type === "audio" || mediaItem?.type === "video"; - } - ); - - const canSplitSelected = selectedElements.every( - ({ trackId: tId, elementId: eId }) => { - const selectedTrack = _tracks.find((t) => t.id === tId); - const selectedElement = selectedTrack?.elements.find( - (e) => e.id === eId - ); - if (!selectedElement) return false; - const effectiveStart = selectedElement.startTime; - const effectiveEnd = - selectedElement.startTime + - (selectedElement.duration - - selectedElement.trimStart - - selectedElement.trimEnd); - return currentTime > effectiveStart && currentTime < effectiveEnd; - } - ); - - return { - isMultipleSelected, - isCurrentElementSelected, - hasAudioElements, - canSplitSelected, - currentTime, - }; - }, - }; -}); - -function buildTextElement( - raw: TextElement | DragData, - startTime: number -): CreateTimelineElement { - const t = raw as Partial; - - return { - type: "text", - name: t.name ?? DEFAULT_TEXT_ELEMENT.name, - content: t.content ?? DEFAULT_TEXT_ELEMENT.content, - duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime, - trimStart: 0, - trimEnd: 0, - fontSize: - typeof t.fontSize === "number" - ? t.fontSize - : DEFAULT_TEXT_ELEMENT.fontSize, - fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, - color: t.color ?? DEFAULT_TEXT_ELEMENT.color, - backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor, - textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, - fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, - fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, - textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, - x: typeof t.x === "number" ? t.x : DEFAULT_TEXT_ELEMENT.x, - y: typeof t.y === "number" ? t.y : DEFAULT_TEXT_ELEMENT.y, - rotation: - typeof t.rotation === "number" - ? t.rotation - : DEFAULT_TEXT_ELEMENT.rotation, - opacity: - typeof t.opacity === "number" ? t.opacity : DEFAULT_TEXT_ELEMENT.opacity, - }; -} + setClipboard: (clipboard) => { + set({ clipboard }); + }, +})); -- cgit v1.3.1