Implement audio player component and integrate with history and sample lists

- Added a new AudioPlayer component for audio playback functionality, utilizing WaveSurfer for waveform visualization.
- Integrated audio playback controls into the HistoryTable and SampleList components, allowing users to play audio directly from their history and samples.
- Updated player state management to handle audio URL, ID, title, and playback state.
- Introduced a custom Slider component for volume control and seek functionality.
- Enhanced UI to ensure the AudioPlayer is always visible except in settings, improving user experience.
This commit is contained in:
Jamie Pine
2026-01-25 13:12:50 -08:00
parent 6fa2966371
commit f80f870e93
8 changed files with 604 additions and 79 deletions
+22 -21
View File
@@ -13,34 +13,35 @@
"check": "biome check --write src"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"zustand": "^4.5.0",
"react-hook-form": "^7.53.0",
"@hookform/resolvers": "^3.9.0",
"zod": "^3.23.8",
"wavesurfer.js": "^7.0.0",
"lucide-react": "^0.454.0",
"date-fns": "^3.6.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.4",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-alert-dialog": "^1.1.1"
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@tauri-apps/api": "^2.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.454.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
"zod": "^3.23.8",
"zustand": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
+38 -32
View File
@@ -7,6 +7,7 @@ import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
import { Toaster } from '@/components/ui/toaster';
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
import { Sidebar } from '@/components/Sidebar';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
// Track if server is starting to prevent duplicate starts
@@ -83,40 +84,45 @@ function App() {
}
return (
<div className="min-h-screen bg-background flex">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<div className="min-h-screen bg-background flex flex-col">
<div className="flex flex-1">
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="flex-1 ml-20">
<div className="container mx-auto px-8 py-8 max-w-7xl">
{activeTab === 'profiles' && (
<div className="space-y-4">
<ProfileList />
</div>
)}
{activeTab === 'generate' && (
<div className="space-y-4">
<GenerationForm />
</div>
)}
{activeTab === 'history' && (
<div className="space-y-4">
<HistoryTable />
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<ConnectionForm />
<ServerStatus />
<main className="flex-1 ml-20 pb-20">
<div className="container mx-auto px-8 py-8 max-w-7xl">
{activeTab === 'profiles' && (
<div className="space-y-4">
<ProfileList />
</div>
<ModelManagement />
</div>
)}
</div>
</main>
)}
{activeTab === 'generate' && (
<div className="space-y-4">
<GenerationForm />
</div>
)}
{activeTab === 'history' && (
<div className="space-y-4">
<HistoryTable />
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<ConnectionForm />
<ServerStatus />
</div>
<ModelManagement />
</div>
)}
</div>
</main>
</div>
{/* Audio Player - always visible except on settings */}
{activeTab !== 'settings' && <AudioPlayer />}
<Toaster />
</div>
@@ -0,0 +1,466 @@
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { formatAudioDuration } from '@/lib/utils/audio';
import { usePlayerStore } from '@/stores/playerStore';
export function AudioPlayer() {
const {
audioUrl,
title,
isPlaying,
currentTime,
duration,
volume,
isLooping,
setIsPlaying,
setCurrentTime,
setDuration,
setVolume,
toggleLoop,
} = usePlayerStore();
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const loadingRef = useRef(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize WaveSurfer (only when audioUrl exists and container is ready)
useEffect(() => {
// Don't initialize if no audioUrl or already initialized
if (!audioUrl) {
return;
}
if (wavesurferRef.current) {
console.log('WaveSurfer already initialized, skipping');
return;
}
console.log('Creating NEW WaveSurfer instance');
// Wait for container to be properly rendered
const initWaveSurfer = () => {
const container = waveformRef.current;
if (!container) {
// Container not ready yet, retry
setTimeout(initWaveSurfer, 50);
return;
}
// Check if container has dimensions and is visible
const rect = container.getBoundingClientRect();
const style = window.getComputedStyle(container);
const isVisible =
rect.width > 0 &&
rect.height > 0 &&
style.display !== 'none' &&
style.visibility !== 'hidden';
if (!isVisible) {
// Retry after a short delay
setTimeout(initWaveSurfer, 50);
return;
}
console.log('Initializing WaveSurfer...', {
container,
width: rect.width,
height: rect.height,
});
try {
const wavesurfer = WaveSurfer.create({
container: container,
waveColor: '#ffffff',
progressColor: '#d3d3d3',
cursorColor: 'hsl(var(--primary))',
barWidth: 2,
barRadius: 2,
height: 80,
normalize: true,
backend: 'WebAudio',
interact: true, // Enable interaction (click to seek)
mediaControls: false, // Don't show native controls
});
wavesurferRef.current = wavesurfer;
console.log('WaveSurfer created successfully');
} catch (error) {
console.error('Failed to create WaveSurfer:', error);
setError(
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
const wavesurfer = wavesurferRef.current;
if (!wavesurfer) return;
// Update store when time changes
wavesurfer.on('timeupdate', (time) => {
setCurrentTime(time);
});
// Update store when duration is loaded
wavesurfer.on('ready', () => {
const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
setIsLoading(false);
setError(null);
console.log('Audio ready, duration:', dur);
console.log('Waveform should be visible now');
// Ensure volume is set
const currentVolume = usePlayerStore.getState().volume;
wavesurfer.setVolume(currentVolume);
// Get the underlying audio element and ensure it's not muted
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.volume = currentVolume;
mediaElement.muted = false;
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
// Auto-play when ready
// Use a small delay to ensure audio element is fully ready
setTimeout(() => {
wavesurfer.play().catch((error) => {
console.error('Failed to autoplay:', error);
// Don't show error for autoplay failures (browser restrictions)
});
}, 100);
});
// Handle play/pause
wavesurfer.on('play', () => {
setIsPlaying(true);
// Ensure audio element is not muted when playing
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
mediaElement.muted = false;
const currentVolume = usePlayerStore.getState().volume;
mediaElement.volume = currentVolume;
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
}
});
wavesurfer.on('pause', () => setIsPlaying(false));
wavesurfer.on('finish', () => {
// Check loop state from store
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play();
} else {
setIsPlaying(false);
}
});
// Handle errors
wavesurfer.on('error', (error) => {
console.error('WaveSurfer error:', error);
setIsLoading(false);
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
});
// Handle loading
wavesurfer.on('loading', (percent) => {
setIsLoading(true);
if (percent === 100) {
setIsLoading(false);
}
});
// Load audio immediately if audioUrl is already set
if (audioUrl) {
console.log('WaveSurfer ready, loading audio:', audioUrl);
loadingRef.current = true;
setIsLoading(true);
// Stop any current playback before loading new audio
if (wavesurfer.isPlaying()) {
wavesurfer.pause();
}
wavesurfer
.load(audioUrl)
.then(() => {
console.log('Audio loaded into WaveSurfer');
loadingRef.current = false;
})
.catch((error) => {
console.error('Failed to load audio into WaveSurfer:', error);
loadingRef.current = false;
setIsLoading(false);
setError(
`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
);
});
}
};
// Use double requestAnimationFrame to ensure DOM is fully rendered
let rafId1: number;
let rafId2: number;
let timeoutId: number | null = null;
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
// Add a small delay to ensure container is fully laid out
timeoutId = setTimeout(() => {
initWaveSurfer();
}, 10);
});
});
return () => {
console.log('Cleaning up WaveSurfer initialization effect');
if (rafId1) cancelAnimationFrame(rafId1);
if (rafId2) cancelAnimationFrame(rafId2);
if (timeoutId) clearTimeout(timeoutId);
if (wavesurferRef.current) {
console.log('Destroying WaveSurfer instance');
try {
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.pause();
mediaElement.src = '';
}
wavesurferRef.current.destroy();
} catch (error) {
console.error('Error destroying WaveSurfer:', error);
}
wavesurferRef.current = null;
}
};
}, [audioUrl, setIsPlaying, setCurrentTime, setDuration]);
// Load audio when URL changes (only if WaveSurfer is already initialized)
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!audioUrl || !wavesurfer) {
// Reset state when no audio or WaveSurfer not ready
if (!audioUrl && wavesurfer) {
wavesurfer.pause();
wavesurfer.seekTo(0);
loadingRef.current = false;
setIsLoading(false);
setDuration(0);
setCurrentTime(0);
setError(null);
}
return;
}
// CRITICAL: Force stop any current playback and cancel any pending loads
// This must happen BEFORE any early returns
console.log('Audio URL changed to:', audioUrl);
// COMPLETELY stop and destroy the current audio
try {
// First pause if playing
if (wavesurfer.isPlaying()) {
console.log('Pausing current playback');
wavesurfer.pause();
}
// Stop the media element explicitly
const mediaElement = wavesurfer.getMediaElement();
if (mediaElement) {
console.log('Stopping media element');
mediaElement.pause();
mediaElement.currentTime = 0;
mediaElement.src = '';
}
// Use empty() to completely destroy the waveform and media element
console.log('Calling wavesurfer.empty() to destroy audio');
wavesurfer.empty();
} catch (error) {
console.error('Error stopping previous audio:', error);
// Continue anyway to load new audio
}
// Reset loading state to allow new load (cancel any pending loads)
loadingRef.current = false;
// Now start the new load
loadingRef.current = true;
setIsLoading(true);
setError(null);
setCurrentTime(0);
setDuration(0);
// Load new audio
console.log('Starting new audio load for:', audioUrl);
wavesurfer
.load(audioUrl)
.then(() => {
console.log('Audio load promise resolved');
// Don't set loading to false here - wait for 'ready' event
})
.catch((error) => {
console.error('Failed to load audio:', error);
console.error('Audio URL:', audioUrl);
loadingRef.current = false;
setIsLoading(false);
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
});
}, [audioUrl, setCurrentTime, setDuration]);
// Sync play/pause state (only when user clicks play/pause button, not auto-sync)
// This effect is kept for external state changes but should be minimal
useEffect(() => {
if (!wavesurferRef.current || duration === 0) return;
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
// Only auto-play if audio is ready
wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
} else if (!isPlaying && wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
}
}, [isPlaying, setIsPlaying, duration]);
// Sync volume
useEffect(() => {
if (wavesurferRef.current) {
wavesurferRef.current.setVolume(volume);
// Also ensure the underlying audio element volume is set
const mediaElement = wavesurferRef.current.getMediaElement();
if (mediaElement) {
mediaElement.volume = volume;
mediaElement.muted = volume === 0;
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
}
}
}, [volume]);
// Handle loop - WaveSurfer handles this via the 'finish' event
const handlePlayPause = () => {
if (!wavesurferRef.current) {
console.error('WaveSurfer not initialized');
return;
}
// Check if audio is loaded
if (duration === 0 && !isLoading) {
console.error('Audio not loaded yet');
setError('Audio not loaded. Please wait...');
return;
}
if (wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
} else {
wavesurferRef.current.play().catch((error) => {
console.error('Failed to play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
}
};
const handleSeek = (value: number[]) => {
if (!wavesurferRef.current || duration === 0) return;
const progress = value[0] / 100;
wavesurferRef.current.seekTo(progress);
};
const handleVolumeChange = (value: number[]) => {
setVolume(value[0] / 100);
};
// Don't render if no audio
if (!audioUrl) {
return null;
}
return (
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center gap-4">
{/* Play/Pause Button */}
<Button
variant="ghost"
size="icon"
onClick={handlePlayPause}
disabled={isLoading || duration === 0}
className="shrink-0"
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
{/* Waveform */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div ref={waveformRef} className="w-full min-h-[80px]" />
{duration > 0 && (
<Slider
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
onValueChange={handleSeek}
max={100}
step={0.1}
className="w-full"
/>
)}
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-2">Loading audio...</div>
)}
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
</div>
{/* Time Display */}
<div className="flex items-center gap-2 text-sm text-muted-foreground shrink-0 min-w-[100px]">
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
<span>/</span>
<span className="font-mono">{formatAudioDuration(duration)}</span>
</div>
{/* Title */}
{title && (
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
)}
{/* Loop Button */}
<Button
variant="ghost"
size="icon"
onClick={toggleLoop}
className={isLooping ? 'text-primary' : ''}
title="Toggle loop"
>
<Repeat className="h-4 w-4" />
</Button>
{/* Volume Control */}
<div className="flex items-center gap-2 shrink-0 w-[120px]">
<Button
variant="ghost"
size="icon"
onClick={() => setVolume(volume > 0 ? 0 : 1)}
className="h-8 w-8"
>
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</Button>
<Slider
value={[volume * 100]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
/>
</div>
</div>
</div>
</div>
);
}
+11 -10
View File
@@ -14,6 +14,7 @@ import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import { useDeleteGeneration, useHistory } from '@/lib/hooks/useHistory';
import { formatDate, formatDuration } from '@/lib/utils/format';
import { usePlayerStore } from '@/stores/playerStore';
export function HistoryTable() {
const [page, setPage] = useState(0);
@@ -26,17 +27,14 @@ export function HistoryTable() {
});
const deleteGeneration = useDeleteGeneration();
const setAudio = usePlayerStore((state) => state.setAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
const isPlaying = usePlayerStore((state) => state.isPlaying);
const handlePlay = (audioId: string) => {
const handlePlay = (audioId: string, text: string) => {
const audioUrl = apiClient.getAudioUrl(audioId);
const audio = new Audio(audioUrl);
audio.play().catch((_error) => {
toast({
title: 'Error',
description: 'Failed to play audio',
variant: 'destructive',
});
});
// If clicking the same audio that's playing, it will be handled by the player
setAudio(audioUrl, audioId, text.substring(0, 50));
};
const handleDownload = (audioId: string, text: string) => {
@@ -98,8 +96,11 @@ export function HistoryTable() {
<Button
variant="ghost"
size="icon"
onClick={() => handlePlay(gen.id)}
onClick={() => handlePlay(gen.id, gen.text)}
aria-label="Play audio"
className={
currentAudioId === gen.id && isPlaying ? 'text-primary' : ''
}
>
<Play className="h-4 w-4" />
</Button>
+16 -11
View File
@@ -1,9 +1,10 @@
import { Plus, Trash2 } from 'lucide-react';
import { Plus, Trash2, Play } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
import { useServerStore } from '@/stores/serverStore';
import { usePlayerStore } from '@/stores/playerStore';
import { SampleUpload } from './SampleUpload';
interface SampleListProps {
@@ -16,6 +17,9 @@ export function SampleList({ profileId }: SampleListProps) {
const [uploadOpen, setUploadOpen] = useState(false);
const { toast } = useToast();
const serverUrl = useServerStore((state) => state.serverUrl);
const setAudio = usePlayerStore((state) => state.setAudio);
const currentAudioId = usePlayerStore((state) => state.audioId);
const isPlaying = usePlayerStore((state) => state.isPlaying);
const handleDelete = (sampleId: string) => {
if (confirm('Are you sure you want to delete this sample?')) {
@@ -23,16 +27,9 @@ export function SampleList({ profileId }: SampleListProps) {
}
};
const handlePlay = (audioPath: string) => {
const handlePlay = (audioPath: string, referenceText: string, sampleId: string) => {
const audioUrl = `${serverUrl}${audioPath}`;
const audio = new Audio(audioUrl);
audio.play().catch((_error) => {
toast({
title: 'Error',
description: 'Failed to play audio',
variant: 'destructive',
});
});
setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
};
if (isLoading) {
@@ -65,7 +62,15 @@ export function SampleList({ profileId }: SampleListProps) {
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => handlePlay(sample.audio_path)}>
<Button
variant="ghost"
size="sm"
onClick={() => handlePlay(sample.audio_path, sample.reference_text, sample.id)}
className={
currentAudioId === sample.id && isPlaying ? 'text-primary' : ''
}
>
<Play className="h-4 w-4 mr-1" />
Play
</Button>
<Button
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils/cn';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
+23 -5
View File
@@ -1,37 +1,55 @@
import { create } from 'zustand';
interface PlayerState {
currentAudioId: string | null;
audioUrl: string | null;
audioId: string | null;
title: string | null;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isLooping: boolean;
setCurrentAudio: (audioId: string | null) => void;
setAudio: (url: string, id: string, title?: string) => void;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
setVolume: (volume: number) => void;
toggleLoop: () => void;
reset: () => void;
}
export const usePlayerStore = create<PlayerState>((set) => ({
currentAudioId: null,
audioUrl: null,
audioId: null,
title: null,
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
isLooping: false,
setCurrentAudio: (audioId) => set({ currentAudioId: audioId, currentTime: 0, isPlaying: false }),
setAudio: (url, id, title) =>
set({
audioUrl: url,
audioId: id,
title: title || null,
currentTime: 0,
isPlaying: false,
}),
setIsPlaying: (playing) => set({ isPlaying: playing }),
setCurrentTime: (time) => set({ currentTime: time }),
setDuration: (duration) => set({ duration }),
setVolume: (volume) => set({ volume }),
toggleLoop: () => set((state) => ({ isLooping: !state.isLooping })),
reset: () =>
set({
currentAudioId: null,
audioUrl: null,
audioId: null,
title: null,
isPlaying: false,
currentTime: 0,
duration: 0,
isLooping: false,
}),
}));
+3
View File
@@ -26,6 +26,7 @@
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
@@ -294,6 +295,8 @@
"@radix-ui/react-separator": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slider": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tabs": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],