import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio'; import { useCallback, useEffect, useRef, useState } from 'react'; import { audioFocus } from '@/core/audio/audio-focus'; import type { PlaybackItem, PlayerStatus } from '../types'; interface UsePlayerResult { status: PlayerStatus; queueLength: number; enqueueTtsAudio: (audioBase64: string) => void; enqueue: (item: PlaybackItem) => void; stop: () => void; } /** * Self-contained audio playback hook with queue management. * * - Uses useAudioPlayer + useAudioPlayerStatus for native playback * - Detects playback completion to auto-advance the queue * - Coordinates with audioFocus for recording/playback mutual exclusion * - Subscribes to audioFocus owner changes to resume after recording ends */ export function usePlayer(): UsePlayerResult { const queueRef = useRef([]); const [status, setStatus] = useState('idle'); const [queueLength, setQueueLength] = useState(0); const [currentSource, setCurrentSource] = useState(null); const isPlayingRef = useRef(false); const wasBlockedByRecorderRef = useRef(false); const isPlayNextInProgressRef = useRef(false); const player = useAudioPlayer(currentSource); const playerStatus = useAudioPlayerStatus(player); // Start playback when a new source is set useEffect(() => { if (currentSource && player) { player.play(); isPlayingRef.current = true; } }, [currentSource, player]); const playNext = useCallback(async () => { if (isPlayNextInProgressRef.current) return; isPlayNextInProgressRef.current = true; try { if (queueRef.current.length === 0) { setCurrentSource(null); setStatus('idle'); setQueueLength(0); await audioFocus.release(); return; } const acquired = await audioFocus.acquireForPlayback(); if (!acquired) { setStatus('idle'); return; } if (queueRef.current.length === 0) return; const next = queueRef.current.shift()!; setQueueLength(queueRef.current.length); setStatus('playing'); setCurrentSource(next.uri); } finally { isPlayNextInProgressRef.current = false; } }, []); // Detect playback completion → advance queue useEffect(() => { if (!currentSource || !isPlayingRef.current) return; const { playing, currentTime, duration } = playerStatus; const finished = !playing && duration > 0 && currentTime >= duration - 0.05; if (finished) { isPlayingRef.current = false; playNext(); } }, [playerStatus, currentSource, playNext]); // Subscribe to audioFocus owner changes for recorder → idle recovery useEffect(() => { const unsub = audioFocus.onOwnerChange((owner) => { if (owner === 'recorder') { wasBlockedByRecorderRef.current = queueRef.current.length > 0 || currentSource !== null; } if (owner === null && wasBlockedByRecorderRef.current) { wasBlockedByRecorderRef.current = false; if (queueRef.current.length > 0 && status === 'idle') { playNext(); } } }); return unsub; }, [status, currentSource, playNext]); const enqueue = useCallback( async (item: PlaybackItem) => { queueRef.current.push(item); setQueueLength(queueRef.current.length); if (status === 'idle' && !currentSource) { await playNext(); } }, [status, currentSource, playNext], ); const enqueueTtsAudio = useCallback( (audioBase64: string) => { const uri = `data:audio/mp3;base64,${audioBase64}`; enqueue({ uri, label: 'TTS' }); }, [enqueue], ); const stop = useCallback(async () => { queueRef.current = []; setQueueLength(0); isPlayingRef.current = false; if (player) { player.pause(); } setCurrentSource(null); setStatus('idle'); await audioFocus.release(); }, [player]); return { status, queueLength, enqueueTtsAudio, enqueue, stop }; }