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; /** Pause native playback without draining queue(与 stop 清空队列不同)。 */ pausePlayback: () => void; /** Continue after pausePlayback(需 status === 'paused') */ resumePlayback: () => 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 [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, // Expo's native player deactivates AVAudioSession on pause by default. // We manage session ownership centrally via audioFocus, so keep it active // until audioFocus.release() explicitly tears it down. keepAudioSessionActive: true, }; }, [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; /** 先于 isLoaded「抢暂停」时需保留暂停,避免本条自动 play 覆盖 pause */ if (status === 'paused') return; player.play(); isPlayingRef.current = true; }, [currentSource, player, playerStatus.isLoaded, status]); 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.releaseIfOwnedBy('player'); return; } const acquired = await audioFocus.acquireForPlayback(); if (!acquired) { /** * 录音占用时 acquire 失败且队列尚未 shift;若用户进入会话前焦点已在 * `recorder`,可能不会再次触发 `onOwnerChange('recorder')`,旧的 * `wasBlockedByRecorderRef` 不会被置位,录音结束后也不会重试 playNext。 */ wasBlockedByRecorderRef.current = true; 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 (status === 'paused') return; 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, status]); const pausePlayback = useCallback(() => { setStatus((s) => { if (s !== 'playing') return s; if (player) { player.pause(); } isPlayingRef.current = false; return 'paused'; }); }, [player]); const resumePlayback = useCallback(async () => { if (status !== 'paused') return; const acquired = await audioFocus.acquireForPlayback(); if (!acquired) { setStatus('idle'); return; } if (!player) return; if (!playerStatus.isLoaded) return; player.play(); setStatus('playing'); isPlayingRef.current = true; }, [status, player, playerStatus.isLoaded]); // 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 && playbackActiveUriRef.current === null ) { void playNext(); } } }); return unsub; }, [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.releaseIfOwnedBy('player'); 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.releaseIfOwnedBy('player'); }, [player]); return { status, queueLength, currentSource, currentPlaybackItem, enqueue, enqueueExclusive, pausePlayback, resumePlayback, stop, }; }