Files
life-echo/app-expo/src/features/voice/hooks/use-player.ts
Kevin 7ad52fce89 feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset,
  safer uploaded-avatar path validation, preset_avatars + HTTP tests.
- Expo: personal-info (library + presets), profile tab avatar,
  resolveApiMediaUrl, auth hooks cache sync, Web multipart helper,
  partial-save messaging + profile i18n.
- Includes existing edits to conversation screen and voice use-player.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 13:51:43 +08:00

245 lines
7.9 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 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<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);
/** 同步反映「当前是否正在播放某条 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);
/**
* 必须在 `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) {
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 && 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.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,
};
}