summaryrefslogtreecommitdiff
path: root/apps/web/src/sounds
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/sounds
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/sounds')
-rw-r--r--apps/web/src/sounds/components/assets-view.tsx563
-rw-r--r--apps/web/src/sounds/sounds-store.ts251
-rw-r--r--apps/web/src/sounds/types.ts39
-rw-r--r--apps/web/src/sounds/use-sound-search.ts154
4 files changed, 1007 insertions, 0 deletions
diff --git a/apps/web/src/sounds/components/assets-view.tsx b/apps/web/src/sounds/components/assets-view.tsx
new file mode 100644
index 00000000..fc7528c8
--- /dev/null
+++ b/apps/web/src/sounds/components/assets-view.tsx
@@ -0,0 +1,563 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { useSoundSearch } from "@/sounds/use-sound-search";
+import { useSoundsStore } from "@/sounds/sounds-store";
+import type { SavedSound, SoundEffect } from "@/sounds/types";
+import { cn } from "@/utils/ui";
+import {
+ FavouriteIcon,
+ FilterMailIcon,
+ PauseIcon,
+ PlayIcon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function SoundsView() {
+ return (
+ <div className="flex h-full flex-col">
+ <Tabs defaultValue="sound-effects" className="flex h-full flex-col">
+ <div className="px-3 pt-4 pb-0">
+ <TabsList>
+ <TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
+ <TabsTrigger value="saved">Saved</TabsTrigger>
+ </TabsList>
+ </div>
+ <Separator className="my-4" />
+ <TabsContent
+ value="sound-effects"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SoundEffectsView />
+ </TabsContent>
+ <TabsContent
+ value="saved"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SavedSoundsView />
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+}
+
+function SoundEffectsView() {
+ const {
+ topSoundEffects,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ scrollPosition,
+ setScrollPosition,
+ loadSavedSounds,
+ showCommercialOnly,
+ toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ } = useSoundsStore();
+ const {
+ results: searchResults,
+ isLoading: isSearching,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ } = useSoundSearch({
+ query: searchQuery,
+ commercialOnly: showCommercialOnly,
+ });
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const { scrollAreaRef, handleScroll } = useInfiniteScroll({
+ onLoadMore: loadMore,
+ hasMore: hasNextPage,
+ isLoading: isLoadingMore || isSearching,
+ });
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ useEffect(() => {
+ if (hasLoaded) {
+ return;
+ }
+
+ let shouldIgnore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!shouldIgnore) {
+ setLoading({ loading: true });
+ setError({ error: null });
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads",
+ );
+
+ if (!shouldIgnore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects({ sounds: data.results });
+ setHasLoaded({ loaded: true });
+
+ setCurrentPage({ page: 1 });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ }
+ } catch (error) {
+ if (!shouldIgnore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError({
+ error:
+ error instanceof Error ? error.message : "Failed to load sounds",
+ });
+ }
+ } finally {
+ if (!shouldIgnore) {
+ setLoading({ loading: false });
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100, {});
+
+ return () => {
+ shouldIgnore = true;
+ clearTimeout(timeoutId);
+ };
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
+
+ useEffect(() => {
+ if (!scrollAreaRef.current || scrollPosition <= 0) {
+ return;
+ }
+
+ const restoreScrollPosition = () => {
+ scrollAreaRef.current?.scrollTo({ top: scrollPosition });
+ };
+
+ const timeoutId = setTimeout(restoreScrollPosition, 100, {});
+
+ return () => clearTimeout(timeoutId);
+ }, [scrollPosition, scrollAreaRef]);
+
+ const handleScrollWithPosition = ({
+ currentTarget,
+ }: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop } = currentTarget;
+ setScrollPosition({ position: scrollTop });
+ handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>);
+ };
+
+ const displayedSounds = searchQuery ? searchResults : topSoundEffects;
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center gap-3">
+ <Input
+ placeholder="Search sound effects"
+ className="w-full"
+ containerClassName="w-full"
+ value={searchQuery}
+ onChange={({ currentTarget }) =>
+ setSearchQuery({ query: currentTarget.value })
+ }
+ showClearIcon
+ onClear={() => setSearchQuery({ query: "" })}
+ />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className={cn(showCommercialOnly && "text-primary")}
+ >
+ <HugeiconsIcon icon={FilterMailIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuCheckboxItem
+ checked={showCommercialOnly}
+ onCheckedChange={() => toggleCommercialFilter()}
+ >
+ Show only commercially licensed
+ </DropdownMenuCheckboxItem>
+ <div className="text-muted-foreground px-2 py-1.5 text-xs">
+ {showCommercialOnly
+ ? "Only showing sounds licensed for commercial use"
+ : "Showing all sounds regardless of license"}
+ </div>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea
+ className="h-full flex-1"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScrollWithPosition}
+ >
+ <div className="flex flex-col gap-4">
+ {isLoading && !searchQuery && (
+ <div className="text-muted-foreground text-sm">
+ Loading sounds...
+ </div>
+ )}
+ {isSearching && searchQuery && (
+ <div className="text-muted-foreground text-sm">Searching...</div>
+ )}
+ {displayedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={sound}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ {!isLoading && !isSearching && displayedSounds.length === 0 && (
+ <div className="text-muted-foreground text-sm">
+ {searchQuery ? "No sounds found" : "No sounds available"}
+ </div>
+ )}
+ {isLoadingMore && (
+ <div className="text-muted-foreground py-4 text-center text-sm">
+ Loading more sounds...
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SavedSoundsView() {
+ const {
+ savedSounds,
+ isLoadingSavedSounds,
+ savedSoundsError,
+ loadSavedSounds,
+ clearSavedSounds,
+ } = useSoundsStore();
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const [showClearDialog, setShowClearDialog] = useState(false);
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ const convertToSoundEffect = ({
+ savedSound,
+ }: {
+ savedSound: SavedSound;
+ }): SoundEffect => ({
+ id: savedSound.id,
+ name: savedSound.name,
+ description: "",
+ url: "",
+ previewUrl: savedSound.previewUrl,
+ downloadUrl: savedSound.downloadUrl,
+ duration: savedSound.duration,
+ filesize: 0,
+ type: "audio",
+ channels: 0,
+ bitrate: 0,
+ bitdepth: 0,
+ samplerate: 0,
+ username: savedSound.username,
+ tags: savedSound.tags,
+ license: savedSound.license,
+ created: savedSound.savedAt,
+ downloads: 0,
+ rating: 0,
+ ratingCount: 0,
+ });
+
+ if (isLoadingSavedSounds) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-muted-foreground text-sm">
+ Loading saved sounds...
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSoundsError) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-destructive text-sm">
+ Error: {savedSoundsError}
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSounds.length === 0) {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No saved sounds</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click the heart icon on any sound to save it here
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center justify-between">
+ <p className="text-muted-foreground text-sm">
+ {savedSounds.length} saved{" "}
+ {savedSounds.length === 1 ? "sound" : "sounds"}
+ </p>
+ <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className="text-muted-foreground hover:text-destructive h-auto !opacity-100"
+ >
+ Clear all
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Clear all saved sounds?</DialogTitle>
+ <DialogDescription>
+ This will permanently remove all {savedSounds.length} saved
+ sounds from your collection. This action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="text" onClick={() => setShowClearDialog(false)}>
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await clearSavedSounds();
+ setShowClearDialog(false);
+ }}
+ >
+ Clear all sounds
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea className="h-full flex-1">
+ <div className="flex flex-col gap-4">
+ {savedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={convertToSoundEffect({ savedSound: sound })}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+interface AudioItemProps {
+ sound: SoundEffect;
+ isPlaying: boolean;
+ onPlay: ({ sound }: { sound: SoundEffect }) => void;
+}
+
+function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
+ const { addSoundToTimeline, isSoundSaved, toggleSavedSound } =
+ useSoundsStore();
+ const isSaved = isSoundSaved({ soundId: sound.id });
+
+ const handleClick = () => {
+ onPlay({ sound });
+ };
+
+ const handleSaveClick = ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ toggleSavedSound({ soundEffect: sound });
+ };
+
+ const handleAddToTimeline = async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await addSoundToTimeline({ sound });
+ };
+
+ return (
+ <div className="group flex items-center gap-3 opacity-100 hover:opacity-75">
+ <button
+ type="button"
+ className="flex min-w-0 flex-1 items-center gap-3 text-left"
+ onClick={handleClick}
+ >
+ <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md">
+ <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" />
+ {isPlaying ? (
+ <HugeiconsIcon icon={PauseIcon} className="size-5" />
+ ) : (
+ <HugeiconsIcon icon={PlayIcon} className="size-5" />
+ )}
+ </div>
+
+ <div className="min-w-0 flex-1 overflow-hidden">
+ <p className="truncate text-sm font-medium">{sound.name}</p>
+ <span className="text-muted-foreground block truncate text-xs">
+ {sound.username}
+ </span>
+ </div>
+ </button>
+
+ <div className="flex items-center gap-3 pr-2">
+ <Button
+ variant="text"
+ size="icon"
+ className="text-muted-foreground hover:text-foreground w-auto !opacity-100"
+ onClick={handleAddToTimeline}
+ title="Add to timeline"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ className={`hover:text-foreground w-auto !opacity-100 ${
+ isSaved
+ ? "text-red-500 hover:text-red-600"
+ : "text-muted-foreground"
+ }`}
+ onClick={handleSaveClick}
+ title={isSaved ? "Remove from saved" : "Save sound"}
+ >
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className={`${isSaved ? "fill-current" : ""}`}
+ />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/sounds/sounds-store.ts b/apps/web/src/sounds/sounds-store.ts
new file mode 100644
index 00000000..b9b3f070
--- /dev/null
+++ b/apps/web/src/sounds/sounds-store.ts
@@ -0,0 +1,251 @@
+import { create } from "zustand";
+import type { SoundEffect, SavedSound } from "@/sounds/types";
+import { storageService } from "@/services/storage/service";
+import { toast } from "sonner";
+import { EditorCore } from "@/core";
+import { buildLibraryAudioElement } from "@/timeline/element-utils";
+
+interface SoundsStore {
+ 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;
+
+ 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 }));
+ },
+
+ searchQuery: "",
+ searchResults: [],
+ isSearching: false,
+ searchError: null,
+ lastSearchQuery: "",
+ scrollPosition: 0,
+ currentPage: 1,
+ hasNextPage: false,
+ totalCount: 0,
+ isLoadingMore: false,
+ 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 }),
+
+ 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,
+ }),
+
+ 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 }) => {
+ try {
+ await storageService.saveSoundEffect({ soundEffect });
+
+ 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 }) => {
+ try {
+ await storageService.removeSavedSound({ soundId });
+
+ 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 }) => {
+ const { savedSounds } = get();
+ return savedSounds.some((sound) => sound.id === soundId);
+ },
+
+ toggleSavedSound: async ({ soundEffect }) => {
+ const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
+
+ 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);
+ }
+ },
+
+ addSoundToTimeline: async ({ sound }) => {
+ 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 response = await fetch(audioUrl);
+ if (!response.ok)
+ throw new Error(`Failed to download audio: ${response.statusText}`);
+
+ const arrayBuffer = await response.arrayBuffer();
+ const audioContext = new AudioContext();
+ const buffer = await audioContext.decodeAudioData(arrayBuffer);
+
+ const element = buildLibraryAudioElement({
+ sourceUrl: audioUrl,
+ name: sound.name,
+ duration: sound.duration,
+ startTime: currentTime,
+ buffer,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto", trackType: "audio" },
+ 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/sounds/types.ts b/apps/web/src/sounds/types.ts
new file mode 100644
index 00000000..f7526846
--- /dev/null
+++ b/apps/web/src/sounds/types.ts
@@ -0,0 +1,39 @@
+export interface SoundEffect {
+ id: number;
+ name: string;
+ description: string;
+ url: string;
+ previewUrl?: string;
+ downloadUrl?: string;
+ duration: number;
+ filesize: number;
+ type: string;
+ channels: number;
+ bitrate: number;
+ bitdepth: number;
+ samplerate: number;
+ username: string;
+ tags: string[];
+ license: string;
+ created: string;
+ downloads: number;
+ rating: number;
+ ratingCount: number;
+}
+
+export interface SavedSound {
+ id: number; // freesound id
+ name: string;
+ username: string;
+ previewUrl?: string;
+ downloadUrl?: string;
+ duration: number;
+ tags: string[];
+ license: string;
+ savedAt: string; // iso date string
+}
+
+export interface SavedSoundsData {
+ sounds: SavedSound[];
+ lastModified: string;
+}
diff --git a/apps/web/src/sounds/use-sound-search.ts b/apps/web/src/sounds/use-sound-search.ts
new file mode 100644
index 00000000..79bc3110
--- /dev/null
+++ b/apps/web/src/sounds/use-sound-search.ts
@@ -0,0 +1,154 @@
+import { useEffect } from "react";
+import { useSoundsStore } from "@/sounds/sounds-store";
+
+export function useSoundSearch({
+ query,
+ commercialOnly,
+}: {
+ query: string;
+ commercialOnly: boolean;
+}) {
+ const {
+ searchResults,
+ isSearching,
+ searchError,
+ lastSearchQuery,
+ currentPage,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ setLoadingMore,
+ appendSearchResults,
+ appendTopSounds,
+ resetPagination,
+ } = useSoundsStore();
+
+ const loadMore = async () => {
+ if (isLoadingMore || !hasNextPage) return;
+
+ try {
+ setLoadingMore({ loading: true });
+ const nextPage = currentPage + 1;
+
+ const searchParams = new URLSearchParams({
+ page: nextPage.toString(),
+ type: "effects",
+ });
+
+ if (query.trim()) {
+ searchParams.set("q", query);
+ }
+
+ searchParams.set("commercial_only", commercialOnly.toString());
+ const response = await fetch(
+ `/api/sounds/search?${searchParams.toString()}`,
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+
+ if (query.trim()) {
+ appendSearchResults(data.results);
+ } else {
+ appendTopSounds(data.results);
+ }
+
+ setCurrentPage({ page: nextPage });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount(data.count);
+ } else {
+ setSearchError({ error: `Load more failed: ${response.status}` });
+ }
+ } catch (err) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Load more failed",
+ });
+ } finally {
+ setLoadingMore({ loading: false });
+ }
+ };
+
+ useEffect(() => {
+ if (!query.trim()) {
+ setSearchResults({ results: [] });
+ setSearchError({ error: null });
+ setLastSearchQuery({ query: "" });
+ return;
+ }
+
+ if (query === lastSearchQuery && searchResults.length > 0) {
+ return;
+ }
+
+ let ignore = false;
+
+ const timeoutId = setTimeout(async () => {
+ try {
+ setSearching({ searching: true });
+ setSearchError({ error: 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({ results: data.results });
+ setLastSearchQuery({ query: query });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ setCurrentPage({ page: 1 });
+ } else {
+ setSearchError({ error: `Search failed: ${response.status}` });
+ }
+ }
+ } catch (err) {
+ if (!ignore) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Search failed",
+ });
+ }
+ } finally {
+ if (!ignore) {
+ setSearching({ searching: 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,
+ };
+}