mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
563 lines
16 KiB
TypeScript
563 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
import { useState, useMemo, 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,
|
|
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,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { cn } from "@/utils/ui";
|
|
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
|
|
|
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,
|
|
showCommercialOnly,
|
|
toggleCommercialFilter,
|
|
hasLoaded,
|
|
setTopSoundEffects,
|
|
setLoading,
|
|
setError,
|
|
setHasLoaded,
|
|
setCurrentPage,
|
|
setHasNextPage,
|
|
setTotalCount,
|
|
} = useSoundsStore();
|
|
const {
|
|
results: searchResults,
|
|
isLoading: isSearching,
|
|
loadMore,
|
|
hasNextPage,
|
|
isLoadingMore,
|
|
} = useSoundSearch({ query: searchQuery, commercialOnly: showCommercialOnly });
|
|
|
|
// Audio playback state
|
|
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();
|
|
|
|
if (!hasLoaded) {
|
|
let ignore = false;
|
|
|
|
const fetchTopSounds = async () => {
|
|
try {
|
|
if (!ignore) {
|
|
setLoading({ loading: true });
|
|
setError({ error: 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({ sounds: data.results });
|
|
setHasLoaded({ loaded: true });
|
|
|
|
setCurrentPage({ page: 1 });
|
|
setHasNextPage({ hasNext: !!data.next });
|
|
setTotalCount({ count: data.count });
|
|
}
|
|
} catch (error) {
|
|
if (!ignore) {
|
|
console.error("Failed to fetch top sounds:", error);
|
|
setError({
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to load sounds",
|
|
});
|
|
}
|
|
} finally {
|
|
if (!ignore) {
|
|
setLoading({ loading: false });
|
|
}
|
|
}
|
|
};
|
|
|
|
const timeoutId = setTimeout(fetchTopSounds, 100);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
ignore = true;
|
|
};
|
|
}
|
|
|
|
if (scrollAreaRef.current && scrollPosition > 0) {
|
|
const timeoutId = setTimeout(() => {
|
|
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [
|
|
hasLoaded,
|
|
setTopSoundEffects,
|
|
setLoading,
|
|
setError,
|
|
setHasLoaded,
|
|
setCurrentPage,
|
|
setHasNextPage,
|
|
setTotalCount,
|
|
]);
|
|
|
|
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
|
|
const { scrollTop } = event.currentTarget;
|
|
setScrollPosition({ position: scrollTop });
|
|
handleScroll(event);
|
|
};
|
|
|
|
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">
|
|
<div className="flex items-center gap-3">
|
|
<Input
|
|
placeholder="Search sound effects"
|
|
className="bg-panel-accent w-full"
|
|
containerClassName="w-full"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery({ query: e.target.value })}
|
|
showClearIcon
|
|
onClear={() => setSearchQuery({ query: "" })}
|
|
/>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="text"
|
|
size="icon"
|
|
className={cn(showCommercialOnly && "text-primary")}
|
|
>
|
|
<ListFilter />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<DropdownMenuCheckboxItem
|
|
checked={showCommercialOnly}
|
|
onCheckedChange={() => toggleCommercialFilter()}
|
|
>
|
|
Show only commercially licensed
|
|
</DropdownMenuCheckboxItem>
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
{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="flex-1 h-full"
|
|
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(sound)}
|
|
isSaved={isSoundSaved({ soundId: sound.id })}
|
|
onToggleSaved={() => toggleSavedSound({ soundEffect: 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({ soundId: sound.id })}
|
|
onToggleSaved={() =>
|
|
toggleSavedSound({ soundEffect: 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="size-5" />
|
|
) : (
|
|
<PlayIcon className="size-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 />
|
|
</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={`${isSaved ? "fill-current" : ""}`} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|