diff options
Diffstat (limited to 'apps/web/src/stores')
| -rw-r--r-- | apps/web/src/stores/panel-store.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/stores/playback-store.ts | 32 | ||||
| -rw-r--r-- | apps/web/src/stores/project-store.ts | 126 | ||||
| -rw-r--r-- | apps/web/src/stores/sounds-store.ts | 282 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 41 |
5 files changed, 474 insertions, 13 deletions
diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts index b97235fa..ebbce3c0 100644 --- a/apps/web/src/stores/panel-store.ts +++ b/apps/web/src/stores/panel-store.ts @@ -17,12 +17,15 @@ interface PanelState { mainContent: number; timeline: number; + mediaViewMode: "grid" | "list"; + // Actions 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 const usePanelStore = create<PanelState>()( @@ -31,12 +34,15 @@ export const usePanelStore = create<PanelState>()( // Default sizes - optimized for responsiveness ...DEFAULT_PANEL_SIZES, + mediaViewMode: "grid" as const, + // Actions setToolsPanel: (size) => set({ toolsPanel: size }), setPreviewPanel: (size) => set({ previewPanel: size }), setPropertiesPanel: (size) => set({ propertiesPanel: size }), setMainContent: (size) => set({ mainContent: size }), setTimeline: (size) => set({ timeline: size }), + setMediaViewMode: (mode) => set({ mediaViewMode: mode }), }), { name: "panel-sizes", diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts index 543865f9..feea0f46 100644 --- a/apps/web/src/stores/playback-store.ts +++ b/apps/web/src/stores/playback-store.ts @@ -1,5 +1,7 @@ import { create } from "zustand"; import type { PlaybackState, PlaybackControls } from "@/types/playback"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useProjectStore } from "./project-store"; interface PlaybackStore extends PlaybackState, PlaybackControls { setDuration: (duration: number) => void; @@ -20,13 +22,33 @@ const startTimer = (store: () => PlaybackStore) => { lastUpdate = now; const newTime = state.currentTime + delta * state.speed; - if (newTime >= state.duration) { - // When video completes, pause and reset playhead to start + + // 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 30fps"); + + const frameOffset = 1 / (projectFps ?? 30); // Stop 1 frame before end based on project FPS + const stopTime = Math.max(0, effectiveDuration - frameOffset); + state.pause(); - state.setCurrentTime(0); - // Notify video elements to sync with reset + state.setCurrentTime(stopTime); + // Notify video elements to sync with end position window.dispatchEvent( - new CustomEvent("playback-seek", { detail: { time: 0 } }) + new CustomEvent("playback-seek", { + detail: { time: stopTime }, + }) ); } else { state.setCurrentTime(newTime); diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 7bb38039..5c617dff 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -11,6 +11,7 @@ interface ProjectStore { savedProjects: TProject[]; isLoading: boolean; isInitialized: boolean; + invalidProjectIds?: Set<string>; // Actions createNewProject: (name: string) => Promise<string>; @@ -28,10 +29,20 @@ interface ProjectStore { ) => Promise<void>; updateProjectFps: (fps: number) => 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) => ({ @@ -39,6 +50,98 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ 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 || 30; + 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(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 || 30; + 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 || 30; + 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(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: TProject = { @@ -50,6 +153,8 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ backgroundColor: "#000000", backgroundType: "color", blurIntensity: 8, + bookmarks: [], + fps: 30, }; set({ activeProject: newProject }); @@ -230,9 +335,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; const newProject: TProject = { + ...project, // Copy all properties from the original project id: generateUUID(), name: `(${nextNumber}) ${baseName}`, - thumbnail: project.thumbnail, createdAt: new Date(), updatedAt: new Date(), }; @@ -357,4 +462,23 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ return sortedProjects; }, + + // Global invalid project ID tracking implementation + 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/sounds-store.ts b/apps/web/src/stores/sounds-store.ts new file mode 100644 index 00000000..215253fb --- /dev/null +++ b/apps/web/src/stores/sounds-store.ts @@ -0,0 +1,282 @@ +import { create } from "zustand";
+import type { SoundEffect, SavedSound } from "@/types/sounds";
+import { storageService } from "@/lib/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";
+
+interface SoundsStore {
+ topSoundEffects: SoundEffect[];
+ isLoading: boolean;
+ error: string | null;
+ hasLoaded: boolean;
+
+ // Filter state
+ showCommercialOnly: boolean;
+ toggleCommercialFilter: () => void;
+
+ // Search state
+ searchQuery: string;
+ searchResults: SoundEffect[];
+ isSearching: boolean;
+ searchError: string | null;
+ lastSearchQuery: string;
+ scrollPosition: number;
+
+ // Pagination state
+ currentPage: number;
+ hasNextPage: boolean;
+ totalCount: number;
+ isLoadingMore: boolean;
+
+ // Saved sounds state
+ savedSounds: SavedSound[];
+ isSavedSoundsLoaded: boolean;
+ isLoadingSavedSounds: boolean;
+ savedSoundsError: string | null;
+
+ // Timeline integration
+ addSoundToTimeline: (sound: SoundEffect) => Promise<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>;
+}
+
+export const useSoundsStore = create<SoundsStore>((set, get) => ({
+ topSoundEffects: [],
+ isLoading: false,
+ error: null,
+ hasLoaded: false,
+ showCommercialOnly: true,
+
+ toggleCommercialFilter: () => {
+ set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
+ },
+
+ // Search state
+ searchQuery: "",
+ searchResults: [],
+ isSearching: false,
+ searchError: null,
+ lastSearchQuery: "",
+ scrollPosition: 0,
+
+ // Pagination state
+ currentPage: 1,
+ hasNextPage: false,
+ totalCount: 0,
+ isLoadingMore: false,
+
+ // Saved sounds state
+ savedSounds: [],
+ isSavedSoundsLoaded: false,
+ isLoadingSavedSounds: false,
+ savedSoundsError: null,
+
+ setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }),
+ setLoading: (loading) => set({ isLoading: loading }),
+ setError: (error) => set({ error }),
+ setHasLoaded: (loaded) => set({ hasLoaded: loaded }),
+
+ // Search actions
+ setSearchQuery: (query) => set({ searchQuery: query }),
+ setSearchResults: (results) =>
+ set({ searchResults: results, currentPage: 1 }),
+ setSearching: (searching) => set({ isSearching: searching }),
+ setSearchError: (error) => set({ searchError: error }),
+ setLastSearchQuery: (query) => set({ lastSearchQuery: query }),
+ setScrollPosition: (position) => set({ scrollPosition: position }),
+
+ // Pagination actions
+ setCurrentPage: (page) => set({ currentPage: page }),
+ setHasNextPage: (hasNext) => set({ hasNextPage: hasNext }),
+ setTotalCount: (count) => set({ totalCount: count }),
+ setLoadingMore: (loading) => set({ isLoadingMore: loading }),
+ appendSearchResults: (results) =>
+ set((state) => ({
+ searchResults: [...state.searchResults, ...results],
+ })),
+ appendTopSounds: (results) =>
+ set((state) => ({
+ topSoundEffects: [...state.topSoundEffects, ...results],
+ })),
+ resetPagination: () =>
+ set({
+ currentPage: 1,
+ hasNextPage: false,
+ totalCount: 0,
+ isLoadingMore: false,
+ }),
+
+ // Saved sounds actions
+ loadSavedSounds: async () => {
+ if (get().isSavedSoundsLoaded) return;
+
+ try {
+ set({ isLoadingSavedSounds: true, savedSoundsError: null });
+ const savedSoundsData = await storageService.loadSavedSounds();
+ set({
+ savedSounds: savedSoundsData.sounds,
+ isSavedSoundsLoaded: true,
+ isLoadingSavedSounds: false,
+ });
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to load saved sounds";
+ set({
+ savedSoundsError: errorMessage,
+ isLoadingSavedSounds: false,
+ });
+ console.error("Failed to load saved sounds:", error);
+ }
+ },
+
+ saveSoundEffect: async (soundEffect: SoundEffect) => {
+ try {
+ await storageService.saveSoundEffect(soundEffect);
+
+ // Refresh saved sounds
+ const savedSoundsData = await storageService.loadSavedSounds();
+ set({ savedSounds: savedSoundsData.sounds });
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to save sound";
+ set({ savedSoundsError: errorMessage });
+ toast.error("Failed to save sound");
+ console.error("Failed to save sound:", error);
+ }
+ },
+
+ removeSavedSound: async (soundId: number) => {
+ try {
+ await storageService.removeSavedSound(soundId);
+
+ // Update local state immediately
+ set((state) => ({
+ savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
+ }));
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to remove sound";
+ set({ savedSoundsError: errorMessage });
+ toast.error("Failed to remove sound");
+ console.error("Failed to remove sound:", error);
+ }
+ },
+
+ isSoundSaved: (soundId: number) => {
+ const { savedSounds } = get();
+ return savedSounds.some((sound) => sound.id === soundId);
+ },
+
+ toggleSavedSound: async (soundEffect: SoundEffect) => {
+ const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
+
+ if (isSoundSaved(soundEffect.id)) {
+ await removeSavedSound(soundEffect.id);
+ } else {
+ await saveSoundEffect(soundEffect);
+ }
+ },
+
+ clearSavedSounds: async () => {
+ try {
+ await storageService.clearSavedSounds();
+ set({
+ savedSounds: [],
+ savedSoundsError: null,
+ });
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to clear saved sounds";
+ set({ savedSoundsError: errorMessage });
+ toast.error("Failed to clear saved sounds");
+ console.error("Failed to clear saved sounds:", error);
+ }
+ },
+
+ addSoundToTimeline: async (sound) => {
+ const activeProject = useProjectStore.getState().activeProject;
+ if (!activeProject) {
+ toast.error("No active project");
+ return false;
+ }
+
+ const audioUrl = sound.previewUrl;
+ if (!audioUrl) {
+ toast.error("Sound file not available");
+ return false;
+ }
+
+ try {
+ const response = await fetch(audioUrl);
+ if (!response.ok)
+ throw new Error(`Failed to download audio: ${response.statusText}`);
+
+ const blob = await response.blob();
+ const file = new File([blob], `${sound.name}.mp3`, {
+ type: "audio/mpeg",
+ });
+
+ await useMediaStore.getState().addMediaItem(activeProject.id, {
+ name: sound.name,
+ type: "audio",
+ file,
+ duration: sound.duration,
+ url: URL.createObjectURL(file),
+ });
+
+ const mediaItem = useMediaStore
+ .getState()
+ .mediaItems.find((item) => item.file === file);
+ if (!mediaItem) throw new Error("Failed to create media item");
+
+ const success = useTimelineStore
+ .getState()
+ .addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime);
+
+ if (success) {
+ return true;
+ }
+ throw new Error("Failed to add to timeline - check for overlaps");
+ } catch (error) {
+ console.error("Failed to add sound to timeline:", error);
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Failed to add sound to timeline",
+ { id: `sound-${sound.id}` }
+ );
+ return false;
+ }
+ },
+}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f3497be9..71ddedca 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -126,6 +126,7 @@ interface TimelineStore { pushHistory?: boolean ) => void; toggleTrackMute: (trackId: string) => void; + toggleElementHidden: (trackId: string, elementId: string) => void; // Split operations for elements splitElement: ( @@ -868,6 +869,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { ); }, + toggleElementHidden: (trackId, elementId) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId + ? { ...element, hidden: !element.hidden } + : element + ), + } + : track + ) + ); + }, + updateTextElement: (trackId, elementId, updates) => { get().pushHistory(); updateTracksAndSave( @@ -1426,16 +1445,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { addMediaAtTime: (item, currentTime = 0) => { const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = get().findOrCreateTrack(trackType); - const duration = item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; - if (get().checkElementOverlap(targetTrackId, currentTime, duration)) { - toast.error( - "Cannot place element here - it would overlap with existing elements" - ); - return false; + // Get all tracks of the right type + const tracks = get()._tracks.filter((t) => t.type === trackType); + + // Try to find a track with no overlap + let targetTrackId = null; + for (const track of tracks) { + if (!get().checkElementOverlap(track.id, currentTime, duration)) { + targetTrackId = track.id; + break; + } + } + + // If no free track found, create a new one + if (!targetTrackId) { + targetTrackId = get().addTrack(trackType); } get().addElementToTrack(targetTrackId, { |
