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; /** Current playback source URI (file, https, or data URL). */ currentSource: string | null; enqueue: (item: PlaybackItem) => void; /** Replace queue and play this item (e.g. user voice bubble vs other sources). */ enqueueExclusive: (item: PlaybackItem) => Promise; 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); /** 同步反映「当前是否正在播放某条 URI」;enqueue 不能依赖 state,否则 await stop() 后仍为陈旧闭包。 */ const playbackActiveUriRef = useRef(null); /** 当前 source 是否已进入过 playing=true,避免换源瞬间 playerStatus 仍带上一首的 duration 而误判「已播完」。 */ const trackHasPlayedRef = useRef(false); const player = useAudioPlayer(currentSource, { downloadFirst: false }); 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) { playbackActiveUriRef.current = null; 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'); trackHasPlayedRef.current = false; playbackActiveUriRef.current = next.uri; setCurrentSource(next.uri); } finally { isPlayNextInProgressRef.current = false; } }, []); useEffect(() => { if (playerStatus.playing) { trackHasPlayedRef.current = true; } }, [playerStatus.playing]); // Detect playback completion → advance queue(必须曾 playing,避免换源瞬间沿用上一条的 duration/currentTime) useEffect(() => { if (!currentSource || !isPlayingRef.current) return; const { playing, currentTime, duration } = playerStatus; const finished = trackHasPlayedRef.current && !playing && duration > 0 && currentTime >= duration - 0.05; if (finished) { trackHasPlayedRef.current = false; 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); const shouldKick = queueRef.current.length === 1 && playbackActiveUriRef.current === null; if (shouldKick) { await playNext(); } }, [playNext], ); const enqueueExclusive = useCallback( async (item: PlaybackItem) => { queueRef.current = [item]; setQueueLength(1); isPlayingRef.current = false; if (player) { player.pause(); } playbackActiveUriRef.current = null; setCurrentSource(null); setStatus('idle'); await audioFocus.release(); await playNext(); }, [player, playNext], ); const stop = useCallback(async () => { queueRef.current = []; setQueueLength(0); isPlayingRef.current = false; if (player) { player.pause(); } playbackActiveUriRef.current = null; setCurrentSource(null); setStatus('idle'); await audioFocus.release(); }, [player]); return { status, queueLength, currentSource, enqueue, enqueueExclusive, stop, }; }