import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio'; import { useCallback, useEffect, useMemo, 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; /** 当前正在播放的队列项(含 kind / messageRef),队列为空或未开始为 null */ currentPlaybackItem: PlaybackItem | 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 [currentPlaybackItem, setCurrentPlaybackItem] = 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); /** 远程 HTTPS 需先下载再解码,否则再读(仅 URL、无 base64)可能无声;本地/data URL 保持 false */ const playerOptions = useMemo(() => { const remote = typeof currentSource === 'string' && (currentSource.startsWith('https://') || currentSource.startsWith('http://')); return { downloadFirst: remote }; }, [currentSource]); const player = useAudioPlayer(currentSource, playerOptions); const playerStatus = useAudioPlayerStatus(player); /** * 必须在 `isLoaded` 之后再 `play()`。 * expo-audio 在 `downloadFirst: true` 时先用 null 建 player,再在内部 effect 里异步 * `resolveSourceWithDownload` 后 `replace()`(见 node_modules/expo-audio/build/ExpoAudio.js)。 * 若仅在 `currentSource` 变化时立刻 `play()`,会在 replace 完成前播放 → 远程 URL(再读)无声。 */ useEffect(() => { if (!currentSource || !player) return; if (!playerStatus.isLoaded) return; player.play(); isPlayingRef.current = true; }, [currentSource, player, playerStatus.isLoaded]); const playNext = useCallback(async () => { if (isPlayNextInProgressRef.current) return; isPlayNextInProgressRef.current = true; try { if (queueRef.current.length === 0) { playbackActiveUriRef.current = null; setCurrentPlaybackItem(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; setCurrentPlaybackItem(next); 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; setCurrentPlaybackItem(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; setCurrentPlaybackItem(null); setCurrentSource(null); setStatus('idle'); await audioFocus.release(); }, [player]); return { status, queueLength, currentSource, currentPlaybackItem, enqueue, enqueueExclusive, stop, }; }