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/hooks/use-sound-search.ts | 155 +++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 apps/web/src/hooks/use-sound-search.ts (limited to 'apps/web/src/hooks') diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts new file mode 100644 index 00000000..e815a668 --- /dev/null +++ b/apps/web/src/hooks/use-sound-search.ts @@ -0,0 +1,155 @@ +import { useEffect } from "react"; +import { useSoundsStore } from "@/stores/sounds-store"; + +/** + * Custom hook for searching sound effects with race condition protection. + * Uses global Zustand store to persist search state across tab switches. + * - Debounced search (300ms) + * - Race condition protection with cleanup + * - Proper error handling + */ + +export function useSoundSearch(query: string) { + const { + searchResults, + isSearching, + searchError, + lastSearchQuery, + currentPage, + hasNextPage, + isLoadingMore, + totalCount, + setSearchResults, + setSearching, + setSearchError, + setLastSearchQuery, + setCurrentPage, + setHasNextPage, + setTotalCount, + setLoadingMore, + appendSearchResults, + appendTopSounds, + resetPagination, + } = useSoundsStore(); + + // Load more function for infinite scroll + const loadMore = async () => { + if (isLoadingMore || !hasNextPage) return; + + try { + setLoadingMore(true); + const nextPage = currentPage + 1; + + const searchParams = new URLSearchParams({ + page: nextPage.toString(), + type: "effects", + }); + + if (query.trim()) { + searchParams.set("q", query); + } + + const response = await fetch( + `/api/sounds/search?${searchParams.toString()}` + ); + + if (response.ok) { + const data = await response.json(); + + // Append to appropriate array based on whether we have a query + if (query.trim()) { + appendSearchResults(data.results); + } else { + appendTopSounds(data.results); + } + + setCurrentPage(nextPage); + setHasNextPage(!!data.next); + setTotalCount(data.count); + } else { + setSearchError(`Load more failed: ${response.status}`); + } + } catch (err) { + setSearchError(err instanceof Error ? err.message : "Load more failed"); + } finally { + setLoadingMore(false); + } + }; + + useEffect(() => { + if (!query.trim()) { + setSearchResults([]); + setSearchError(null); + setLastSearchQuery(""); + // Don't reset pagination here - top sounds pagination is managed by prefetcher + return; + } + + // If we already searched for this query and have results, don't search again + if (query === lastSearchQuery && searchResults.length > 0) { + return; + } + + let ignore = false; + + const timeoutId = setTimeout(async () => { + try { + setSearching(true); + setSearchError(null); + resetPagination(); + + const response = await fetch( + `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1` + ); + + if (!ignore) { + if (response.ok) { + const data = await response.json(); + setSearchResults(data.results); + setLastSearchQuery(query); + setHasNextPage(!!data.next); + setTotalCount(data.count); + setCurrentPage(1); + } else { + setSearchError(`Search failed: ${response.status}`); + } + } + } catch (err) { + if (!ignore) { + setSearchError(err instanceof Error ? err.message : "Search failed"); + } + } finally { + if (!ignore) { + setSearching(false); + } + } + }, 300); + + return () => { + clearTimeout(timeoutId); + ignore = true; + }; + }, [ + query, + lastSearchQuery, + searchResults.length, + setSearchResults, + setSearching, + setSearchError, + setLastSearchQuery, + setCurrentPage, + setHasNextPage, + setTotalCount, + resetPagination, + ]); + + return { + results: searchResults, + isLoading: isSearching, + error: searchError, + loadMore, + hasNextPage, + isLoadingMore, + totalCount, + }; +} -- 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/hooks') 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