From 48b694bbd875740bbdb350001bb688c2fbf4ad1f Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 4 Aug 2025 01:30:33 +0200 Subject: feat: sound effects functionality with freesound's api --- apps/web/src/stores/sounds-store.ts | 273 ++++++++++++++++++++++++++++++++++ apps/web/src/stores/timeline-store.ts | 22 ++- 2 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/stores/sounds-store.ts (limited to 'apps/web/src/stores') diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts new file mode 100644 index 00000000..3771c07e --- /dev/null +++ b/apps/web/src/stores/sounds-store.ts @@ -0,0 +1,273 @@ +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; + + // Search state + searchQuery: string; + searchResults: SoundEffect[]; + isSearching: boolean; + searchError: string | null; + lastSearchQuery: string; + scrollPosition: number; + + // Pagination state + currentPage: number; + hasNextPage: boolean; + totalCount: number; + isLoadingMore: boolean; + + // Saved sounds state + savedSounds: SavedSound[]; + isSavedSoundsLoaded: boolean; + isLoadingSavedSounds: boolean; + savedSoundsError: string | null; + + // Timeline integration + addSoundToTimeline: (sound: SoundEffect) => Promise; + + setTopSoundEffects: (sounds: SoundEffect[]) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setHasLoaded: (loaded: boolean) => void; + + // Search actions + setSearchQuery: (query: string) => void; + setSearchResults: (results: SoundEffect[]) => void; + setSearching: (searching: boolean) => void; + setSearchError: (error: string | null) => void; + setLastSearchQuery: (query: string) => void; + setScrollPosition: (position: number) => void; + + // Pagination actions + setCurrentPage: (page: number) => void; + setHasNextPage: (hasNext: boolean) => void; + setTotalCount: (count: number) => void; + setLoadingMore: (loading: boolean) => void; + appendSearchResults: (results: SoundEffect[]) => void; + appendTopSounds: (results: SoundEffect[]) => void; + resetPagination: () => void; + + // Saved sounds actions + loadSavedSounds: () => Promise; + saveSoundEffect: (soundEffect: SoundEffect) => Promise; + removeSavedSound: (soundId: number) => Promise; + isSoundSaved: (soundId: number) => boolean; + toggleSavedSound: (soundEffect: SoundEffect) => Promise; + clearSavedSounds: () => Promise; +} + +export const useSoundsStore = create((set, get) => ({ + topSoundEffects: [], + isLoading: false, + error: null, + hasLoaded: false, + + // 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 0f441176..71ddedca 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1445,16 +1445,24 @@ export const useTimelineStore = create((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, { -- cgit v1.3.1