@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.
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/README.md +1608 -2
- package/android/build.gradle +70 -0
- package/android/consumer-rules.pro +21 -0
- package/android/src/main/AndroidManifest.xml +25 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/AppVisibility.kt +67 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/AudioDevices.kt +67 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/AudioRouter.kt +487 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/CallServiceTypes.kt +120 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/NativeTimers.kt +106 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/NetworkWatch.kt +120 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/OutputChoice.kt +65 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/RtcConfig.kt +83 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/ScoLink.kt +61 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitCallService.kt +287 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcInitProvider.kt +215 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcModule.kt +313 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcPackage.kt +30 -0
- package/android/src/main/res/drawable/voltkit_rtc_default_call_icon.xml +15 -0
- package/android/src/main/res/raw/voltkit_rtc_keep.xml +7 -0
- package/app.plugin.js +17 -0
- package/lib/commonjs/audio.js +215 -0
- package/lib/commonjs/audio.js.map +1 -0
- package/lib/commonjs/components.js +207 -0
- package/lib/commonjs/components.js.map +1 -0
- package/lib/commonjs/create-client.js +47 -0
- package/lib/commonjs/create-client.js.map +1 -0
- package/lib/commonjs/errors.js +99 -0
- package/lib/commonjs/errors.js.map +1 -0
- package/lib/commonjs/globals.js +61 -0
- package/lib/commonjs/globals.js.map +1 -0
- package/lib/commonjs/index.js +234 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/local-media.js +392 -0
- package/lib/commonjs/local-media.js.map +1 -0
- package/lib/commonjs/native-session.js +365 -0
- package/lib/commonjs/native-session.js.map +1 -0
- package/lib/commonjs/native.js +99 -0
- package/lib/commonjs/native.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/permissions.js +166 -0
- package/lib/commonjs/permissions.js.map +1 -0
- package/lib/commonjs/platform/android.js +126 -0
- package/lib/commonjs/platform/android.js.map +1 -0
- package/lib/commonjs/platform/common.js +138 -0
- package/lib/commonjs/platform/common.js.map +1 -0
- package/lib/commonjs/platform/devices.js +151 -0
- package/lib/commonjs/platform/devices.js.map +1 -0
- package/lib/commonjs/platform/index.js +35 -0
- package/lib/commonjs/platform/index.js.map +1 -0
- package/lib/commonjs/platform/ios.js +54 -0
- package/lib/commonjs/platform/ios.js.map +1 -0
- package/lib/commonjs/platform/streams.js +273 -0
- package/lib/commonjs/platform/streams.js.map +1 -0
- package/lib/commonjs/platform/timers.js +71 -0
- package/lib/commonjs/platform/timers.js.map +1 -0
- package/lib/commonjs/platform/visibility.js +60 -0
- package/lib/commonjs/platform/visibility.js.map +1 -0
- package/lib/commonjs/preflight.js +77 -0
- package/lib/commonjs/preflight.js.map +1 -0
- package/lib/commonjs/provider.js +75 -0
- package/lib/commonjs/provider.js.map +1 -0
- package/lib/commonjs/specs/NativeVoltkitRtc.js +29 -0
- package/lib/commonjs/specs/NativeVoltkitRtc.js.map +1 -0
- package/lib/module/audio.js +207 -0
- package/lib/module/audio.js.map +1 -0
- package/lib/module/components.js +200 -0
- package/lib/module/components.js.map +1 -0
- package/lib/module/create-client.js +42 -0
- package/lib/module/create-client.js.map +1 -0
- package/lib/module/errors.js +87 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/globals.js +53 -0
- package/lib/module/globals.js.map +1 -0
- package/lib/module/index.js +33 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/local-media.js +387 -0
- package/lib/module/local-media.js.map +1 -0
- package/lib/module/native-session.js +360 -0
- package/lib/module/native-session.js.map +1 -0
- package/lib/module/native.js +87 -0
- package/lib/module/native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/permissions.js +160 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/platform/android.js +122 -0
- package/lib/module/platform/android.js.map +1 -0
- package/lib/module/platform/common.js +128 -0
- package/lib/module/platform/common.js.map +1 -0
- package/lib/module/platform/devices.js +142 -0
- package/lib/module/platform/devices.js.map +1 -0
- package/lib/module/platform/index.js +24 -0
- package/lib/module/platform/index.js.map +1 -0
- package/lib/module/platform/ios.js +49 -0
- package/lib/module/platform/ios.js.map +1 -0
- package/lib/module/platform/streams.js +268 -0
- package/lib/module/platform/streams.js.map +1 -0
- package/lib/module/platform/timers.js +67 -0
- package/lib/module/platform/timers.js.map +1 -0
- package/lib/module/platform/visibility.js +54 -0
- package/lib/module/platform/visibility.js.map +1 -0
- package/lib/module/preflight.js +72 -0
- package/lib/module/preflight.js.map +1 -0
- package/lib/module/provider.js +70 -0
- package/lib/module/provider.js.map +1 -0
- package/lib/module/specs/NativeVoltkitRtc.js +37 -0
- package/lib/module/specs/NativeVoltkitRtc.js.map +1 -0
- package/lib/typescript/commonjs/audio.d.ts +58 -0
- package/lib/typescript/commonjs/audio.d.ts.map +1 -0
- package/lib/typescript/commonjs/components.d.ts +58 -0
- package/lib/typescript/commonjs/components.d.ts.map +1 -0
- package/lib/typescript/commonjs/create-client.d.ts +35 -0
- package/lib/typescript/commonjs/create-client.d.ts.map +1 -0
- package/lib/typescript/commonjs/errors.d.ts +34 -0
- package/lib/typescript/commonjs/errors.d.ts.map +1 -0
- package/lib/typescript/commonjs/globals.d.ts +16 -0
- package/lib/typescript/commonjs/globals.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +24 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/local-media.d.ts +86 -0
- package/lib/typescript/commonjs/local-media.d.ts.map +1 -0
- package/lib/typescript/commonjs/native-session.d.ts +38 -0
- package/lib/typescript/commonjs/native-session.d.ts.map +1 -0
- package/lib/typescript/commonjs/native.d.ts +18 -0
- package/lib/typescript/commonjs/native.d.ts.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/permissions.d.ts +30 -0
- package/lib/typescript/commonjs/permissions.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/android.d.ts +4 -0
- package/lib/typescript/commonjs/platform/android.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/common.d.ts +101 -0
- package/lib/typescript/commonjs/platform/common.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/devices.d.ts +42 -0
- package/lib/typescript/commonjs/platform/devices.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/index.d.ts +6 -0
- package/lib/typescript/commonjs/platform/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/ios.d.ts +4 -0
- package/lib/typescript/commonjs/platform/ios.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/streams.d.ts +82 -0
- package/lib/typescript/commonjs/platform/streams.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/timers.d.ts +19 -0
- package/lib/typescript/commonjs/platform/timers.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/visibility.d.ts +11 -0
- package/lib/typescript/commonjs/platform/visibility.d.ts.map +1 -0
- package/lib/typescript/commonjs/preflight.d.ts +19 -0
- package/lib/typescript/commonjs/preflight.d.ts.map +1 -0
- package/lib/typescript/commonjs/provider.d.ts +35 -0
- package/lib/typescript/commonjs/provider.d.ts.map +1 -0
- package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts +157 -0
- package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts.map +1 -0
- package/lib/typescript/module/audio.d.ts +58 -0
- package/lib/typescript/module/audio.d.ts.map +1 -0
- package/lib/typescript/module/components.d.ts +58 -0
- package/lib/typescript/module/components.d.ts.map +1 -0
- package/lib/typescript/module/create-client.d.ts +35 -0
- package/lib/typescript/module/create-client.d.ts.map +1 -0
- package/lib/typescript/module/errors.d.ts +34 -0
- package/lib/typescript/module/errors.d.ts.map +1 -0
- package/lib/typescript/module/globals.d.ts +16 -0
- package/lib/typescript/module/globals.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +24 -0
- package/lib/typescript/module/index.d.ts.map +1 -0
- package/lib/typescript/module/local-media.d.ts +86 -0
- package/lib/typescript/module/local-media.d.ts.map +1 -0
- package/lib/typescript/module/native-session.d.ts +38 -0
- package/lib/typescript/module/native-session.d.ts.map +1 -0
- package/lib/typescript/module/native.d.ts +18 -0
- package/lib/typescript/module/native.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/permissions.d.ts +30 -0
- package/lib/typescript/module/permissions.d.ts.map +1 -0
- package/lib/typescript/module/platform/android.d.ts +4 -0
- package/lib/typescript/module/platform/android.d.ts.map +1 -0
- package/lib/typescript/module/platform/common.d.ts +101 -0
- package/lib/typescript/module/platform/common.d.ts.map +1 -0
- package/lib/typescript/module/platform/devices.d.ts +42 -0
- package/lib/typescript/module/platform/devices.d.ts.map +1 -0
- package/lib/typescript/module/platform/index.d.ts +6 -0
- package/lib/typescript/module/platform/index.d.ts.map +1 -0
- package/lib/typescript/module/platform/ios.d.ts +4 -0
- package/lib/typescript/module/platform/ios.d.ts.map +1 -0
- package/lib/typescript/module/platform/streams.d.ts +82 -0
- package/lib/typescript/module/platform/streams.d.ts.map +1 -0
- package/lib/typescript/module/platform/timers.d.ts +19 -0
- package/lib/typescript/module/platform/timers.d.ts.map +1 -0
- package/lib/typescript/module/platform/visibility.d.ts +11 -0
- package/lib/typescript/module/platform/visibility.d.ts.map +1 -0
- package/lib/typescript/module/preflight.d.ts +19 -0
- package/lib/typescript/module/preflight.d.ts.map +1 -0
- package/lib/typescript/module/provider.d.ts +35 -0
- package/lib/typescript/module/provider.d.ts.map +1 -0
- package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts +157 -0
- package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts.map +1 -0
- package/package.json +140 -4
- package/plugin/build/android.d.ts +36 -0
- package/plugin/build/android.js +265 -0
- package/plugin/build/index.d.ts +9 -0
- package/plugin/build/index.js +54 -0
- package/plugin/build/ios.d.ts +4 -0
- package/plugin/build/ios.js +43 -0
- package/plugin/build/options.d.ts +93 -0
- package/plugin/build/options.js +140 -0
- package/react-native.config.js +11 -0
- package/src/audio.ts +221 -0
- package/src/components.tsx +233 -0
- package/src/create-client.ts +53 -0
- package/src/errors.ts +81 -0
- package/src/globals.ts +54 -0
- package/src/index.ts +95 -0
- package/src/local-media.ts +420 -0
- package/src/native-session.tsx +384 -0
- package/src/native.ts +84 -0
- package/src/permissions.ts +173 -0
- package/src/platform/android.ts +128 -0
- package/src/platform/common.ts +157 -0
- package/src/platform/devices.ts +148 -0
- package/src/platform/index.ts +22 -0
- package/src/platform/ios.ts +48 -0
- package/src/platform/streams.ts +285 -0
- package/src/platform/timers.ts +72 -0
- package/src/platform/visibility.ts +59 -0
- package/src/preflight.ts +60 -0
- package/src/provider.tsx +88 -0
- package/src/specs/NativeVoltkitRtc.ts +180 -0
package/README.md
CHANGED
|
@@ -1,3 +1,1609 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @voltkit-rtc/react-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React Native SDK for [VoltKit RTC](https://voltkit.in/rtc): video and voice
|
|
4
|
+
calls, audio rooms and chat in a React Native app, on Android, with iOS in
|
|
5
|
+
preview. It has the hooks of [`@voltkit-rtc/react`](https://voltkit.in/docs/react-sdk),
|
|
6
|
+
so a React call screen ports by changing the import and its markup, and runs
|
|
7
|
+
the browser SDK [`@voltkit-rtc/client`](https://voltkit.in/docs/client-sdk) on
|
|
8
|
+
react-native-webrtc, with what a phone adds: camera and microphone permissions
|
|
9
|
+
asked before a device opens, audio routing to the earpiece, the loudspeaker and
|
|
10
|
+
headsets, a call service that keeps a call going in the background, native
|
|
11
|
+
timers so a reconnect still happens with the screen off, video views, and an
|
|
12
|
+
Expo config plugin.
|
|
13
|
+
|
|
14
|
+
**Tested on Android (emulator, API 37.1); iOS in preview; earpiece and
|
|
15
|
+
Bluetooth routing tested in unit tests, not on hardware.** See
|
|
16
|
+
[Testing status](#testing-status).
|
|
17
|
+
|
|
18
|
+
| | |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| **Provider** | `VoltkitProvider` |
|
|
21
|
+
| **Hooks** | `useRoom` `useConnectionState` `useParticipants` `useRemoteTracks` `useLocalMedia` `useNetworkQuality` `useWalletLow` `useVoltkitEvent` `useVoltkitClient` |
|
|
22
|
+
| **Media hooks** | `useAudioLevels` `useActiveSpeakers` `useIsSpeaking` `useStreamFallback` `usePublishFallback` `useMediaWarnings` `useCpuConstrained` `useDevices` |
|
|
23
|
+
| **Audio, permissions and the call service** | `useAudioOutput` `setSpeakerphoneOn` `selectAudioOutput` `usePermissions` `requestMediaPermissions` `checkMediaPermissions` `useCallService` |
|
|
24
|
+
| **Components** | `VideoTrack` `AudioTrack` |
|
|
25
|
+
| **Functions** | `createClient` `reactNativeClientOptions` `reactNativePlatform` `preflight` `registerVoltkitGlobals` |
|
|
26
|
+
| **Constants** | `SDK_VERSION` |
|
|
27
|
+
|
|
28
|
+
Your backend mints a short-lived room token with
|
|
29
|
+
[`@voltkit-rtc/server`](https://voltkit.in/docs/server-sdk) (or the Python, Go,
|
|
30
|
+
PHP or Java server SDK) and hands the app `{ token, wsUrl }`. The API secret
|
|
31
|
+
never reaches the app.
|
|
32
|
+
|
|
33
|
+
Contents: [Install](#install) ·
|
|
34
|
+
[Permissions](#permissions) ·
|
|
35
|
+
[Quick start](#quick-start) ·
|
|
36
|
+
[VoltkitProvider](#voltkitprovider) ·
|
|
37
|
+
[Hooks](#hooks) ·
|
|
38
|
+
[VideoTrack and AudioTrack](#videotrack-and-audiotrack) ·
|
|
39
|
+
[Audio routing](#audio-routing) ·
|
|
40
|
+
[Calls in the background](#calls-in-the-background) ·
|
|
41
|
+
[Screen sharing](#screen-sharing) ·
|
|
42
|
+
[Chat](#chat) ·
|
|
43
|
+
[Moderation](#moderation-seats-and-audio-rooms) ·
|
|
44
|
+
[Tokens and wallet](#token-refresh-and-wallet-notices) ·
|
|
45
|
+
[Your own client](#your-own-client) ·
|
|
46
|
+
[Errors](#errors) ·
|
|
47
|
+
[Troubleshooting](#troubleshooting) ·
|
|
48
|
+
[iOS preview](#ios-preview) ·
|
|
49
|
+
[Testing status](#testing-status) ·
|
|
50
|
+
[Help](#help)
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
### Bare React Native
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npm install @voltkit-rtc/react-native @voltkit-rtc/react @voltkit-rtc/client react-native-webrtc
|
|
58
|
+
npx react-native run-android # the package has native Android code: build the app again
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`@voltkit-rtc/client`, `@voltkit-rtc/react` and react-native-webrtc are peer
|
|
62
|
+
dependencies, installed beside the SDK so the app, the hooks and the video views
|
|
63
|
+
share one copy of each. Autolinking finds the package. Nothing goes into
|
|
64
|
+
`MainApplication` or `MainActivity`: a startup provider in the package sets up
|
|
65
|
+
react-native-webrtc's audio before it starts, and release builds need no
|
|
66
|
+
ProGuard or R8 rules of your own (the SDK carries the one react-native-webrtc
|
|
67
|
+
155.0.1 needs). Types are included; ES modules and CommonJS both work.
|
|
68
|
+
|
|
69
|
+
**TypeScript.** Nothing to add to `tsconfig.json`: the SDK's streams and
|
|
70
|
+
tracks are typed by shape (`MediaStreamLike` and `MediaStreamTrackLike`,
|
|
71
|
+
react-native-webrtc's `MediaStream` and `MediaStreamTrack`), so
|
|
72
|
+
`useLocalMedia().stream?.getVideoTracks()[0]` and a remote track's `track`
|
|
73
|
+
compile with `@react-native/typescript-config` as it comes, without the DOM
|
|
74
|
+
library, and under Expo's `tsconfig.base` alike.
|
|
75
|
+
|
|
76
|
+
| Requirement | Value |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| React Native | 0.82 or newer, with the New Architecture; tested with 0.88.0 and Hermes |
|
|
79
|
+
| React | 19.1 or newer |
|
|
80
|
+
| react-native-webrtc | 155.0.1 or a later 155.x |
|
|
81
|
+
| `@voltkit-rtc/client` | 0.9.13 or a later 0.9.x |
|
|
82
|
+
| `@voltkit-rtc/react` | 0.1.1 or a later 0.1.x |
|
|
83
|
+
| Android | minSdk 24 (Android 7.0), react-native-webrtc's minimum |
|
|
84
|
+
| Expo | SDK 58 or newer, in a development build; not Expo Go |
|
|
85
|
+
| iOS | Preview: see [iOS preview](#ios-preview) |
|
|
86
|
+
| Licence | MIT |
|
|
87
|
+
|
|
88
|
+
**Globals.** Importing `@voltkit-rtc/react-native` registers
|
|
89
|
+
react-native-webrtc's WebRTC classes as globals, which mediasoup-client needs
|
|
90
|
+
(`registerVoltkitGlobals()`), so there is no `registerGlobals()` of your own to
|
|
91
|
+
call. Calling it again does nothing, and it returns `false` without throwing
|
|
92
|
+
where react-native-webrtc's native module is missing (Expo Go, an app not built
|
|
93
|
+
again): the provider then reports that through `onError`.
|
|
94
|
+
|
|
95
|
+
**One WebRTC per app.** Do not add a second WebRTC build: a fork of
|
|
96
|
+
react-native-webrtc, such as `@livekit/react-native-webrtc` or
|
|
97
|
+
`@stream-io/react-native-webrtc`, or a native SDK that bundles its own, such as
|
|
98
|
+
VoltKit's Android SDK `in.voltkit:voltkit-rtc`. Two copies of libwebrtc in one
|
|
99
|
+
APK clash on native symbols, and two call engines compete for the microphone
|
|
100
|
+
and the audio routing.
|
|
101
|
+
|
|
102
|
+
### Expo (development builds)
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npx expo install @voltkit-rtc/react-native @voltkit-rtc/react @voltkit-rtc/client react-native-webrtc expo-dev-client
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Add the SDK's config plugin to `app.json` (or `app.config.ts`). It writes the
|
|
109
|
+
Android permissions, the call service and the `in.voltkit.rtc.*` meta-data into
|
|
110
|
+
the manifest, and the usage strings and background modes into `Info.plist`, so
|
|
111
|
+
you edit no native file:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"expo": {
|
|
116
|
+
"plugins": [
|
|
117
|
+
[
|
|
118
|
+
"@voltkit-rtc/react-native",
|
|
119
|
+
{
|
|
120
|
+
"cameraPermission": "Your camera is shown to the people in your call.",
|
|
121
|
+
"microphonePermission": "Your microphone is heard by the people in your call."
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Option | Default | What it does |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| `cameraPermission`, `microphonePermission` | the app's own `Info.plist` text, else `Allow $(PRODUCT_NAME) to use your camera in calls.` (`... microphone ...`) | The iOS prompts (`NSCameraUsageDescription`, `NSMicrophoneUsageDescription`) |
|
|
132
|
+
| `android.camera` | `true` | Declares `CAMERA`, with the camera features marked as not required (`uses-feature android:required="false"`), so Google Play does not hide the app from devices without them. `false` for an app that never publishes video |
|
|
133
|
+
| `android.callService` | `true` | Declares the [call service](#calls-in-the-background) and its permissions. `false` declares no foreground service: nothing to declare to Google Play, but a call drops a few seconds after the app leaves the screen |
|
|
134
|
+
| `android.callServiceCamera` | `false` | Adds the `camera` type and `FOREGROUND_SERVICE_CAMERA`, for an app that keeps the camera on in the background (`background={{ pauseCamera: false }}`) |
|
|
135
|
+
| `android.screenShare` | `false` | Turns on react-native-webrtc's screen-capture service for [screen sharing](#screen-sharing), with `FOREGROUND_SERVICE_MEDIA_PROJECTION` and the `mediaProjection` type on the call service |
|
|
136
|
+
| `android.audioMode` | `"communication"` | `"media"` for an app whose users only listen: see [Audio routing](#audio-routing) |
|
|
137
|
+
| `android.hardwareEchoCancellation` | `true` | The phone's own echo canceller and noise suppressor, where it has them |
|
|
138
|
+
| `ios.backgroundAudio` | `true` | Adds `audio` to `UIBackgroundModes`, so a call keeps its sound in the background |
|
|
139
|
+
| `ios.voip` | `false` | Adds `voip` to `UIBackgroundModes`, for an app that integrates CallKit itself |
|
|
140
|
+
|
|
141
|
+
Then `npx expo run:android` builds the app with its native code and installs it;
|
|
142
|
+
from then on `npx expo start` serves your JavaScript to that build with fast
|
|
143
|
+
refresh. Build again after installing or upgrading the SDK or
|
|
144
|
+
react-native-webrtc, and after changing the plugin's options, with
|
|
145
|
+
`npx expo prebuild --clean` first. Your app's `blockedPermissions` win over the
|
|
146
|
+
plugin, which warns at prebuild when a block leaves the call service or the
|
|
147
|
+
screen-capture service unable to run. **Expo Go cannot run the SDK**: it is one
|
|
148
|
+
app with the native modules Expo ships, and react-native-webrtc and this SDK
|
|
149
|
+
bring native code of their own. In Expo Go nothing crashes: the provider
|
|
150
|
+
reports `media-unavailable` with `data.reason` `webrtc-missing` and a message
|
|
151
|
+
that says it needs a development build. Everything about Expo, EAS Build and
|
|
152
|
+
release builds: https://voltkit.in/docs/react-native-expo.
|
|
153
|
+
|
|
154
|
+
## Permissions
|
|
155
|
+
|
|
156
|
+
Android, in `android/app/src/main/AndroidManifest.xml`; the Expo plugin writes
|
|
157
|
+
these for you:
|
|
158
|
+
|
|
159
|
+
```xml
|
|
160
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
161
|
+
<!-- Only if you publish video: -->
|
|
162
|
+
<uses-permission android:name="android.permission.CAMERA" />
|
|
163
|
+
|
|
164
|
+
<!-- Google Play reads RECORD_AUDIO and CAMERA as hardware the app cannot do without, and hides the app from
|
|
165
|
+
devices without them, unless the manifest marks them as not required. The camera entries go with CAMERA. -->
|
|
166
|
+
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
|
167
|
+
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
|
168
|
+
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
|
169
|
+
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`INTERNET`, `ACCESS_NETWORK_STATE` and `MODIFY_AUDIO_SETTINGS` come with the
|
|
173
|
+
SDK's own manifest, and Bluetooth headsets need no Bluetooth permission. The
|
|
174
|
+
[call service](#calls-in-the-background) has entries of its own.
|
|
175
|
+
|
|
176
|
+
iOS (preview), in your app's `Info.plist`:
|
|
177
|
+
|
|
178
|
+
```xml
|
|
179
|
+
<key>NSCameraUsageDescription</key>
|
|
180
|
+
<string>Your camera is shown to the people in your call.</string>
|
|
181
|
+
<key>NSMicrophoneUsageDescription</key>
|
|
182
|
+
<string>Your microphone is heard by the people in your call.</string>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`publish()` asks for what it is about to open, the microphone and the camera,
|
|
186
|
+
before any device opens; a viewer who only watches is never asked. A refusal
|
|
187
|
+
rejects `publish()` with `RpcError` `media-unavailable`, with `data.permission`
|
|
188
|
+
(`'camera'` or `'microphone'`) and `data.status`: `'denied'` when the system may
|
|
189
|
+
ask again, `'blocked'` once the user has refused for good (on Android, after
|
|
190
|
+
choosing "Don't allow" twice), when only the app's settings can grant it.
|
|
191
|
+
|
|
192
|
+
To ask earlier, on a screen before the call,
|
|
193
|
+
`requestMediaPermissions(['camera', 'microphone'])` shows the system's dialogs
|
|
194
|
+
for those not granted yet and resolves to each one's status: `'granted'`,
|
|
195
|
+
`'denied'` (the dialog may still show) or `'blocked'`. It never rejects.
|
|
196
|
+
`checkMediaPermissions(kinds)` reads the statuses without asking, and
|
|
197
|
+
`usePermissions(kinds)` keeps them as state, checked again whenever the app
|
|
198
|
+
comes back to the front (the user may have changed them in the settings). On
|
|
199
|
+
Android 13 and later, ask for `'notifications'` too, so the call's notification
|
|
200
|
+
shows in the notification shade; the call works without it, and older Android
|
|
201
|
+
versions have nothing to ask:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
import { Button, Linking, Text, View } from 'react-native';
|
|
205
|
+
import { requestMediaPermissions, usePermissions } from '@voltkit-rtc/react-native';
|
|
206
|
+
|
|
207
|
+
/** A screen before the call: the camera and microphone, and the call notification on the way. */
|
|
208
|
+
export function BeforeTheCall({ onReady }: { onReady: () => void }) {
|
|
209
|
+
const media = usePermissions(['camera', 'microphone']);
|
|
210
|
+
const blocked = media.statuses?.camera === 'blocked' || media.statuses?.microphone === 'blocked';
|
|
211
|
+
|
|
212
|
+
async function ask() {
|
|
213
|
+
const statuses = await media.request();
|
|
214
|
+
await requestMediaPermissions(['notifications']);
|
|
215
|
+
if (statuses.camera === 'granted' && statuses.microphone === 'granted') onReady();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<View>
|
|
220
|
+
{blocked && <Text>Allow the camera and microphone in Settings to join in.</Text>}
|
|
221
|
+
<Button title={blocked ? 'Open Settings' : 'Continue'} onPress={blocked ? () => void Linking.openSettings() : ask} />
|
|
222
|
+
</View>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The SDK's own captures afterwards, such as a reconnect that opens the devices
|
|
228
|
+
again or a camera switch, only check and never ask: a dialog must not open in
|
|
229
|
+
the background.
|
|
230
|
+
|
|
231
|
+
## Quick start
|
|
232
|
+
|
|
233
|
+
**1. A token from your backend.** Ask your endpoint right before joining, with
|
|
234
|
+
your app's own sign-in. [Token server](https://voltkit.in/docs/token-server)
|
|
235
|
+
builds the endpoint in several languages:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// token.ts
|
|
239
|
+
export type Credentials = { token: string; wsUrl: string };
|
|
240
|
+
|
|
241
|
+
export async function fetchCredentials(callId: string, accessToken: string): Promise<Credentials> {
|
|
242
|
+
const res = await fetch(`https://api.example.com/calls/${encodeURIComponent(callId)}/token`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { Authorization: `Bearer ${accessToken}` }, // your app's sign-in, never the RTC secret
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) throw new Error(`Could not get a token (${res.status}).`);
|
|
247
|
+
return (await res.json()) as Credentials;
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Tokens are short-lived (ten minutes by default), and the service refreshes them
|
|
252
|
+
during the session. Production `wsUrl`s start with `wss://`: Android refuses
|
|
253
|
+
`ws://` and `http://` in a release build unless the app allows cleartext
|
|
254
|
+
traffic, so allow it only for a test server on your own network, in a debug
|
|
255
|
+
build.
|
|
256
|
+
|
|
257
|
+
**2. A call screen.** The client side of a call: join, your camera and
|
|
258
|
+
microphone, everyone's video, mute, camera off, flip, loudspeaker and leave.
|
|
259
|
+
Remote sound needs no component: react-native-webrtc plays it.
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
// CallScreen.tsx
|
|
263
|
+
import { useState } from 'react';
|
|
264
|
+
import { Button, StyleSheet, Text, View } from 'react-native';
|
|
265
|
+
import {
|
|
266
|
+
VideoTrack,
|
|
267
|
+
VoltkitProvider,
|
|
268
|
+
useAudioOutput,
|
|
269
|
+
useLocalMedia,
|
|
270
|
+
useRemoteTracks,
|
|
271
|
+
useRoom,
|
|
272
|
+
type ClosedReason,
|
|
273
|
+
} from '@voltkit-rtc/react-native';
|
|
274
|
+
import { fetchCredentials, type Credentials } from './token';
|
|
275
|
+
|
|
276
|
+
const ENDED: Record<ClosedReason, string> = {
|
|
277
|
+
left: 'You left the call.',
|
|
278
|
+
kicked: 'You were removed from the call.',
|
|
279
|
+
'room-closed': 'The call has ended.',
|
|
280
|
+
replaced: 'You joined this call from another device.',
|
|
281
|
+
'gave-up': 'The connection was lost.',
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export function CallScreen({ callId, accessToken }: { callId: string; accessToken: string }) {
|
|
285
|
+
const [credentials, setCredentials] = useState<Credentials | null>(null);
|
|
286
|
+
const [attempt, setAttempt] = useState(0);
|
|
287
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
288
|
+
const [busy, setBusy] = useState(false);
|
|
289
|
+
|
|
290
|
+
async function join() {
|
|
291
|
+
setBusy(true);
|
|
292
|
+
setNotice(null);
|
|
293
|
+
try {
|
|
294
|
+
setCredentials(await fetchCredentials(callId, accessToken));
|
|
295
|
+
setAttempt((n) => n + 1); // a new key: a new provider with a fresh client
|
|
296
|
+
} catch (err) {
|
|
297
|
+
setNotice((err as Error).message);
|
|
298
|
+
} finally {
|
|
299
|
+
setBusy(false);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!credentials) {
|
|
304
|
+
return (
|
|
305
|
+
<View style={styles.center}>
|
|
306
|
+
{notice && <Text>{notice}</Text>}
|
|
307
|
+
<Button title={busy ? 'Joining…' : 'Join the call'} onPress={join} disabled={busy} />
|
|
308
|
+
</View>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<VoltkitProvider
|
|
314
|
+
key={attempt}
|
|
315
|
+
wsUrl={credentials.wsUrl}
|
|
316
|
+
token={credentials.token}
|
|
317
|
+
onError={(err) => console.warn('[voltkit]', err.message)} // warnings too: see Errors
|
|
318
|
+
onClosed={(reason) => {
|
|
319
|
+
setCredentials(null);
|
|
320
|
+
setNotice(ENDED[reason]);
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
<Call onRetry={join} />
|
|
324
|
+
</VoltkitProvider>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function Call({ onRetry }: { onRetry: () => void }) {
|
|
329
|
+
const { state, info, error, leave } = useRoom();
|
|
330
|
+
const local = useLocalMedia();
|
|
331
|
+
const audio = useAudioOutput();
|
|
332
|
+
const videos = useRemoteTracks({ kind: 'video' });
|
|
333
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
334
|
+
const report = (err: Error) => setProblem(err.message);
|
|
335
|
+
|
|
336
|
+
if (state === 'idle' && error) {
|
|
337
|
+
return (
|
|
338
|
+
<View style={styles.center}>
|
|
339
|
+
<Text>Could not join: {error.message}</Text>
|
|
340
|
+
<Button title="Try again" onPress={onRetry} />
|
|
341
|
+
</View>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (state === 'idle' || state === 'connecting') {
|
|
345
|
+
return (
|
|
346
|
+
<View style={styles.center}>
|
|
347
|
+
<Text>Joining…</Text>
|
|
348
|
+
</View>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const canPublish = info?.role === 'host' || info?.role === 'cohost';
|
|
353
|
+
const camera = local.stream?.getVideoTracks()[0];
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<View style={styles.screen}>
|
|
357
|
+
{state === 'reconnecting' && <Text>Reconnecting…</Text>}
|
|
358
|
+
{problem && <Text>{problem}</Text>}
|
|
359
|
+
|
|
360
|
+
<View style={styles.tiles}>
|
|
361
|
+
{camera && <VideoTrack track={camera} mirror={local.facingMode === 'user'} style={styles.tile} />}
|
|
362
|
+
{videos.map((t) => (
|
|
363
|
+
<VideoTrack key={t.producerId} track={t.track} producerId={t.producerId} objectFit="cover" style={styles.tile} />
|
|
364
|
+
))}
|
|
365
|
+
</View>
|
|
366
|
+
|
|
367
|
+
<View style={styles.controls}>
|
|
368
|
+
{canPublish && !local.stream && (
|
|
369
|
+
<Button title="Start camera and microphone" onPress={() => local.publish().catch(report)} />
|
|
370
|
+
)}
|
|
371
|
+
{local.stream && (
|
|
372
|
+
<>
|
|
373
|
+
<Button title={local.micEnabled ? 'Mute' : 'Unmute'} onPress={() => local.setMicEnabled(!local.micEnabled).catch(report)} />
|
|
374
|
+
<Button title={local.camEnabled ? 'Camera off' : 'Camera on'} onPress={() => local.setCamEnabled(!local.camEnabled).catch(report)} />
|
|
375
|
+
<Button title="Flip" onPress={() => local.switchCamera().catch(report)} />
|
|
376
|
+
</>
|
|
377
|
+
)}
|
|
378
|
+
{audio.supported && (
|
|
379
|
+
<Button
|
|
380
|
+
title={audio.speakerphone ? 'Earpiece' : 'Loudspeaker'}
|
|
381
|
+
onPress={() => audio.setSpeakerphoneOn(!audio.speakerphone).catch(report)}
|
|
382
|
+
/>
|
|
383
|
+
)}
|
|
384
|
+
<Button title="Leave" onPress={leave} />
|
|
385
|
+
</View>
|
|
386
|
+
</View>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const styles = StyleSheet.create({
|
|
391
|
+
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
|
|
392
|
+
screen: { flex: 1, backgroundColor: '#000' },
|
|
393
|
+
tiles: { flex: 1, flexDirection: 'row', flexWrap: 'wrap' },
|
|
394
|
+
tile: { width: '50%', aspectRatio: 3 / 4 },
|
|
395
|
+
controls: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8, padding: 12 },
|
|
396
|
+
});
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**3. The app:**
|
|
400
|
+
|
|
401
|
+
```tsx
|
|
402
|
+
// App.tsx
|
|
403
|
+
import { View } from 'react-native';
|
|
404
|
+
import { CallScreen } from './CallScreen';
|
|
405
|
+
|
|
406
|
+
export default function App() {
|
|
407
|
+
// Your own sign-in gives the access token; the call id comes from your app's data.
|
|
408
|
+
return (
|
|
409
|
+
<View style={{ flex: 1 }}>
|
|
410
|
+
<CallScreen callId="call-42" accessToken="your-session-token" />
|
|
411
|
+
</View>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
What each part does:
|
|
417
|
+
|
|
418
|
+
- **The provider** makes the client, connects it, and closes it when it
|
|
419
|
+
unmounts. On joining it takes the phone's call audio (call mode, audio focus,
|
|
420
|
+
where sound plays) and, if the app declares it, starts the
|
|
421
|
+
[call service](#calls-in-the-background); it lets both go when the call ends.
|
|
422
|
+
`onError` hears what is not the answer to a call you made: warnings, a call
|
|
423
|
+
service that could not start, and in Expo Go the missing native code.
|
|
424
|
+
`onClosed` says why a session ended. A closed session stays closed, so joining
|
|
425
|
+
again mounts a new provider (a new `key`) with a fresh token.
|
|
426
|
+
- **`publish()`** asks for the microphone and camera permissions, then opens the
|
|
427
|
+
devices and publishes them; only hosts and co-hosts may. With no options it
|
|
428
|
+
sends the highest quality the project allows, 1080p unless your project's
|
|
429
|
+
settings say otherwise. That is your users' mobile data and your egress: pass
|
|
430
|
+
`{ quality: '720p' }` where they matter more than the picture.
|
|
431
|
+
- **Your own tile** is mirrored while the front camera is in use. `producerId`
|
|
432
|
+
on a remote `VideoTrack` turns on adaptive video: it is received at the size
|
|
433
|
+
it is laid out, and paused while it is hidden or the app is in the background.
|
|
434
|
+
- **The loudspeaker button** moves call audio between the loudspeaker and the
|
|
435
|
+
earpiece; a connected headset still wins. Calls start on the loudspeaker. For
|
|
436
|
+
a voice call held to the ear, pass `audio={{ speakerphone: false }}` to the
|
|
437
|
+
provider.
|
|
438
|
+
- **The hooks follow the client.** After `setMicEnabled(false)`, `micEnabled`
|
|
439
|
+
is false in every component that uses `useLocalMedia()`, and a reconnect
|
|
440
|
+
shows as `reconnecting` without unmounting anything.
|
|
441
|
+
|
|
442
|
+
## VoltkitProvider
|
|
443
|
+
|
|
444
|
+
`<VoltkitProvider>` holds one `StreamClient` for the components below it. It is
|
|
445
|
+
the React SDK's provider underneath, so every re-exported hook works below it,
|
|
446
|
+
and its props are `VoltkitProviderProps`: the React provider's, plus three of
|
|
447
|
+
its own.
|
|
448
|
+
|
|
449
|
+
| Prop | Type | |
|
|
450
|
+
| --- | --- | --- |
|
|
451
|
+
| `wsUrl` | `string` | the room's WebSocket URL, from your backend with the token |
|
|
452
|
+
| `token` | `string` | a room token your backend minted |
|
|
453
|
+
| `connect` | `boolean` | connect the client when it is idle; default `true`. With `false`, call `useRoom().connect()` yourself |
|
|
454
|
+
| `onConnected` | `(result: JoinResult) => void` | after every successful join: the first, and each automatic reconnect |
|
|
455
|
+
| `onError` | `(error: Error) => void` | every `error` event (warnings included), a failed connect, a token the session refused, a call service that could not start, and missing native code |
|
|
456
|
+
| `onClosed` | `(reason: ClosedReason) => void` | the session ended for good: `left`, `kicked`, `room-closed`, `gave-up` or `replaced` |
|
|
457
|
+
| `client` | `StreamClient \| null` | a client you made with `createClient` and own, used instead of `wsUrl` and `token`; never closed by the provider |
|
|
458
|
+
| `clientOptions` | `ClientOptions` | options for the client the provider makes, laid over React Native's own. Read once, when the client is made, like `token` |
|
|
459
|
+
| `audio` | `AudioSessionOptions \| false` | the call audio: `speakerphone` (the loudspeaker, `true`, the default, or the earpiece, `false`, when no headset is connected, unless `setSpeakerphoneOn` chose) and `manage` (default `true`). `false`, or `manage: false`, leaves the phone's audio mode, focus and routing to your app |
|
|
460
|
+
| `background` | `BackgroundOptions` | what switches off while the app is in the background: the camera (`pauseCamera`, default `true`) and the microphone (`pauseMic`, default `false`) |
|
|
461
|
+
| `callService` | `boolean \| CallServiceText` | start the [call service](#calls-in-the-background) when the call connects, if the app declares it. Default `true`; `false` never starts it; an object changes the notification's words: `title` ("Call in progress"), `text` ("Tap to return to the call") and `leaveLabel` ("Leave") |
|
|
462
|
+
| `children` | `ReactNode` | |
|
|
463
|
+
|
|
464
|
+
- **The client it makes** has the options
|
|
465
|
+
`reactNativeClientOptions({ background, clientOptions })` returns: the
|
|
466
|
+
mediasoup handler (`ReactNative106`), the React Native platform
|
|
467
|
+
(`reactNativePlatform()`), and `background` as the client's
|
|
468
|
+
`pauseCameraInBackground` and `pauseMicInBackground`, with the `clientOptions`
|
|
469
|
+
you give laid over them. A client you make yourself for the `client` prop
|
|
470
|
+
comes from `createClient` (see [Your own client](#your-own-client)): a plain
|
|
471
|
+
`new StreamClient(...)` would look for a browser.
|
|
472
|
+
- **Where the native code is missing,** nothing throws and the hooks keep their
|
|
473
|
+
defaults: in Expo Go, or an app not built again after installing
|
|
474
|
+
react-native-webrtc, the provider makes no client and `onError` gets
|
|
475
|
+
`media-unavailable` with `data.reason` `webrtc-missing`; on Android without
|
|
476
|
+
the SDK's own native module, `data.reason` is `native-missing`, and the call
|
|
477
|
+
works without background calls, audio routing and native timers.
|
|
478
|
+
- **A new `token` for the same session** (the same project, room and identity,
|
|
479
|
+
read from the token without verifying it: the server does that) goes to the
|
|
480
|
+
live session with `updateToken`, with no reconnect: that is how you
|
|
481
|
+
[refresh a token](#token-refresh-and-wallet-notices). A token for another room
|
|
482
|
+
or identity closes the client and makes one for the new session. A new `wsUrl`
|
|
483
|
+
replaces the client, and taking `token` or `wsUrl` away closes it.
|
|
484
|
+
- **A closed session stays closed.** To join again, render the provider with a
|
|
485
|
+
new `key` and a fresh token, as the call screen does.
|
|
486
|
+
- **With `client`** the provider uses yours as it is: it connects it when it is
|
|
487
|
+
idle (unless `connect` is `false`) and never closes it.
|
|
488
|
+
- **The callbacks are always the latest** you passed. A client the provider lets
|
|
489
|
+
go of, on unmount or for a new `wsUrl`, reports nothing more.
|
|
490
|
+
- **StrictMode** runs effects twice in development. The provider then makes a
|
|
491
|
+
client, closes it before it connects, and makes the one it keeps: one socket,
|
|
492
|
+
one connect, nothing left open.
|
|
493
|
+
|
|
494
|
+
## Hooks
|
|
495
|
+
|
|
496
|
+
The hooks are the React SDK's, with the same names, returns and defaults. Every
|
|
497
|
+
hook returns its default until the provider has a client, then follows the
|
|
498
|
+
client and renders again only when what it returns has changed. A hook outside
|
|
499
|
+
any `VoltkitProvider` does not throw: it keeps returning its default.
|
|
500
|
+
|
|
501
|
+
| Hook | Returns | Default |
|
|
502
|
+
| --- | --- | --- |
|
|
503
|
+
| `useRoom()` | `RoomState`: `client`, `info`, `state`, `error`, `connect()`, `leave()` | `state` is `'idle'`; `client`, `info` and `error` are null |
|
|
504
|
+
| `useConnectionState()` | `'idle'`, `'connecting'`, `'connected'`, `'reconnecting'` or `'closed'` | `'idle'` |
|
|
505
|
+
| `useParticipants()` | `ParticipantInfo[]`: everyone in the room, you included | `[]` |
|
|
506
|
+
| `useRemoteTracks(filter?)` | `RemoteTrack[]`: every track this client receives, `producerId`, `identity`, `kind`, `source`, `track`, `paused`; the filter takes `identity`, `kind` and `source` | `[]` |
|
|
507
|
+
| `useLocalMedia()` | `LocalMedia`: your camera, microphone and screen, and the calls that change them, with `switchCamera()` and `facingMode` | nothing published |
|
|
508
|
+
| `useNetworkQuality()` | `NetworkQuality`: `level`, `rttMs`, `lossPct`, `availableKbps`, about every 3 s | `null` |
|
|
509
|
+
| `useWalletLow()` | `WalletLowState`: `active`, `remainingSeconds`, `notice` | not active |
|
|
510
|
+
| `useVoltkitEvent(event, handler)` | nothing: calls `handler` for each event while mounted | |
|
|
511
|
+
| `useVoltkitClient()` | the provider's `StreamClient` | `null` |
|
|
512
|
+
| `useAudioOutput()` | `AudioOutput`: `supported`, `outputs`, `current`, `speakerphone`, `select(deviceId)`, `setSpeakerphoneOn(on)`: see [Audio routing](#audio-routing) | not supported |
|
|
513
|
+
| `usePermissions(kinds)` | `Permissions`: `statuses` (each `'granted'`, `'denied'` or `'blocked'`), `granted`, `request()`, `refresh()`: see [Permissions](#permissions) | `statuses` is null until first checked |
|
|
514
|
+
| `useCallService()` | `CallServiceState`: `declared`, `running`, `types`: see [Calls in the background](#calls-in-the-background) | not declared |
|
|
515
|
+
|
|
516
|
+
The media hooks are the React SDK's too: `useAudioLevels()` (every
|
|
517
|
+
participant's sound level, `speaking` included), `useActiveSpeakers()` (who is
|
|
518
|
+
speaking, most active first), `useIsSpeaking(identity)` (renders only when it
|
|
519
|
+
changes, so it suits every tile), `useStreamFallback(producerId)` (why a remote
|
|
520
|
+
video is held back on a poor connection: `'bandwidth'`, `'audio-protection'`,
|
|
521
|
+
or null), `usePublishFallback()`, `useMediaWarnings()` (`mic-silent`,
|
|
522
|
+
`camera-fps-low`, `camera-interrupted`, `mic-interrupted`),
|
|
523
|
+
`useCpuConstrained()` and `useDevices(kind?)`. Two differences on a phone:
|
|
524
|
+
|
|
525
|
+
- **Sound levels** are read from the connection's statistics every 500 ms,
|
|
526
|
+
where a browser reads them about four times a second, so `useAudioLevels`,
|
|
527
|
+
`useActiveSpeakers` and `useIsSpeaking` change twice a second.
|
|
528
|
+
- **`useDevices`** lists the cameras (`'videoinput'`, each with `facing`:
|
|
529
|
+
`front`, `back` or `external`), the microphones (`'audioinput'`) and, on
|
|
530
|
+
Android, the outputs (`'audiooutput'`), each with its `type`. For an output
|
|
531
|
+
picker, `useAudioOutput()` is simpler.
|
|
532
|
+
|
|
533
|
+
```tsx
|
|
534
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
535
|
+
import { VideoTrack, useIsSpeaking, useStreamFallback } from '@voltkit-rtc/react-native';
|
|
536
|
+
import type { RemoteTrack } from '@voltkit-rtc/react-native';
|
|
537
|
+
|
|
538
|
+
/** One remote video, with a speaking ring and a note while a poor connection holds the video back. */
|
|
539
|
+
export function Tile({ track }: { track: RemoteTrack }) {
|
|
540
|
+
const speaking = useIsSpeaking(track.identity);
|
|
541
|
+
const held = useStreamFallback(track.producerId);
|
|
542
|
+
return (
|
|
543
|
+
<View style={[styles.tile, speaking && styles.speaking]}>
|
|
544
|
+
{held ? (
|
|
545
|
+
<Text style={styles.note}>{track.identity}: poor connection</Text>
|
|
546
|
+
) : (
|
|
547
|
+
<VideoTrack track={track.track} producerId={track.producerId} style={styles.video} />
|
|
548
|
+
)}
|
|
549
|
+
</View>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const styles = StyleSheet.create({
|
|
554
|
+
tile: { width: '50%', aspectRatio: 3 / 4 },
|
|
555
|
+
speaking: { borderWidth: 2, borderColor: '#22c55e' },
|
|
556
|
+
video: { flex: 1 },
|
|
557
|
+
note: { padding: 8 },
|
|
558
|
+
});
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
`useVoltkitClient()` is the provider's client, for everything the hooks do not
|
|
562
|
+
wrap: chat (`sendMessage`, `getMessages`), moderation (`raiseHand`, `promote`,
|
|
563
|
+
`forceMute`, `kick`, `endRoom`), seats (`claim`, `release`, `blockSlot`),
|
|
564
|
+
`switchDevice`, `getDiagnostics`. Its methods, events and options are the
|
|
565
|
+
browser SDK's: https://voltkit.in/docs/client-sdk.
|
|
566
|
+
|
|
567
|
+
### useLocalMedia
|
|
568
|
+
|
|
569
|
+
`useLocalMedia(): LocalMedia` is your camera, microphone and screen.
|
|
570
|
+
|
|
571
|
+
| State | |
|
|
572
|
+
| --- | --- |
|
|
573
|
+
| `stream` | your camera and microphone as published (`client.local`), or null: a `MediaStreamLike` (react-native-webrtc's stream, by shape), so `stream.getVideoTracks()[0]` is the camera for a `VideoTrack` |
|
|
574
|
+
| `publishing` | anything is published: camera, microphone or screen |
|
|
575
|
+
| `micEnabled`, `camEnabled` | switched on |
|
|
576
|
+
| `audioQuality`, `videoQuality`, `videoFrameRate` | what is sent, after the project's limits: `'std'` or `'hd'`; `'1080p'`, `'720p'` or `'480p'`; `30` or `60` |
|
|
577
|
+
| `screenSharing`, `screenStream` | the screen being shared (a `MediaStreamLike`), for a preview |
|
|
578
|
+
| `permissions` | `MediaPermissions`: what this project lets this client publish. Offer only these |
|
|
579
|
+
| `facingMode` | `'user'` (front) or `'environment'` (back): the camera in use; null without a camera |
|
|
580
|
+
|
|
581
|
+
| Call | |
|
|
582
|
+
| --- | --- |
|
|
583
|
+
| `publish(options?)` | ask for the permissions, then open the camera and microphone and publish them (hosts and cohosts) |
|
|
584
|
+
| `unpublish()` | stop publishing everything, screen included, and release the devices |
|
|
585
|
+
| `setMicEnabled(on)`, `setCamEnabled(on)` | switch one off or on; viewers are told |
|
|
586
|
+
| `setAudioQuality(quality)` | `'std'` or `'hd'` while live; resolves to the quality in use |
|
|
587
|
+
| `setVideoQuality(quality)` | while live; raises the camera's capture first when it is smaller; resolves to the quality used |
|
|
588
|
+
| `setVideoFrameRate(frameRate)` | `30` or `60` while live; resolves to the frame rate used |
|
|
589
|
+
| `switchCamera()` | front and back on the same track and producer, so viewers see no gap; resolves `true` when the front camera is now in use. `media-unavailable` before a camera is published |
|
|
590
|
+
| `startScreenShare(options?)`, `stopScreenShare()` | Android screen sharing, after the system's consent: see [Screen sharing](#screen-sharing) |
|
|
591
|
+
|
|
592
|
+
**`publish(options?: LocalPublishOptions)`** takes the browser SDK's
|
|
593
|
+
`PublishOptions` (`quality`, `frameRate`, `audioQuality`, `layers`, `codec`,
|
|
594
|
+
`degradationPreference`) plus:
|
|
595
|
+
|
|
596
|
+
| Option | Default | |
|
|
597
|
+
| --- | --- | --- |
|
|
598
|
+
| `audio` | `true` | `true` for VoltKit's microphone settings, `false` for no microphone, or your own constraints, used as given |
|
|
599
|
+
| `video` | `true` | `true` for the quality's frame and frame rate, `false` for no camera, or your own constraints |
|
|
600
|
+
| `facingMode` | `'user'` | `'user'` (front) or `'environment'` (back): which camera to open. `switchCamera()` flips it while live |
|
|
601
|
+
|
|
602
|
+
What it captures: the frame of `quality` (1080p 1920×1080, 720p 1280×720, 480p
|
|
603
|
+
854×480) at `frameRate` (30 unless you ask for 60), and an echo-cancelled,
|
|
604
|
+
noise-suppressed microphone, stereo for `audioQuality: 'hd'`. `quality`
|
|
605
|
+
defaults to the highest the project allows: the best picture, and the most
|
|
606
|
+
data. A phone sends two copies of its camera, half and full size, so each
|
|
607
|
+
viewer receives the one their connection can carry; `codec: 'auto'` picks H.264
|
|
608
|
+
only where the phone has a hardware H.264 encoder, and VP8 everywhere else.
|
|
609
|
+
60 fps is delivered only when the capture source provides it. Phone makers
|
|
610
|
+
usually keep 60 fps for their own camera app, so most apps get 30 fps;
|
|
611
|
+
`setVideoFrameRate(60)` then resolves to `30` with a `frame-rate-fallback`
|
|
612
|
+
warning. Every quality and its data use: https://voltkit.in/docs/video-quality.
|
|
613
|
+
|
|
614
|
+
```tsx
|
|
615
|
+
import { useLocalMedia } from '@voltkit-rtc/react-native';
|
|
616
|
+
|
|
617
|
+
export function useBackCameraFirst() {
|
|
618
|
+
const local = useLocalMedia();
|
|
619
|
+
return async function start(): Promise<boolean> {
|
|
620
|
+
await local.publish({ facingMode: 'environment', quality: '720p' }); // start on the back camera
|
|
621
|
+
return local.switchCamera(); // resolves true: the front camera is in use now
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Any camera: `useDevices('videoinput')` lists them, each with `facing`, and
|
|
627
|
+
`client.switchDevice('videoinput', deviceId)` switches the published one. An id
|
|
628
|
+
no camera has rejects with `bad-params`; a switch that fails rejects with
|
|
629
|
+
`camera-failed`, and the old camera stays on air. Microphones:
|
|
630
|
+
`useDevices('audioinput')` lists them, and
|
|
631
|
+
`client.switchDevice('audioinput', deviceId)` records from another one, such as
|
|
632
|
+
a wired headset's, without opening a new track.
|
|
633
|
+
|
|
634
|
+
Before opening any device, `publish` refuses with an `RpcError`: see
|
|
635
|
+
[When publishing fails](#when-publishing-fails).
|
|
636
|
+
|
|
637
|
+
## VideoTrack and AudioTrack
|
|
638
|
+
|
|
639
|
+
### VideoTrack
|
|
640
|
+
|
|
641
|
+
`<VideoTrack track ...>` shows one video track in a native view
|
|
642
|
+
(react-native-webrtc's `RTCView`), sized by `style`. Its props are
|
|
643
|
+
`VideoTrackProps`:
|
|
644
|
+
|
|
645
|
+
| Prop | |
|
|
646
|
+
| --- | --- |
|
|
647
|
+
| `track` | the video to show: a remote track's `track`, or your own camera, `useLocalMedia().stream?.getVideoTracks()[0]`. Nothing while null or undefined. A `MediaStreamTrackLike`: react-native-webrtc's track by shape, which a remote track's `track` fits as the client types it, so no DOM library is needed |
|
|
648
|
+
| `producerId` | a remote video's `producerId`: the client then receives it at the size this view is laid out, and pauses it at the server while the view is not `visible` or the app is in the background (adaptive stream). Leave it out for your own camera |
|
|
649
|
+
| `mirror` | flip it horizontally, as people expect to see their own front camera |
|
|
650
|
+
| `objectFit` | `'cover'` (the default) fills the view and crops the edges; `'contain'` shows the whole picture, with bars |
|
|
651
|
+
| `zOrder` | which of two overlapping video views is drawn on top, as for `RTCView`: 0 for a full-screen video, 1 for a preview over it |
|
|
652
|
+
| `visible` | `false` while the view is off screen or covered: with `producerId`, the video is paused at the server until it is `true` again. Default `true` |
|
|
653
|
+
| `onDimensionsChange` | called with the video's width and height when its first frame arrives and when they change |
|
|
654
|
+
| `style`, `testID` | as for any view |
|
|
655
|
+
|
|
656
|
+
Each track is shown through one native stream that the SDK makes, shares
|
|
657
|
+
between the views of that track, and releases before the client closes the
|
|
658
|
+
connection the track came on. Do not wrap tracks in `new MediaStream()` for an
|
|
659
|
+
`RTCView` of your own: react-native-webrtc ends the app when a stream is
|
|
660
|
+
released after its track's connection has closed, which happens on every
|
|
661
|
+
reconnect and when a call ends. A view that shows no frame within 3 s mounts
|
|
662
|
+
its native view again once (react-native-webrtc issue 1611, black video).
|
|
663
|
+
|
|
664
|
+
### AudioTrack
|
|
665
|
+
|
|
666
|
+
Remote sound plays by itself, so a call needs no `AudioTrack`. Render one only
|
|
667
|
+
to change one person's volume, or to mute them on this phone. It renders
|
|
668
|
+
nothing. Its props are `AudioTrackProps`:
|
|
669
|
+
|
|
670
|
+
| Prop | |
|
|
671
|
+
| --- | --- |
|
|
672
|
+
| `track` | a remote audio track. Nothing while null or undefined |
|
|
673
|
+
| `volume` | 0 to 1 (clamped), this track's playback volume, where 1 is the track as received. Unset leaves it as it is |
|
|
674
|
+
| `muted` | silence this track here only; it plays again when the prop is `false` or the component unmounts |
|
|
675
|
+
|
|
676
|
+
```tsx
|
|
677
|
+
import { AudioTrack, useRemoteTracks } from '@voltkit-rtc/react-native';
|
|
678
|
+
|
|
679
|
+
/** One person's microphone at half volume on this phone; everyone else plays as received. */
|
|
680
|
+
export function QuietVoice({ identity }: { identity: string }) {
|
|
681
|
+
const [mic] = useRemoteTracks({ identity, source: 'mic' });
|
|
682
|
+
return mic ? <AudioTrack track={mic.track} volume={0.5} /> : null;
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
## Audio routing
|
|
687
|
+
|
|
688
|
+
While a client is connected, the SDK runs the phone's call audio: it switches to
|
|
689
|
+
call mode, takes audio focus, and plays sound on a Bluetooth headset, else a
|
|
690
|
+
wired or USB headset, else the loudspeaker or the earpiece. When the last
|
|
691
|
+
client closes, it puts everything back as it found it. Two clients in one app,
|
|
692
|
+
such as the two rooms of a PK battle, share it.
|
|
693
|
+
|
|
694
|
+
```tsx
|
|
695
|
+
import { Pressable, Text, View } from 'react-native';
|
|
696
|
+
import { useAudioOutput } from '@voltkit-rtc/react-native';
|
|
697
|
+
|
|
698
|
+
const NAMES: Record<string, string> = { speaker: 'Loudspeaker', earpiece: 'Phone', wiredHeadset: 'Headset', bluetooth: 'Bluetooth', usb: 'USB' };
|
|
699
|
+
|
|
700
|
+
export function OutputPicker() {
|
|
701
|
+
const audio = useAudioOutput();
|
|
702
|
+
if (!audio.supported) return null; // false on iOS (preview) and in the media audio mode
|
|
703
|
+
return (
|
|
704
|
+
<View>
|
|
705
|
+
{audio.outputs.map((device) => (
|
|
706
|
+
<Pressable key={device.deviceId} onPress={() => void audio.select(device.deviceId)}>
|
|
707
|
+
<Text style={{ fontWeight: device.deviceId === audio.current?.deviceId ? 'bold' : 'normal' }}>
|
|
708
|
+
{device.label || NAMES[device.type ?? ''] || device.deviceId}
|
|
709
|
+
</Text>
|
|
710
|
+
</Pressable>
|
|
711
|
+
))}
|
|
712
|
+
</View>
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
- **`useAudioOutput(): AudioOutput`**: `supported` (outputs can be chosen:
|
|
718
|
+
Android with the SDK's native module, in the `communication` audio mode),
|
|
719
|
+
`outputs` (`MediaDevice[]`: the loudspeaker, the earpiece, and the headsets
|
|
720
|
+
connected now, each with `deviceId`, `label` and `type`: `speaker`,
|
|
721
|
+
`earpiece`, `wiredHeadset`, `bluetooth`, `usb` or `other`), `current` (where
|
|
722
|
+
call audio plays now, or null while no call holds the audio), `speakerphone`
|
|
723
|
+
(the loudspeaker is in use or, before a call, preferred), `select(deviceId)`
|
|
724
|
+
and `setSpeakerphoneOn(on)`. Headsets that come and go are followed.
|
|
725
|
+
- **`select(deviceId)`** plays call audio on that output while it stays
|
|
726
|
+
connected, even over a headset; an output that is not there rejects with
|
|
727
|
+
`bad-params`. **`setSpeakerphoneOn(true)`** picks the loudspeaker when no
|
|
728
|
+
headset is connected, and `false` the earpiece; a headset still wins. Both are
|
|
729
|
+
also plain functions, `selectAudioOutput(deviceId)` and
|
|
730
|
+
`setSpeakerphoneOn(on)`, which work before any client exists, so a screen
|
|
731
|
+
before the call can set them: the choice holds for the calls joined later.
|
|
732
|
+
They reject with `media-unavailable` on iOS (`data.platform` `'ios'`) and
|
|
733
|
+
where the SDK's native module is missing.
|
|
734
|
+
- **`client.switchDevice('audiooutput', deviceId)`**, from `useVoltkitClient()`,
|
|
735
|
+
does what `select` does.
|
|
736
|
+
- **Android 12 and later** route call audio with `setCommunicationDevice`. On
|
|
737
|
+
Android 7 to 11, Bluetooth call audio goes over SCO, which some headsets and
|
|
738
|
+
car kits refuse. A headset that refuses, or says nothing for 3 seconds, is
|
|
739
|
+
passed over: call audio goes to the next output, and that headset is not
|
|
740
|
+
tried again until the user picks it, a device is added, or the call ends.
|
|
741
|
+
- **No Bluetooth permission** is needed: the routing goes through Android's
|
|
742
|
+
`AudioManager`.
|
|
743
|
+
- **Echo on one phone model.** The phone's own echo canceller and noise
|
|
744
|
+
suppressor are used where it has them. If one model echoes, turn them off with
|
|
745
|
+
the Expo plugin's `android.hardwareEchoCancellation: false` (or the
|
|
746
|
+
`in.voltkit.rtc.HW_AEC` meta-data, below), and WebRTC's own echo canceller
|
|
747
|
+
does the work.
|
|
748
|
+
- **Apps whose users only listen** can set the audio mode to `media` (the
|
|
749
|
+
plugin's `android.audioMode`, or the `in.voltkit.rtc.AUDIO_MODE` meta-data):
|
|
750
|
+
sound plays at the media volume and on Bluetooth's high-quality profile, with
|
|
751
|
+
no earpiece, and outputs follow the system: `useAudioOutput().supported` is
|
|
752
|
+
false, and `select` and `switchDevice('audiooutput', ...)` reject with
|
|
753
|
+
`bad-params`. It is fixed for the life of the app's process: never use it
|
|
754
|
+
where anyone may speak, since a listener brought on stage would echo.
|
|
755
|
+
- **`audio={{ manage: false }}`** on the provider leaves the phone's audio mode,
|
|
756
|
+
focus and routing to your app.
|
|
757
|
+
- **The emulator** has only the loudspeaker. Earpiece and Bluetooth routing are
|
|
758
|
+
tested in unit tests, not on hardware: test them on a phone.
|
|
759
|
+
|
|
760
|
+
In a React Native app without Expo, the audio settings are meta-data in
|
|
761
|
+
`AndroidManifest.xml`, read once when the app starts. These are the defaults:
|
|
762
|
+
|
|
763
|
+
```xml
|
|
764
|
+
<application>
|
|
765
|
+
<!-- communication (calls and audio rooms) or media (listen-only apps) -->
|
|
766
|
+
<meta-data android:name="in.voltkit.rtc.AUDIO_MODE" android:value="communication" />
|
|
767
|
+
<!-- the phone's own echo canceller and noise suppressor, where it has them -->
|
|
768
|
+
<meta-data android:name="in.voltkit.rtc.HW_AEC" android:value="true" />
|
|
769
|
+
<!-- react-native-webrtc's screen-capture service: see Screen sharing -->
|
|
770
|
+
<meta-data android:name="in.voltkit.rtc.SCREEN_SHARE" android:value="false" />
|
|
771
|
+
</application>
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
On iOS (preview) the SDK does not route audio: `useAudioOutput().supported` is
|
|
775
|
+
false, and `setSpeakerphoneOn` and `selectAudioOutput` reject with
|
|
776
|
+
`media-unavailable` (`data.platform` is `'ios'`).
|
|
777
|
+
|
|
778
|
+
## Calls in the background
|
|
779
|
+
|
|
780
|
+
Android stops a call that has gone to the background unless the app runs a
|
|
781
|
+
foreground service that it started while it was visible:
|
|
782
|
+
|
|
783
|
+
- **The network.** Without one, Android cut the test app's connections about
|
|
784
|
+
6 seconds after it went to the background or the screen went off, and the
|
|
785
|
+
call dropped (measured on the Android emulator while building this SDK).
|
|
786
|
+
- **The microphone.** An app in the background records silence unless a
|
|
787
|
+
foreground service is running, which from Android 11 must have the
|
|
788
|
+
`microphone` type.
|
|
789
|
+
- **Timers.** React Native's JavaScript timers stop in the background and while
|
|
790
|
+
the screen is off. The SDK runs the client's own timers natively (the
|
|
791
|
+
reconnect backoff, the connection checks), so with the service keeping the
|
|
792
|
+
app alive, a call still reconnects with the screen off.
|
|
793
|
+
|
|
794
|
+
The SDK's foreground service, `VoltkitCallService`, keeps the call going. The
|
|
795
|
+
provider starts it when the call connects, while the app is visible, and stops
|
|
796
|
+
it when the call ends. It is not in the SDK's own manifest: the app declares
|
|
797
|
+
it, so an app that never runs calls in the background has nothing to declare
|
|
798
|
+
to Google Play.
|
|
799
|
+
|
|
800
|
+
### Declaring the call service
|
|
801
|
+
|
|
802
|
+
In an Expo app, the config plugin declares it unless you set
|
|
803
|
+
`android.callService` to `false`. In a React Native app without Expo, add this
|
|
804
|
+
to `android/app/src/main/AndroidManifest.xml`, then build the app again:
|
|
805
|
+
|
|
806
|
+
```xml
|
|
807
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
808
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
809
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
|
810
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
|
811
|
+
<!-- Android 13+: shows the call notification in the notification shade (ask for it at run time). -->
|
|
812
|
+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
813
|
+
|
|
814
|
+
<application>
|
|
815
|
+
<!-- Inside your existing <application> element. -->
|
|
816
|
+
<service
|
|
817
|
+
android:name="com.voltkit.rtc.reactnative.VoltkitCallService"
|
|
818
|
+
android:exported="false"
|
|
819
|
+
android:foregroundServiceType="microphone|mediaPlayback" />
|
|
820
|
+
</application>
|
|
821
|
+
</manifest>
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
The service picks its foreground service types from what the app declares,
|
|
825
|
+
what the user has granted and what the call publishes:
|
|
826
|
+
|
|
827
|
+
| Type | When the service runs with it |
|
|
828
|
+
| --- | --- |
|
|
829
|
+
| `microphone` | `RECORD_AUDIO` is granted: the call keeps its microphone |
|
|
830
|
+
| `mediaPlayback` | otherwise: a listener, who keeps hearing the call |
|
|
831
|
+
| `camera` | only if you declare it, with `FOREGROUND_SERVICE_CAMERA`, and keep the camera on in the background with `background={{ pauseCamera: false }}` (for a client you made, its own `createClient(..., { background: { pauseCamera: false } })`: the service reads the client's setting) |
|
|
832
|
+
| `mediaProjection` | only while a screen share is live, if you declare it (with `FOREGROUND_SERVICE_MEDIA_PROJECTION` on Android 14 and later) |
|
|
833
|
+
|
|
834
|
+
`useCallService(): CallServiceState` says whether the app declares the service
|
|
835
|
+
(`declared`), whether it runs (`running`) and with which types (`types`). If it
|
|
836
|
+
cannot start, because a permission is missing from the manifest or the app was
|
|
837
|
+
already in the background, `onError` gets `media-unavailable` with
|
|
838
|
+
`data.feature` set to `'callService'` and a message that names what is missing.
|
|
839
|
+
The call goes on, in the foreground only. `callService={false}` on the provider
|
|
840
|
+
never starts it.
|
|
841
|
+
|
|
842
|
+
```tsx
|
|
843
|
+
import { Text } from 'react-native';
|
|
844
|
+
import { useCallService } from '@voltkit-rtc/react-native';
|
|
845
|
+
|
|
846
|
+
/** A line for a settings screen: whether this build keeps calls going in the background. */
|
|
847
|
+
export function BackgroundCallsNote() {
|
|
848
|
+
const service = useCallService();
|
|
849
|
+
if (!service.declared) return <Text>Calls end when the app leaves the screen in this build.</Text>;
|
|
850
|
+
if (!service.running) return <Text>The call runs in the foreground only right now.</Text>;
|
|
851
|
+
return <Text>Call service running with {service.types.join(', ')}.</Text>;
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
### What happens in the background
|
|
856
|
+
|
|
857
|
+
The app counts as in the background while none of its activities is started,
|
|
858
|
+
as Android's process lifecycle says. A permission dialog or the screen-share
|
|
859
|
+
consent dialog on top of the app does not count. In the background:
|
|
860
|
+
|
|
861
|
+
- **Received videos** shown with a `producerId` pause at the server, so they use
|
|
862
|
+
no data, and resume when the app returns. The sound keeps playing.
|
|
863
|
+
- **The camera** switches off, so viewers see the camera off rather than a
|
|
864
|
+
frozen picture, and back on when the app returns, unless the user or a
|
|
865
|
+
moderator switched it meanwhile. `background={{ pauseCamera: false }}` keeps
|
|
866
|
+
it on, which needs the service's `camera` type; without it Android stops a
|
|
867
|
+
background app's camera anyway.
|
|
868
|
+
- **The microphone** keeps sending, as a call needs. `background={{ pauseMic: true }}`
|
|
869
|
+
switches it off too; leave that off for calls.
|
|
870
|
+
- **What the user switches** while the app is in the background is theirs to
|
|
871
|
+
keep. A camera that comes on air there through something started before (a
|
|
872
|
+
publish finishing, a reconnect publishing again) is held off until the app
|
|
873
|
+
returns.
|
|
874
|
+
|
|
875
|
+
### The notification
|
|
876
|
+
|
|
877
|
+
While the service runs, Android shows its notification: "Call in progress",
|
|
878
|
+
"Tap to return to the call", and a Leave button. Tapping it opens the app;
|
|
879
|
+
Leave ends the call (`onClosed` gets `left`, and the others see you leave).
|
|
880
|
+
Change the words on the provider:
|
|
881
|
+
|
|
882
|
+
```tsx
|
|
883
|
+
import type { ReactNode } from 'react';
|
|
884
|
+
import { VoltkitProvider } from '@voltkit-rtc/react-native';
|
|
885
|
+
|
|
886
|
+
export function CallProvider({ wsUrl, token, children }: { wsUrl: string; token: string; children: ReactNode }) {
|
|
887
|
+
return (
|
|
888
|
+
<VoltkitProvider
|
|
889
|
+
wsUrl={wsUrl}
|
|
890
|
+
token={token}
|
|
891
|
+
callService={{ title: 'Call with Asha', text: 'Tap to return to the call', leaveLabel: 'Hang up' }}
|
|
892
|
+
>
|
|
893
|
+
{children}
|
|
894
|
+
</VoltkitProvider>
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
The small icon is your app's drawable named `voltkit_call_notification_icon`
|
|
900
|
+
(or `notification_icon`, or `ic_notification`), and the SDK's own white handset
|
|
901
|
+
when there is none; never the launcher icon, which Android would draw as a
|
|
902
|
+
solid square. On Android 13 and later the notification shows in the
|
|
903
|
+
notification shade only if the user allowed notifications; the service runs
|
|
904
|
+
either way. Swiping the app away from the recent apps ends the call: the others
|
|
905
|
+
see you leave, and the service stops.
|
|
906
|
+
|
|
907
|
+
### Google Play's declaration
|
|
908
|
+
|
|
909
|
+
An app that targets Android 14 or later and declares foreground service types
|
|
910
|
+
declares each type in Play Console, under **Monitor and improve → App
|
|
911
|
+
content**: what the feature does, what happens to the user if the task is
|
|
912
|
+
deferred or interrupted, and a link to a video that shows a user starting the
|
|
913
|
+
feature. For the call service, for example:
|
|
914
|
+
|
|
915
|
+
- **Microphone:** "Keeps the user's call audio going when they switch apps or
|
|
916
|
+
turn the screen off during a call they started." The video starts a call,
|
|
917
|
+
goes to the home screen, and keeps talking.
|
|
918
|
+
- **Media playback:** "Keeps a call audible for a listener who leaves the app."
|
|
919
|
+
The video joins a room as a listener, goes to the home screen, and keeps
|
|
920
|
+
listening.
|
|
921
|
+
- **Camera** and **media projection**, if you declare them: the camera kept on
|
|
922
|
+
in the background, and screen sharing.
|
|
923
|
+
|
|
924
|
+
Without the service (the plugin's `android.callService: false`, or no
|
|
925
|
+
`<service>` block) there is nothing to declare, and a call drops about 6
|
|
926
|
+
seconds after the app leaves the screen.
|
|
927
|
+
|
|
928
|
+
### Xiaomi, Redmi and POCO phones
|
|
929
|
+
|
|
930
|
+
Some phone makers' battery savers stop background apps beyond what Android
|
|
931
|
+
itself does, and Xiaomi's are known for it. VoltKit has tested the call service
|
|
932
|
+
on the Android emulator, not on these phones. If calls end in the background on
|
|
933
|
+
them although `useCallService().running` was true, ask users to set your app's
|
|
934
|
+
battery saver to "No restrictions" in the app's settings, and to allow
|
|
935
|
+
Autostart.
|
|
936
|
+
|
|
937
|
+
### iOS
|
|
938
|
+
|
|
939
|
+
In the iOS preview there is no call service. Calls that keep their sound in the
|
|
940
|
+
background need the `audio` background mode (`UIBackgroundModes`), which the
|
|
941
|
+
Expo plugin adds unless you set `ios.backgroundAudio` to `false`. VoltKit has
|
|
942
|
+
not tested background calls on iOS.
|
|
943
|
+
|
|
944
|
+
## Screen sharing
|
|
945
|
+
|
|
946
|
+
Screen sharing runs on Android, through react-native-webrtc's screen-capture
|
|
947
|
+
service. Turn it on with the Expo plugin's `android.screenShare: true`, or in a
|
|
948
|
+
React Native app without Expo add the permission and the meta-data, and the
|
|
949
|
+
`mediaProjection` type to the call service:
|
|
950
|
+
|
|
951
|
+
```xml
|
|
952
|
+
<!-- FOREGROUND_SERVICE is in the call service's entries above; react-native-webrtc's service needs it too. -->
|
|
953
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
954
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
|
955
|
+
|
|
956
|
+
<application>
|
|
957
|
+
<meta-data android:name="in.voltkit.rtc.SCREEN_SHARE" android:value="true" />
|
|
958
|
+
<service
|
|
959
|
+
android:name="com.voltkit.rtc.reactnative.VoltkitCallService"
|
|
960
|
+
android:exported="false"
|
|
961
|
+
android:foregroundServiceType="microphone|mediaPlayback|mediaProjection" />
|
|
962
|
+
</application>
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
```tsx
|
|
966
|
+
import { Button } from 'react-native';
|
|
967
|
+
import { RpcError } from '@voltkit-rtc/client';
|
|
968
|
+
import { useLocalMedia } from '@voltkit-rtc/react-native';
|
|
969
|
+
|
|
970
|
+
export function ShareScreenButton({ onProblem }: { onProblem: (text: string) => void }) {
|
|
971
|
+
const local = useLocalMedia();
|
|
972
|
+
|
|
973
|
+
async function toggle() {
|
|
974
|
+
try {
|
|
975
|
+
if (local.screenSharing) await local.stopScreenShare();
|
|
976
|
+
else await local.startScreenShare(); // Android asks the user first
|
|
977
|
+
} catch (err) {
|
|
978
|
+
const code = err instanceof RpcError ? err.code : '';
|
|
979
|
+
if (code === 'screen-share-denied') return; // the user said no: nothing to show
|
|
980
|
+
if (code === 'screen-share-service-required') onProblem('Screen sharing is not set up in this build.');
|
|
981
|
+
else if (code === 'screen-share-unsupported') onProblem('Screen sharing needs Android.');
|
|
982
|
+
else onProblem('Could not share the screen.');
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return <Button title={local.screenSharing ? 'Stop sharing' : 'Share screen'} onPress={toggle} />;
|
|
987
|
+
}
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
- Android asks the user before every share; on recent versions they choose one
|
|
991
|
+
app or the entire screen. The screen goes out beside the camera as a second
|
|
992
|
+
video, with `source` `screen`, and without sound.
|
|
993
|
+
- Stopping from the status bar or the notification ends the share too
|
|
994
|
+
(`screenShareEnded`), and from Android 15 QPR1 the system also ends it when
|
|
995
|
+
the screen locks. The SDK stops react-native-webrtc's screen-capture service
|
|
996
|
+
when a share ends.
|
|
997
|
+
- `screen-share-service-required`: the build does not have the screen-capture
|
|
998
|
+
service. `screen-share-denied`: the user cancelled.
|
|
999
|
+
`screen-share-unsupported`: iOS. `screen-share-active`: a share is already
|
|
1000
|
+
running or starting. `screen-share-failed`: the capture could not start, with
|
|
1001
|
+
the reason in the message.
|
|
1002
|
+
|
|
1003
|
+
## Chat
|
|
1004
|
+
|
|
1005
|
+
There are no chat hooks: chat is on the client the provider holds. Listen with
|
|
1006
|
+
`useVoltkitEvent` and send with `useVoltkitClient()`:
|
|
1007
|
+
|
|
1008
|
+
```tsx
|
|
1009
|
+
import { useState } from 'react';
|
|
1010
|
+
import { Button, FlatList, Text, TextInput, View } from 'react-native';
|
|
1011
|
+
import { RpcError, type ChatMessage } from '@voltkit-rtc/client';
|
|
1012
|
+
import { useVoltkitClient, useVoltkitEvent } from '@voltkit-rtc/react-native';
|
|
1013
|
+
|
|
1014
|
+
export function Chat() {
|
|
1015
|
+
const client = useVoltkitClient();
|
|
1016
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
1017
|
+
const [draft, setDraft] = useState('');
|
|
1018
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
1019
|
+
|
|
1020
|
+
useVoltkitEvent('message', (m) => setMessages((list) => [...list, m])); // yours included
|
|
1021
|
+
useVoltkitEvent('messageDeleted', ({ id }) => setMessages((list) => list.filter((m) => m.id !== id)));
|
|
1022
|
+
|
|
1023
|
+
async function send() {
|
|
1024
|
+
if (!client || !draft.trim()) return;
|
|
1025
|
+
try {
|
|
1026
|
+
await client.sendMessage(draft);
|
|
1027
|
+
setDraft('');
|
|
1028
|
+
setNotice(null);
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
if (err instanceof RpcError && err.code === 'chat-muted') setNotice('A moderator has muted you in this chat.');
|
|
1031
|
+
else if (err instanceof RpcError && err.code === 'rate-limited') setNotice('Slow down a little.');
|
|
1032
|
+
else setNotice('Your message was not sent.');
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return (
|
|
1037
|
+
<View style={{ flex: 1 }}>
|
|
1038
|
+
<FlatList
|
|
1039
|
+
data={messages}
|
|
1040
|
+
keyExtractor={(m) => m.id}
|
|
1041
|
+
renderItem={({ item }) => (
|
|
1042
|
+
<Text>
|
|
1043
|
+
<Text style={{ fontWeight: 'bold' }}>{item.from}</Text> {item.text}
|
|
1044
|
+
</Text>
|
|
1045
|
+
)}
|
|
1046
|
+
/>
|
|
1047
|
+
{notice && <Text>{notice}</Text>}
|
|
1048
|
+
<TextInput value={draft} onChangeText={setDraft} onSubmitEditing={() => void send()} placeholder="Message" />
|
|
1049
|
+
<Button title="Send" onPress={() => void send()} disabled={!client} />
|
|
1050
|
+
</View>
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
Render it inside the provider. A `Text` shows a message as text, whatever it
|
|
1056
|
+
contains. `client.getMessages({ limit: 50 })` returns earlier messages. Chat
|
|
1057
|
+
without a room uses `ChatClient` from `@voltkit-rtc/client`. Direct messages,
|
|
1058
|
+
moderation and history: https://voltkit.in/docs/chat.
|
|
1059
|
+
|
|
1060
|
+
## Moderation, seats and audio rooms
|
|
1061
|
+
|
|
1062
|
+
Everything that is not state is on the client: `useVoltkitClient()` returns it,
|
|
1063
|
+
and `useVoltkitEvent()` listens to any of its events. A listener raises a hand
|
|
1064
|
+
with `raiseHand()`, the host brings them on stage with
|
|
1065
|
+
`promote(identity, 'cohost')`, and a moderator uses
|
|
1066
|
+
`forceMute(identity, 'audio')`, `kick(identity, reason)` and `endRoom()`; seats
|
|
1067
|
+
are `claim(key)`, `release(key)` and `blockSlot(key)`. The service enforces
|
|
1068
|
+
each one from the role in the token.
|
|
1069
|
+
|
|
1070
|
+
```tsx
|
|
1071
|
+
import { useState } from 'react';
|
|
1072
|
+
import { Button, Text, View } from 'react-native';
|
|
1073
|
+
import { useVoltkitClient, useVoltkitEvent } from '@voltkit-rtc/react-native';
|
|
1074
|
+
|
|
1075
|
+
/** For the host: who raised a hand, and a button to bring each one on stage. */
|
|
1076
|
+
export function RaisedHands() {
|
|
1077
|
+
const client = useVoltkitClient();
|
|
1078
|
+
const [hands, setHands] = useState<string[]>([]);
|
|
1079
|
+
|
|
1080
|
+
useVoltkitEvent('handRaised', ({ identity, handRaised }) =>
|
|
1081
|
+
setHands((list) => [...list.filter((i) => i !== identity), ...(handRaised ? [identity] : [])]),
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
return (
|
|
1085
|
+
<View>
|
|
1086
|
+
{hands.map((identity) => (
|
|
1087
|
+
<View key={identity}>
|
|
1088
|
+
<Text>{identity} raised a hand</Text>
|
|
1089
|
+
<Button title="Bring on stage" onPress={() => void client?.promote(identity, 'cohost')} />
|
|
1090
|
+
</View>
|
|
1091
|
+
))}
|
|
1092
|
+
</View>
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
For a voice call held to the ear, give the provider
|
|
1098
|
+
`audio={{ speakerphone: false }}` and publish the microphone alone with
|
|
1099
|
+
`publish({ video: false })`. One-to-one voice calls and group audio rooms, with
|
|
1100
|
+
a stage, seats and chat, are built on https://voltkit.in/docs/voice-calls and
|
|
1101
|
+
https://voltkit.in/docs/audio-rooms; their calls have the same names here.
|
|
1102
|
+
|
|
1103
|
+
## Token refresh and wallet notices
|
|
1104
|
+
|
|
1105
|
+
The service sends the session a fresh token over its connection by itself, so
|
|
1106
|
+
usually there is nothing to do. When it cannot (an older server, or a project
|
|
1107
|
+
that is out of credit, at a usage limit or suspended), the client fires
|
|
1108
|
+
`tokenExpiring` about a minute before its token expires. Mint a new one and
|
|
1109
|
+
render the provider with it:
|
|
1110
|
+
|
|
1111
|
+
```tsx
|
|
1112
|
+
import { useVoltkitEvent } from '@voltkit-rtc/react-native';
|
|
1113
|
+
import { fetchCredentials } from './token';
|
|
1114
|
+
|
|
1115
|
+
// Inside the provider, beside <Call />:
|
|
1116
|
+
// <RefreshToken callId={callId} accessToken={accessToken} onToken={(token) => setCredentials((c) => (c ? { ...c, token } : c))} />
|
|
1117
|
+
export function RefreshToken(props: { callId: string; accessToken: string; onToken: (token: string) => void }) {
|
|
1118
|
+
useVoltkitEvent('tokenExpiring', async () => {
|
|
1119
|
+
try {
|
|
1120
|
+
const { token } = await fetchCredentials(props.callId, props.accessToken);
|
|
1121
|
+
props.onToken(token);
|
|
1122
|
+
} catch {
|
|
1123
|
+
// The session keeps its token: only a reconnect after it has expired needs a new one.
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
The provider sees a new token for the same session and hands it to the live
|
|
1131
|
+
session with `updateToken`, with no reconnect. A refresh the server refuses
|
|
1132
|
+
(`bad-token`) is reported through `onError`, and the session keeps its own
|
|
1133
|
+
token.
|
|
1134
|
+
|
|
1135
|
+
With the VoltKit Wallet, the session is warned while the credits the project
|
|
1136
|
+
may spend cover less than about a minute. `useWalletLow()` turns the warnings
|
|
1137
|
+
into state:
|
|
1138
|
+
|
|
1139
|
+
```tsx
|
|
1140
|
+
import { Text } from 'react-native';
|
|
1141
|
+
import { useWalletLow } from '@voltkit-rtc/react-native';
|
|
1142
|
+
|
|
1143
|
+
export function CreditBanner() {
|
|
1144
|
+
const { active, remainingSeconds } = useWalletLow();
|
|
1145
|
+
if (!active) return null;
|
|
1146
|
+
return <Text accessibilityRole="alert">Call credit runs out in about {remainingSeconds} s.</Text>;
|
|
1147
|
+
}
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
A recharge clears it. If the credits run out, the room closes: `onClosed` gets
|
|
1151
|
+
`room-closed`, and `roomClosed` carries the reason `quota-exhausted`.
|
|
1152
|
+
Flat-minute (legacy) projects get `usageLimit` instead:
|
|
1153
|
+
`useVoltkitEvent('usageLimit', ({ endsAt }) => ...)`.
|
|
1154
|
+
|
|
1155
|
+
## Your own client
|
|
1156
|
+
|
|
1157
|
+
`createClient(wsUrl, token, options?)` makes a `StreamClient` for React Native,
|
|
1158
|
+
not yet connected: pass it to the provider's `client` prop, or call `connect()`
|
|
1159
|
+
yourself, and `close()` it when you are done, since the provider never closes a
|
|
1160
|
+
client it was given. The background pause and the call service's types follow
|
|
1161
|
+
that client's own `background` options; the provider's `background` prop only
|
|
1162
|
+
describes a client the provider makes. `CreateClientOptions` is
|
|
1163
|
+
`{ background?: BackgroundOptions; clientOptions?: ClientOptions }`. It throws
|
|
1164
|
+
`media-unavailable` (`webrtc-missing`) where react-native-webrtc's native module
|
|
1165
|
+
is missing. Never make one with `new StreamClient(...)` alone, which would look
|
|
1166
|
+
for a browser.
|
|
1167
|
+
|
|
1168
|
+
```tsx
|
|
1169
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
1170
|
+
import { VoltkitProvider, createClient } from '@voltkit-rtc/react-native';
|
|
1171
|
+
import type { StreamClient } from '@voltkit-rtc/react-native';
|
|
1172
|
+
|
|
1173
|
+
/** A client the app owns: the second room of a PK battle, a client kept across screens. */
|
|
1174
|
+
export function OwnClient({ wsUrl, token, children }: { wsUrl: string; token: string; children: ReactNode }) {
|
|
1175
|
+
const [client, setClient] = useState<StreamClient | null>(null);
|
|
1176
|
+
useEffect(() => {
|
|
1177
|
+
const own = createClient(wsUrl, token, { background: { pauseCamera: false } });
|
|
1178
|
+
setClient(own);
|
|
1179
|
+
return () => {
|
|
1180
|
+
void own.close();
|
|
1181
|
+
};
|
|
1182
|
+
}, [wsUrl, token]);
|
|
1183
|
+
return <VoltkitProvider client={client}>{children}</VoltkitProvider>;
|
|
1184
|
+
}
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
`reactNativeClientOptions(options?)` returns the `ClientOptions` such a client
|
|
1188
|
+
is made with: `handlerName: 'ReactNative106'`, `platform: reactNativePlatform()`,
|
|
1189
|
+
`pauseCameraInBackground` and `pauseMicInBackground` from `background`, and then
|
|
1190
|
+
your `clientOptions`, which win. `reactNativePlatform()` is the platform the
|
|
1191
|
+
client runs on in React Native (`ClientOptions.platform` of `@voltkit-rtc/client`
|
|
1192
|
+
0.9.12): capture, devices and audio routing, streams and their release order,
|
|
1193
|
+
the app's visibility from the process lifecycle, the network, and native
|
|
1194
|
+
timers. One object per process, shared by every client; `createClient` and the
|
|
1195
|
+
provider pass it for you.
|
|
1196
|
+
|
|
1197
|
+
## Errors
|
|
1198
|
+
|
|
1199
|
+
Errors and warnings are `RpcError`s from `@voltkit-rtc/client` with a
|
|
1200
|
+
machine-readable `code` (`ErrorCode`): switch on the code, never the message.
|
|
1201
|
+
`data` carries the details, such as which permission was refused. Warnings
|
|
1202
|
+
(`frame-rate-fallback`, `audio-quality-not-allowed`, ...) change nothing else
|
|
1203
|
+
and need no action. Every code: https://voltkit.in/docs/errors.
|
|
1204
|
+
|
|
1205
|
+
| Where | What arrives |
|
|
1206
|
+
| --- | --- |
|
|
1207
|
+
| `onError` on the provider | every `error` event from the client, failures and warnings alike, a failed connect, a token the session refused, and a call service that could not start |
|
|
1208
|
+
| `useRoom().error` | the last of those, until a join succeeds |
|
|
1209
|
+
| the promise of the call | `useRoom().connect()`, `publish()`, `switchCamera()` and every other `useLocalMedia()` call, `select` and `setSpeakerphoneOn`, and the client's own calls such as `sendMessage()`, reject with the error |
|
|
1210
|
+
| `onClosed` | the session ended for good, and why |
|
|
1211
|
+
|
|
1212
|
+
`VideoTrack` and `AudioTrack` throw nothing. Without a client (outside a
|
|
1213
|
+
provider, or before it has made one) every `useLocalMedia()` call rejects with
|
|
1214
|
+
`not-connected`. Where react-native-webrtc's native code is missing, the
|
|
1215
|
+
provider makes no client at all: only `onError` hears it, with
|
|
1216
|
+
`media-unavailable` and `data.reason` `webrtc-missing`, while
|
|
1217
|
+
`useRoom().state` stays `idle`, so log `onError` while you develop.
|
|
1218
|
+
|
|
1219
|
+
### When joining fails
|
|
1220
|
+
|
|
1221
|
+
The provider connects once and does not retry a connect that failed: `onError`
|
|
1222
|
+
and `useRoom().error` get the error, and the state goes back to `idle`.
|
|
1223
|
+
`isTerminalError(code)` from `@voltkit-rtc/client` says whether the same token
|
|
1224
|
+
would be refused again. Turn the code into words for the user:
|
|
1225
|
+
|
|
1226
|
+
```ts
|
|
1227
|
+
import { RpcError, isTerminalError } from '@voltkit-rtc/client';
|
|
1228
|
+
|
|
1229
|
+
export function joinProblem(err: Error): { text: string; canRetry: boolean } {
|
|
1230
|
+
const code = err instanceof RpcError ? err.code : '';
|
|
1231
|
+
switch (code) {
|
|
1232
|
+
case 'bad-token':
|
|
1233
|
+
case 'no-token':
|
|
1234
|
+
return { text: 'Your session expired. Join again.', canRetry: true }; // a fresh token fixes it
|
|
1235
|
+
case 'room-ended':
|
|
1236
|
+
case 'no-room':
|
|
1237
|
+
return { text: 'This call has ended.', canRetry: false };
|
|
1238
|
+
case 'kicked':
|
|
1239
|
+
return { text: 'You were removed from this call.', canRetry: false };
|
|
1240
|
+
case 'quota-exhausted':
|
|
1241
|
+
case 'daily-limit-reached':
|
|
1242
|
+
case 'monthly-limit-reached':
|
|
1243
|
+
case 'tenant-suspended':
|
|
1244
|
+
case 'billing-unavailable':
|
|
1245
|
+
return { text: 'Calls are unavailable right now.', canRetry: false }; // your project's account, not the user
|
|
1246
|
+
case 'participant-quota':
|
|
1247
|
+
case 'no-capacity':
|
|
1248
|
+
return { text: 'The call is full right now. Try again in a moment.', canRetry: true };
|
|
1249
|
+
default:
|
|
1250
|
+
return { text: 'Could not connect. Check your connection and try again.', canRetry: !isTerminalError(code) };
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
### When publishing fails
|
|
1256
|
+
|
|
1257
|
+
`publish()` refuses before it opens any device, so the camera light never comes
|
|
1258
|
+
on for a publish that cannot happen:
|
|
1259
|
+
|
|
1260
|
+
| `code` | When |
|
|
1261
|
+
| --- | --- |
|
|
1262
|
+
| `media-unavailable` | a permission was refused: `data.permission` and `data.status`, see [Permissions](#permissions) |
|
|
1263
|
+
| `not-connected` | the session is not connected, or there is no client yet |
|
|
1264
|
+
| `forbidden` | your role is not `host` or `cohost` |
|
|
1265
|
+
| `source-already-published` | a camera and microphone are already published, or a publish is under way: `unpublish()` first |
|
|
1266
|
+
| `media-disabled` | the project switched that kind off. Check `permissions.audio` and `permissions.video`, or pass `audio: false` or `video: false` |
|
|
1267
|
+
|
|
1268
|
+
A camera or microphone that another app holds, or that fails to open, rejects
|
|
1269
|
+
with `media-unavailable`, with `data.reason` set to the capture error's name (or
|
|
1270
|
+
`capture-failed`) and its words in the message: offer audio only with
|
|
1271
|
+
`publish({ video: false })`. The other calls refuse with codes too:
|
|
1272
|
+
`setMicEnabled(true)` and `setCamEnabled(true)` with `force-muted` while a
|
|
1273
|
+
moderator holds that device off, `switchCamera()` with `media-unavailable`
|
|
1274
|
+
before a camera is published, and `startScreenShare()` with the
|
|
1275
|
+
[screen-share codes](#screen-sharing).
|
|
1276
|
+
|
|
1277
|
+
```ts
|
|
1278
|
+
import { RpcError } from '@voltkit-rtc/client';
|
|
1279
|
+
|
|
1280
|
+
type MediaProblem = {
|
|
1281
|
+
permission?: 'camera' | 'microphone';
|
|
1282
|
+
status?: 'denied' | 'blocked';
|
|
1283
|
+
reason?: string;
|
|
1284
|
+
feature?: string;
|
|
1285
|
+
platform?: string;
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
/** Words for a media-unavailable error, or null for any other error. */
|
|
1289
|
+
export function mediaProblem(err: unknown): string | null {
|
|
1290
|
+
if (!(err instanceof RpcError) || err.code !== 'media-unavailable') return null;
|
|
1291
|
+
const data = (err.data ?? {}) as MediaProblem;
|
|
1292
|
+
if (data.permission) return data.status === 'blocked' ? `Allow the ${data.permission} in Settings.` : `The ${data.permission} is needed.`;
|
|
1293
|
+
if (data.reason === 'webrtc-missing') return 'This build cannot make calls. Install the latest version of the app.';
|
|
1294
|
+
if (data.feature === 'callService') return 'Calls may stop when you leave the app.';
|
|
1295
|
+
return 'The camera or microphone is not available.';
|
|
1296
|
+
}
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
### Codes from React Native
|
|
1300
|
+
|
|
1301
|
+
| `code` | What happened | What to do |
|
|
1302
|
+
| --- | --- | --- |
|
|
1303
|
+
| `media-unavailable` with `data.permission` | the user refused the camera or microphone; `data.status` is `denied` or `blocked` | ask again, or send them to the app's settings (`Linking.openSettings()`) when it is `blocked` |
|
|
1304
|
+
| `media-unavailable` with `data.reason` `webrtc-missing` | the app has no react-native-webrtc native code: Expo Go, or an app not built again after installing the SDK. The message says it needs a development build | build the app again; use a development build, not Expo Go |
|
|
1305
|
+
| `media-unavailable` with `data.reason` `native-missing` | the SDK's own native module is not in the app (Android): background calls, audio routing and native timers are off; `data.feature` names what asked | build the app again |
|
|
1306
|
+
| `media-unavailable` with another `data.reason` | the camera or microphone could not open: the reason is the capture error's name, or `capture-failed` | offer audio only, or try again |
|
|
1307
|
+
| `media-unavailable` with `data.feature` `callService` | the call service could not start: a manifest entry is missing, or the app was in the background | add what the message names; the call goes on in the foreground |
|
|
1308
|
+
| `media-unavailable` with `data.platform` `ios` | audio routing on iOS: `setSpeakerphoneOn`, `selectAudioOutput` or `switchDevice('audiooutput', ...)` | leave the control out on iOS |
|
|
1309
|
+
| `camera-failed` | the camera could not switch; the old one stays on air | try again |
|
|
1310
|
+
| `bad-params` | an id no camera, microphone or output has, or an output chosen in the `media` audio mode | use an id from `useDevices` or `useAudioOutput` |
|
|
1311
|
+
| `screen-share-service-required`, `screen-share-denied`, `screen-share-unsupported`, `screen-share-active`, `screen-share-failed` | screen sharing is not set up in the build, the user cancelled, iOS, a share is already starting, or the capture could not start | see [Screen sharing](#screen-sharing) |
|
|
1312
|
+
| `music-unavailable` | in-app music needs a browser | leave it out of the app |
|
|
1313
|
+
|
|
1314
|
+
### Reconnection
|
|
1315
|
+
|
|
1316
|
+
The client reconnects by itself, with backoff, and publishes your camera and
|
|
1317
|
+
microphone again when it is back. `useConnectionState()` and `useRoom().state`
|
|
1318
|
+
go to `reconnecting`, then back to `connected`: keep the call on screen and say
|
|
1319
|
+
it is reconnecting. A network change, such as Wi-Fi to mobile data, is handled
|
|
1320
|
+
the same way: the media path is repaired where it can be (the
|
|
1321
|
+
`mediaReconnecting` event), or the client rejoins. The SDK also listens to
|
|
1322
|
+
Android's network callback, so a reconnect waiting out its backoff tries again
|
|
1323
|
+
as soon as the network is back, also with the screen off while the call service
|
|
1324
|
+
runs. If it cannot reconnect, `onError` gets why (`reconnect-gave-up`, or a
|
|
1325
|
+
refusal such as `room-ended`), then `onClosed` gets `gave-up`, `room-closed` or
|
|
1326
|
+
`kicked`.
|
|
1327
|
+
|
|
1328
|
+
## Troubleshooting
|
|
1329
|
+
|
|
1330
|
+
Named after what the build, the log or the user shows. Echo, silence, frozen
|
|
1331
|
+
video and blocked connections, which are the same on every platform:
|
|
1332
|
+
https://voltkit.in/docs/troubleshooting.
|
|
1333
|
+
|
|
1334
|
+
### `WebRTC native module not found`
|
|
1335
|
+
|
|
1336
|
+
react-native-webrtc's own error when its native code is not in the app. The app
|
|
1337
|
+
was not built again after `npm install`: run `npx react-native run-android`, or
|
|
1338
|
+
make a new development build. Expo Go cannot load it at all.
|
|
1339
|
+
|
|
1340
|
+
### "needs a development build"
|
|
1341
|
+
|
|
1342
|
+
The SDK's `media-unavailable` error when native code is missing: `data.reason`
|
|
1343
|
+
is `webrtc-missing` for react-native-webrtc's module, and `native-missing` for
|
|
1344
|
+
the SDK's own. The app runs in Expo Go, or was not built again after installing
|
|
1345
|
+
or upgrading the packages. Nothing crashes; build the app again.
|
|
1346
|
+
|
|
1347
|
+
### "the app's native code is @voltkit-rtc/react-native ... rebuild the app"
|
|
1348
|
+
|
|
1349
|
+
A warning in the log when the app's native code was built from another version
|
|
1350
|
+
of the SDK than its JavaScript (`SDK_VERSION`): the package was upgraded without
|
|
1351
|
+
building the app again, or a development build is older than the JavaScript it
|
|
1352
|
+
loads. Build the app again.
|
|
1353
|
+
|
|
1354
|
+
### `ERROR: R8: Missing class org.webrtc.Dav1dDecoder$Natives`
|
|
1355
|
+
|
|
1356
|
+
A release build with react-native-webrtc 155.0.1 fails with
|
|
1357
|
+
`Missing class org.webrtc.Dav1dDecoder$Natives (referenced from: org.webrtc.Dav1dDecoder$Natives org.webrtc.Dav1dDecoderJni.get() and 2 other contexts)`.
|
|
1358
|
+
The WebRTC build it brings references a class it does not ship, and nothing
|
|
1359
|
+
calls it. `@voltkit-rtc/react-native`'s R8 rules silence it, so with the SDK
|
|
1360
|
+
installed release builds need nothing more. If you see it, the build is not
|
|
1361
|
+
using the SDK's rules: add the line to `android/app/proguard-rules.pro`, or in
|
|
1362
|
+
Expo to `expo-build-properties`' `android.extraProguardRules`:
|
|
1363
|
+
|
|
1364
|
+
```text
|
|
1365
|
+
-dontwarn org.webrtc.Dav1dDecoder$Natives
|
|
1366
|
+
```
|
|
1367
|
+
|
|
1368
|
+
### The call drops about 6 seconds after the app goes to the background
|
|
1369
|
+
|
|
1370
|
+
Or after the screen turns off. The call service is not running: Android cuts
|
|
1371
|
+
the app's network a few seconds after it leaves the screen. Check
|
|
1372
|
+
`useCallService()`: `declared` false means the manifest has no `<service>`
|
|
1373
|
+
block, or the Expo plugin's `android.callService` is `false`; `running` false
|
|
1374
|
+
with `declared` true means it could not start, and `onError` said why
|
|
1375
|
+
(`data.feature` is `callService`). It must start while the app is visible,
|
|
1376
|
+
which the provider does when the call connects. See
|
|
1377
|
+
[Calls in the background](#calls-in-the-background), and for Xiaomi phones
|
|
1378
|
+
[their battery settings](#xiaomi-redmi-and-poco-phones).
|
|
1379
|
+
|
|
1380
|
+
### `No video stream for react tag`
|
|
1381
|
+
|
|
1382
|
+
react-native-webrtc's `RTCView` logs this when it is handed a stream it cannot
|
|
1383
|
+
find, and shows black (react-native-webrtc issue 1611). Show video with
|
|
1384
|
+
`VideoTrack`, which makes the native stream for each track itself and keeps it
|
|
1385
|
+
while the track is shown. If you render `RTCView` yourself, make one stream per
|
|
1386
|
+
track, keep it while the view is shown, and never release it after the call has
|
|
1387
|
+
ended.
|
|
1388
|
+
|
|
1389
|
+
### `forgot to call registerGlobals()`
|
|
1390
|
+
|
|
1391
|
+
mediasoup-client's warning, or `UnsupportedError: device not supported`, when
|
|
1392
|
+
`@voltkit-rtc/client` runs in React Native without the SDK: react-native-webrtc's
|
|
1393
|
+
globals are not registered and no handler is named. Import
|
|
1394
|
+
`@voltkit-rtc/react-native` before anything that uses the client (it registers
|
|
1395
|
+
them), and make clients with `createClient` or the provider, never with
|
|
1396
|
+
`new StreamClient(...)`.
|
|
1397
|
+
|
|
1398
|
+
### `Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION`
|
|
1399
|
+
|
|
1400
|
+
Android 14 refuses screen capture that starts without a running screen-capture
|
|
1401
|
+
service. With the SDK, sharing in a build without the service fails before
|
|
1402
|
+
that, with `screen-share-service-required`: turn on the Expo plugin's
|
|
1403
|
+
`android.screenShare`, or add the meta-data and permission from
|
|
1404
|
+
[Screen sharing](#screen-sharing), and build the app again.
|
|
1405
|
+
|
|
1406
|
+
### `MissingForegroundServiceTypeException: Starting FGS without a type`
|
|
1407
|
+
|
|
1408
|
+
Android 14 throws this when an app starts a foreground service whose manifest
|
|
1409
|
+
entry has no `android:foregroundServiceType`. There the SDK's call service
|
|
1410
|
+
never starts without a type: if its `<service>` entry declares neither
|
|
1411
|
+
`microphone` nor `mediaPlayback`, it does not start, and `onError` gets
|
|
1412
|
+
`media-unavailable` (`data.feature` `callService`) saying so. If you copied a
|
|
1413
|
+
foreground service for calls from another SDK's guide, remove it:
|
|
1414
|
+
`VoltkitCallService` does that job.
|
|
1415
|
+
|
|
1416
|
+
### Silence or a black picture on the emulator
|
|
1417
|
+
|
|
1418
|
+
The Android emulator's microphone records silence, and its cameras are
|
|
1419
|
+
simulated and slow. Test publishing sound and video on a phone; on the
|
|
1420
|
+
emulator, check receiving from a browser in the same room.
|
|
1421
|
+
|
|
1422
|
+
### No earpiece on the emulator
|
|
1423
|
+
|
|
1424
|
+
The emulator has only the loudspeaker, so `useAudioOutput().outputs` lists one
|
|
1425
|
+
output and `setSpeakerphoneOn(false)` has nowhere else to go. Test the earpiece
|
|
1426
|
+
and headsets on a phone.
|
|
1427
|
+
|
|
1428
|
+
### Echo on one phone model
|
|
1429
|
+
|
|
1430
|
+
Turn off the phone's own echo canceller with the Expo plugin's
|
|
1431
|
+
`android.hardwareEchoCancellation: false` (or `in.voltkit.rtc.HW_AEC` set to
|
|
1432
|
+
`false`) and build again; WebRTC's own canceller takes over. A headset or the
|
|
1433
|
+
earpiece removes echo entirely.
|
|
1434
|
+
|
|
1435
|
+
### Google Play rejects the app for 16 KB page sizes
|
|
1436
|
+
|
|
1437
|
+
Google Play requires apps that target Android 15 and later to support 16 KB
|
|
1438
|
+
memory pages. The WebRTC build that react-native-webrtc 155 brings is 16 KB
|
|
1439
|
+
aligned, as react-native-webrtc's maintainers say and VoltKit's emulator build
|
|
1440
|
+
(x86_64) showed; a rejection usually means a second copy of WebRTC from another
|
|
1441
|
+
SDK in the app. Look for more than one `libjingle_peerconnection_so.so` (or
|
|
1442
|
+
another SDK's WebRTC library) in the APK, and check the alignment with
|
|
1443
|
+
`zipalign -c -P 16 -v 4 app-release.apk`, which ends with
|
|
1444
|
+
"Verification successful".
|
|
1445
|
+
|
|
1446
|
+
### `CLEARTEXT communication ... not permitted`
|
|
1447
|
+
|
|
1448
|
+
The `wsUrl` (or your token endpoint) is `ws://` or `http://`, which Android
|
|
1449
|
+
refuses in a release build. Production URLs are `wss://` and `https://`. For a
|
|
1450
|
+
test server on your own network, allow cleartext for that host in a debug build
|
|
1451
|
+
only.
|
|
1452
|
+
|
|
1453
|
+
### Build problems
|
|
1454
|
+
|
|
1455
|
+
| Message | Fix |
|
|
1456
|
+
| --- | --- |
|
|
1457
|
+
| `@voltkit-rtc/react-native needs react-native-webrtc: npm install react-native-webrtc, then rebuild the app` | install react-native-webrtc, a peer dependency, then build again |
|
|
1458
|
+
| `Duplicate class org.webrtc...` | another package bundles WebRTC's Java classes, such as a react-native-webrtc fork or a native call SDK: remove it |
|
|
1459
|
+
| `minSdkVersion 23 cannot be smaller than version 24` | raise `minSdkVersion` to 24 (in Expo, with `expo-build-properties`) |
|
|
1460
|
+
| `@voltkit-rtc/react-native: the Expo config plugin's build, plugin/build, is missing` | the app uses a checkout of the SDK, not the published package: run `npm run build:plugin` in `react-native-sdk`, then prebuild again |
|
|
1461
|
+
| Prebuild warns `» android: @voltkit-rtc/react-native android.callService: ...` or `... android.screenShare: ...` | a `blockedPermissions` conflict: the service needs a permission your app blocks, which the warning names. Unblock it, or turn the option off |
|
|
1462
|
+
|
|
1463
|
+
## iOS preview
|
|
1464
|
+
|
|
1465
|
+
iOS is JavaScript only in 0.1.0: the SDK has no native iOS module, and
|
|
1466
|
+
react-native-webrtc's iOS code does the media (`cd ios && pod install` installs
|
|
1467
|
+
it). It is written to run there, but VoltKit has not compiled or run it on an
|
|
1468
|
+
iPhone or a simulator, so treat it as a preview.
|
|
1469
|
+
|
|
1470
|
+
- **Works as on Android, untested:** joining, publishing and receiving, the
|
|
1471
|
+
hooks, `VideoTrack`, chat, moderation and reconnection. Permissions go through
|
|
1472
|
+
react-native-webrtc's permission requests (iOS asks once: a refusal is
|
|
1473
|
+
`blocked` from then on), and the app counts as visible unless iOS reports it
|
|
1474
|
+
in the background.
|
|
1475
|
+
- **Android only:** audio routing (`useAudioOutput().supported` is false;
|
|
1476
|
+
`setSpeakerphoneOn` and `selectAudioOutput` reject with `media-unavailable`,
|
|
1477
|
+
`data.platform` `'ios'`), the call service (`useCallService().declared` is
|
|
1478
|
+
false), and screen sharing (`screen-share-unsupported`).
|
|
1479
|
+
- **Info.plist:** `NSCameraUsageDescription`, `NSMicrophoneUsageDescription`,
|
|
1480
|
+
and `UIBackgroundModes` with `audio` for calls that keep their sound in the
|
|
1481
|
+
background. The Expo plugin writes them.
|
|
1482
|
+
- **CocoaPods** plans to stop accepting new pods on its trunk on 2 December
|
|
1483
|
+
2026. react-native-webrtc's iOS WebRTC build comes from it, so follow
|
|
1484
|
+
react-native-webrtc's notes for its later versions.
|
|
1485
|
+
|
|
1486
|
+
The Flutter SDK (`voltkit_rtc`) supports iOS 13 and later. Write to
|
|
1487
|
+
contact@voltkit.in to try the React Native SDK on iOS with us.
|
|
1488
|
+
|
|
1489
|
+
## Testing status
|
|
1490
|
+
|
|
1491
|
+
**Tested on Android (emulator, API 37.1); iOS in preview; earpiece and
|
|
1492
|
+
Bluetooth routing tested in unit tests, not on hardware.** Tested with:
|
|
1493
|
+
|
|
1494
|
+
| | Version |
|
|
1495
|
+
| --- | --- |
|
|
1496
|
+
| React Native | 0.88.0, New Architecture, Hermes |
|
|
1497
|
+
| React | 19.3.0 |
|
|
1498
|
+
| react-native-webrtc | 155.0.1 (WebRTC `org.jitsi:webrtc` 155.0.1) |
|
|
1499
|
+
| Expo | SDK 58 (`expo` 58.0.19), development build, R8-minified release build |
|
|
1500
|
+
| `@voltkit-rtc/client` | 0.9.13 |
|
|
1501
|
+
| `@voltkit-rtc/react` | 0.1.1 |
|
|
1502
|
+
| Android | the Android emulator (a Pixel 10 Pro AVD, x86_64, API 37.1, Google Play image); earpiece and Bluetooth routing in unit tests, not on hardware |
|
|
1503
|
+
| iOS | not tested: preview |
|
|
1504
|
+
|
|
1505
|
+
What ran before this release, in this order:
|
|
1506
|
+
|
|
1507
|
+
- **Unit tests.** The JavaScript (`npm test`, Jest with React Native's preset
|
|
1508
|
+
and react-native-webrtc's native side mocked): the provider against a real
|
|
1509
|
+
client, a fake signalling server and a fake mediasoup handler (connect,
|
|
1510
|
+
publish, close with code 1000); the release order of the native streams
|
|
1511
|
+
behind `VideoTrack` on a reconnect, a kick and a closed room; publish and
|
|
1512
|
+
view races; permissions refused before any capture; the call audio held by
|
|
1513
|
+
one client and by two, and given back; the call service's start, update,
|
|
1514
|
+
stop, Leave and swipe-away; the `media` audio mode; the iOS preview's
|
|
1515
|
+
refusals; `preflight`; and the Expo plugin's manifest and Info.plist output,
|
|
1516
|
+
snapshot by snapshot, including a real `expo config --type introspect`. The
|
|
1517
|
+
Android module (JVM tests): the output choice (earpiece, loudspeaker, wired
|
|
1518
|
+
and USB headsets, Bluetooth), the Bluetooth SCO link and its timeout, the
|
|
1519
|
+
call service and its types, the native timers, the network watch, the
|
|
1520
|
+
preferred microphone and the manifest meta-data. The client
|
|
1521
|
+
(`@voltkit-rtc/client`'s own suite, with a React Native fake platform): no
|
|
1522
|
+
DOM global touched, every stream and track released over 50 cycles,
|
|
1523
|
+
statistics-based audio levels, adaptive stream from a view's size, camera
|
|
1524
|
+
switching, platform timers, and the background pause, ported case by case
|
|
1525
|
+
from the Android SDK's device test and the Flutter SDK's lifecycle tests.
|
|
1526
|
+
- **The client under a React Native-like platform against the real controller
|
|
1527
|
+
and media node** (`make -C voltkit-rtc rn-core-live`): a camera consumer at
|
|
1528
|
+
the real temporal layers, a reconnect in under a second, every received track
|
|
1529
|
+
released through the platform while the connection is live, every platform
|
|
1530
|
+
timer cleared after `close()`, no DOM or host-timer use, no token kept where a
|
|
1531
|
+
serialized client would show it.
|
|
1532
|
+
- **On the emulator**, the example app's R8 release build (Hermes) driven by the
|
|
1533
|
+
in-app scenario runner (`make -C voltkit-rtc rn-device-e2e`), against the real
|
|
1534
|
+
controller and media node: 44 of the 48 scenarios in the device suite passed
|
|
1535
|
+
on the release build and none failed. They cover joining, leaving and the
|
|
1536
|
+
close codes; reconnects after a network cut, a path change, a long stay in
|
|
1537
|
+
the background and a token refresh; the camera and microphone published, with
|
|
1538
|
+
viewers decoding every top-layer frame; dynacast and adaptive stream; the
|
|
1539
|
+
camera switch; permissions; the loss of audio focus; the background pause and
|
|
1540
|
+
the call service through 120 s in the background and with the screen off;
|
|
1541
|
+
screen sharing; the pre-call check; a Chromium peer across the LAN; and the
|
|
1542
|
+
`media` audio mode. Logcat showed no fatal exception, no
|
|
1543
|
+
`No video stream for react tag` and no unexpected `E ReactNativeJS` line.
|
|
1544
|
+
The other four are limits of the emulator or the test harness, with the
|
|
1545
|
+
evidence recorded, not faults found in the SDK: the publisher fallback (the
|
|
1546
|
+
emulator's microphone is silent, so Opus sent 72 packets in the 15 s window
|
|
1547
|
+
where the check needs 150), tracks ending under Android's camera and
|
|
1548
|
+
microphone privacy toggles (the AVD restricts the camera and silences the
|
|
1549
|
+
microphone without ending either track), a scripted tap of the notification's
|
|
1550
|
+
Leave action (the action is in the notification and the leave-to-close path
|
|
1551
|
+
is unit-tested on both sides; uiautomator cannot read a shade whose
|
|
1552
|
+
chronometer redraws it every second), and the swipe away from Recents (on
|
|
1553
|
+
this image Android delivered the removal to the service 4.6 to 10.6 s after
|
|
1554
|
+
the gesture, or not within 20 s; whenever it arrives, the SDK closes the
|
|
1555
|
+
client at once).
|
|
1556
|
+
- **Not on hardware**: the earpiece, Bluetooth and wired headsets, the phone's
|
|
1557
|
+
own echo canceller and encoders, and real microphone levels (the emulator's
|
|
1558
|
+
microphone records silence, so publishing sound is checked by the bytes sent
|
|
1559
|
+
and the node's ingress). iOS was not compiled or run.
|
|
1560
|
+
|
|
1561
|
+
## Help
|
|
1562
|
+
|
|
1563
|
+
Documentation: https://voltkit.in/docs/react-native-sdk (the guide),
|
|
1564
|
+
https://voltkit.in/docs/react-native-sdk-reference (every export, prop and
|
|
1565
|
+
option), https://voltkit.in/docs/react-native-expo (the config plugin,
|
|
1566
|
+
development builds and EAS) and the tutorial
|
|
1567
|
+
https://voltkit.in/guides/add-video-calling-to-a-react-native-app. Questions
|
|
1568
|
+
and bug reports: contact@voltkit.in.
|
|
1569
|
+
|
|
1570
|
+
For a support ticket, attach the client's diagnostics snapshot. It names the
|
|
1571
|
+
SDK (`SDK_VERSION`), react-native-webrtc and the Android version, and holds no
|
|
1572
|
+
token and no media:
|
|
1573
|
+
|
|
1574
|
+
```tsx
|
|
1575
|
+
import { Button, Share } from 'react-native';
|
|
1576
|
+
import { useVoltkitClient } from '@voltkit-rtc/react-native';
|
|
1577
|
+
|
|
1578
|
+
export function ShareDiagnostics() {
|
|
1579
|
+
const client = useVoltkitClient();
|
|
1580
|
+
async function share() {
|
|
1581
|
+
if (!client) return;
|
|
1582
|
+
await Share.share({ message: JSON.stringify(await client.getDiagnostics()) });
|
|
1583
|
+
}
|
|
1584
|
+
return <Button title="Share diagnostics" onPress={share} />;
|
|
1585
|
+
}
|
|
1586
|
+
```
|
|
1587
|
+
|
|
1588
|
+
`preflight(wsUrl, token, options?)` is a pre-call check: it joins with a client
|
|
1589
|
+
of its own, publishes the microphone for a few seconds when its permission is
|
|
1590
|
+
already granted (it asks for nothing; pass `media` to test streams of your own),
|
|
1591
|
+
and resolves to a `PreflightReport` of how media travelled, the round trip,
|
|
1592
|
+
loss, upload and any device problem. It never rejects: read `report.ok`, and the
|
|
1593
|
+
steps say what failed. Give it a token for a test room of its own, since the
|
|
1594
|
+
same identity joining anywhere replaces its live call.
|
|
1595
|
+
|
|
1596
|
+
```ts
|
|
1597
|
+
import { preflight } from '@voltkit-rtc/react-native';
|
|
1598
|
+
|
|
1599
|
+
export async function checkBeforeTheCall(wsUrl: string, token: string): Promise<string> {
|
|
1600
|
+
const report = await preflight(wsUrl, token, { durationMs: 4000 });
|
|
1601
|
+
if (report.ok) return `Ready: ${report.level ?? 'unknown'} (${report.rttMs ?? '?'} ms, ${report.lossPct ?? '?'} % loss)`;
|
|
1602
|
+
const failed = report.steps.find((step) => !step.ok);
|
|
1603
|
+
return `Not ready: ${failed?.name ?? 'unknown'} ${failed?.error ?? ''}`.trim();
|
|
1604
|
+
}
|
|
1605
|
+
```
|
|
1606
|
+
|
|
1607
|
+
## Licence
|
|
1608
|
+
|
|
1609
|
+
MIT
|