Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app
This commit is contained in:
131
app-expo/src/features/voice/hooks/use-player.ts
Normal file
131
app-expo/src/features/voice/hooks/use-player.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { audioFocus } from '@/core/audio/audio-focus';
|
||||
|
||||
import type { PlaybackItem, PlayerStatus } from '../types';
|
||||
|
||||
interface UsePlayerResult {
|
||||
status: PlayerStatus;
|
||||
queueLength: number;
|
||||
enqueueTtsAudio: (audioBase64: string) => void;
|
||||
enqueue: (item: PlaybackItem) => 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 isPlayingRef = useRef(false);
|
||||
const wasBlockedByRecorderRef = useRef(false);
|
||||
|
||||
const player = useAudioPlayer(currentSource);
|
||||
const playerStatus = useAudioPlayerStatus(player);
|
||||
|
||||
// Start playback when a new source is set
|
||||
useEffect(() => {
|
||||
if (currentSource && player) {
|
||||
player.play();
|
||||
isPlayingRef.current = true;
|
||||
}
|
||||
}, [currentSource, player]);
|
||||
|
||||
const playNext = useCallback(async () => {
|
||||
if (queueRef.current.length === 0) {
|
||||
setCurrentSource(null);
|
||||
setStatus('idle');
|
||||
setQueueLength(0);
|
||||
await audioFocus.release();
|
||||
return;
|
||||
}
|
||||
|
||||
const acquired = await audioFocus.acquireForPlayback();
|
||||
if (!acquired) {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
const next = queueRef.current.shift()!;
|
||||
setQueueLength(queueRef.current.length);
|
||||
setStatus('playing');
|
||||
setCurrentSource(next.uri);
|
||||
}, []);
|
||||
|
||||
// Detect playback completion → advance queue
|
||||
useEffect(() => {
|
||||
if (!currentSource || !isPlayingRef.current) return;
|
||||
|
||||
const { playing, currentTime, duration } = playerStatus;
|
||||
const finished = !playing && duration > 0 && currentTime >= duration - 0.05;
|
||||
|
||||
if (finished) {
|
||||
isPlayingRef.current = false;
|
||||
playNext();
|
||||
}
|
||||
}, [playerStatus, currentSource, playNext]);
|
||||
|
||||
// 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);
|
||||
|
||||
if (status === 'idle' && !currentSource) {
|
||||
await playNext();
|
||||
}
|
||||
},
|
||||
[status, currentSource, playNext],
|
||||
);
|
||||
|
||||
const enqueueTtsAudio = useCallback(
|
||||
(audioBase64: string) => {
|
||||
const uri = `data:audio/mp3;base64,${audioBase64}`;
|
||||
enqueue({ uri, label: 'TTS' });
|
||||
},
|
||||
[enqueue],
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
queueRef.current = [];
|
||||
setQueueLength(0);
|
||||
isPlayingRef.current = false;
|
||||
|
||||
if (player) {
|
||||
player.pause();
|
||||
}
|
||||
|
||||
setCurrentSource(null);
|
||||
setStatus('idle');
|
||||
await audioFocus.release();
|
||||
}, [player]);
|
||||
|
||||
return { status, queueLength, enqueueTtsAudio, enqueue, stop };
|
||||
}
|
||||
Reference in New Issue
Block a user