Files
life-echo/app-expo/src/features/voice/hooks/use-player.ts
Kevin 3921c5ec24 fix(app-expo): allow read-aloud on other split segments while TTS paused
Match playback refs to the correct assistant segment so the interrupt overlay
does not block other bubbles, and preempt local playback when switching segments.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 14:55:10 +08:00

324 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}