1import { AudioModule, setAudioModeAsync, setIsAudioActiveAsync } from "expo-audio";
2
3type AudioSessionToken = {
4 readonly kind: "playback" | "recording";
5 readonly onCancel?: () => void;
6 readonly onRelease?: () => void;
7 released: boolean;
8};
9
10let owner: AudioSessionToken | null = null;
11let pending = Promise.resolve();
12
13// Native session calls are async. Finish an old activation before releasing it
14// or configuring its successor, so it cannot reactivate a released session.
15function enqueue<T>(operation: () => Promise<T>): Promise<T> {
16 const result = pending.then(operation);
17 pending = result.then(
18 () => {},
19 () => {},
20 );
21 return result;
22}
23
24export function pauseAudioPlayer(player: { pause(deactivateSession?: boolean): void }): void {
25 // Expo Audio otherwise deactivates iOS's shared session 100 ms after pause,
26 // even when a video or recorder has already taken ownership.
27 if (owner && !owner.released) player.pause(false);
28 else player.pause();
29}
30
31export function disposeAudioPlayer(player: {
32 pause(deactivateSession?: boolean): void;
33 release(): void;
34}): void {
35 void enqueue(async () => {
36 try {
37 pauseAudioPlayer(player);
38 } finally {
39 player.release();
40 }
41 }).catch(() => {});
42}
43
44export function acquireAudioSession(
45 kind: AudioSessionToken["kind"],
46 onCancel?: () => void,
47 onRelease?: () => void,
48) {
49 const token: AudioSessionToken = { kind, onCancel, onRelease, released: false };
50 if (kind === "playback" && owner?.kind === "recording" && !owner.released) return token;
51 const previous = owner;
52 // Publish before cancelling the old player, including callbacks already queued by Expo.
53 AudioModule.setAudioSessionOwner?.(true);
54 owner = token;
55 if (previous && !previous.released) previous.onCancel?.();
56 return token;
57}
58
59export function isAudioSessionOwner(token: AudioSessionToken): boolean {
60 return owner === token && !token.released;
61}
62
63export function activateAudioSession(
64 token: AudioSessionToken,
65 nativePlayback = false,
66): Promise<boolean> {
67 return enqueue(async () => {
68 if (!isAudioSessionOwner(token)) return false;
69 // Video configures and activates its own native session, including moviePlayback on iOS.
70 if (nativePlayback) return true;
71 await setAudioModeAsync({
72 allowsRecording: token.kind === "recording",
73 interruptionMode: "doNotMix",
74 playsInSilentMode: true,
75 shouldPlayInBackground: false,
76 });
77 if (!isAudioSessionOwner(token)) return false;
78 await setIsAudioActiveAsync(true);
79 return isAudioSessionOwner(token);
80 });
81}
82
83export function releaseAudioSession(token: AudioSessionToken): Promise<void> {
84 token.released = true;
85 return enqueue(async () => {
86 if (owner !== token) return;
87 try {
88 if (token.kind === "recording") await setAudioModeAsync({ allowsRecording: false });
89 } finally {
90 if (owner === token) {
91 if (token.kind === "recording") await setIsAudioActiveAsync(false);
92 // Expo pause releases the native session without disabling its global playback gate.
93 // Repeat it after any pending activation, even if playback never started.
94 token.onRelease?.();
95 if (owner === token) {
96 AudioModule.setAudioSessionOwner?.(false);
97 owner = null;
98 }
99 }
100 }
101 });
102}
103
104export function createAudioPlaybackSession(
105 player: { play(): void; pause(deactivateSession?: boolean): void },
106 nativePlayback = false,
107 onRequestedChange?: (requested: boolean) => void,
108) {
109 let token: AudioSessionToken | null = null;
110 const stopPlayer = () => {
111 // useAudioPlayer/useVideoPlayer may have disposed the native ref during unmount.
112 try {
113 if (nativePlayback) player.pause();
114 else pauseAudioPlayer(player);
115 } catch {}
116 };
117 const pause = () => {
118 const previous = token;
119 token = null;
120 onRequestedChange?.(false);
121 stopPlayer();
122 if (previous) void releaseAudioSession(previous).catch(() => {});
123 };
124 const acquire = () => {
125 const next = acquireAudioSession("playback", pause, stopPlayer);
126 if (!isAudioSessionOwner(next)) {
127 stopPlayer();
128 return null;
129 }
130 token = next;
131 onRequestedChange?.(true);
132 return next;
133 };
134 return {
135 pause,
136 get requested() {
137 return token !== null;
138 },
139 async play(prepare?: () => Promise<void>) {
140 const current = token ?? acquire();
141 if (!current) return;
142 try {
143 await prepare?.();
144 if (!(await activateAudioSession(current, nativePlayback))) return;
145 if (token !== current || !isAudioSessionOwner(current)) return;
146 player.play();
147 } catch (error) {
148 if (token === current) pause();
149 throw error;
150 }
151 },
152 // A stopped clock is also reported during buffering and seeks. Native
153 // pause intent must call pause() separately from playingChange.
154 playingChanged(playing: boolean) {
155 if (playing && token === null) acquire();
156 },
157 };
158}