summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-04 01:30:33 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-04 01:30:33 +0200
commit48b694bbd875740bbdb350001bb688c2fbf4ad1f (patch)
treea86e73777d4800b6c5ebf7ab094a8410c51ba7f0 /apps
parentc0651fec193bcea9a1764ca8435d7191c5d3980d (diff)
downloadopencut-48b694bbd875740bbdb350001bb688c2fbf4ad1f.tar.gz
feat: sound effects functionality with freesound's api
Diffstat (limited to 'apps')
-rw-r--r--apps/web/.env.example6
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts254
-rw-r--r--apps/web/src/app/editor/[project_id]/layout.tsx13
-rw-r--r--apps/web/src/app/globals.css2
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx4
-rw-r--r--apps/web/src/components/editor/media-panel/store.ts6
-rw-r--r--apps/web/src/components/editor/media-panel/views/audio.tsx19
-rw-r--r--apps/web/src/components/editor/media-panel/views/sounds.tsx463
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx9
-rw-r--r--apps/web/src/components/providers/global-prefetcher.ts78
-rw-r--r--apps/web/src/components/ui/input.tsx62
-rw-r--r--apps/web/src/env.ts4
-rw-r--r--apps/web/src/hooks/use-sound-search.ts155
-rw-r--r--apps/web/src/lib/storage/storage-service.ts92
-rw-r--r--apps/web/src/lib/storage/types.ts1
-rw-r--r--apps/web/src/stores/sounds-store.ts273
-rw-r--r--apps/web/src/stores/timeline-store.ts22
-rw-r--r--apps/web/src/types/sounds.ts39
18 files changed, 1457 insertions, 45 deletions
diff --git a/apps/web/.env.example b/apps/web/.env.example
index b21f9a92..af84fd77 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -16,4 +16,8 @@ UPSTASH_REDIS_REST_TOKEN=example_token
# Marble Blog
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
-NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com \ No newline at end of file
+NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
+
+# Freesound (generate at https://freesound.org/apiv2/apply/)
+FREESOUND_CLIENT_ID=...
+FREESOUND_API_KEY=... \ No newline at end of file
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts
new file mode 100644
index 00000000..15954140
--- /dev/null
+++ b/apps/web/src/app/api/sounds/search/route.ts
@@ -0,0 +1,254 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { env } from "@/env";
+import { baseRateLimit } from "@/lib/rate-limit";
+
+const searchParamsSchema = z.object({
+ q: z.string().max(500, "Query too long").optional(),
+ type: z.enum(["songs", "effects"]).optional(),
+ page: z.coerce.number().int().min(1).max(1000).default(1),
+ page_size: z.coerce.number().int().min(1).max(150).default(20),
+ sort: z
+ .enum(["downloads", "rating", "created", "score"])
+ .default("downloads"),
+ min_rating: z.coerce.number().min(0).max(5).default(3),
+});
+
+const freesoundResultSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string().url(),
+ previews: z
+ .object({
+ "preview-hq-mp3": z.string().url(),
+ "preview-lq-mp3": z.string().url(),
+ "preview-hq-ogg": z.string().url(),
+ "preview-lq-ogg": z.string().url(),
+ })
+ .optional(),
+ download: z.string().url().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ num_downloads: z.number().optional(),
+ avg_rating: z.number().optional(),
+ num_ratings: z.number().optional(),
+});
+
+const freesoundResponseSchema = z.object({
+ count: z.number(),
+ next: z.string().url().nullable(),
+ previous: z.string().url().nullable(),
+ results: z.array(freesoundResultSchema),
+});
+
+const transformedResultSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string(),
+ previewUrl: z.string().optional(),
+ downloadUrl: z.string().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ downloads: z.number().optional(),
+ rating: z.number().optional(),
+ ratingCount: z.number().optional(),
+});
+
+const apiResponseSchema = z.object({
+ count: z.number(),
+ next: z.string().nullable(),
+ previous: z.string().nullable(),
+ results: z.array(transformedResultSchema),
+ query: z.string().optional(),
+ type: z.string(),
+ page: z.number(),
+ pageSize: z.number(),
+ sort: z.string(),
+ minRating: z.number().optional(),
+});
+
+export async function GET(request: NextRequest) {
+ try {
+ const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
+ const { success } = await baseRateLimit.limit(ip);
+
+ if (!success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ const { searchParams } = new URL(request.url);
+
+ const validationResult = searchParamsSchema.safeParse({
+ q: searchParams.get("q") || undefined,
+ type: searchParams.get("type") || undefined,
+ page: searchParams.get("page") || undefined,
+ page_size: searchParams.get("page_size") || undefined,
+ sort: searchParams.get("sort") || undefined,
+ min_rating: searchParams.get("min_rating") || undefined,
+ });
+
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 }
+ );
+ }
+
+ const {
+ q: query,
+ type,
+ page,
+ page_size: pageSize,
+ sort,
+ min_rating,
+ } = validationResult.data;
+
+ if (type === "songs") {
+ return NextResponse.json(
+ {
+ error: "Songs are not available yet",
+ message:
+ "Song search functionality is coming soon. Try searching for sound effects instead.",
+ },
+ { status: 501 }
+ );
+ }
+
+ const baseUrl = "https://freesound.org/apiv2/search/text/";
+
+ // Use score sorting for search queries, downloads for top sounds
+ const sortParam = query
+ ? sort === "score"
+ ? "score"
+ : `${sort}_desc`
+ : `${sort}_desc`;
+
+ const params = new URLSearchParams({
+ query: query || "",
+ token: env.FREESOUND_API_KEY,
+ page: page.toString(),
+ page_size: pageSize.toString(),
+ sort: sortParam,
+ fields:
+ "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
+ });
+
+ // Always apply sound effect filters (since we're primarily a sound effects search)
+ if (type === "effects" || !type) {
+ params.append("filter", "duration:[* TO 30.0]");
+ params.append("filter", `avg_rating:[${min_rating} TO *]`);
+ 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"
+ );
+ }
+
+ const response = await fetch(`${baseUrl}?${params.toString()}`);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("Freesound API error:", response.status, errorText);
+ return NextResponse.json(
+ { error: "Failed to search sounds" },
+ { status: response.status }
+ );
+ }
+
+ const rawData = await response.json();
+
+ const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
+ if (!freesoundValidation.success) {
+ console.error(
+ "Invalid Freesound API response:",
+ freesoundValidation.error
+ );
+ return NextResponse.json(
+ { error: "Invalid response from Freesound API" },
+ { status: 502 }
+ );
+ }
+
+ const data = freesoundValidation.data;
+
+ const transformedResults = data.results.map((result) => ({
+ id: result.id,
+ name: result.name,
+ description: result.description,
+ url: result.url,
+ previewUrl:
+ result.previews?.["preview-hq-mp3"] ||
+ result.previews?.["preview-lq-mp3"],
+ downloadUrl: result.download,
+ duration: result.duration,
+ filesize: result.filesize,
+ type: result.type,
+ channels: result.channels,
+ bitrate: result.bitrate,
+ bitdepth: result.bitdepth,
+ samplerate: result.samplerate,
+ username: result.username,
+ tags: result.tags,
+ license: result.license,
+ created: result.created,
+ downloads: result.num_downloads || 0,
+ rating: result.avg_rating || 0,
+ ratingCount: result.num_ratings || 0,
+ }));
+
+ const responseData = {
+ count: data.count,
+ next: data.next,
+ previous: data.previous,
+ results: transformedResults,
+ query: query || "",
+ type: type || "effects",
+ page,
+ pageSize,
+ sort,
+ minRating: min_rating,
+ };
+
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 }
+ );
+ }
+
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Error searching sounds:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx
new file mode 100644
index 00000000..151f7f94
--- /dev/null
+++ b/apps/web/src/app/editor/[project_id]/layout.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { useGlobalPrefetcher } from "@/components/providers/global-prefetcher";
+
+export default function EditorLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ useGlobalPrefetcher();
+
+ return <div>{children}</div>;
+}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index a6b86ae4..ca78ee26 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -23,7 +23,7 @@
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(216, 13%, 92%);
--accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0 100% 40%);
+ --destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 83%);
--input: hsl(0 0% 85.1%);
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx
index 4d80b642..70eb69dd 100644
--- a/apps/web/src/components/editor/media-panel/index.tsx
+++ b/apps/web/src/components/editor/media-panel/index.tsx
@@ -4,7 +4,7 @@ import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
-import { AudioView } from "./views/audio";
+import { SoundsView } from "./views/sounds";
import { Separator } from "@/components/ui/separator";
import { SettingsView } from "./views/settings";
@@ -13,7 +13,7 @@ export function MediaPanel() {
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
- audio: <AudioView />,
+ sounds: <SoundsView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts
index 577417a9..e1212030 100644
--- a/apps/web/src/components/editor/media-panel/store.ts
+++ b/apps/web/src/components/editor/media-panel/store.ts
@@ -15,7 +15,7 @@ import { create } from "zustand";
export type Tab =
| "media"
- | "audio"
+ | "sounds"
| "text"
| "stickers"
| "effects"
@@ -30,9 +30,9 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
icon: VideoIcon,
label: "Media",
},
- audio: {
+ sounds: {
icon: MusicIcon,
- label: "Audio",
+ label: "Sounds",
},
text: {
icon: TypeIcon,
diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx
deleted file mode 100644
index 0d3c3f1e..00000000
--- a/apps/web/src/components/editor/media-panel/views/audio.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-"use client";
-
-import { Input } from "@/components/ui/input";
-import { useState } from "react";
-
-export function AudioView() {
- const [search, setSearch] = useState("");
- return (
- <div className="h-full flex flex-col gap-2 p-4">
- <Input
- placeholder="Search songs and artists"
- className="bg-panel-accent"
- value={search}
- onChange={(e) => setSearch(e.target.value)}
- />
- <div className="flex flex-col gap-2" />
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx
new file mode 100644
index 00000000..306b81d3
--- /dev/null
+++ b/apps/web/src/components/editor/media-panel/views/sounds.tsx
@@ -0,0 +1,463 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+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 { 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 {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ PropertyGroup,
+ PropertyItem,
+ PropertyItemValue,
+} from "@/components/editor/properties-panel/property-item";
+
+export function SoundsView() {
+ return (
+ <div className="h-full flex flex-col">
+ <Tabs defaultValue="sound-effects" className="flex flex-col h-full">
+ <div className="px-3 pt-4 pb-0">
+ <TabsList>
+ <TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
+ <TabsTrigger value="songs">Songs</TabsTrigger>
+ <TabsTrigger value="saved">Saved</TabsTrigger>
+ </TabsList>
+ </div>
+ <Separator className="my-4" />
+ <TabsContent
+ value="sound-effects"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <SoundEffectsView />
+ </TabsContent>
+ <TabsContent
+ value="saved"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <SavedSoundsView />
+ </TabsContent>
+ <TabsContent
+ value="songs"
+ className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
+ >
+ <SongsView />
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+}
+
+function SoundEffectsView() {
+ const {
+ topSoundEffects,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ scrollPosition,
+ setScrollPosition,
+ loadSavedSounds,
+ isSoundSaved,
+ toggleSavedSound,
+ } = useSoundsStore();
+ const {
+ results: searchResults,
+ isLoading: isSearching,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ } = useSoundSearch(searchQuery);
+
+ // Audio playback state
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null
+ );
+
+ // Scroll position persistence
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
+
+ // Load saved sounds and restore scroll position when component mounts
+ useEffect(() => {
+ loadSavedSounds();
+
+ if (scrollAreaRef.current && scrollPosition > 0) {
+ const timeoutId = setTimeout(() => {
+ scrollAreaRef.current?.scrollTo({ top: scrollPosition });
+ }, 100); // Small delay to ensure content is rendered
+
+ return () => clearTimeout(timeoutId);
+ }
+ }, []); // Only run on mount
+
+ // Track scroll position changes and handle infinite scroll
+ const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ setScrollPosition(scrollTop);
+
+ // Trigger loadMore when scrolled to within 200px of bottom
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - 200;
+ if (nearBottom && hasNextPage && !isLoadingMore && !isSearching) {
+ loadMore();
+ }
+ };
+
+ // Use your existing design, just swap the data source
+ const displayedSounds = useMemo(() => {
+ const sounds = searchQuery ? searchResults : topSoundEffects;
+ return sounds;
+ }, [searchQuery, searchResults, topSoundEffects]);
+
+ const playSound = (sound: SoundEffect) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ // Stop previous sound
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", (e) => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ return (
+ <div className="flex flex-col gap-5 mt-1 h-full">
+ <Input
+ placeholder="Search sound effects"
+ className="bg-panel-accent"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ showClearIcon
+ onClear={() => setSearchQuery("")}
+ />
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea
+ className="flex-1 h-full"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScroll}
+ >
+ <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(sound)}
+ isSaved={isSoundSaved(sound.id)}
+ onToggleSaved={() => toggleSavedSound(sound)}
+ />
+ ))}
+ {!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 text-sm text-center py-4">
+ Loading more sounds...
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SavedSoundsView() {
+ const {
+ savedSounds,
+ isLoadingSavedSounds,
+ savedSoundsError,
+ loadSavedSounds,
+ isSoundSaved,
+ toggleSavedSound,
+ clearSavedSounds,
+ } = useSoundsStore();
+
+ // Audio playback state
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null
+ );
+
+ // Clear confirmation dialog state
+ const [showClearDialog, setShowClearDialog] = useState(false);
+
+ // Load saved sounds when tab becomes active
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ const playSound = (sound: SavedSound) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ // Stop previous sound
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", (e) => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ // Convert SavedSound to SoundEffect for compatibility with AudioItem
+ const convertToSoundEffect = (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 items-center justify-center h-full">
+ <div className="text-muted-foreground text-sm">
+ Loading saved sounds...
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSoundsError) {
+ return (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-destructive text-sm">
+ Error: {savedSoundsError}
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSounds.length === 0) {
+ return (
+ <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
+ <HeartIcon
+ className="w-10 h-10 text-muted-foreground"
+ strokeWidth={1.5}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No saved sounds</p>
+ <p className="text-sm text-muted-foreground text-balance">
+ Click the heart icon on any sound to save it here
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-5 mt-1 h-full">
+ <div className="flex items-center justify-between">
+ <p className="text-sm text-muted-foreground">
+ {savedSounds.length} saved{" "}
+ {savedSounds.length === 1 ? "sound" : "sounds"}
+ </p>
+ <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className="h-auto text-muted-foreground hover:text-destructive !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 () => {
+ await clearSavedSounds();
+ setShowClearDialog(false);
+ }}
+ >
+ Clear all sounds
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea className="flex-1 h-full">
+ <div className="flex flex-col gap-4">
+ {savedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={convertToSoundEffect(sound)}
+ isPlaying={playingId === sound.id}
+ onPlay={() => playSound(sound)}
+ isSaved={isSoundSaved(sound.id)}
+ onToggleSaved={() =>
+ toggleSavedSound(convertToSoundEffect(sound))
+ }
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SongsView() {
+ return <div>Songs</div>;
+}
+
+interface AudioItemProps {
+ sound: SoundEffect;
+ isPlaying: boolean;
+ onPlay: () => void;
+ isSaved: boolean;
+ onToggleSaved: () => void;
+}
+
+function AudioItem({
+ sound,
+ isPlaying,
+ onPlay,
+ isSaved,
+ onToggleSaved,
+}: AudioItemProps) {
+ const { addSoundToTimeline } = useSoundsStore();
+
+ const handleClick = () => {
+ onPlay();
+ };
+
+ const handleSaveClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onToggleSaved();
+ };
+
+ const handleAddToTimeline = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ await addSoundToTimeline(sound);
+ };
+
+ return (
+ <div
+ className="group flex items-center gap-3 opacity-100 hover:opacity-75 transition-opacity cursor-pointer"
+ onClick={handleClick}
+ >
+ <div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0">
+ <div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" />
+ {isPlaying ? (
+ <PauseIcon className="w-5 h-5" />
+ ) : (
+ <PlayIcon className="w-5 h-5" />
+ )}
+ </div>
+
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <p className="font-medium truncate text-sm">{sound.name}</p>
+ <span className="text-xs text-muted-foreground truncate block">
+ {sound.username}
+ </span>
+ </div>
+
+ <div className="flex items-center gap-3 pr-2">
+ <Button
+ variant="text"
+ size="icon"
+ className="text-muted-foreground hover:text-foreground !opacity-100 w-auto"
+ onClick={handleAddToTimeline}
+ title="Add to timeline"
+ >
+ <PlusIcon className="w-4 h-4" />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ className={`hover:text-foreground !opacity-100 w-auto ${
+ isSaved
+ ? "text-red-500 hover:text-red-600"
+ : "text-muted-foreground"
+ }`}
+ onClick={handleSaveClick}
+ title={isSaved ? "Remove from saved" : "Save sound"}
+ >
+ <HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 8f41fbf3..7a2cc7f2 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ScrollArea } from "../../ui/scroll-area";
+import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "../../ui/button";
import {
Scissors,
@@ -746,12 +746,7 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
- <ScrollArea
- className="w-full h-full"
- ref={tracksScrollRef}
- type="scroll"
- showHorizontalScrollbar
- >
+ <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
style={{
diff --git a/apps/web/src/components/providers/global-prefetcher.ts b/apps/web/src/components/providers/global-prefetcher.ts
new file mode 100644
index 00000000..2d7c684c
--- /dev/null
+++ b/apps/web/src/components/providers/global-prefetcher.ts
@@ -0,0 +1,78 @@
+"use client";
+
+import { useEffect } from "react";
+import { useSoundsStore } from "@/stores/sounds-store";
+
+export function useGlobalPrefetcher() {
+ const {
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ } = useSoundsStore();
+
+ useEffect(() => {
+ if (hasLoaded) return;
+
+ let ignore = false;
+
+ const prefetchTopSounds = async () => {
+ try {
+ if (!ignore) {
+ setLoading(true);
+ setError(null);
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads"
+ );
+
+ if (!ignore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects(data.results);
+ setHasLoaded(true);
+
+ // Set pagination state for top sounds
+ setCurrentPage(1);
+ setHasNextPage(!!data.next);
+ setTotalCount(data.count);
+ }
+ } catch (error) {
+ if (!ignore) {
+ console.error("Failed to prefetch top sounds:", error);
+ setError(
+ error instanceof Error ? error.message : "Failed to load sounds"
+ );
+ }
+ } finally {
+ if (!ignore) {
+ setLoading(false);
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(prefetchTopSounds, 100);
+
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index d4cd57a1..4fb0f5b4 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { Eye, EyeOff } from "lucide-react";
+import { Eye, EyeOff, X } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
@@ -7,39 +7,91 @@ import { Button } from "./button";
interface InputProps extends React.ComponentProps<"input"> {
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
+ showClearIcon?: boolean;
+ onClear?: () => void;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
- { className, type, showPassword, onShowPasswordChange, value, ...props },
+ {
+ className,
+ type,
+ showPassword,
+ onShowPasswordChange,
+ showClearIcon,
+ onClear,
+ value,
+ onFocus,
+ onBlur,
+ ...props
+ },
ref
) => {
+ const [isFocused, setIsFocused] = React.useState(false);
+
const isPassword = type === "password";
const showPasswordToggle = isPassword && onShowPasswordChange;
+ const showClear =
+ showClearIcon &&
+ onClear &&
+ value &&
+ String(value).length > 0 &&
+ isFocused;
const inputType = isPassword && showPassword ? "text" : type;
+ const hasIcons = showPasswordToggle || showClear;
+ const iconCount = Number(showPasswordToggle) + Number(showClear);
+ const paddingRight =
+ iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : "";
+
return (
- <div className={showPassword ? "relative w-full" : ""}>
+ <div className={hasIcons ? "relative w-full" : ""}>
<input
type={inputType}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
- showPasswordToggle && "pr-10",
+ paddingRight,
className
)}
ref={ref}
value={value}
+ onFocus={(e) => {
+ setIsFocused(true);
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ setIsFocused(false);
+ onBlur?.(e);
+ }}
{...props}
/>
+ {showClear && (
+ <Button
+ type="button"
+ variant="text"
+ size="icon"
+ onMouseDown={(e) => {
+ e.preventDefault();
+ onClear?.();
+ }}
+ className="absolute right-0 top-0 h-full px-3 text-muted-foreground !opacity-100"
+ aria-label="Clear input"
+ >
+ <X className="!size-[0.85]" />
+ </Button>
+ )}
{showPasswordToggle && (
<Button
type="button"
variant="text"
size="icon"
onClick={() => onShowPasswordChange?.(!showPassword)}
- className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground"
+ className={cn(
+ "absolute top-0 h-full px-3 text-muted-foreground hover:text-foreground",
+ showClear ? "right-10" : "right-0"
+ )}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
index 8071107e..fd4ad523 100644
--- a/apps/web/src/env.ts
+++ b/apps/web/src/env.ts
@@ -15,6 +15,8 @@ export const env = createEnv({
.default("development"),
UPSTASH_REDIS_REST_URL: z.string().url(),
UPSTASH_REDIS_REST_TOKEN: z.string(),
+ FREESOUND_CLIENT_ID: z.string(),
+ FREESOUND_API_KEY: z.string(),
},
client: {},
runtimeEnv: {
@@ -23,5 +25,7 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
+ FREESOUND_CLIENT_ID: process.env.FREESOUND_CLIENT_ID,
+ FREESOUND_API_KEY: process.env.FREESOUND_API_KEY,
},
});
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,
+ };
+}
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 2ff63ac7..98001591 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -9,9 +9,11 @@ import {
TimelineData,
} from "./types";
import { TimelineTrack } from "@/types/timeline";
+import { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds";
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
+ private savedSoundsAdapter: IndexedDBAdapter<SavedSoundsData>;
private config: StorageConfig;
constructor() {
@@ -19,6 +21,7 @@ class StorageService {
projectsDb: "video-editor-projects",
mediaDb: "video-editor-media",
timelineDb: "video-editor-timelines",
+ savedSoundsDb: "video-editor-saved-sounds",
version: 1,
};
@@ -27,6 +30,12 @@ class StorageService {
"projects",
this.config.version
);
+
+ this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>(
+ this.config.savedSoundsDb,
+ "saved-sounds",
+ this.config.version
+ );
}
// Helper to get project-specific media adapters
@@ -264,6 +273,89 @@ class StorageService {
};
}
+ async loadSavedSounds(): Promise<SavedSoundsData> {
+ try {
+ const savedSoundsData = await this.savedSoundsAdapter.get("user-sounds");
+ return (
+ savedSoundsData || {
+ sounds: [],
+ lastModified: new Date().toISOString(),
+ }
+ );
+ } catch (error) {
+ console.error("Failed to load saved sounds:", error);
+ return { sounds: [], lastModified: new Date().toISOString() };
+ }
+ }
+
+ async saveSoundEffect(soundEffect: SoundEffect): Promise<void> {
+ try {
+ const currentData = await this.loadSavedSounds();
+
+ // Check if sound is already saved
+ if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) {
+ return; // Already saved
+ }
+
+ const savedSound: SavedSound = {
+ id: soundEffect.id,
+ name: soundEffect.name,
+ username: soundEffect.username,
+ previewUrl: soundEffect.previewUrl,
+ downloadUrl: soundEffect.downloadUrl,
+ duration: soundEffect.duration,
+ tags: soundEffect.tags,
+ license: soundEffect.license,
+ savedAt: new Date().toISOString(),
+ };
+
+ const updatedData: SavedSoundsData = {
+ sounds: [...currentData.sounds, savedSound],
+ lastModified: new Date().toISOString(),
+ };
+
+ await this.savedSoundsAdapter.set("user-sounds", updatedData);
+ } catch (error) {
+ console.error("Failed to save sound effect:", error);
+ throw error;
+ }
+ }
+
+ async removeSavedSound(soundId: number): Promise<void> {
+ try {
+ const currentData = await this.loadSavedSounds();
+
+ const updatedData: SavedSoundsData = {
+ sounds: currentData.sounds.filter((sound) => sound.id !== soundId),
+ lastModified: new Date().toISOString(),
+ };
+
+ await this.savedSoundsAdapter.set("user-sounds", updatedData);
+ } catch (error) {
+ console.error("Failed to remove saved sound:", error);
+ throw error;
+ }
+ }
+
+ async isSoundSaved(soundId: number): Promise<boolean> {
+ try {
+ const currentData = await this.loadSavedSounds();
+ return currentData.sounds.some((sound) => sound.id === soundId);
+ } catch (error) {
+ console.error("Failed to check if sound is saved:", error);
+ return false;
+ }
+ }
+
+ async clearSavedSounds(): Promise<void> {
+ try {
+ await this.savedSoundsAdapter.remove("user-sounds");
+ } catch (error) {
+ console.error("Failed to clear saved sounds:", error);
+ throw error;
+ }
+ }
+
// Check browser support
isOPFSSupported(): boolean {
return OPFSAdapter.isSupported();
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
index 0cfafd47..127b1690 100644
--- a/apps/web/src/lib/storage/types.ts
+++ b/apps/web/src/lib/storage/types.ts
@@ -30,6 +30,7 @@ export interface StorageConfig {
projectsDb: string;
mediaDb: string;
timelineDb: string;
+ savedSoundsDb: string;
version: number;
}
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<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,
+
+ // 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<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, {
diff --git a/apps/web/src/types/sounds.ts b/apps/web/src/types/sounds.ts
new file mode 100644
index 00000000..faa485eb
--- /dev/null
+++ b/apps/web/src/types/sounds.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;
+}