Matthias Nott
2026-03-07 0e888d62af1434fef231e11a5c307a5b48a8deb1
services/audio.ts
....@@ -10,20 +10,34 @@
1010 durationMs: number;
1111 }
1212
13
+// --- Singleton audio player ---
14
+// Only ONE audio can play at a time. Any new play request stops the current one.
15
+
1316 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;
1519
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>();
1922
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);
2226 }
2327
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 {
2530 playingListeners.add(cb);
2631 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;
2741 }
2842
2943 export async function requestPermissions(): Promise<boolean> {
....@@ -44,9 +58,13 @@
4458 return tmpPath;
4559 }
4660
61
+// --- Audio queue for chaining sequential voice notes (autoplay) ---
62
+const audioQueue: Array<{ uri: string; onFinish?: () => void }> = [];
63
+let processingQueue = false;
64
+
4765 /**
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).
5068 */
5169 export async function playAudio(
5270 uri: string,
....@@ -56,6 +74,18 @@
5674 if (!processingQueue) {
5775 processAudioQueue();
5876 }
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);
5989 }
6090
6191 async function processAudioQueue(): Promise<void> {
....@@ -72,35 +102,57 @@
72102
73103 function playOneAudio(uri: string, onFinish?: () => void): Promise<void> {
74104 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
+
75133 try {
76134 await setAudioModeAsync({ playsInSilentMode: true });
77135
78
- const player = createAudioPlayer(uri);
136
+ player = createAudioPlayer(uri);
79137 currentPlayer = player;
80
- notifyListeners(true);
138
+ notifyListeners(uri);
81139
82140 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();
91144 }
92145 });
93146
94147 player.play();
95148 } catch (error) {
96149 console.error("Failed to play audio:", error);
150
+ settled = true;
151
+ cancelCurrent = null;
152
+ clearTimeout(timer);
97153 resolve();
98154 }
99155 });
100
-}
101
-
102
-export function isPlaying(): boolean {
103
- return currentPlayer !== null;
104156 }
105157
106158 /**
....@@ -108,7 +160,9 @@
108160 */
109161 export async function stopPlayback(): Promise<void> {
110162 audioQueue.length = 0;
111
- if (currentPlayer) {
163
+ if (cancelCurrent) {
164
+ cancelCurrent();
165
+ } else if (currentPlayer) {
112166 try {
113167 currentPlayer.pause();
114168 currentPlayer.remove();
....@@ -116,7 +170,7 @@
116170 // Ignore cleanup errors
117171 }
118172 currentPlayer = null;
119
- notifyListeners(false);
173
+ notifyListeners(null);
120174 }
121175 }
122176