import { createAudioPlayer, requestRecordingPermissionsAsync, setAudioModeAsync, } from "expo-audio"; import * as LegacyFileSystem from "expo-file-system/legacy"; import { AppState } from "react-native"; export interface RecordingResult { uri: string; durationMs: number; } // --- Autoplay suppression --- // Don't autoplay voice messages when the app is in the background // or when the user is on a phone call (detected via audio interruption). let _autoplayEnabled = true; let _audioInterrupted = false; // Track app state — suppress autoplay when backgrounded AppState.addEventListener("change", (state) => { _autoplayEnabled = state === "active"; }); /** Check if autoplay is safe right now (app in foreground, no interruption). */ export function canAutoplay(): boolean { return _autoplayEnabled && !_audioInterrupted; } /** Called externally to signal audio interruption (e.g., phone call started/ended). */ export function setAudioInterrupted(interrupted: boolean): void { _audioInterrupted = interrupted; } // --- Singleton audio player --- // Only ONE audio can play at a time. Any new play request stops the current one. let currentPlayer: ReturnType | null = null; let currentUri: string | null = null; let cancelCurrent: (() => void) | null = null; // Listeners get the URI of what's playing (or null when stopped) const playingListeners = new Set<(uri: string | null) => void>(); function notifyListeners(uri: string | null): void { currentUri = uri; for (const cb of playingListeners) cb(uri); } /** Subscribe to playing state changes. Returns unsubscribe function. */ export function onPlayingChange(cb: (uri: string | null) => void): () => void { playingListeners.add(cb); return () => { playingListeners.delete(cb); }; } /** Get the URI currently playing, or null. */ export function playingUri(): string | null { return currentUri; } export function isPlaying(): boolean { return currentPlayer !== null; } export async function requestPermissions(): Promise { const { status } = await requestRecordingPermissionsAsync(); return status === "granted"; } let audioCounter = 0; /** * Convert a base64 audio string to a file URI. */ export async function saveBase64Audio(base64: string, ext = "m4a"): Promise { const tmpPath = `${LegacyFileSystem.cacheDirectory}pailot-voice-${++audioCounter}.${ext}`; await LegacyFileSystem.writeAsStringAsync(tmpPath, base64, { encoding: LegacyFileSystem.EncodingType.Base64, }); return tmpPath; } // --- Audio queue for chaining sequential voice notes (autoplay) --- const audioQueue: Array<{ uri: string; onFinish?: () => void }> = []; let processingQueue = false; /** * Play audio. Stops any current playback first (singleton). * Multiple calls chain sequentially via queue (for chunked voice notes). */ export async function playAudio( uri: string, onFinish?: () => void ): Promise { audioQueue.push({ uri, onFinish }); if (!processingQueue) { processAudioQueue(); } } /** * Play a single audio file, stopping any current playback first. * Does NOT queue — immediately replaces whatever is playing. */ export async function playSingle( uri: string, onFinish?: () => void ): Promise { await stopPlayback(); await playOneAudio(uri, onFinish); } async function processAudioQueue(): Promise { if (processingQueue) return; processingQueue = true; while (audioQueue.length > 0) { const item = audioQueue.shift()!; await playOneAudio(item.uri, item.onFinish, false); } processingQueue = false; } function playOneAudio(uri: string, onFinish?: () => void, cancelPrevious = true): Promise { return new Promise(async (resolve) => { let settled = false; const finish = () => { if (settled) return; settled = true; cancelCurrent = null; clearTimeout(timer); onFinish?.(); try { player?.pause(); } catch { /* ignore */ } try { player?.remove(); } catch { /* ignore */ } if (currentPlayer === player) { currentPlayer = null; notifyListeners(null); } resolve(); }; // Stop any currently playing audio first (only for non-queued calls) if (cancelPrevious && cancelCurrent) { cancelCurrent(); } // Register cancel callback so stopPlayback can abort us cancelCurrent = finish; // Safety timeout const timer = setTimeout(finish, 5 * 60 * 1000); let player: ReturnType | null = null; try { await setAudioModeAsync({ playsInSilentMode: true }); player = createAudioPlayer(uri); currentPlayer = player; notifyListeners(uri); player.addListener("playbackStatusUpdate", (status) => { if (!status.playing && status.currentTime > 0) { if (status.duration <= 0 || status.currentTime >= status.duration) { // Playback finished naturally finish(); } else { // Paused mid-playback — likely audio interruption (phone call) setAudioInterrupted(true); } } else if (status.playing && _audioInterrupted) { // Resumed after interruption setAudioInterrupted(false); } }); player.play(); } catch (error) { console.error("Failed to play audio:", error); settled = true; cancelCurrent = null; clearTimeout(timer); resolve(); } }); } /** * Stop current playback and clear the queue. */ export async function stopPlayback(): Promise { audioQueue.length = 0; if (cancelCurrent) { cancelCurrent(); } else if (currentPlayer) { try { currentPlayer.pause(); currentPlayer.remove(); } catch { // Ignore cleanup errors } currentPlayer = null; notifyListeners(null); } } export async function encodeAudioToBase64(uri: string): Promise { const result = await LegacyFileSystem.readAsStringAsync(uri, { encoding: LegacyFileSystem.EncodingType.Base64, }); return result; }