Files
life-echo/app-expo/src/features/voice/hooks/use-player.ts

324 lines
11 KiB
TypeScript
Raw Normal View History

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<void>;
/** 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<PlaybackItem[]>([]);
const [status, setStatus] = useState<PlayerStatus>('idle');
const [queueLength, setQueueLength] = useState(0);
const [currentSource, setCurrentSource] = useState<string | null>(null);
const [currentPlaybackItem, setCurrentPlaybackItem] =
useState<PlaybackItem | null>(null);
const isPlayingRef = useRef(false);
const wasBlockedByRecorderRef = useRef(false);
const isPlayNextInProgressRef = useRef(false);
/** 供 `enqueue` 判断「同一条助手消息的下一段 TTS」不依赖 React state 闭包陈旧。 */
const currentPlaybackItemRef = useRef<PlaybackItem | null>(null);
/** 与 `status` 同步;`pausePlayback` 等在同一事件循环内立即更新,避免 WS 紧跟着 `enqueue(tts_auto)` 时读到陈旧 `playing`。 */
const statusRef = useRef<PlayerStatus>('idle');
/** 同步反映「当前是否正在播放某条 URI」enqueue 不能依赖 state否则 await stop() 后仍为陈旧闭包。 */
const playbackActiveUriRef = useRef<string | null>(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,
};
}