import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { audioFocus } from '@/core/audio/audio-focus'; import { parseAssistantSplitListKey } from '@/features/conversation/message-split'; import type { PlaybackItem, PlayerStatus } from '../types'; function isLaterSegmentOfSameAssistantBubble( current: PlaybackItem | null | undefined, incoming: PlaybackItem, ): boolean { if (incoming.kind !== 'tts_auto' || current?.kind !== 'tts_auto') { return false; } const a = parseAssistantSplitListKey(current.messageRef?.listKey); const b = parseAssistantSplitListKey(incoming.messageRef?.listKey); if (!a || !b) return false; return ( a.messageId === b.messageId && b.segmentIndex > a.segmentIndex ); } 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); /** 供 `enqueue` 判断「同一条助手消息的下一段 TTS」;不依赖 React state 闭包陈旧。 */ const currentPlaybackItemRef = useRef(null); /** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */ const statusRef = useRef('idle'); /** 同步反映「当前是否正在播放某条 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); useEffect(() => { statusRef.current = status; }, [status]); useEffect(() => { currentPlaybackItemRef.current = currentPlaybackItem; }, [currentPlaybackItem]); /** * 必须在 `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); statusRef.current = 'idle'; 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; statusRef.current = 'idle'; setStatus('idle'); return; } if (queueRef.current.length === 0) return; const next = queueRef.current.shift()!; setQueueLength(queueRef.current.length); statusRef.current = 'playing'; 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; statusRef.current = 'paused'; return 'paused'; }); }, [player]); const resumePlayback = useCallback(async () => { if (status !== 'paused') return; const acquired = await audioFocus.acquireForPlayback(); if (!acquired) { statusRef.current = 'idle'; setStatus('idle'); return; } if (!player) return; if (!playerStatus.isLoaded) return; player.play(); statusRef.current = 'playing'; 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) => { /** * 用户在助手自动朗读中途点暂停时,`playbackActiveUriRef` 仍指向当前条, * 后续 `tts_auto` 默认堆在队列里且不会 `playNext`。 * 无分段 listKey 时:新片段到达表示「另一条 / 最新一条」应只播它 → 清暂停态与队列。 * 有 `{uuid}_seg_{n}` 且 n 递增:同一落库助手消息的多段 TTS → 只入队,不抢轨。 */ const skipPausedClearForSplitContinue = item.kind === 'tts_auto' && statusRef.current === 'paused' && isLaterSegmentOfSameAssistantBubble( currentPlaybackItemRef.current, item, ); if ( item.kind === 'tts_auto' && statusRef.current === 'paused' && !skipPausedClearForSplitContinue ) { queueRef.current = []; setQueueLength(0); isPlayingRef.current = false; if (player) { player.pause(); } playbackActiveUriRef.current = null; setCurrentPlaybackItem(null); setCurrentSource(null); statusRef.current = 'idle'; setStatus('idle'); await audioFocus.releaseIfOwnedBy('player'); } queueRef.current.push(item); setQueueLength(queueRef.current.length); const shouldKick = queueRef.current.length === 1 && playbackActiveUriRef.current === null; if (shouldKick) { await playNext(); } }, [playNext, player], ); 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); statusRef.current = 'idle'; 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); statusRef.current = 'idle'; setStatus('idle'); await audioFocus.releaseIfOwnedBy('player'); }, [player]); return { status, queueLength, currentSource, currentPlaybackItem, enqueue, enqueueExclusive, pausePlayback, resumePlayback, stop, }; }