@voltkit-rtc/react-native 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1608 -2
  4. package/android/build.gradle +70 -0
  5. package/android/consumer-rules.pro +21 -0
  6. package/android/src/main/AndroidManifest.xml +25 -0
  7. package/android/src/main/java/com/voltkit/rtc/reactnative/AppVisibility.kt +67 -0
  8. package/android/src/main/java/com/voltkit/rtc/reactnative/AudioDevices.kt +67 -0
  9. package/android/src/main/java/com/voltkit/rtc/reactnative/AudioRouter.kt +487 -0
  10. package/android/src/main/java/com/voltkit/rtc/reactnative/CallServiceTypes.kt +120 -0
  11. package/android/src/main/java/com/voltkit/rtc/reactnative/NativeTimers.kt +106 -0
  12. package/android/src/main/java/com/voltkit/rtc/reactnative/NetworkWatch.kt +120 -0
  13. package/android/src/main/java/com/voltkit/rtc/reactnative/OutputChoice.kt +65 -0
  14. package/android/src/main/java/com/voltkit/rtc/reactnative/RtcConfig.kt +83 -0
  15. package/android/src/main/java/com/voltkit/rtc/reactnative/ScoLink.kt +61 -0
  16. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitCallService.kt +287 -0
  17. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcInitProvider.kt +215 -0
  18. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcModule.kt +313 -0
  19. package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcPackage.kt +30 -0
  20. package/android/src/main/res/drawable/voltkit_rtc_default_call_icon.xml +15 -0
  21. package/android/src/main/res/raw/voltkit_rtc_keep.xml +7 -0
  22. package/app.plugin.js +17 -0
  23. package/lib/commonjs/audio.js +215 -0
  24. package/lib/commonjs/audio.js.map +1 -0
  25. package/lib/commonjs/components.js +207 -0
  26. package/lib/commonjs/components.js.map +1 -0
  27. package/lib/commonjs/create-client.js +47 -0
  28. package/lib/commonjs/create-client.js.map +1 -0
  29. package/lib/commonjs/errors.js +99 -0
  30. package/lib/commonjs/errors.js.map +1 -0
  31. package/lib/commonjs/globals.js +61 -0
  32. package/lib/commonjs/globals.js.map +1 -0
  33. package/lib/commonjs/index.js +234 -0
  34. package/lib/commonjs/index.js.map +1 -0
  35. package/lib/commonjs/local-media.js +392 -0
  36. package/lib/commonjs/local-media.js.map +1 -0
  37. package/lib/commonjs/native-session.js +365 -0
  38. package/lib/commonjs/native-session.js.map +1 -0
  39. package/lib/commonjs/native.js +99 -0
  40. package/lib/commonjs/native.js.map +1 -0
  41. package/lib/commonjs/package.json +1 -0
  42. package/lib/commonjs/permissions.js +166 -0
  43. package/lib/commonjs/permissions.js.map +1 -0
  44. package/lib/commonjs/platform/android.js +126 -0
  45. package/lib/commonjs/platform/android.js.map +1 -0
  46. package/lib/commonjs/platform/common.js +138 -0
  47. package/lib/commonjs/platform/common.js.map +1 -0
  48. package/lib/commonjs/platform/devices.js +151 -0
  49. package/lib/commonjs/platform/devices.js.map +1 -0
  50. package/lib/commonjs/platform/index.js +35 -0
  51. package/lib/commonjs/platform/index.js.map +1 -0
  52. package/lib/commonjs/platform/ios.js +54 -0
  53. package/lib/commonjs/platform/ios.js.map +1 -0
  54. package/lib/commonjs/platform/streams.js +273 -0
  55. package/lib/commonjs/platform/streams.js.map +1 -0
  56. package/lib/commonjs/platform/timers.js +71 -0
  57. package/lib/commonjs/platform/timers.js.map +1 -0
  58. package/lib/commonjs/platform/visibility.js +60 -0
  59. package/lib/commonjs/platform/visibility.js.map +1 -0
  60. package/lib/commonjs/preflight.js +77 -0
  61. package/lib/commonjs/preflight.js.map +1 -0
  62. package/lib/commonjs/provider.js +75 -0
  63. package/lib/commonjs/provider.js.map +1 -0
  64. package/lib/commonjs/specs/NativeVoltkitRtc.js +29 -0
  65. package/lib/commonjs/specs/NativeVoltkitRtc.js.map +1 -0
  66. package/lib/module/audio.js +207 -0
  67. package/lib/module/audio.js.map +1 -0
  68. package/lib/module/components.js +200 -0
  69. package/lib/module/components.js.map +1 -0
  70. package/lib/module/create-client.js +42 -0
  71. package/lib/module/create-client.js.map +1 -0
  72. package/lib/module/errors.js +87 -0
  73. package/lib/module/errors.js.map +1 -0
  74. package/lib/module/globals.js +53 -0
  75. package/lib/module/globals.js.map +1 -0
  76. package/lib/module/index.js +33 -0
  77. package/lib/module/index.js.map +1 -0
  78. package/lib/module/local-media.js +387 -0
  79. package/lib/module/local-media.js.map +1 -0
  80. package/lib/module/native-session.js +360 -0
  81. package/lib/module/native-session.js.map +1 -0
  82. package/lib/module/native.js +87 -0
  83. package/lib/module/native.js.map +1 -0
  84. package/lib/module/package.json +1 -0
  85. package/lib/module/permissions.js +160 -0
  86. package/lib/module/permissions.js.map +1 -0
  87. package/lib/module/platform/android.js +122 -0
  88. package/lib/module/platform/android.js.map +1 -0
  89. package/lib/module/platform/common.js +128 -0
  90. package/lib/module/platform/common.js.map +1 -0
  91. package/lib/module/platform/devices.js +142 -0
  92. package/lib/module/platform/devices.js.map +1 -0
  93. package/lib/module/platform/index.js +24 -0
  94. package/lib/module/platform/index.js.map +1 -0
  95. package/lib/module/platform/ios.js +49 -0
  96. package/lib/module/platform/ios.js.map +1 -0
  97. package/lib/module/platform/streams.js +268 -0
  98. package/lib/module/platform/streams.js.map +1 -0
  99. package/lib/module/platform/timers.js +67 -0
  100. package/lib/module/platform/timers.js.map +1 -0
  101. package/lib/module/platform/visibility.js +54 -0
  102. package/lib/module/platform/visibility.js.map +1 -0
  103. package/lib/module/preflight.js +72 -0
  104. package/lib/module/preflight.js.map +1 -0
  105. package/lib/module/provider.js +70 -0
  106. package/lib/module/provider.js.map +1 -0
  107. package/lib/module/specs/NativeVoltkitRtc.js +37 -0
  108. package/lib/module/specs/NativeVoltkitRtc.js.map +1 -0
  109. package/lib/typescript/commonjs/audio.d.ts +58 -0
  110. package/lib/typescript/commonjs/audio.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/components.d.ts +58 -0
  112. package/lib/typescript/commonjs/components.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/create-client.d.ts +35 -0
  114. package/lib/typescript/commonjs/create-client.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/errors.d.ts +34 -0
  116. package/lib/typescript/commonjs/errors.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/globals.d.ts +16 -0
  118. package/lib/typescript/commonjs/globals.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/index.d.ts +24 -0
  120. package/lib/typescript/commonjs/index.d.ts.map +1 -0
  121. package/lib/typescript/commonjs/local-media.d.ts +86 -0
  122. package/lib/typescript/commonjs/local-media.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/native-session.d.ts +38 -0
  124. package/lib/typescript/commonjs/native-session.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/native.d.ts +18 -0
  126. package/lib/typescript/commonjs/native.d.ts.map +1 -0
  127. package/lib/typescript/commonjs/package.json +1 -0
  128. package/lib/typescript/commonjs/permissions.d.ts +30 -0
  129. package/lib/typescript/commonjs/permissions.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/platform/android.d.ts +4 -0
  131. package/lib/typescript/commonjs/platform/android.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/platform/common.d.ts +101 -0
  133. package/lib/typescript/commonjs/platform/common.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/platform/devices.d.ts +42 -0
  135. package/lib/typescript/commonjs/platform/devices.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/platform/index.d.ts +6 -0
  137. package/lib/typescript/commonjs/platform/index.d.ts.map +1 -0
  138. package/lib/typescript/commonjs/platform/ios.d.ts +4 -0
  139. package/lib/typescript/commonjs/platform/ios.d.ts.map +1 -0
  140. package/lib/typescript/commonjs/platform/streams.d.ts +82 -0
  141. package/lib/typescript/commonjs/platform/streams.d.ts.map +1 -0
  142. package/lib/typescript/commonjs/platform/timers.d.ts +19 -0
  143. package/lib/typescript/commonjs/platform/timers.d.ts.map +1 -0
  144. package/lib/typescript/commonjs/platform/visibility.d.ts +11 -0
  145. package/lib/typescript/commonjs/platform/visibility.d.ts.map +1 -0
  146. package/lib/typescript/commonjs/preflight.d.ts +19 -0
  147. package/lib/typescript/commonjs/preflight.d.ts.map +1 -0
  148. package/lib/typescript/commonjs/provider.d.ts +35 -0
  149. package/lib/typescript/commonjs/provider.d.ts.map +1 -0
  150. package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts +157 -0
  151. package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts.map +1 -0
  152. package/lib/typescript/module/audio.d.ts +58 -0
  153. package/lib/typescript/module/audio.d.ts.map +1 -0
  154. package/lib/typescript/module/components.d.ts +58 -0
  155. package/lib/typescript/module/components.d.ts.map +1 -0
  156. package/lib/typescript/module/create-client.d.ts +35 -0
  157. package/lib/typescript/module/create-client.d.ts.map +1 -0
  158. package/lib/typescript/module/errors.d.ts +34 -0
  159. package/lib/typescript/module/errors.d.ts.map +1 -0
  160. package/lib/typescript/module/globals.d.ts +16 -0
  161. package/lib/typescript/module/globals.d.ts.map +1 -0
  162. package/lib/typescript/module/index.d.ts +24 -0
  163. package/lib/typescript/module/index.d.ts.map +1 -0
  164. package/lib/typescript/module/local-media.d.ts +86 -0
  165. package/lib/typescript/module/local-media.d.ts.map +1 -0
  166. package/lib/typescript/module/native-session.d.ts +38 -0
  167. package/lib/typescript/module/native-session.d.ts.map +1 -0
  168. package/lib/typescript/module/native.d.ts +18 -0
  169. package/lib/typescript/module/native.d.ts.map +1 -0
  170. package/lib/typescript/module/package.json +1 -0
  171. package/lib/typescript/module/permissions.d.ts +30 -0
  172. package/lib/typescript/module/permissions.d.ts.map +1 -0
  173. package/lib/typescript/module/platform/android.d.ts +4 -0
  174. package/lib/typescript/module/platform/android.d.ts.map +1 -0
  175. package/lib/typescript/module/platform/common.d.ts +101 -0
  176. package/lib/typescript/module/platform/common.d.ts.map +1 -0
  177. package/lib/typescript/module/platform/devices.d.ts +42 -0
  178. package/lib/typescript/module/platform/devices.d.ts.map +1 -0
  179. package/lib/typescript/module/platform/index.d.ts +6 -0
  180. package/lib/typescript/module/platform/index.d.ts.map +1 -0
  181. package/lib/typescript/module/platform/ios.d.ts +4 -0
  182. package/lib/typescript/module/platform/ios.d.ts.map +1 -0
  183. package/lib/typescript/module/platform/streams.d.ts +82 -0
  184. package/lib/typescript/module/platform/streams.d.ts.map +1 -0
  185. package/lib/typescript/module/platform/timers.d.ts +19 -0
  186. package/lib/typescript/module/platform/timers.d.ts.map +1 -0
  187. package/lib/typescript/module/platform/visibility.d.ts +11 -0
  188. package/lib/typescript/module/platform/visibility.d.ts.map +1 -0
  189. package/lib/typescript/module/preflight.d.ts +19 -0
  190. package/lib/typescript/module/preflight.d.ts.map +1 -0
  191. package/lib/typescript/module/provider.d.ts +35 -0
  192. package/lib/typescript/module/provider.d.ts.map +1 -0
  193. package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts +157 -0
  194. package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts.map +1 -0
  195. package/package.json +140 -4
  196. package/plugin/build/android.d.ts +36 -0
  197. package/plugin/build/android.js +265 -0
  198. package/plugin/build/index.d.ts +9 -0
  199. package/plugin/build/index.js +54 -0
  200. package/plugin/build/ios.d.ts +4 -0
  201. package/plugin/build/ios.js +43 -0
  202. package/plugin/build/options.d.ts +93 -0
  203. package/plugin/build/options.js +140 -0
  204. package/react-native.config.js +11 -0
  205. package/src/audio.ts +221 -0
  206. package/src/components.tsx +233 -0
  207. package/src/create-client.ts +53 -0
  208. package/src/errors.ts +81 -0
  209. package/src/globals.ts +54 -0
  210. package/src/index.ts +95 -0
  211. package/src/local-media.ts +420 -0
  212. package/src/native-session.tsx +384 -0
  213. package/src/native.ts +84 -0
  214. package/src/permissions.ts +173 -0
  215. package/src/platform/android.ts +128 -0
  216. package/src/platform/common.ts +157 -0
  217. package/src/platform/devices.ts +148 -0
  218. package/src/platform/index.ts +22 -0
  219. package/src/platform/ios.ts +48 -0
  220. package/src/platform/streams.ts +285 -0
  221. package/src/platform/timers.ts +72 -0
  222. package/src/platform/visibility.ts +59 -0
  223. package/src/preflight.ts +60 -0
  224. package/src/provider.tsx +88 -0
  225. package/src/specs/NativeVoltkitRtc.ts +180 -0
package/src/audio.ts ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Call audio on Android: the speaker or the earpiece, headsets and Bluetooth, through this package's audio router (a
3
+ * port of the Android SDK's AudioRouter, AudioSwitch's rules). Process-wide, like Android's AudioManager: every
4
+ * connected client holds the call audio (the provider does it), and the last to let go puts the device's audio mode,
5
+ * speakerphone and microphone mute back as it found them. setSpeakerphoneOn and selectAudioOutput work before any
6
+ * client exists: the choice is kept for the call.
7
+ *
8
+ * In the audio mode `media` (the Expo plugin's android.audioMode, for apps whose users only listen) sound follows the
9
+ * system's media routing: there is no earpiece and nothing to choose. On iOS (preview) there is no routing at all.
10
+ */
11
+ import { useMemo, useSyncExternalStore } from 'react';
12
+ import { Platform } from 'react-native';
13
+ import { RpcError, type MediaDevice, type StreamClient } from '@voltkit-rtc/client';
14
+ import { listen, nativeConstants, nativeModule, requireNative, toMediaDevice } from './native';
15
+ import { logError, nativeFailure } from './errors';
16
+
17
+ /** Whether this app chose the speaker itself (setSpeakerphoneOn): the provider then leaves that choice alone. */
18
+ let speakerChosen: boolean | null = null;
19
+
20
+ /**
21
+ * Play call audio on the loudspeaker (true) or the earpiece (false) when no headset is connected; a wired or Bluetooth
22
+ * headset still wins. Kept until changed, and applied to calls joined later. Rejects with `media-unavailable` on iOS
23
+ * and where the native module is missing.
24
+ */
25
+ export async function setSpeakerphoneOn(on: boolean): Promise<void> {
26
+ const module = requireNative('Choosing the speaker or the earpiece');
27
+ module.setSpeakerphoneOn(Boolean(on));
28
+ speakerChosen = Boolean(on);
29
+ outputs.preferenceChanged();
30
+ }
31
+
32
+ /**
33
+ * Play call audio on this output (a deviceId from useAudioOutput().outputs) while it stays connected. Rejects with
34
+ * `bad-params` for an output that is not there and in the audio mode `media`, and with `media-unavailable` on iOS and
35
+ * where the native module is missing.
36
+ */
37
+ export async function selectAudioOutput(deviceId: string): Promise<void> {
38
+ const module = requireNative('Choosing the audio output');
39
+ let routed: boolean;
40
+ try {
41
+ routed = await module.selectAudioOutput(deviceId);
42
+ } catch (err) {
43
+ throw nativeFailure(err);
44
+ }
45
+ if (!routed) {
46
+ throw new RpcError('bad-params', nativeConstants()?.audioMode === 'media'
47
+ ? 'in the audio mode media, sound follows the system\'s media routing: there is no output to choose'
48
+ : `there is no audio output with the id ${deviceId}`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The speaker preference a newly joined call holds the audio with: the provider's (default the loudspeaker), unless
54
+ * the app chose with setSpeakerphoneOn, which the router keeps (null).
55
+ */
56
+ export function speakerPreference(provider: boolean | undefined): boolean | null {
57
+ return speakerChosen !== null ? null : provider ?? true;
58
+ }
59
+
60
+ // ------------------------------------------------------------------------------------------- holding the call audio
61
+
62
+ /**
63
+ * One client's hold on the call audio: acquired once it is connected, released when it closes or the provider lets go
64
+ * of it. Released a moment late, so React's StrictMode (an effect unmounted and mounted again at once) neither
65
+ * releases nor acquires twice.
66
+ */
67
+ class AudioHold {
68
+ private holder: Promise<number | null> | null = null;
69
+ private users = 0;
70
+ private pendingRelease = false;
71
+
72
+ constructor(private readonly onError: (error: Error) => void) {}
73
+
74
+ retain(): void {
75
+ this.users += 1;
76
+ this.pendingRelease = false;
77
+ }
78
+
79
+ /** One user less; the hold goes when none is left. */
80
+ unretain(): void {
81
+ this.users = Math.max(0, this.users - 1);
82
+ if (this.users > 0) return;
83
+ this.pendingRelease = true;
84
+ queueMicrotask(() => {
85
+ if (this.pendingRelease && this.users === 0) this.release();
86
+ });
87
+ }
88
+
89
+ acquire(speakerphone: boolean | null): void {
90
+ if (this.holder) return;
91
+ const module = nativeModule();
92
+ if (!module) return;
93
+ this.holder = module.audioAcquire(speakerphone).then(
94
+ (id) => id,
95
+ (err: unknown) => {
96
+ this.onError(nativeFailure(err));
97
+ return null;
98
+ },
99
+ );
100
+ }
101
+
102
+ release(): void {
103
+ this.pendingRelease = false;
104
+ const holder = this.holder;
105
+ if (!holder) return;
106
+ this.holder = null;
107
+ void holder.then((id) => {
108
+ if (id !== null) nativeModule()?.audioRelease(id);
109
+ });
110
+ }
111
+ }
112
+
113
+ const holds = new WeakMap<StreamClient, AudioHold>();
114
+
115
+ /** The call audio hold of `client`. */
116
+ export function audioHoldOf(client: StreamClient, onError: (error: Error) => void): AudioHold {
117
+ let hold = holds.get(client);
118
+ if (!hold) {
119
+ hold = new AudioHold(onError);
120
+ holds.set(client, hold);
121
+ }
122
+ return hold;
123
+ }
124
+
125
+ // ------------------------------------------------------------------------------------------- useAudioOutput
126
+
127
+ export interface AudioOutput {
128
+ /** Outputs can be chosen here: Android with the native module, in the audio mode `communication`. */
129
+ supported: boolean;
130
+ /** Where call audio can play: speaker, earpiece, wired and USB headsets, Bluetooth. Empty where not supported. */
131
+ outputs: MediaDevice[];
132
+ /** Where call audio plays now, while a call holds the audio; null otherwise. */
133
+ current: MediaDevice | null;
134
+ /** The loudspeaker is in use (or, with no call holding the audio, preferred). */
135
+ speakerphone: boolean;
136
+ /** Play call audio on this output (selectAudioOutput). */
137
+ select(deviceId: string): Promise<void>;
138
+ /** The loudspeaker (true) or the earpiece (false), when no headset is connected (setSpeakerphoneOn). */
139
+ setSpeakerphoneOn(on: boolean): Promise<void>;
140
+ }
141
+
142
+ interface OutputState {
143
+ supported: boolean;
144
+ outputs: MediaDevice[];
145
+ current: MediaDevice | null;
146
+ speakerphone: boolean;
147
+ }
148
+
149
+ const NONE: OutputState = Object.freeze({ supported: false, outputs: Object.freeze([]) as unknown as MediaDevice[], current: null, speakerphone: false });
150
+
151
+ /** The audio outputs, kept current from the router's events while something listens. */
152
+ class OutputStore {
153
+ private state: OutputState = NONE;
154
+ private readonly listeners = new Set<() => void>();
155
+ private stop: (() => void) | null = null;
156
+
157
+ subscribe = (listener: () => void): (() => void) => {
158
+ this.listeners.add(listener);
159
+ if (this.listeners.size === 1) this.start();
160
+ return () => {
161
+ this.listeners.delete(listener);
162
+ if (this.listeners.size === 0) {
163
+ this.stop?.();
164
+ this.stop = null;
165
+ }
166
+ };
167
+ };
168
+
169
+ snapshot = (): OutputState => this.state;
170
+
171
+ /** The app changed the speaker preference: what is shown before a call holds the audio follows it. */
172
+ preferenceChanged(): void {
173
+ if (this.listeners.size > 0) this.set({ ...this.state, speakerphone: this.state.current ? this.state.current.type === 'speaker' : speakerChosen ?? true });
174
+ }
175
+
176
+ private start(): void {
177
+ const module = nativeModule();
178
+ const supported = Platform.OS === 'android' && module !== null && nativeConstants()?.audioMode !== 'media';
179
+ if (!module) {
180
+ this.set(NONE);
181
+ return;
182
+ }
183
+ this.set({ ...this.state, supported, speakerphone: this.state.current ? this.state.current.type === 'speaker' : speakerChosen ?? true });
184
+ const stops = [
185
+ listen(module.onAudioDevicesChanged, 'onAudioDevicesChanged', (e) => this.setOutputs(e.devices.map(toMediaDevice))),
186
+ listen(module.onAudioOutputChanged, 'onAudioOutputChanged', (e) => this.setCurrent(e.device ? toMediaDevice(e.device) : null)),
187
+ ];
188
+ this.stop = () => {
189
+ for (const stop of stops) stop();
190
+ };
191
+ module.listAudioDevices().then((list) => this.setOutputs(list.map(toMediaDevice)), (err: unknown) => logError('could not list the audio devices', err));
192
+ module.currentAudioOutput().then((device) => this.setCurrent(device ? toMediaDevice(device) : null), (err: unknown) => logError('could not read the audio output', err));
193
+ }
194
+
195
+ private setOutputs(devices: MediaDevice[]): void {
196
+ const outputs = this.state.supported ? devices.filter((d) => d.kind === 'audiooutput') : [];
197
+ const same = outputs.length === this.state.outputs.length && outputs.every((d, i) => d.deviceId === this.state.outputs[i].deviceId && d.label === this.state.outputs[i].label);
198
+ if (!same) this.set({ ...this.state, outputs });
199
+ }
200
+
201
+ private setCurrent(device: MediaDevice | null): void {
202
+ if (device?.deviceId === this.state.current?.deviceId) return;
203
+ this.set({ ...this.state, current: device, speakerphone: device ? device.type === 'speaker' : speakerChosen ?? true });
204
+ }
205
+
206
+ private set(next: OutputState): void {
207
+ this.state = next;
208
+ for (const listener of [...this.listeners]) listener();
209
+ }
210
+ }
211
+
212
+ const outputs = new OutputStore();
213
+
214
+ /**
215
+ * Where call audio plays, and the choice of output (an Android "speaker / phone / headset" picker). Works before any
216
+ * call: a choice made then is kept. On iOS and without the native module `supported` is false and the calls reject.
217
+ */
218
+ export function useAudioOutput(): AudioOutput {
219
+ const state = useSyncExternalStore(outputs.subscribe, outputs.snapshot, outputs.snapshot);
220
+ return useMemo(() => ({ ...state, select: selectAudioOutput, setSpeakerphoneOn }), [state]);
221
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * VideoTrack and AudioTrack, React Native's versions of @voltkit-rtc/react's.
3
+ *
4
+ * VideoTrack shows a track in react-native-webrtc's RTCView, which plays a stream by URL. Its stream is a wrapper made
5
+ * through platform/streams.ts, one per track and shared by every view of it, released (never the track) when the last
6
+ * view goes -- or earlier, while the connection still exists, when the client is about to close what receives the
7
+ * track or stops it: releasing a wrapper after its track died kills the app (Phase 0 finding 1). The URL is set one
8
+ * frame after mounting, and the native view is mounted again once if no frame size arrives within 3 s
9
+ * (react-native-webrtc #1611, black video).
10
+ *
11
+ * AudioTrack renders nothing: React Native plays every remote audio track by itself. It sets one track's volume
12
+ * (react-native-webrtc's `_setVolume`, unity at 1) or mutes it here only.
13
+ */
14
+ import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react';
15
+ import { StyleSheet, View, type LayoutChangeEvent, type StyleProp, type ViewStyle } from 'react-native';
16
+ import type { StreamClient, ViewBox, ViewSource } from '@voltkit-rtc/client';
17
+ import { useVoltkitClient } from '@voltkit-rtc/react';
18
+ import { loadWebRTC } from './globals';
19
+ import { logError } from './errors';
20
+ import { streamBook } from './platform/streams';
21
+ import type { MediaStreamTrackLike } from './platform/common';
22
+
23
+ /** How long a view waits for its first frame before its native view is mounted again (react-native-webrtc #1611). */
24
+ export const FIRST_FRAME_TIMEOUT_MS = 3000;
25
+
26
+ export interface VideoTrackProps {
27
+ /**
28
+ * The video to show: a remote track's `track`, or your camera (`useLocalMedia().stream?.getVideoTracks()[0]`). Nothing
29
+ * while null. Typed by shape (`MediaStreamTrackLike`), so the app needs no DOM library for it.
30
+ */
31
+ track: MediaStreamTrackLike | null | undefined;
32
+ /**
33
+ * The remote video's `producerId`: it is then received at the size this view is shown and paused at the server while
34
+ * the view is not `visible` (adaptive stream). Leave it out for your own camera.
35
+ */
36
+ producerId?: string;
37
+ /** Flip it horizontally, as people expect to see their own front camera. */
38
+ mirror?: boolean;
39
+ /** `cover` (the default: fill the view, cropping) or `contain` (all of the picture, with bars). */
40
+ objectFit?: 'cover' | 'contain';
41
+ /** Which of two overlapping videos is on top (RTCView's zOrder): 0 for a full-screen video, 1 for a preview over it. */
42
+ zOrder?: number;
43
+ /** Whether the view can be seen now (a list scrolled past it, a hidden tab): with `producerId`, the video pauses at the server while false. */
44
+ visible?: boolean;
45
+ /** The video's size in pixels: its first frame arrived, or the size changed. */
46
+ onDimensionsChange?: (size: { width: number; height: number }) => void;
47
+ style?: StyleProp<ViewStyle>;
48
+ testID?: string;
49
+ }
50
+
51
+ type Report = (box: ViewBox | null, visible: boolean | null) => void;
52
+
53
+ /** A video track in an RTCView, sized by `style`. */
54
+ export function VideoTrack({
55
+ track,
56
+ producerId,
57
+ mirror = false,
58
+ objectFit = 'cover',
59
+ zOrder,
60
+ visible = true,
61
+ onDimensionsChange,
62
+ style,
63
+ testID,
64
+ }: VideoTrackProps): ReactElement {
65
+ const client = useVoltkitClient();
66
+ const [url, setUrl] = useState<string | null>(null);
67
+ // The RTCView's key: changed once to mount it again when no frame came.
68
+ const [mount, setMount] = useState(0);
69
+ const sawFrame = useRef(false);
70
+ const dimensions = useRef(onDimensionsChange);
71
+ useEffect(() => {
72
+ dimensions.current = onDimensionsChange;
73
+ });
74
+
75
+ // The wrapper stream, held while this view shows the track; its URL one frame after mounting.
76
+ useEffect(() => {
77
+ setUrl(null);
78
+ if (!track) return;
79
+ const view = streamBook().view(track);
80
+ if (!view) return;
81
+ let frame: number | null = requestAnimationFrame(() => {
82
+ frame = null;
83
+ setUrl(view.url);
84
+ });
85
+ const stopEnd = view.onEnd(() => {
86
+ if (frame !== null) cancelAnimationFrame(frame);
87
+ frame = null;
88
+ setUrl(null);
89
+ });
90
+ return () => {
91
+ if (frame !== null) cancelAnimationFrame(frame);
92
+ stopEnd();
93
+ view.release();
94
+ };
95
+ }, [track]);
96
+
97
+ // react-native-webrtc #1611: a view that never shows a frame is mounted again, once per stream.
98
+ useEffect(() => {
99
+ sawFrame.current = false;
100
+ if (!url) return;
101
+ const timer = setTimeout(() => {
102
+ if (!sawFrame.current) setMount((n) => n + 1);
103
+ }, FIRST_FRAME_TIMEOUT_MS);
104
+ return () => clearTimeout(timer);
105
+ }, [url]);
106
+
107
+ // Adaptive stream: the view reports its size (onLayout) and visibility (`visible`) through a ViewSource.
108
+ const box = useRef<ViewBox | null>(null);
109
+ const shown = useRef(visible);
110
+ const reports = useRef(new Set<Report>());
111
+ const tell = (nextBox: ViewBox | null, nextVisible: boolean | null) => {
112
+ for (const report of [...reports.current]) {
113
+ try {
114
+ report(nextBox, nextVisible);
115
+ } catch (err) {
116
+ logError('listener for videoView threw', err);
117
+ }
118
+ }
119
+ };
120
+
121
+ useEffect(() => {
122
+ if (shown.current === visible) return;
123
+ shown.current = visible;
124
+ tell(null, visible);
125
+ }, [visible]);
126
+
127
+ useEffect(() => {
128
+ if (!box.current || box.current.fit === objectFit) return;
129
+ box.current = { ...box.current, fit: objectFit };
130
+ tell(box.current, null);
131
+ }, [objectFit]);
132
+
133
+ useEffect(() => {
134
+ const attach = (client as (StreamClient & { attachVideo?: StreamClient['attachVideo'] }) | null)?.attachVideo;
135
+ if (!client || !producerId || typeof attach !== 'function') return;
136
+ const source: ViewSource = {
137
+ subscribe(onChange) {
138
+ reports.current.add(onChange);
139
+ if (box.current || !shown.current) onChange(box.current, shown.current);
140
+ return () => {
141
+ reports.current.delete(onChange);
142
+ };
143
+ },
144
+ };
145
+ return attach.call(client, producerId, source);
146
+ }, [client, producerId]);
147
+
148
+ const onLayout = useCallback((event: LayoutChangeEvent) => {
149
+ const { width, height } = event.nativeEvent.layout;
150
+ const previous = box.current;
151
+ if (previous && previous.width === width && previous.height === height && previous.fit === objectFit) return;
152
+ box.current = { width, height, fit: objectFit };
153
+ tell(box.current, null);
154
+ }, [objectFit]);
155
+
156
+ const onFrameSize = useCallback((event: { nativeEvent: { width: number; height: number } }) => {
157
+ sawFrame.current = true;
158
+ const { width, height } = event.nativeEvent;
159
+ try {
160
+ dimensions.current?.({ width, height });
161
+ } catch (err) {
162
+ logError('listener for onDimensionsChange threw', err);
163
+ }
164
+ }, []);
165
+
166
+ const RTCView = loadWebRTC()?.RTCView;
167
+ return (
168
+ <View style={style} onLayout={onLayout} testID={testID} collapsable={false}>
169
+ {RTCView && url ? (
170
+ <RTCView
171
+ key={mount}
172
+ streamURL={url}
173
+ mirror={mirror}
174
+ objectFit={objectFit}
175
+ zOrder={zOrder}
176
+ onDimensionsChange={onFrameSize}
177
+ style={StyleSheet.absoluteFill}
178
+ />
179
+ ) : null}
180
+ </View>
181
+ );
182
+ }
183
+
184
+ export interface AudioTrackProps {
185
+ /** The audio to set: a remote track's `track`. Nothing while null. Typed by shape (`MediaStreamTrackLike`). */
186
+ track: MediaStreamTrackLike | null | undefined;
187
+ /** 0 to 1, clamped (1 is the track as received). Unset leaves it as it is. This track only. */
188
+ volume?: number;
189
+ /** Silence it here only (the track is switched off while muted, and back on after). */
190
+ muted?: boolean;
191
+ }
192
+
193
+ interface AudioLike {
194
+ readonly kind: string;
195
+ readonly readyState: string;
196
+ enabled: boolean;
197
+ _setVolume?(volume: number): void;
198
+ }
199
+
200
+ /** One remote audio track's volume, or muted, while mounted. Renders nothing: React Native plays remote audio itself. */
201
+ export function AudioTrack({ track, volume, muted = false }: AudioTrackProps): null {
202
+ useEffect(() => {
203
+ const audio = track as unknown as AudioLike | null | undefined;
204
+ if (!audio || audio.kind !== 'audio' || typeof audio._setVolume !== 'function') return;
205
+ if (typeof volume !== 'number' || Number.isNaN(volume)) return;
206
+ const gain = Math.min(1, Math.max(0, volume));
207
+ try {
208
+ audio._setVolume(gain);
209
+ } catch (err) {
210
+ logError('could not set a track\'s volume', err);
211
+ }
212
+ return () => {
213
+ if (audio.readyState === 'ended') return;
214
+ try {
215
+ audio._setVolume?.(1);
216
+ } catch (err) {
217
+ logError('could not set a track\'s volume', err);
218
+ }
219
+ };
220
+ }, [track, volume]);
221
+
222
+ useEffect(() => {
223
+ const audio = track as unknown as AudioLike | null | undefined;
224
+ if (!audio || !muted) return;
225
+ const was = audio.enabled;
226
+ audio.enabled = false;
227
+ return () => {
228
+ if (audio.readyState !== 'ended') audio.enabled = was;
229
+ };
230
+ }, [track, muted]);
231
+
232
+ return null;
233
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A StreamClient for React Native: react-native-webrtc's mediasoup handler (ReactNative106) and this package's
3
+ * platform, with the background pause on for the camera. The provider makes its clients this way; an app that makes and
4
+ * owns its client (the provider's `client` prop, a PK battle's second client) uses createClient.
5
+ */
6
+ import { StreamClient, type ClientOptions } from '@voltkit-rtc/client';
7
+ import { registerVoltkitGlobals } from './globals';
8
+ import { webrtcMissing } from './errors';
9
+ import { reactNativePlatform } from './platform';
10
+
11
+ /** What happens to the camera and microphone while the app is in the background. */
12
+ export interface BackgroundOptions {
13
+ /**
14
+ * Switch the camera off while the app is in the background or the screen is off, and back on when it returns (only
15
+ * if this switched it off): viewers see the camera off rather than a frozen picture. Android stops a background
16
+ * app's camera anyway. Default true.
17
+ */
18
+ pauseCamera?: boolean;
19
+ /** The same for the microphone. Leave it off for calls, which go on talking in the background. Default false. */
20
+ pauseMic?: boolean;
21
+ }
22
+
23
+ export interface CreateClientOptions {
24
+ /** The background pause: camera on by default, microphone off. */
25
+ background?: BackgroundOptions;
26
+ /** StreamClient options of your own; they win over the ones set here. */
27
+ clientOptions?: ClientOptions;
28
+ }
29
+
30
+ /**
31
+ * The ClientOptions a React Native client is made with: `{ handlerName: 'ReactNative106', platform,
32
+ * pauseCameraInBackground, pauseMicInBackground, ...clientOptions }`.
33
+ */
34
+ export function reactNativeClientOptions(options: CreateClientOptions = {}): ClientOptions {
35
+ const { background, clientOptions } = options;
36
+ return {
37
+ handlerName: 'ReactNative106',
38
+ platform: reactNativePlatform(),
39
+ pauseCameraInBackground: background?.pauseCamera ?? true,
40
+ pauseMicInBackground: background?.pauseMic ?? false,
41
+ ...clientOptions,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * A StreamClient for React Native, not yet connected: call `connect()`. Throws `media-unavailable` with
47
+ * `{ reason: 'webrtc-missing' }` where react-native-webrtc's native module is missing (Expo Go: it needs a development
48
+ * build).
49
+ */
50
+ export function createClient(wsUrl: string, token: string, options: CreateClientOptions = {}): StreamClient {
51
+ if (!registerVoltkitGlobals()) throw webrtcMissing();
52
+ return new StreamClient(wsUrl, token, reactNativeClientOptions(options));
53
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The errors this package raises. Every one is an RpcError with a code from the VoltKit error registry
3
+ * (@voltkit-rtc/client ERROR_CODES, checked code for code against every SDK by scripts/test/sdk-contract-sdks.mjs), so
4
+ * an app switches on `error.code` exactly as it does in a browser or the Android SDK. What is missing goes in `data`:
5
+ *
6
+ * - `media-unavailable` `{ reason: 'webrtc-missing' }`: react-native-webrtc's native module is not in the app (Expo Go,
7
+ * or an app not rebuilt after installing it). The message says it needs a development build.
8
+ * - `media-unavailable` `{ reason: 'native-missing', feature }`: this package's own native module is not in the app.
9
+ * - `media-unavailable` `{ platform: 'ios', feature }`: not in the iOS preview (audio routing: setSpeakerphoneOn,
10
+ * selectAudioOutput, switchDevice('audiooutput')). The call service and screen sharing do not raise it there: the
11
+ * service is simply not declared (useCallService), and the client answers `screen-share-unsupported`.
12
+ * - `media-unavailable` `{ permission: 'camera' | 'microphone', status: 'denied' | 'blocked' }`: a refused permission,
13
+ * the field names of the Android SDK.
14
+ * - `media-unavailable` `{ feature: 'callService' }`: the call service could not start or keep running.
15
+ */
16
+ import { RpcError } from '@voltkit-rtc/client';
17
+
18
+ export type MediaPermissionKind = 'camera' | 'microphone';
19
+
20
+ /** react-native-webrtc's native module is missing: Expo Go, or the app was not rebuilt. */
21
+ export function webrtcMissing(): RpcError {
22
+ return new RpcError(
23
+ 'media-unavailable',
24
+ "react-native-webrtc's native module is not in this app: @voltkit-rtc/react-native needs a development build (Expo Go cannot load it). Install react-native-webrtc and rebuild the app (npx expo run:android, or an EAS development build).",
25
+ { reason: 'webrtc-missing' },
26
+ );
27
+ }
28
+
29
+ /** This package's own native module is missing: the app was not rebuilt after installing it. */
30
+ export function nativeMissing(feature: string): RpcError {
31
+ return new RpcError(
32
+ 'media-unavailable',
33
+ `${feature} needs @voltkit-rtc/react-native's native module, which is not in this app: it needs a development build. Rebuild the app after installing the package (Expo Go cannot load it).`,
34
+ { reason: 'native-missing', feature },
35
+ );
36
+ }
37
+
38
+ /** Not in the iOS preview of 0.1.0. */
39
+ export function iosUnsupported(feature: string): RpcError {
40
+ return new RpcError('media-unavailable', `${feature} is not available in the iOS preview of @voltkit-rtc/react-native`, { platform: 'ios', feature });
41
+ }
42
+
43
+ /** A camera or microphone permission the user refused: `blocked` when Android will not ask again. */
44
+ export function permissionRefused(permission: MediaPermissionKind, status: 'denied' | 'blocked'): RpcError {
45
+ const what = permission === 'camera' ? 'camera' : 'microphone';
46
+ const why = status === 'blocked'
47
+ ? `the ${what} permission is blocked: the user has to allow it in the app's settings`
48
+ : `the ${what} permission was not granted`;
49
+ return new RpcError('media-unavailable', why, { permission, status });
50
+ }
51
+
52
+ /** The call service could not start, or Android stopped it. */
53
+ export function callServiceFailed(message: string): RpcError {
54
+ return new RpcError('media-unavailable', `the call service: ${message}`, { feature: 'callService' });
55
+ }
56
+
57
+ /** A failure a native module or react-native-webrtc reported, as an RpcError with a registry code. */
58
+ export function nativeFailure(error: unknown, code: 'media-unavailable' | 'camera-failed' | 'screen-share-failed' = 'media-unavailable', data?: Record<string, unknown>): RpcError {
59
+ if (error instanceof RpcError) return error;
60
+ return new RpcError(code, describeError(error), data);
61
+ }
62
+
63
+ /** A readable message from anything thrown: an Error, react-native-webrtc's MediaStreamError (not an Error), a string. */
64
+ export function describeError(error: unknown): string {
65
+ if (typeof error === 'string') return error;
66
+ if (error && typeof error === 'object') {
67
+ const { name, message } = error as { name?: unknown; message?: unknown };
68
+ const text = typeof message === 'string' && message.length > 0 ? message : '';
69
+ const label = typeof name === 'string' && name.length > 0 && name !== 'Error' ? name : '';
70
+ if (label && text && !text.includes(label)) return `${label}: ${text}`;
71
+ if (text) return text;
72
+ if (label) return label;
73
+ }
74
+ return String(error);
75
+ }
76
+
77
+ /** Report a failure from code no caller awaits: the error listener's own errors never escape into the app. */
78
+ export function logError(context: string, error: unknown): void {
79
+ // eslint-disable-next-line no-console
80
+ console.error(`[voltkit-rtc] ${context}`, error);
81
+ }
package/src/globals.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * react-native-webrtc, loaded only once its native module is known to be in the app. Its entry module talks to
3
+ * WebRTCModule as soon as it runs, so in Expo Go (or an app not rebuilt after installing it) merely importing it can
4
+ * throw or leave the app in a broken state. This package never imports it at the top level: everything goes through
5
+ * `loadWebRTC()`, which answers null instead, and `registerVoltkitGlobals()` (run when the package is imported) is safe
6
+ * anywhere.
7
+ */
8
+ import { NativeModules } from 'react-native';
9
+ import type * as WebRTC from 'react-native-webrtc';
10
+
11
+ export type WebRTCModule = typeof WebRTC;
12
+
13
+ let loaded: WebRTCModule | null = null;
14
+ let registered = false;
15
+
16
+ /** Whether react-native-webrtc's native module is in this app. */
17
+ export function webrtcAvailable(): boolean {
18
+ return (NativeModules as Record<string, unknown>).WebRTCModule != null;
19
+ }
20
+
21
+ /** react-native-webrtc, or null when its native module is not in the app (Expo Go, an app not rebuilt). */
22
+ export function loadWebRTC(): WebRTCModule | null {
23
+ if (loaded) return loaded;
24
+ if (!webrtcAvailable()) return null;
25
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
26
+ loaded = require('react-native-webrtc') as WebRTCModule;
27
+ return loaded;
28
+ }
29
+
30
+ /**
31
+ * Install react-native-webrtc's WebRTC classes as globals (RTCPeerConnection, MediaStream, ...), which mediasoup-client
32
+ * needs. Runs when the package is imported; calling it again does nothing. Returns false, without throwing, where
33
+ * react-native-webrtc's native module is missing: the provider then reports `media-unavailable` with
34
+ * `{ reason: 'webrtc-missing' }`.
35
+ */
36
+ export function registerVoltkitGlobals(): boolean {
37
+ if (registered) return true;
38
+ const webrtc = loadWebRTC();
39
+ if (!webrtc) return false;
40
+ webrtc.registerGlobals();
41
+ registered = true;
42
+ return true;
43
+ }
44
+
45
+ /** The version of react-native-webrtc in the app, for getDiagnostics(); null when it cannot be read. */
46
+ export function webrtcVersion(): string | null {
47
+ try {
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
49
+ const pkg = require('react-native-webrtc/package.json') as { version?: unknown };
50
+ return typeof pkg.version === 'string' ? pkg.version : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }