import { useAudioPlayer as useExpoAudioPlayer, setAudioModeAsync } from 'expo-audio'; import { Paths, File } from 'expo-file-system'; import { useState, useRef, useEffect } from 'react'; import { Platform } from 'react-native'; interface QueuedAudio { audioData: Blob; resolve: (duration: number) => void; reject: (error: Error) => void; } interface AudioPlayerOptions { useSpeaker?: boolean; } /** * Convert Blob to file URI for expo-audio playback */ async function blobToFile(blob: Blob): Promise { // Read blob as array buffer const arrayBuffer = await blob.arrayBuffer(); // Create temporary file in cache directory const fileName = `audio_${Date.now()}.mp3`; const file = new File(Paths.cache, fileName); // Write array buffer to file const uint8Array = new Uint8Array(arrayBuffer); file.create(); const stream = file.writableStream(); const writer = stream.getWriter(); await writer.write(uint8Array); await writer.close(); return file; } /** * Hook for audio playback with queue system matching web version functionality */ export function useAudioPlayer(options?: AudioPlayerOptions) { const player = useExpoAudioPlayer(); const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); const queueRef = useRef([]); const isProcessingQueueRef = useRef(false); const currentRejectRef = useRef<((error: Error) => void) | null>(null); const currentFileRef = useRef(null); // Configure audio mode based on speaker/earpiece preference useEffect(() => { async function configureAudioMode() { try { if (Platform.OS === 'android') { // On Android, use shouldRouteThroughEarpiece await setAudioModeAsync({ shouldRouteThroughEarpiece: !options?.useSpeaker, }); } else if (Platform.OS === 'ios') { // On iOS, the audio routing is controlled by the AVAudioSession category // The default category should work, but we can configure it if needed // For now, we rely on the iOS default behavior which routes to the receiver // unless the user has changed the route (e.g., via speaker button in Control Center) } } catch (error) { console.warn('[AudioPlayer] Failed to configure audio mode:', error); } } configureAudioMode(); }, [options?.useSpeaker]); // Monitor playback status useEffect(() => { if (!player.playing && isPlaying && !isPaused) { // Playback finished setIsPlaying(false); processNextInQueue(); } }, [player.playing, isPlaying, isPaused]); async function play(audioData: Blob): Promise { return new Promise((resolve, reject) => { // Add to queue with its promise handlers queueRef.current.push({ audioData, resolve, reject }); // Start processing queue if not already processing if (!isProcessingQueueRef.current) { processQueue(); } }); } async function processQueue(): Promise { if (isProcessingQueueRef.current || queueRef.current.length === 0) { return; } isProcessingQueueRef.current = true; while (queueRef.current.length > 0) { const item = queueRef.current.shift()!; try { const duration = await playAudio(item.audioData); item.resolve(duration); } catch (error) { item.reject(error as Error); } } isProcessingQueueRef.current = false; } async function processNextInQueue(): Promise { if (queueRef.current.length > 0) { const item = queueRef.current.shift()!; try { const duration = await playAudio(item.audioData); item.resolve(duration); } catch (error) { item.reject(error as Error); } } else { isProcessingQueueRef.current = false; } } async function playAudio(audioData: Blob): Promise { return new Promise(async (resolve, reject) => { currentRejectRef.current = reject; try { console.log( `[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})` ); // Convert blob to file const file = await blobToFile(audioData); currentFileRef.current = file; setIsPlaying(true); setIsPaused(false); // Load and play audio await player.replace({ uri: file.uri }); player.play(); // Wait for playback to finish const checkInterval = setInterval(() => { if (!player.playing && player.duration > 0) { clearInterval(checkInterval); const duration = player.duration; console.log(`[AudioPlayer] Playback finished (duration: ${duration}s)`); setIsPlaying(false); currentRejectRef.current = null; // Clean up temporary file if (currentFileRef.current) { try { currentFileRef.current.delete(); } catch (err: any) { console.warn('[AudioPlayer] Failed to delete temp file:', err); } currentFileRef.current = null; } resolve(duration); } }, 100); } catch (error) { console.error('[AudioPlayer] Error playing audio:', error); setIsPlaying(false); currentRejectRef.current = null; // Clean up temporary file if (currentFileRef.current) { try { currentFileRef.current.delete(); } catch (err: any) { console.warn('[AudioPlayer] Failed to delete temp file:', err); } currentFileRef.current = null; } reject(error); } }); } function stop(): void { // Stop currently playing audio if (player) { player.pause(); } setIsPlaying(false); setIsPaused(false); // Clean up temporary file if (currentFileRef.current) { try { currentFileRef.current.delete(); } catch (err: any) { console.warn('[AudioPlayer] Failed to delete temp file:', err); } currentFileRef.current = null; } // Reject the current playing promise if it exists if (currentRejectRef.current) { currentRejectRef.current(new Error('Playback stopped')); currentRejectRef.current = null; } // Reject all pending promises in the queue while (queueRef.current.length > 0) { const item = queueRef.current.shift()!; item.reject(new Error('Playback stopped')); } isProcessingQueueRef.current = false; } function pause(): void { if (player && isPlaying && !isPaused) { player.pause(); setIsPaused(true); console.log('[AudioPlayer] Playback paused'); } } function resume(): void { if (player && isPaused) { player.play(); setIsPaused(false); console.log('[AudioPlayer] Playback resumed'); } } function clearQueue(): void { // Reject all pending promises in the queue while (queueRef.current.length > 0) { const item = queueRef.current.shift()!; item.reject(new Error('Queue cleared')); } } return { play, stop, pause, resume, isPlaying: () => isPlaying, isPaused: () => isPaused, clearQueue, }; }