summaryrefslogtreecommitdiff
path: root/apps/web/src/stores
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/stores
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/stores')
-rw-r--r--apps/web/src/stores/assets-panel-store.tsx107
-rw-r--r--apps/web/src/stores/editor-store.ts129
-rw-r--r--apps/web/src/stores/keybindings-store.ts409
-rw-r--r--apps/web/src/stores/keybindings/migrations/index.ts28
-rw-r--r--apps/web/src/stores/keybindings/migrations/v2-to-v3.ts26
-rw-r--r--apps/web/src/stores/media-store.ts296
-rw-r--r--apps/web/src/stores/panel-store.ts292
-rw-r--r--apps/web/src/stores/playback-store.ts174
-rw-r--r--apps/web/src/stores/project-store.ts571
-rw-r--r--apps/web/src/stores/scene-store.ts318
-rw-r--r--apps/web/src/stores/sounds-store.ts475
-rw-r--r--apps/web/src/stores/stickers-store.ts343
-rw-r--r--apps/web/src/stores/text-properties-store.ts30
-rw-r--r--apps/web/src/stores/timeline-store.ts1913
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 });
+ },
+}));