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 From acc2089d0d6975d9cede833b083f9d1d2f78504d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 4 Aug 2025 02:59:07 +0200 Subject: feat: add license filtering for sound effects --- apps/web/src/app/api/sounds/search/route.ts | 11 ++++ .../components/editor/media-panel/views/sounds.tsx | 62 ++++++++++++++++++---- apps/web/src/components/ui/dropdown-menu.tsx | 10 ++-- apps/web/src/components/ui/input.tsx | 4 +- apps/web/src/hooks/use-sound-search.ts | 3 +- apps/web/src/stores/sounds-store.ts | 9 ++++ 6 files changed, 84 insertions(+), 15 deletions(-) (limited to 'apps/web/src/stores') diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts index 15954140..c89bc76c 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -12,6 +12,7 @@ const searchParamsSchema = z.object({ .enum(["downloads", "rating", "created", "score"]) .default("downloads"), min_rating: z.coerce.number().min(0).max(5).default(3), + commercial_only: z.coerce.boolean().default(true), }); const freesoundResultSchema = z.object({ @@ -124,6 +125,7 @@ export async function GET(request: NextRequest) { page_size: pageSize, sort, min_rating, + commercial_only, } = validationResult.data; if (type === "songs") { @@ -160,6 +162,15 @@ export async function GET(request: NextRequest) { if (type === "effects" || !type) { params.append("filter", "duration:[* TO 30.0]"); params.append("filter", `avg_rating:[${min_rating} TO *]`); + + // Filter by license if commercial_only is true + if (commercial_only) { + params.append( + "filter", + 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' + ); + } + params.append( "filter", "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx index 5ffa51a9..ed0db8ab 100644 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx @@ -5,11 +5,23 @@ import { useState, useMemo, useRef, useEffect } from "react"; import { Separator } from "@/components/ui/separator"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { PlayIcon, PauseIcon, HeartIcon, PlusIcon } from "lucide-react"; +import { + PlayIcon, + PauseIcon, + HeartIcon, + PlusIcon, + ListFilter, +} from "lucide-react"; import { useSoundsStore } from "@/stores/sounds-store"; import { useSoundSearch } from "@/hooks/use-sound-search"; import type { SoundEffect, SavedSound } from "@/types/sounds"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, +} from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, @@ -19,6 +31,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; export function SoundsView() { return ( @@ -66,6 +79,8 @@ function SoundEffectsView() { loadSavedSounds, isSoundSaved, toggleSavedSound, + showCommercialOnly, + toggleCommercialFilter, } = useSoundsStore(); const { results: searchResults, @@ -73,7 +88,7 @@ function SoundEffectsView() { loadMore, hasNextPage, isLoadingMore, - } = useSoundSearch(searchQuery); + } = useSoundSearch(searchQuery, showCommercialOnly); // Audio playback state const [playingId, setPlayingId] = useState(null); @@ -144,14 +159,41 @@ function SoundEffectsView() { return (
- setSearchQuery(e.target.value)} - showClearIcon - onClear={() => setSearchQuery("")} - /> +
+ setSearchQuery(e.target.value)} + showClearIcon + onClear={() => setSearchQuery("")} + /> + + + + + + + Show only commercially licensed + +
+ {showCommercialOnly + ? "Only showing sounds licensed for commercial use" + : "Showing all sounds regardless of license"} +
+
+
+
{ + e.preventDefault(); + }} {...props} > - + {children} + - {children} )); + DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 4fb0f5b4..440191f2 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -9,6 +9,7 @@ interface InputProps extends React.ComponentProps<"input"> { onShowPasswordChange?: (show: boolean) => void; showClearIcon?: boolean; onClear?: () => void; + containerClassName?: string; } const Input = React.forwardRef( @@ -16,6 +17,7 @@ const Input = React.forwardRef( { className, type, + containerClassName, showPassword, onShowPasswordChange, showClearIcon, @@ -45,7 +47,7 @@ const Input = React.forwardRef( iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; return ( -
+
void; + // Search state searchQuery: string; searchResults: SoundEffect[]; @@ -72,6 +76,11 @@ export const useSoundsStore = create((set, get) => ({ isLoading: false, error: null, hasLoaded: false, + showCommercialOnly: true, + + toggleCommercialFilter: () => { + set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); + }, // Search state searchQuery: "", -- cgit v1.3.1