| .. | .. |
|---|
| 10 | 10 | durationMs: number; |
|---|
| 11 | 11 | } |
|---|
| 12 | 12 | |
|---|
| 13 | +// --- Singleton audio player --- |
|---|
| 14 | +// Only ONE audio can play at a time. Any new play request stops the current one. |
|---|
| 15 | + |
|---|
| 13 | 16 | let currentPlayer: ReturnType<typeof createAudioPlayer> | null = null; |
|---|
| 14 | | -const playingListeners = new Set<(playing: boolean) => void>(); |
|---|
| 17 | +let currentUri: string | null = null; |
|---|
| 18 | +let cancelCurrent: (() => void) | null = null; |
|---|
| 15 | 19 | |
|---|
| 16 | | -// Audio queue for chaining sequential voice notes |
|---|
| 17 | | -const audioQueue: Array<{ uri: string; onFinish?: () => void }> = []; |
|---|
| 18 | | -let processingQueue = false; |
|---|
| 20 | +// Listeners get the URI of what's playing (or null when stopped) |
|---|
| 21 | +const playingListeners = new Set<(uri: string | null) => void>(); |
|---|
| 19 | 22 | |
|---|
| 20 | | -function notifyListeners(playing: boolean): void { |
|---|
| 21 | | - for (const cb of playingListeners) cb(playing); |
|---|
| 23 | +function notifyListeners(uri: string | null): void { |
|---|
| 24 | + currentUri = uri; |
|---|
| 25 | + for (const cb of playingListeners) cb(uri); |
|---|
| 22 | 26 | } |
|---|
| 23 | 27 | |
|---|
| 24 | | -export function onPlayingChange(cb: (playing: boolean) => void): () => void { |
|---|
| 28 | +/** Subscribe to playing state changes. Returns unsubscribe function. */ |
|---|
| 29 | +export function onPlayingChange(cb: (uri: string | null) => void): () => void { |
|---|
| 25 | 30 | playingListeners.add(cb); |
|---|
| 26 | 31 | return () => { playingListeners.delete(cb); }; |
|---|
| 32 | +} |
|---|
| 33 | + |
|---|
| 34 | +/** Get the URI currently playing, or null. */ |
|---|
| 35 | +export function playingUri(): string | null { |
|---|
| 36 | + return currentUri; |
|---|
| 37 | +} |
|---|
| 38 | + |
|---|
| 39 | +export function isPlaying(): boolean { |
|---|
| 40 | + return currentPlayer !== null; |
|---|
| 27 | 41 | } |
|---|
| 28 | 42 | |
|---|
| 29 | 43 | export async function requestPermissions(): Promise<boolean> { |
|---|
| .. | .. |
|---|
| 44 | 58 | return tmpPath; |
|---|
| 45 | 59 | } |
|---|
| 46 | 60 | |
|---|
| 61 | +// --- Audio queue for chaining sequential voice notes (autoplay) --- |
|---|
| 62 | +const audioQueue: Array<{ uri: string; onFinish?: () => void }> = []; |
|---|
| 63 | +let processingQueue = false; |
|---|
| 64 | + |
|---|
| 47 | 65 | /** |
|---|
| 48 | | - * Queue audio for playback. Multiple calls chain sequentially — |
|---|
| 49 | | - * the next voice note plays only after the current one finishes. |
|---|
| 66 | + * Play audio. Stops any current playback first (singleton). |
|---|
| 67 | + * Multiple calls chain sequentially via queue (for chunked voice notes). |
|---|
| 50 | 68 | */ |
|---|
| 51 | 69 | export async function playAudio( |
|---|
| 52 | 70 | uri: string, |
|---|
| .. | .. |
|---|
| 56 | 74 | if (!processingQueue) { |
|---|
| 57 | 75 | processAudioQueue(); |
|---|
| 58 | 76 | } |
|---|
| 77 | +} |
|---|
| 78 | + |
|---|
| 79 | +/** |
|---|
| 80 | + * Play a single audio file, stopping any current playback first. |
|---|
| 81 | + * Does NOT queue — immediately replaces whatever is playing. |
|---|
| 82 | + */ |
|---|
| 83 | +export async function playSingle( |
|---|
| 84 | + uri: string, |
|---|
| 85 | + onFinish?: () => void |
|---|
| 86 | +): Promise<void> { |
|---|
| 87 | + await stopPlayback(); |
|---|
| 88 | + await playOneAudio(uri, onFinish); |
|---|
| 59 | 89 | } |
|---|
| 60 | 90 | |
|---|
| 61 | 91 | async function processAudioQueue(): Promise<void> { |
|---|
| .. | .. |
|---|
| 72 | 102 | |
|---|
| 73 | 103 | function playOneAudio(uri: string, onFinish?: () => void): Promise<void> { |
|---|
| 74 | 104 | return new Promise<void>(async (resolve) => { |
|---|
| 105 | + let settled = false; |
|---|
| 106 | + const finish = () => { |
|---|
| 107 | + if (settled) return; |
|---|
| 108 | + settled = true; |
|---|
| 109 | + cancelCurrent = null; |
|---|
| 110 | + clearTimeout(timer); |
|---|
| 111 | + onFinish?.(); |
|---|
| 112 | + try { player?.pause(); } catch { /* ignore */ } |
|---|
| 113 | + try { player?.remove(); } catch { /* ignore */ } |
|---|
| 114 | + if (currentPlayer === player) { |
|---|
| 115 | + currentPlayer = null; |
|---|
| 116 | + notifyListeners(null); |
|---|
| 117 | + } |
|---|
| 118 | + resolve(); |
|---|
| 119 | + }; |
|---|
| 120 | + |
|---|
| 121 | + // Stop any currently playing audio first |
|---|
| 122 | + if (cancelCurrent) { |
|---|
| 123 | + cancelCurrent(); |
|---|
| 124 | + } |
|---|
| 125 | + |
|---|
| 126 | + // Register cancel callback so stopPlayback can abort us |
|---|
| 127 | + cancelCurrent = finish; |
|---|
| 128 | + |
|---|
| 129 | + // Safety timeout |
|---|
| 130 | + const timer = setTimeout(finish, 5 * 60 * 1000); |
|---|
| 131 | + let player: ReturnType<typeof createAudioPlayer> | null = null; |
|---|
| 132 | + |
|---|
| 75 | 133 | try { |
|---|
| 76 | 134 | await setAudioModeAsync({ playsInSilentMode: true }); |
|---|
| 77 | 135 | |
|---|
| 78 | | - const player = createAudioPlayer(uri); |
|---|
| 136 | + player = createAudioPlayer(uri); |
|---|
| 79 | 137 | currentPlayer = player; |
|---|
| 80 | | - notifyListeners(true); |
|---|
| 138 | + notifyListeners(uri); |
|---|
| 81 | 139 | |
|---|
| 82 | 140 | player.addListener("playbackStatusUpdate", (status) => { |
|---|
| 83 | | - if (!status.playing && status.currentTime >= status.duration && status.duration > 0) { |
|---|
| 84 | | - onFinish?.(); |
|---|
| 85 | | - player.remove(); |
|---|
| 86 | | - if (currentPlayer === player) { |
|---|
| 87 | | - currentPlayer = null; |
|---|
| 88 | | - if (audioQueue.length === 0) notifyListeners(false); |
|---|
| 89 | | - } |
|---|
| 90 | | - resolve(); |
|---|
| 141 | + if (!status.playing && status.currentTime > 0 && |
|---|
| 142 | + (status.duration <= 0 || status.currentTime >= status.duration)) { |
|---|
| 143 | + finish(); |
|---|
| 91 | 144 | } |
|---|
| 92 | 145 | }); |
|---|
| 93 | 146 | |
|---|
| 94 | 147 | player.play(); |
|---|
| 95 | 148 | } catch (error) { |
|---|
| 96 | 149 | console.error("Failed to play audio:", error); |
|---|
| 150 | + settled = true; |
|---|
| 151 | + cancelCurrent = null; |
|---|
| 152 | + clearTimeout(timer); |
|---|
| 97 | 153 | resolve(); |
|---|
| 98 | 154 | } |
|---|
| 99 | 155 | }); |
|---|
| 100 | | -} |
|---|
| 101 | | - |
|---|
| 102 | | -export function isPlaying(): boolean { |
|---|
| 103 | | - return currentPlayer !== null; |
|---|
| 104 | 156 | } |
|---|
| 105 | 157 | |
|---|
| 106 | 158 | /** |
|---|
| .. | .. |
|---|
| 108 | 160 | */ |
|---|
| 109 | 161 | export async function stopPlayback(): Promise<void> { |
|---|
| 110 | 162 | audioQueue.length = 0; |
|---|
| 111 | | - if (currentPlayer) { |
|---|
| 163 | + if (cancelCurrent) { |
|---|
| 164 | + cancelCurrent(); |
|---|
| 165 | + } else if (currentPlayer) { |
|---|
| 112 | 166 | try { |
|---|
| 113 | 167 | currentPlayer.pause(); |
|---|
| 114 | 168 | currentPlayer.remove(); |
|---|
| .. | .. |
|---|
| 116 | 170 | // Ignore cleanup errors |
|---|
| 117 | 171 | } |
|---|
| 118 | 172 | currentPlayer = null; |
|---|
| 119 | | - notifyListeners(false); |
|---|
| 173 | + notifyListeners(null); |
|---|
| 120 | 174 | } |
|---|
| 121 | 175 | } |
|---|
| 122 | 176 | |
|---|