From b1cf7926c7b6edca3b9b2a1ed144b393476c5d0f Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 26 Jan 2026 16:44:49 -0800 Subject: [PATCH] Add audio sample components for recording, system capture, and upload - Introduced `AudioSampleRecording`, `AudioSampleSystem`, and `AudioSampleUpload` components to handle audio recording, system audio capture, and file uploads respectively. - Implemented play/pause functionality for audio playback across all components, enhancing user interaction. - Refactored `ProfileForm` and `SampleUpload` to utilize new audio components, improving code organization and maintainability. - Added hooks for audio playback management, ensuring consistent audio handling and cleanup across the application. --- .../VoiceProfiles/AudioSampleRecording.tsx | 122 +++++ .../VoiceProfiles/AudioSampleSystem.tsx | 122 +++++ .../VoiceProfiles/AudioSampleUpload.tsx | 148 ++++++ .../components/VoiceProfiles/ProfileForm.tsx | 432 +++--------------- .../components/VoiceProfiles/SampleUpload.tsx | 409 ++--------------- app/src/lib/hooks/useAudioPlayer.ts | 66 +++ 6 files changed, 568 insertions(+), 731 deletions(-) create mode 100644 app/src/components/VoiceProfiles/AudioSampleRecording.tsx create mode 100644 app/src/components/VoiceProfiles/AudioSampleSystem.tsx create mode 100644 app/src/components/VoiceProfiles/AudioSampleUpload.tsx create mode 100644 app/src/lib/hooks/useAudioPlayer.ts diff --git a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx new file mode 100644 index 00000000..34c3c372 --- /dev/null +++ b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx @@ -0,0 +1,122 @@ +import { Mic, Square, Play, Pause } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { formatAudioDuration } from '@/lib/utils/audio'; + +interface AudioSampleRecordingProps { + file: File | null | undefined; + isRecording: boolean; + duration: number; + onStart: () => void; + onStop: () => void; + onCancel: () => void; + onTranscribe: () => void; + onPlayPause: () => void; + isPlaying: boolean; + isTranscribing?: boolean; +} + +export function AudioSampleRecording({ + file, + isRecording, + duration, + onStart, + onStop, + onCancel, + onTranscribe, + onPlayPause, + isPlaying, + isTranscribing = false, +}: AudioSampleRecordingProps) { + return ( + + Record Audio + +
+ {!isRecording && !file && ( +
+ +

+ Click to start recording. Maximum duration: 30 seconds. +

+
+ )} + + {isRecording && ( +
+
+
+
+ + {formatAudioDuration(duration)} + +
+
+ +

+ {formatAudioDuration(30 - duration)} remaining +

+
+ )} + + {file && !isRecording && ( +
+
+ + Recording complete +
+

+ File: {file.name} +

+
+ + + +
+
+ )} +
+ + + + ); +} diff --git a/app/src/components/VoiceProfiles/AudioSampleSystem.tsx b/app/src/components/VoiceProfiles/AudioSampleSystem.tsx new file mode 100644 index 00000000..5882aa54 --- /dev/null +++ b/app/src/components/VoiceProfiles/AudioSampleSystem.tsx @@ -0,0 +1,122 @@ +import { Monitor, Square, Play, Pause, Mic } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { formatAudioDuration } from '@/lib/utils/audio'; + +interface AudioSampleSystemProps { + file: File | null | undefined; + isRecording: boolean; + duration: number; + onStart: () => void; + onStop: () => void; + onCancel: () => void; + onTranscribe: () => void; + onPlayPause: () => void; + isPlaying: boolean; + isTranscribing?: boolean; +} + +export function AudioSampleSystem({ + file, + isRecording, + duration, + onStart, + onStop, + onCancel, + onTranscribe, + onPlayPause, + isPlaying, + isTranscribing = false, +}: AudioSampleSystemProps) { + return ( + + Capture System Audio + +
+ {!isRecording && !file && ( +
+ +

+ Capture audio from your system. Maximum duration: 30 seconds. +

+
+ )} + + {isRecording && ( +
+
+
+
+ + {formatAudioDuration(duration)} + +
+
+ +

+ {formatAudioDuration(30 - duration)} remaining +

+
+ )} + + {file && !isRecording && ( +
+
+ + Capture complete +
+

+ File: {file.name} +

+
+ + + +
+
+ )} +
+ + + + ); +} diff --git a/app/src/components/VoiceProfiles/AudioSampleUpload.tsx b/app/src/components/VoiceProfiles/AudioSampleUpload.tsx new file mode 100644 index 00000000..f3c4c111 --- /dev/null +++ b/app/src/components/VoiceProfiles/AudioSampleUpload.tsx @@ -0,0 +1,148 @@ +import { Mic, Pause, Play, Upload } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { FormControl, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; + +interface AudioSampleUploadProps { + file: File | null | undefined; + onFileChange: (file: File | undefined) => void; + onTranscribe: () => void; + onPlayPause: () => void; + isPlaying: boolean; + isValidating?: boolean; + isTranscribing?: boolean; + isDisabled?: boolean; + fieldName: string; +} + +export function AudioSampleUpload({ + file, + onFileChange, + onTranscribe, + onPlayPause, + isPlaying, + isValidating = false, + isTranscribing = false, + isDisabled = false, + fieldName, +}: AudioSampleUploadProps) { + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + return ( + + Audio File + +
+ { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + onFileChange(selectedFile); + } else { + onFileChange(undefined); + } + }} + className="hidden" + /> +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const droppedFile = e.dataTransfer.files?.[0]; + if (droppedFile?.type.startsWith('audio/')) { + onFileChange(droppedFile); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${ + file + ? 'border-primary bg-primary/5' + : isDragging + ? 'border-primary bg-primary/5' + : 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50' + }`} + > + {!file ? ( + <> + +

+ Click to choose a file or drag and drop. Maximum duration: 30 seconds. +

+ + ) : ( + <> +
+ + File uploaded +
+

File: {file.name}

+
+ + + +
+ + )} +
+
+
+ +
+ ); +} diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx index 4089958e..f10798f9 100644 --- a/app/src/components/VoiceProfiles/ProfileForm.tsx +++ b/app/src/components/VoiceProfiles/ProfileForm.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { useEffect, useState, useRef } from 'react'; +import { Mic, Monitor, Upload } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; @@ -26,23 +27,26 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages'; +import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer'; +import { useAudioRecording } from '@/lib/hooks/useAudioRecording'; import { + useAddSample, useCreateProfile, useProfile, useUpdateProfile, - useAddSample, } from '@/lib/hooks/useProfiles'; -import { useTranscription } from '@/lib/hooks/useTranscription'; -import { useAudioRecording } from '@/lib/hooks/useAudioRecording'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; -import { useUIStore } from '@/stores/uiStore'; -import { Mic, Square, Upload, Monitor, Play, Pause } from 'lucide-react'; -import { formatAudioDuration } from '@/lib/utils/audio'; +import { useTranscription } from '@/lib/hooks/useTranscription'; import { isTauri } from '@/lib/tauri'; +import { formatAudioDuration } from '@/lib/utils/audio'; +import { useUIStore } from '@/stores/uiStore'; +import { AudioSampleRecording } from './AudioSampleRecording'; +import { AudioSampleSystem } from './AudioSampleSystem'; +import { AudioSampleUpload } from './AudioSampleUpload'; // Helper function to get audio duration from File async function getAudioDuration(file: File & { recordedDuration?: number }): Promise { @@ -52,11 +56,11 @@ async function getAudioDuration(file: File & { recordedDuration?: number }): Pro if (file.recordedDuration !== undefined && Number.isFinite(file.recordedDuration)) { return file.recordedDuration; } - + return new Promise((resolve, reject) => { const audio = new Audio(); const url = URL.createObjectURL(file); - + audio.addEventListener('loadedmetadata', () => { URL.revokeObjectURL(url); // Check if duration is valid (not Infinity or NaN) @@ -66,12 +70,12 @@ async function getAudioDuration(file: File & { recordedDuration?: number }): Pro reject(new Error('Audio file has invalid duration metadata')); } }); - + audio.addEventListener('error', () => { URL.revokeObjectURL(url); reject(new Error('Failed to load audio file')); }); - + audio.src = url; }); } @@ -116,10 +120,7 @@ export function ProfileForm() { const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('upload'); const [audioDuration, setAudioDuration] = useState(null); const [isValidatingAudio, setIsValidatingAudio] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [isPlaying, setIsPlaying] = useState(false); - const fileInputRef = useRef(null); - const audioRef = useRef(null); + const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer(); const isCreating = !editingProfileId; const form = useForm({ @@ -155,8 +156,9 @@ export function ProfileForm() { console.error('Failed to get audio duration:', error); setAudioDuration(null); // For recordings, we auto-stop at max duration, so we can skip validation errors - const isRecordedFile = selectedFile.name.startsWith('recording-') || - selectedFile.name.startsWith('system-audio-'); + const isRecordedFile = + selectedFile.name.startsWith('recording-') || + selectedFile.name.startsWith('system-audio-'); if (!isRecordedFile) { form.setError('sampleFile', { type: 'manual', @@ -286,11 +288,6 @@ export function ProfileForm() { const result = await transcribe.mutateAsync({ file, language }); form.setValue('referenceText', result.text, { shouldValidate: true }); - - toast({ - title: 'Transcription complete', - description: 'Audio has been transcribed successfully.', - }); } catch (error) { toast({ title: 'Transcription failed', @@ -307,54 +304,12 @@ export function ProfileForm() { cancelSystemRecording(); } form.resetField('sampleFile'); - // Stop any playing audio - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; - } - setIsPlaying(false); + cleanupAudio(); } function handlePlayPause() { const file = form.getValues('sampleFile'); - if (!file) return; - - if (audioRef.current) { - if (isPlaying) { - audioRef.current.pause(); - setIsPlaying(false); - } else { - audioRef.current.play(); - setIsPlaying(true); - } - } else { - const audio = new Audio(URL.createObjectURL(file)); - audioRef.current = audio; - - audio.addEventListener('ended', () => { - setIsPlaying(false); - if (audioRef.current) { - URL.revokeObjectURL(audioRef.current.src); - } - audioRef.current = null; - }); - - audio.addEventListener('error', () => { - setIsPlaying(false); - toast({ - title: 'Playback error', - description: 'Failed to play audio file', - variant: 'destructive', - }); - if (audioRef.current) { - URL.revokeObjectURL(audioRef.current.src); - } - audioRef.current = null; - }); - - audio.play(); - setIsPlaying(true); - } + playPause(file); } async function onSubmit(data: ProfileFormValues) { @@ -483,13 +438,7 @@ export function ProfileForm() { if (isSystemRecording) { cancelSystemRecording(); } - // Stop and cleanup audio - if (audioRef.current) { - audioRef.current.pause(); - URL.revokeObjectURL(audioRef.current.src); - audioRef.current = null; - } - setIsPlaying(false); + cleanupAudio(); } } @@ -588,7 +537,9 @@ export function ProfileForm() { setSampleMode(newMode); }} > - + Upload @@ -610,122 +561,19 @@ export function ProfileForm() { control={form.control} name="sampleFile" render={({ field: { onChange, name } }) => ( - - Audio File - -
- { - const file = e.target.files?.[0]; - if (file) { - onChange(file); - } else { - onChange(undefined); - } - }} - className="hidden" - /> -
{ - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={(e) => { - e.preventDefault(); - setIsDragging(false); - }} - onDrop={(e) => { - e.preventDefault(); - setIsDragging(false); - const file = e.dataTransfer.files?.[0]; - if (file && file.type.startsWith('audio/')) { - onChange(file); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - fileInputRef.current?.click(); - } - }} - className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${ - selectedFile - ? 'border-primary bg-primary/5' - : isDragging - ? 'border-primary bg-primary/5' - : 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50' - }`} - > - {!selectedFile ? ( - <> - -

- Click to choose a file or drag and drop. Maximum duration: 30 seconds. -

- - ) : ( - <> -
- - File uploaded -
-

- File: {selectedFile.name} -

-
- - - -
- - )} -
-
-
- -
+ MAX_AUDIO_DURATION_SECONDS + } + fieldName={name} + /> )} /> @@ -735,95 +583,18 @@ export function ProfileForm() { control={form.control} name="sampleFile" render={() => ( - - Record Audio - -
- {!isRecording && !selectedFile && ( -
- -

- Click to start recording. Maximum duration: 30 seconds. -

-
- )} - - {isRecording && ( -
-
-
-
- - {formatAudioDuration(duration)} - -
-
- -

- {formatAudioDuration(30 - duration)} remaining -

-
- )} - - {selectedFile && !isRecording && ( -
-
- - Recording complete -
-

- File: {selectedFile.name} -

-
- - - -
-
- )} -
- - - + )} /> @@ -834,95 +605,18 @@ export function ProfileForm() { control={form.control} name="sampleFile" render={() => ( - - Capture System Audio - -
- {!isSystemRecording && !selectedFile && ( -
- -

- Capture audio from your system. Maximum duration: 30 seconds. -

-
- )} - - {isSystemRecording && ( -
-
-
-
- - {formatAudioDuration(systemDuration)} - -
-
- -

- {formatAudioDuration(30 - systemDuration)} remaining -

-
- )} - - {selectedFile && !isSystemRecording && ( -
-
- - Capture complete -
-

- File: {selectedFile.name} -

-
- - - -
-
- )} -
- - - + )} /> diff --git a/app/src/components/VoiceProfiles/SampleUpload.tsx b/app/src/components/VoiceProfiles/SampleUpload.tsx index 1a33aeff..cee962a8 100644 --- a/app/src/components/VoiceProfiles/SampleUpload.tsx +++ b/app/src/components/VoiceProfiles/SampleUpload.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { Mic, Monitor, Upload } from 'lucide-react'; +import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; -import { useState, useEffect, useRef } from 'react'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; import { @@ -18,16 +19,18 @@ import { FormLabel, FormMessage, } from '@/components/ui/form'; -import { Textarea } from '@/components/ui/textarea'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; -import { useAddSample, useProfile } from '@/lib/hooks/useProfiles'; -import { useTranscription } from '@/lib/hooks/useTranscription'; import { useAudioRecording } from '@/lib/hooks/useAudioRecording'; +import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer'; +import { useAddSample, useProfile } from '@/lib/hooks/useProfiles'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; -import { Mic, Square, Upload, Monitor, Play, Pause } from 'lucide-react'; -import { formatAudioDuration } from '@/lib/utils/audio'; +import { useTranscription } from '@/lib/hooks/useTranscription'; import { isTauri } from '@/lib/tauri'; +import { AudioSampleUpload } from './AudioSampleUpload'; +import { AudioSampleRecording } from './AudioSampleRecording'; +import { AudioSampleSystem } from './AudioSampleSystem'; const sampleSchema = z.object({ file: z.instanceof(File, { message: 'Please select an audio file' }), @@ -51,10 +54,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp const { data: profile } = useProfile(profileId); const { toast } = useToast(); const [mode, setMode] = useState<'upload' | 'record' | 'system'>('upload'); - const [isDragging, setIsDragging] = useState(false); - const [isPlaying, setIsPlaying] = useState(false); - const fileInputRef = useRef(null); - const audioRef = useRef(null); + const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer(); const form = useForm({ resolver: zodResolver(sampleSchema), @@ -156,11 +156,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp const result = await transcribe.mutateAsync({ file, language }); form.setValue('referenceText', result.text, { shouldValidate: true }); - - toast({ - title: 'Transcription complete', - description: 'Audio has been transcribed successfully.', - }); } catch (error) { toast({ title: 'Transcription failed', @@ -203,13 +198,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp if (isSystemRecording) { cancelSystemRecording(); } - // Stop and cleanup audio - if (audioRef.current) { - audioRef.current.pause(); - URL.revokeObjectURL(audioRef.current.src); - audioRef.current = null; - } - setIsPlaying(false); + cleanupAudio(); } onOpenChange(newOpen); } @@ -220,56 +209,13 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp } else if (mode === 'system') { cancelSystemRecording(); } - // Reset file field by clearing the input form.resetField('file'); - // Stop any playing audio - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; - } - setIsPlaying(false); + cleanupAudio(); } function handlePlayPause() { const file = form.getValues('file'); - if (!file) return; - - if (audioRef.current) { - if (isPlaying) { - audioRef.current.pause(); - setIsPlaying(false); - } else { - audioRef.current.play(); - setIsPlaying(true); - } - } else { - const audio = new Audio(URL.createObjectURL(file)); - audioRef.current = audio; - - audio.addEventListener('ended', () => { - setIsPlaying(false); - if (audioRef.current) { - URL.revokeObjectURL(audioRef.current.src); - } - audioRef.current = null; - }); - - audio.addEventListener('error', () => { - setIsPlaying(false); - toast({ - title: 'Playback error', - description: 'Failed to play audio file', - variant: 'destructive', - }); - if (audioRef.current) { - URL.revokeObjectURL(audioRef.current.src); - } - audioRef.current = null; - }); - - audio.play(); - setIsPlaying(true); - } + playPause(file); } return ( @@ -284,10 +230,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
- setMode(v as 'upload' | 'record' | 'system')} - > + setMode(v as 'upload' | 'record' | 'system')}> @@ -312,119 +255,15 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp control={form.control} name="file" render={({ field: { onChange, name } }) => ( - - Audio File - -
- { - const file = e.target.files?.[0]; - if (file) { - onChange(file); - } - }} - className="hidden" - /> -
{ - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={(e) => { - e.preventDefault(); - setIsDragging(false); - }} - onDrop={(e) => { - e.preventDefault(); - setIsDragging(false); - const file = e.dataTransfer.files?.[0]; - if (file && file.type.startsWith('audio/')) { - onChange(file); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - fileInputRef.current?.click(); - } - }} - className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${ - selectedFile - ? 'border-primary bg-primary/5' - : isDragging - ? 'border-primary bg-primary/5' - : 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50' - }`} - > - {!selectedFile ? ( - <> - -

- Click to choose a file or drag and drop. Maximum duration: 30 seconds. -

- - ) : ( - <> -
- - File uploaded -
-

- File: {selectedFile.name} -

-
- - - -
- - )} -
-
-
- -
+ )} /> @@ -434,95 +273,18 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp control={form.control} name="file" render={() => ( - - Record Audio - -
- {!isRecording && !selectedFile && ( -
- -

- Click to start recording. Maximum duration: 30 seconds. -

-
- )} - - {isRecording && ( -
-
-
-
- - {formatAudioDuration(duration)} - -
-
- -

- {formatAudioDuration(30 - duration)} remaining -

-
- )} - - {selectedFile && !isRecording && ( -
-
- - Recording complete -
-

- File: {selectedFile.name} -

-
- - - -
-
- )} -
- - - + )} /> @@ -533,95 +295,18 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp control={form.control} name="file" render={() => ( - - Capture System Audio - -
- {!isSystemRecording && !selectedFile && ( -
- -

- Capture audio from your system. Maximum duration: 30 seconds. -

-
- )} - - {isSystemRecording && ( -
-
-
-
- - {formatAudioDuration(systemDuration)} - -
-
- -

- {formatAudioDuration(30 - systemDuration)} remaining -

-
- )} - - {selectedFile && !isSystemRecording && mode === 'system' && ( -
-
- - Capture complete -
-

- File: {selectedFile.name} -

-
- - - -
-
- )} -
- - - + )} /> diff --git a/app/src/lib/hooks/useAudioPlayer.ts b/app/src/lib/hooks/useAudioPlayer.ts new file mode 100644 index 00000000..ed247444 --- /dev/null +++ b/app/src/lib/hooks/useAudioPlayer.ts @@ -0,0 +1,66 @@ +import { useRef, useState } from 'react'; +import { useToast } from '@/components/ui/use-toast'; + +export function useAudioPlayer() { + const [isPlaying, setIsPlaying] = useState(false); + const audioRef = useRef(null); + const { toast } = useToast(); + + const playPause = (file: File | null | undefined) => { + if (!file) return; + + if (audioRef.current) { + if (isPlaying) { + audioRef.current.pause(); + setIsPlaying(false); + } else { + audioRef.current.play(); + setIsPlaying(true); + } + } else { + const audio = new Audio(URL.createObjectURL(file)); + audioRef.current = audio; + + audio.addEventListener('ended', () => { + setIsPlaying(false); + if (audioRef.current) { + URL.revokeObjectURL(audioRef.current.src); + } + audioRef.current = null; + }); + + audio.addEventListener('error', () => { + setIsPlaying(false); + toast({ + title: 'Playback error', + description: 'Failed to play audio file', + variant: 'destructive', + }); + if (audioRef.current) { + URL.revokeObjectURL(audioRef.current.src); + } + audioRef.current = null; + }); + + audio.play(); + setIsPlaying(true); + } + }; + + const cleanup = () => { + if (audioRef.current) { + audioRef.current.pause(); + if (audioRef.current.src.startsWith('blob:')) { + URL.revokeObjectURL(audioRef.current.src); + } + audioRef.current = null; + } + setIsPlaying(false); + }; + + return { + isPlaying, + playPause, + cleanup, + }; +}