diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/stores | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/stores')
| -rw-r--r-- | apps/web/src/stores/assets-panel-store.tsx | 107 | ||||
| -rw-r--r-- | apps/web/src/stores/editor-store.ts | 129 | ||||
| -rw-r--r-- | apps/web/src/stores/keybindings-store.ts | 409 | ||||
| -rw-r--r-- | apps/web/src/stores/keybindings/migrations/index.ts | 28 | ||||
| -rw-r--r-- | apps/web/src/stores/keybindings/migrations/v2-to-v3.ts | 26 | ||||
| -rw-r--r-- | apps/web/src/stores/media-store.ts | 296 | ||||
| -rw-r--r-- | apps/web/src/stores/panel-store.ts | 292 | ||||
| -rw-r--r-- | apps/web/src/stores/playback-store.ts | 174 | ||||
| -rw-r--r-- | apps/web/src/stores/project-store.ts | 571 | ||||
| -rw-r--r-- | apps/web/src/stores/scene-store.ts | 318 | ||||
| -rw-r--r-- | apps/web/src/stores/sounds-store.ts | 475 | ||||
| -rw-r--r-- | apps/web/src/stores/stickers-store.ts | 343 | ||||
| -rw-r--r-- | apps/web/src/stores/text-properties-store.ts | 30 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 1913 |
14 files changed, 915 insertions, 4196 deletions
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 }) => ( + <HugeiconsIcon icon={icon} className={className} /> + ); + +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<AssetsPanelStore>((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<PlatformLayout, string> = { - 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<void>; - setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void; - toggleLayoutGuide: (platform: PlatformLayout) => void; + isInitializing: boolean; + isPanelsReady: boolean; + canvasPresets: TCanvasSize[]; + layoutGuide: LayoutGuideSettings; + setInitializing: (loading: boolean) => void; + setPanelsReady: (ready: boolean) => void; + initializeApp: () => Promise<void>; + setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => 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<EditorState>()( - 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"; +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"; -// 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", -}; +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; + 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[]; + 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; +} - // Utility - 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<KeybindingsState>()( - persist( - (set, get) => ({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - keybindingsEnabled: true, - isRecording: false, + 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; + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + newKeybindings[key] = action; - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, - removeKeybinding: (key: ShortcutKey) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; + removeKeybinding: (key: ShortcutKey) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + delete newKeybindings[key]; - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, - resetToDefaults: () => { - set({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - }); - }, + resetToDefaults: () => { + set({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + }); + }, - enableKeybindings: () => { - set({ keybindingsEnabled: true }); - }, + enableKeybindings: () => { + set({ keybindingsEnabled: true }); + }, - disableKeybindings: () => { - set({ keybindingsEnabled: false }); - }, + disableKeybindings: () => { + set({ keybindingsEnabled: false }); + }, - importKeybindings: (config: KeybindingConfig) => { - // Validate all keys and actions - for (const [key, action] of Object.entries(config)) { - // Validate the key format - if (typeof key !== "string" || key.length === 0) { - throw new Error(`Invalid key format: ${key}`); - } - } - set({ - keybindings: { ...config }, - isCustomized: true, - }); - }, + importKeybindings: (config: KeybindingConfig) => { + // Validate all keys and actions + for (const [key] of Object.entries(config)) { + // Validate the key format + if (typeof key !== "string" || key.length === 0) { + throw new Error(`Invalid key format: ${key}`); + } + } + set({ + keybindings: { ...config }, + isCustomized: true, + }); + }, - exportKeybindings: () => { - return get().keybindings; - }, + exportKeybindings: () => { + return get().keybindings; + }, - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => { + const { keybindings } = get(); + const existingAction = keybindings[key]; - if (existingAction && existingAction !== action) { - return { - key, - existingAction, - newAction: action, - }; - } + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } - return null; - }, - setIsRecording: (isRecording: boolean) => { - set({ isRecording }); - }, + 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[]; - }, + 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: 2, - } - ) + 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; + const target = ev.target; - // We may or may not have a modifier key - const modifierKey = getActiveModifier(ev); + // We may or may not have a modifier key + const modifierKey = getActiveModifier(ev); - // We will always have a non-modifier key - const key = getPressedKey(ev); - if (!key) return null; + // We will always have a non-modifier key + const key = getPressedKey(ev); + if (!key) return null; - // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) - if (modifierKey) { - // If the modifier is shift and the target is an input, we ignore - if ( - modifierKey === "shift" && - isDOMElement(target) && - isTypableElement(target) - ) { - return null; - } + // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) + if (modifierKey) { + // If the modifier is shift and the target is an input, we ignore + if ( + modifierKey === "shift" && + isDOMElement(target) && + isTypableDOMElement({ element: target as HTMLElement }) + ) { + return null; + } - return `${modifierKey}+${key}` as ShortcutKey; - } + return `${modifierKey}+${key}` as ShortcutKey; + } - // no modifier key here then we do not do anything while on input - if (isDOMElement(target) && isTypableElement(target)) return null; + // no modifier key here then we do not do anything while on input + if ( + isDOMElement(target) && + isTypableDOMElement({ element: target as HTMLElement }) + ) + return null; - // single key while not input - return `${key}` as ShortcutKey; + // single key while not input + return `${key}` as ShortcutKey; } function getPressedKey(ev: KeyboardEvent): string | null { - // Sometimes the property code is not available on the KeyboardEvent object - const key = (ev.key ?? "").toLowerCase(); - const code = ev.code ?? ""; + // 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"; + if (code === "Space" || key === " " || key === "spacebar" || key === "space") + return "space"; - // Check arrow keys - if (key.startsWith("arrow")) { - return key.slice(5); - } + // Check arrow keys + if (key.startsWith("arrow")) { + return key.slice(5); + } - // Check for special keys - if (key === "tab") return "tab"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; + // Check for special keys + if (key === "tab") return "tab"; + if (key === "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 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; - } - } + // 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; + // 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; + // Check if slash, period or enter + if (key === "/" || key === "." || key === "enter") return key; - // If no other cases match, this is not a valid key - return null; + // If no other cases match, this is not a valid key + return null; } function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; + const modifierKeys = { + ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey, + }; - // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift - // modiferKeys object's keys are sorted to match the above order - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); + // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift + // modiferKeys object's keys are sorted to match the above order + const activeModifier = Object.keys(modifierKeys) + .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) + .join("+"); - return activeModifier === "" ? null : activeModifier; + return activeModifier === "" ? null : activeModifier; } diff --git a/apps/web/src/stores/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<number, MigrationFn> = { + 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<string, string> = { + "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<MediaFile, "id"> - ) => Promise<void>; - removeMediaFile: (projectId: string, id: string) => Promise<void>; - loadProjectMedia: (projectId: string) => Promise<void>; - clearProjectMedia: (projectId: string) => Promise<void>; - 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<number> => { - 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<MediaStore>((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"; +import { PANEL_CONFIG } from "@/constants/editor-constants"; -export type PanelPreset = - | "default" - | "media" - | "inspector" - | "vertical-preview"; - -interface PanelSizes { - toolsPanel: number; - previewPanel: number; - propertiesPanel: number; - mainContent: number; - timeline: number; +export interface PanelSizes { + tools: number; + preview: number; + properties: number; + mainContent: number; + timeline: number; } -export const PRESET_CONFIGS: Record<PanelPreset, PanelSizes> = { - 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<PanelPreset, Partial<PanelSizes>>; - 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<PanelSizes>) => void; + resetPanels: () => void; } export const usePanelStore = create<PanelState>()( - 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<PanelState> = { - presetCustomSizes: newPresetCustomSizes, - resetCounter: resetCounter + 1, - }; + 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<PanelSizes> | null; + toolsPanel?: number; + previewPanel?: number; + propertiesPanel?: number; + mainContent?: number; + timeline?: number; + tools?: number; + preview?: number; + properties?: number; + } + | undefined + | null; - if (preset === activePreset) { - Object.assign(updates, defaultSizes); - } + if (!state) return { panels: { ...PANEL_CONFIG.panels } }; - set(updates); - }, + if (state.panels && typeof state.panels === "object") { + return { + panels: { + ...PANEL_CONFIG.panels, + ...state.panels, + }, + }; + } - getCurrentPresetSizes: () => { - const { - toolsPanel, - previewPanel, - propertiesPanel, - mainContent, - timeline, - } = get(); - return { - toolsPanel, - previewPanel, - propertiesPanel, - mainContent, - timeline, - }; - }, - }), - { - name: "panel-sizes", - } - ) + 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<PlaybackStore>((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<string>; - - // Actions - createNewProject: (name: string) => Promise<string>; - loadProject: (id: string) => Promise<void>; - saveCurrentProject: () => Promise<void>; - loadAllProjects: () => Promise<void>; - deleteProject: (id: string) => Promise<void>; - closeProject: () => void; - renameProject: (projectId: string, name: string) => Promise<void>; - duplicateProject: (projectId: string) => Promise<string>; - updateProjectBackground: (backgroundColor: string) => Promise<void>; - updateBackgroundType: ( - type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: BlurIntensity } - ) => Promise<void>; - updateProjectFps: (fps: number) => Promise<void>; - updateCanvasSize: (size: CanvasSize, mode: CanvasMode) => Promise<void>; - - // Bookmark methods - toggleBookmark: (time: number) => Promise<void>; - isBookmarked: (time: number) => boolean; - removeBookmark: (time: number) => Promise<void>; - - 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<ProjectStore>((set, get) => ({ - activeProject: null, - savedProjects: [], - isLoading: true, - isInitialized: false, - invalidProjectIds: new Set<string>(), - - // 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<string>; - deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>; - renameScene: ({ - sceneId, - name, - }: { - sceneId: string; - name: string; - }) => Promise<void>; - switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>; - - // Scene utilities - getMainScene: () => Scene | null; - getCurrentScene: () => Scene | null; - - // Project integration - loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>; - initializeScenes: ({ - scenes, - currentSceneId, - }: { - scenes: Scene[]; - currentSceneId?: string; - }) => void; - clearScenes: () => void; -} - -export const useSceneStore = create<SceneStore>((set, get) => ({ - currentScene: null, - scenes: [], - - createScene: async ({ name, isMain = false }) => { - const { scenes } = get(); - - const newScene = { - id: generateUUID(), - name, - isMain, - isBackground: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - const updatedScenes = [...scenes, newScene]; - - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (!activeProject) { - throw new Error("No active project"); - } - - const updatedProject = { - ...activeProject, - scenes: updatedScenes, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - set({ scenes: updatedScenes }); - return newScene.id; - } catch (error) { - console.error("Failed to create scene:", error); - throw error; - } - }, - - deleteScene: async ({ sceneId }: { sceneId: string }) => { - const { scenes, currentScene } = get(); - const sceneToDelete = scenes.find((s) => s.id === sceneId); - - if (!sceneToDelete) { - throw new Error("Scene not found"); - } - - if (sceneToDelete.isMain) { - throw new Error("Cannot delete main scene"); - } - - const updatedScenes = scenes.filter((s) => s.id !== sceneId); - - // Determine new current scene if we're deleting the current one - let newCurrentScene = currentScene; - if (currentScene?.id === sceneId) { - newCurrentScene = getMainScene({ scenes: updatedScenes }); - } - - // Update project - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (!activeProject) { - throw new Error("No active project"); - } - - const updatedProject = { - ...activeProject, - scenes: updatedScenes, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - set({ - scenes: updatedScenes, - currentScene: newCurrentScene, - }); - - // If we switched scenes, load the new scene's timeline - if (newCurrentScene && newCurrentScene.id !== currentScene?.id) { - const timelineStore = useTimelineStore.getState(); - await timelineStore.loadProjectTimeline({ - projectId: activeProject.id, - sceneId: newCurrentScene.id, - }); - } - } catch (error) { - console.error("Failed to delete scene:", error); - throw error; - } - }, - - renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { - const { scenes } = get(); - const updatedScenes = scenes.map((scene) => - scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene - ); - - // Update project - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (!activeProject) { - throw new Error("No active project"); - } - - const updatedProject = { - ...activeProject, - scenes: updatedScenes, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - set({ - scenes: updatedScenes, - currentScene: updatedScenes.find((s) => s.id === sceneId) || null, - }); - } catch (error) { - console.error("Failed to rename scene:", error); - throw error; - } - }, - - switchToScene: async ({ sceneId }: { sceneId: string }) => { - const { scenes } = get(); - const targetScene = scenes.find((s) => s.id === sceneId); - - if (!targetScene) { - throw new Error("Scene not found"); - } - - const timelineStore = useTimelineStore.getState(); - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - const { currentScene } = get(); - - if (activeProject && currentScene) { - await timelineStore.saveProjectTimeline({ - projectId: activeProject.id, - sceneId: currentScene.id, - }); - } - - if (activeProject) { - await timelineStore.loadProjectTimeline({ - projectId: activeProject.id, - sceneId, - }); - - const updatedProject = { - ...activeProject, - currentSceneId: sceneId, - updatedAt: new Date(), - }; - - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - } - - set({ currentScene: targetScene }); - }, - - getMainScene: () => { - const { scenes } = get(); - return scenes.find((scene) => scene.isMain) || null; - }, - - getCurrentScene: () => { - return get().currentScene; - }, - - loadProjectScenes: async ({ projectId }: { projectId: string }) => { - try { - const project = await storageService.loadProject({ id: projectId }); - if (project?.scenes) { - const ensuredScenes = project.scenes.map((scene) => ({ - ...scene, - isMain: scene.isMain || false, - })); - const currentScene = - ensuredScenes.find((s) => s.id === project.currentSceneId) || - ensuredScenes[0]; - - set({ - scenes: ensuredScenes, - currentScene, - }); - } - } catch (error) { - console.error("Failed to load project scenes:", error); - set({ scenes: [], currentScene: null }); - } - }, - - initializeScenes: ({ - scenes, - currentSceneId, - }: { - scenes: Scene[]; - currentSceneId?: string; - }) => { - const ensuredScenes = ensureMainScene(scenes); - const currentScene = currentSceneId - ? ensuredScenes.find((s) => s.id === currentSceneId) - : null; - - const fallbackScene = getMainScene({ scenes: ensuredScenes }); - - set({ - scenes: ensuredScenes, - currentScene: currentScene || fallbackScene, - }); - - if (ensuredScenes.length > scenes.length) { - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (activeProject) { - const updatedProject = { - ...activeProject, - scenes: ensuredScenes, - updatedAt: new Date(), - }; - - storageService - .saveProject({ project: updatedProject }) - .then(() => { - useProjectStore.setState({ activeProject: updatedProject }); - }) - .catch((error) => { - console.error( - "Failed to save project with background scene:", - error - ); - }); - } - } - }, - - clearScenes: () => { - set({ - scenes: [], - currentScene: null, - }); - }, -})); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 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;
+ 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;
- // 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<boolean>;
-
- 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<void>;
- saveSoundEffect: (soundEffect: SoundEffect) => Promise<void>;
- removeSavedSound: (soundId: number) => Promise<void>;
- isSoundSaved: (soundId: number) => boolean;
- toggleSavedSound: (soundEffect: SoundEffect) => Promise<void>;
- clearSavedSounds: () => Promise<void>;
+ addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise<boolean>;
+ 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<void>;
+ saveSoundEffect: ({
+ soundEffect,
+ }: {
+ soundEffect: SoundEffect;
+ }) => Promise<void>;
+ removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>;
+ isSoundSaved: ({ soundId }: { soundId: number }) => boolean;
+ toggleSavedSound: ({
+ soundEffect,
+ }: {
+ soundEffect: SoundEffect;
+ }) => Promise<void>;
+ clearSavedSounds: () => Promise<void>;
}
export const useSoundsStore = create<SoundsStore>((set, get) => ({
- topSoundEffects: [],
- isLoading: false,
- error: null,
- hasLoaded: false,
- showCommercialOnly: true,
-
- toggleCommercialFilter: () => {
- set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
- },
+ topSoundEffects: [],
+ isLoading: false,
+ error: null,
+ hasLoaded: false,
+ showCommercialOnly: true,
- // Search state
- searchQuery: "",
- searchResults: [],
- isSearching: false,
- searchError: null,
- lastSearchQuery: "",
- scrollPosition: 0,
+ toggleCommercialFilter: () => {
+ set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
+ },
- // Pagination state
- currentPage: 1,
- hasNextPage: false,
- totalCount: 0,
- isLoadingMore: false,
+ searchQuery: "",
+ searchResults: [],
+ isSearching: false,
+ searchError: null,
+ lastSearchQuery: "",
+ scrollPosition: 0,
+ currentPage: 1,
+ hasNextPage: false,
+ totalCount: 0,
+ isLoadingMore: false,
+ savedSounds: [],
+ isSavedSoundsLoaded: false,
+ isLoadingSavedSounds: false,
+ savedSoundsError: null,
- // 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 }),
+ 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 }),
- setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }),
- setLoading: (loading) => set({ isLoading: loading }),
- setError: (error) => set({ error }),
- setHasLoaded: (loaded) => set({ hasLoaded: loaded }),
+ appendSearchResults: ({ results }) =>
+ set((state) => ({
+ searchResults: [...state.searchResults, ...results],
+ })),
- // 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 }),
+ appendTopSounds: ({ results }) =>
+ set((state) => ({
+ topSoundEffects: [...state.topSoundEffects, ...results],
+ })),
- // 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,
- }),
+ resetPagination: () =>
+ set({
+ currentPage: 1,
+ hasNextPage: false,
+ totalCount: 0,
+ isLoadingMore: false,
+ }),
- // Saved sounds actions
- loadSavedSounds: async () => {
- if (get().isSavedSoundsLoaded) return;
+ 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);
- }
- },
+ 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 });
+ saveSoundEffect: async ({ 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);
- }
- },
+ 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 });
+ removeSavedSound: async ({ soundId }) => {
+ 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);
- }
- },
+ 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);
- },
+ isSoundSaved: ({ soundId }) => {
+ const { savedSounds } = get();
+ return savedSounds.some((sound) => sound.id === soundId);
+ },
- toggleSavedSound: async (soundEffect: SoundEffect) => {
- const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
+ toggleSavedSound: async ({ soundEffect }) => {
+ const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
- if (isSoundSaved(soundEffect.id)) {
- await removeSavedSound(soundEffect.id);
- } else {
- await saveSoundEffect(soundEffect);
- }
- },
+ 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);
- }
- },
+ 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;
- }
+ addSoundToTimeline: async ({ sound }) => {
+ const audioUrl = sound.previewUrl;
+ if (!audioUrl) {
+ toast.error("Sound file not available");
+ return false;
+ }
- 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();
- try {
- const response = await fetch(audioUrl);
- if (!response.ok)
- throw new Error(`Failed to download audio: ${response.statusText}`);
+ 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",
- });
+ const arrayBuffer = await response.arrayBuffer();
+ const audioContext = new AudioContext();
+ const buffer = await audioContext.decodeAudioData(arrayBuffer);
- await useMediaStore.getState().addMediaFile(activeProject.id, {
- name: sound.name,
- type: "audio",
- file,
- duration: sound.duration,
- url: URL.createObjectURL(file),
- });
+ const audioTrack = tracks.find((t) => t.type === "audio");
+ let trackId: string;
- const mediaItem = useMediaStore
- .getState()
- .mediaFiles.find((item) => item.file === file);
- if (!mediaItem) throw new Error("Failed to create media item");
+ if (audioTrack) {
+ trackId = audioTrack.id;
+ } else {
+ trackId = editor.timeline.addTrack({ type: "audio" });
+ }
- const success = useTimelineStore
- .getState()
- .addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime);
+ const element = buildLibraryAudioElement({
+ sourceUrl: audioUrl,
+ name: sound.name,
+ duration: sound.duration,
+ startTime: currentTime,
+ buffer,
+ });
- 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;
- }
- },
+ 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"; + searchQuery: string; + selectedCategory: StickerCategory; + selectedCollection: string | null; + viewMode: ViewMode; + collections: Record<string, IconSet>; + currentCollection: CollectionInfo | null; + searchResults: IconSearchResult | null; + recentStickers: string[]; + isLoadingCollections: boolean; + isLoadingCollection: boolean; + isSearching: boolean; + addingSticker: string | null; - collections: Record<string, IconSet>; - 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<void>; - loadCollection: (prefix: string) => Promise<void>; - searchStickers: (query: string) => Promise<void>; - downloadSticker: (iconName: string) => Promise<File | null>; - addStickerToTimeline: (iconName: string) => Promise<void>; - - addToRecentStickers: (iconName: string) => void; - clearRecentStickers: () => void; + setSearchQuery: ({ query }: { query: string }) => void; + setSelectedCategory: ({ category }: { category: StickerCategory }) => void; + setSelectedCollection: ({ + collection, + }: { + collection: string | null; + }) => void; + setViewMode: ({ mode }: { mode: ViewMode }) => void; + loadCollections: () => Promise<void>; + loadCollection: ({ prefix }: { prefix: string }) => Promise<void>; + searchStickers: ({ query }: { query: string }) => Promise<void>; + addStickerToTimeline: ({ iconName }: { iconName: string }) => void; + addToRecentStickers: ({ iconName }: { iconName: string }) => void; + clearRecentStickers: () => void; } const MAX_RECENT_STICKERS = 50; export const useStickersStore = create<StickersStore>((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 }), + searchQuery: "", + selectedCategory: "all", + selectedCollection: null, + viewMode: "browse", - 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 }); - } - }, + collections: {}, + currentCollection: null, + searchResults: null, + recentStickers: [], - 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 }); - } - }, + isLoadingCollections: false, + isLoadingCollection: false, + isSearching: false, + addingSticker: null, - searchStickers: async (query: string) => { - if (!query.trim()) { - set({ searchResults: null, viewMode: "browse" }); - return; - } + setSearchQuery: ({ query }) => set({ searchQuery: query }), - const { selectedCategory } = get(); + setSelectedCategory: ({ category }) => + set({ + selectedCategory: category, + viewMode: "browse", + selectedCollection: null, + currentCollection: null, + }), - set({ isSearching: true, viewMode: "search" }); - try { - let category: string | undefined; + setSelectedCollection: ({ collection }) => { + set({ + selectedCollection: collection, + viewMode: collection ? "collection" : "browse", + currentCollection: null, + }); - if (selectedCategory !== "all") { - if (selectedCategory === "general") { - category = "General"; - } else if (selectedCategory === "brands") { - category = "Brands / Social"; - } else if (selectedCategory === "emoji") { - category = "Emoji"; - } - } + if (collection) { + get().loadCollection({ prefix: collection }); + } + }, - 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 }); - } - }, + setViewMode: ({ mode }) => set({ viewMode: mode }), - downloadSticker: async (iconName: string) => { - set({ isDownloading: true }); - try { - const svgText = await downloadSvgAsText(iconName, { - width: 200, - height: 200, - }); + 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 }); + } + }, - const fileName = `${iconName.replace(":", "-")}.svg`; - const file = svgToFile(svgText, fileName); + 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 }); + } + }, - get().addToRecentStickers(iconName); + searchStickers: async ({ query }: { query: string }) => { + if (!query.trim()) { + set({ searchResults: null, viewMode: "browse" }); + return; + } - return file; - } catch (error) { - console.error(`Failed to download sticker ${iconName}:`, error); - return null; - } finally { - set({ isDownloading: false }); - } - }, + const { selectedCategory } = get(); - addStickerToTimeline: async (iconName: string) => { - set({ addingSticker: iconName }); - try { - const { activeProject } = useProjectStore.getState(); - if (!activeProject) { - throw new Error("No active project"); - } + 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 }); + } + }, - const file = await get().downloadSticker(iconName); - if (!file) { - throw new Error("Failed to download sticker"); - } + addStickerToTimeline: ({ iconName }: { iconName: string }) => { + set({ addingSticker: iconName }); + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); - const mediaItem: Omit<MediaFile, "id"> = { - name: iconName.replace(":", "-"), - type: "image", - file, - url: URL.createObjectURL(file), - width: 200, - height: 200, - duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, - ephemeral: false, - }; + const stickerTrack = tracks.find((t) => t.type === "sticker"); + let trackId: string; - const { addMediaFile } = useMediaStore.getState(); - await addMediaFile(activeProject.id, mediaItem); + if (stickerTrack) { + trackId = stickerTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "sticker" }); + } - 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 element = buildStickerElement({ iconName, startTime: currentTime }); + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); - const { currentTime } = usePlaybackStore.getState(); - const { addElementAtTime } = useTimelineStore.getState(); - addElementAtTime(added, currentTime); - } finally { - set({ addingSticker: null }); - } - }, + get().addToRecentStickers({ iconName }); + } 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), - }; - }); - }, + 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: [] }), + 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<TextPropertiesTabMeta> = [
- { 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<TextPropertiesState>()(
- 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<TimelineStore["dragState"]>) => void; - startDrag: ( - elementId: string, - trackId: string, - startMouseX: number, - startElementTime: number, - clickOffsetTime: number - ) => void; - updateDragTime: (currentTime: number) => void; - endDrag: () => void; - - // Actions - addTrack: (type: TrackType) => string; - insertTrackAt: (type: TrackType, index: number) => string; - removeTrack: (trackId: string) => void; - removeTrackWithRipple: (trackId: string) => void; - addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; - - 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<string | null>; - - // History actions - undo: () => void; - redo: () => void; - pushHistory: () => void; - - // Persistence actions - loadProjectTimeline: ({ - projectId, - sceneId, - }: { - projectId: string; - sceneId?: string; - }) => Promise<void>; - saveProjectTimeline: ({ - projectId, - sceneId, - }: { - projectId: string; - sceneId?: string; - }) => Promise<void>; - clearTimeline: () => void; - - // Clipboard actions - copySelected: () => void; - pasteAtTime: (time: number) => void; - - // Unified selection-aware actions - deleteSelected: (trackId?: string, elementId?: string) => void; - splitSelected: ( - splitTime: number, - trackId?: string, - elementId?: string - ) => void; - toggleSelectedHidden: (trackId?: string, elementId?: string) => void; - toggleSelectedMuted: (trackId?: string, elementId?: string) => void; - duplicateElement: (trackId: string, elementId: string) => void; - revealElementInMedia: (elementId: string) => void; - replaceElementWithFile: ( - trackId: string, - elementId: string, - file: File - ) => Promise<void>; - getContextMenuState: ( - trackId: string, - elementId: string - ) => { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - hasAudioElements: boolean; - canSplitSelected: boolean; - currentTime: number; - }; - updateTextElement: ( - trackId: string, - elementId: string, - 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<TimelineStore>((set, get) => { - // Helper to update tracks and maintain ordering - const updateTracks = (newTracks: TimelineTrack[]) => { - const tracksWithMain = ensureMainTrack(newTracks); - const sortedTracks = sortTracksByOrder(tracksWithMain); - set({ - _tracks: tracksWithMain, - tracks: sortedTracks, - }); - }; - - // Helper to 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<TimelineStore>((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<MediaFile, "id"> = { - 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<TextElement>; - - 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 }); + }, +})); |
