React Native SDK
@applooma/rtc-react-native brings AppLooma RTC to iOS and Android. It ships the same AppEngine vocabulary as the web, plus a useAppEngine hook and an AppVideoView component that renders native video.
Install#
npm install @applooma/rtc-react-native @applooma/rtc-native-core @applooma/rtc-native-webrtccd ios && pod install
All three are needed. React Native's autolinking only looks at your app's own dependencies and never at what those depend on, so the two native packages have to be installed by name — listing them as dependencies of the SDK would leave them unlinked, and the app would build but crash on the first call. Rebuild the app after installing; a reload of the JavaScript bundle is not enough.
React Native 0.73 or newer and React 18 or newer are peer dependencies.
One call in each native project
The audio engine is set up before React Native starts, so it has to be initialised from the host application rather than from JavaScript. Add the call above any other React Native initialisation.
import com.applooma.rtc.reactnative.AppLoomaRtcimport com.applooma.rtc.reactnative.audio.AudioTypeclass MainApplication : Application(), ReactApplication {override fun onCreate() {// Use MediaAudioType instead if the app only plays audio and never// publishes; CommunicationAudioType is the right default for calls.AppLoomaRtc.setup(this, AudioType.CommunicationAudioType())super.onCreate()}}
import applooma_rtc_native_core@mainclass AppDelegate: UIResponder, UIApplicationDelegate {func application(_ application: UIApplication,didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {AppLoomaRtc.setup()// ...}}
Note
#import "AppLoomaRtc.h" followed by [AppLoomaRtc setup];.Initialise once at startup
initAppLooma(): void registers the native WebRTC globals. Call it once, at the top of your entry file, before any component creates an engine or joins a channel. Joining without it fails.
import { initAppLooma } from '@applooma/rtc-react-native';initAppLooma();// ...the rest of your entry file, AppRegistry.registerComponent, etc.
Permissions
Declare camera and microphone use in the native projects, and request them at runtime on Android before joining.
<key>NSCameraUsageDescription</key><string>Used for video calls.</string><key>NSMicrophoneUsageDescription</key><string>Used for voice and video calls.</string>
<uses-permission android:name="android.permission.CAMERA" /><uses-permission android:name="android.permission.RECORD_AUDIO" /><uses-permission android:name="android.permission.INTERNET" /><uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /><uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
Bluetooth headsets. On Android 12 and above, BLUETOOTH_CONNECT is a runtime permission, and without it audio never reaches a Bluetooth headset. Request PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT together with the microphone when Platform.Version >= 31and treat a refusal as non-fatal — the call continues on the phone's own speaker or earpiece. useAppAudioSession(engine) prefers a connected headset on both platforms (iOS needs no extra permission). The UIKit screens ask for it for you.
A complete example#
Your server issues the token. The app never holds your API secret. See Quickstart for the server side.
import React, { useState } from 'react';import { View, Text, Button, PermissionsAndroid, Platform } from 'react-native';import { useAppEngine, AppVideoView } from '@applooma/rtc-react-native';async function requestPermissions() {if (Platform.OS !== 'android') return;await PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.CAMERA,PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,// Android 12+: lets audio reach Bluetooth headsets; a refusal is harmless....(Number(Platform.Version) >= 31 ? [PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] : []),]);}export function CallScreen({ channel }: { channel: string }) {const { engine, join, leave, connectionState, remoteUsers } =useAppEngine({ appId: 'YOUR_APP_ID' });const [busy, setBusy] = useState(false);async function start() {setBusy(true);try {await requestPermissions();// Token comes from YOUR server.const res = await fetch('https://your-server.com/api/rtc-token', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ channel }),});const { token, wsUrl } = await res.json();await join(token, wsUrl, { role: 'host', camera: true });} finally {setBusy(false);}}return (<View style={{ flex: 1 }}><Text>{connectionState}</Text>{remoteUsers.map((user) => (<AppVideoView key={user.uid} user={user} style={{ flex: 1 }} />))}<AppVideoViewlocalengine={engine}style={{ position: 'absolute', top: 24, right: 16, width: 110, height: 150 }}/><Button title="Join" onPress={start} disabled={busy} /><Button title="Leave" onPress={leave} /></View>);}
useAppEngine#
useAppEngine(config: AppEngineConfig)
Creates one engine for the lifetime of the component and keeps the participant list and connection state in React state. The engine is created on the first render and never recreated, so changing appId afterwards has no effect.
| Returns | Type | Description |
|---|---|---|
engine | AppEngine | The engine. Use it for enableCamera, enableMicrophone, sendData and the event API. |
connectionState | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | Re-renders on every change. |
remoteUsers | AppRemoteUser[] | Everyone else in the channel. Updated when someone joins or leaves and when their tracks start or stop, so tiles re-render as video arrives. |
join | (token, wsUrl, options?) => Promise<void> | Calls engine.joinChannel. Stable across renders. |
leave | () => Promise<void> | Calls engine.leaveChannel. Stable across renders. |
This hook does not expose activeSpeakers. Listen for active-speakers-changed on the engine if you need it.
AppEngine#
AppEngine.create(config: AppEngineConfig): AppEngine builds an engine directly, without the hook. It throws if appId is missing.
AppEngineConfig
| Field | Type | Description |
|---|---|---|
appId | string | Required. Your project App ID from the console. Safe to ship in your binary. |
audioScenario | 'call' | 'media' | call (default) is the phone's communication path: echo cancellation tuned for speech, the earpiece by default. media keeps the loudspeaker and music-grade bandwidth while still running in the communication mode so the echo canceller stays engaged — voice rooms with music and live streams belong on media. Applied by useAppAudioSession. Since 0.5.0. |
video | AppVideoConfig | Your own camera: mode (default stable-hd tuning, see Video modes; unreleased), height (0 / unset = by mode, else 360 / 540 / 720 / 1080 / 1440 / 2160, snapped with a warning), fps, maxBitrate in bits per second (under 10 000 warns; unset = 5 / 4 / 2.2 Mbps at 1440p / 1080p / 720p since 0.5.3), minBitrate (reserved), simulcast, codec, degradation. Since 0.5.0. |
audio | AppAudioOptions | echoCancellation, noiseSuppression, autoGainControl — each switchable on its own; all on by default. Since 0.5.0. |
video.codec defaults to 'auto': H.264 on iOS and VP8 on Android, with a VP8 backup layer so a viewer that cannot decode the main stream is served VP8 by the server instead of a black frame.'h265' and 'av1' (since 0.5.3) are used only when the phone reports an encoder for them; otherwise auto applies with a warning. engine.publishedCodec tells you what was chosen.
video.degradation (since 0.5.3) is what the encoder gives up first under pressure: 'auto' keeps the frame rate and drops resolution, 'keep-resolution' keeps the picture sharp and drops frames, 'keep-framerate', 'balanced'.
Video modes (AppVideoMode)
video.mode sets the overall tuning of your camera. Any field you set yourself (height, maxBitrate, simulcast, degradation) still wins over the mode. Since 0.5.4.
| Mode | What it does |
|---|---|
'stable-hd' | Default. One steady HD picture for calls and live rooms: a single layer (no simulcast), resolution kept under pressure (frame rate gives way first), and receivers pinned to the full picture instead of asking for smaller layers by view size. Size chosen once per device from the table below. |
'ultra-hd-4k' | 2160p30 (3840×2160), one layer, H.265 at 16 Mbps where the phone reports an H.265 encoder, else 25 Mbps. iOS only for now: JavaScript cannot check an Android phone’s encoders for a hardware 3840×2160 path, so isUltraHdSupported() is false on Android and the mode falls back to stable-hd with a warning. |
'adaptive' | The behaviour before this release: three simulcast layers, receivers switch layers by view size and network, resolution may drop to keep motion smooth. For large rooms with many small tiles. |
| Device | Resolution | Bitrate cap |
|---|---|---|
| iOS (hardware encoder on every iPhone) | 1080p30 | 3.5 Mbps |
| Android (JavaScript cannot check the encoder for hardware) | 720p30 | 1.8 Mbps |
4K is a premium mode. It needs a strong network at both ends (20 Mbps or more upload for the sender and download for each viewer is recommended), a device whose encoder can do 3840×2160 — checkAppEngine.isUltraHdSupported() first — and it costs more battery and heat than HD. On a device that fails the check the SDK uses 'stable-hd' instead and logs a warning.
// Default: nothing to set — 'stable-hd'.const engine = AppEngine.create({ appId: 'YOUR_APP_ID' });// Premium 4K where the phone can do it.const video = AppEngine.isUltraHdSupported() ? { mode: 'ultra-hd-4k' as const } : {};// The earlier behaviour (three layers, adaptive stream).const adaptive = AppEngine.create({ appId: 'YOUR_APP_ID', video: { mode: 'adaptive' } });
Getting crisp 1080p
'stable-hd' is now the default, so a plain engine already keeps the resolution and sends one steady 1080p layer where the encoder is hardware (720p on a software encoder). Publish from a strong connection, and watch 'video-quality-changed': reason 'bandwidth'means the uplink is the limit, the CPU reason means the device is — drop to 720p then. To force 1080p on a device the tier would put on 720p, set height and maxBitrate explicitly as below.
const engine = AppEngine.create({appId: 'YOUR_APP_ID',video: { height: 1080, fps: 30, maxBitrate: 4_000_000, degradation: 'keep-resolution' },});engine.on('video-quality-changed', (q) => console.log(q.width, q.height, q.fps, q.bitrateKbps, q.layer, q.reason));
joinChannel
joinChannel(token: string, wsUrl: string, options?: AppJoinOptions): Promise<void>
Connects to the channel encoded in the token, then publishes local media according to options. Rejects if the token is invalid or expired, if the connection fails, or if a permission is missing. Calling it while already in a channel throws Already in a channel — call leaveChannel() first.
| Option | Type | Default | Description |
|---|---|---|---|
role | 'host' | 'cohost' | 'audience' | undefined | Tells the SDK whether to publish. With audience, nothing is published even if camera is true. It must match the role in the token — the token is what the server enforces. |
camera | boolean | false | Publish the camera on join. |
microphone | boolean | true | Publish the microphone on join. Pass false to join muted. |
smartRouting | boolean | true | Connect to the nearest AppLooma media server (applooma.dev URLs only). false uses the wsUrl you passed. Since 0.5.13. |
Everyone already present is reported to you
On join, the SDK emits user-joined and track-subscribed for every participant who was already in the channel, so a viewer arriving mid-stream sees the host immediately. The same happens after a reconnect.
Methods
| Method | Returns | Description |
|---|---|---|
joinChannel(token, wsUrl, options?) | Promise<void> | Connects and publishes, as described above. |
leaveChannel() | Promise<void> | Disconnects, stops local capture and clears the remote user list. The engine stays usable. |
destroy() | Promise<void> | Disconnects and releases everything, including your listeners. The instance cannot be reused. |
enableCamera(on = true) | Promise<void> | Publishes or unpublishes the camera. |
enableMicrophone(on = true) | Promise<void> | Publishes or unpublishes the microphone. |
sendData(data, reliable = true) | Promise<void> | Broadcasts a string or Uint8Array to every participant. Strings are UTF-8 encoded. Pass false for lossy delivery. |
on(event, cb) | this | Registers an event listener. Chainable. |
off(event, cb) | this | Removes a listener. Pass the same function reference you passed to on. |
There is no enableScreenShare or switchCamera on this platform.
State
| Property | Type | Description |
|---|---|---|
connectionState | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | Current connection state. |
localUid | string | The identity your server put in the token. |
channelName | string | The channel currently joined. |
remoteUsers | AppRemoteUser[] | Everyone else in the channel right now. |
localVideoTrack | video track | undefined | The published local camera track, or undefined when the camera is off. Use it to check whether the preview has anything to show. |
config | AppEngineConfig | The config the engine was created with. |
Events#
Register with engine.on(event, cb) and remove with engine.off(event, cb). Both return the engine, so calls chain. An exception thrown inside a listener is caught and logged.
| Event | Payload | When it fires |
|---|---|---|
user-joined | (user: AppRemoteUser) | Someone joined, or was already present when you joined. |
user-left | (user: AppRemoteUser) | Someone left. Also fired for everyone remaining when you get disconnected. |
track-subscribed | (user: AppRemoteUser) | A remote camera or microphone became available. Note the payload is the user, not a track — render with AppVideoView. |
track-unsubscribed | (user: AppRemoteUser) | A remote track stopped. |
connection-state-changed | (state) | The connection state changed. |
data-received | (data: Uint8Array, from?: AppRemoteUser) | Someone called sendData. Decode with TextDecoder if a string was sent. |
gift-received | (gift: AppGiftEvent) | A virtual gift was sent through the REST API. Gifts also arrive on data-received as raw JSON. |
active-speakers-changed | (uids: string[]) | The set of speaking identities changed. May include your own localUid. |
local-video-changed | () | Your own camera track was (re)created — camera off→on, a device change. AppVideoView re-renders by itself; use this for anything else holding your preview. Since 0.5.0. |
first-remote-video-frame | (uid, elapsedMs) | The first decoded frame of that user’s camera reached the screen; elapsedMs counts from joinChannel. Since 0.5.0. |
connection-stage | (stage, uid?, elapsedMs) | 'connected' when the channel is up, 'first-remote-video-frame' per user — each with ms since joinChannel. Since 0.5.0. |
remote-stats | (stats: AppRemoteStats[]) | Every two seconds, one entry per remote user: videoFps, size, videoFreezeCount, packet loss, audioJitterMs — tells you whose picture is stuck or whose audio is dropping. Since 0.5.0. |
video-quality-changed | (quality: AppVideoQualityInfo) | Your own camera's top layer that carries frames changed size, layer or reason: { width, height, fps, bitrateKbps, layer: 'high' | 'medium' | 'low', reason: 'bandwidth' | 'cpu' | 'none' }. Checked every two seconds while the camera is on. Since 0.5.3. |
There is no error or disconnected event on this platform. Watch connection-state-changed for disconnected, and handle failures with try/catch around join.
AppGiftEvent
interface AppGiftEvent {txId: string;gift: { id: string; name: string; imageUrl: string; coinPrice: number };sender: string; // identity of the senderreceiver: string; // identity of the receiverquantity: number;sentAt: string; // ISO 8601}
AppRemoteUser#
| Property | Type | Description |
|---|---|---|
uid | string | The identity your server assigned in the token. Unique in the channel. |
displayName | string | undefined | The name from the token, if your server set one. |
metadata | string | undefined | The metadata from the token, as a string. Parse it yourself if you stored JSON. |
isSpeaking | boolean | True while this user is talking. |
hasVideo | boolean | The user is publishing a camera. Use it to decide between a video tile and an avatar placeholder. |
videoTrack | video track | undefined | The camera track, or undefined. AppVideoView uses this for you. |
These properties read live state rather than a snapshot. They do not trigger a re-render on their own; the hook re-renders on join, leave and track changes, which is when they are worth re-reading.
Automatic codec recovery#
A stream that viewers receive but cannot show is healed by the SDK by itself. There is nothing to turn on. Since 0.5.6.
- When your app shows someone’s camera and it arrives but no picture can be decoded for more than six seconds while their camera is on, the SDK tells that publisher (and only them) with a small internal message. Internal messages never reach your message or data callbacks.
- On the publishing side, reports from two different viewers within 20 seconds (or from the only other person in a one-to-one call) make the SDK republish your camera on a safer path: VP8 if it was on another codec, otherwise once more as a single layer. Viewers see about half a second of interruption; audio is not touched. This happens at most once per join.
- The platform also learns from anonymous device reports (make and model, OS and SDK version, the codec and whether it worked; no user, identity or IP address) and tunes video settings per device model, such as the codec, a resolution or frame rate cap, or a single layer. The SDK fetches that tuning in the background at join, never delaying it, and applies it from the next camera publish.
engine.on('video-codec-fallback', (from, to, reason) => {// e.g. from = 'h264', to = 'vp8', reason = 'viewers_cannot_decode'console.log(`camera switched ${from} -> ${to} (${reason})`);});
reason is 'no_frames', 'encoder_error' or 'viewers_cannot_decode'. engine.publishedCodec reflects the switch. The event is for your logs and support tooling; the app has nothing to do.
Remote diagnostics#
So AppLooma support can look into a problem on your users' devices without asking them to reproduce it, the SDK keeps the SDK's own recent log lines (join, connection state, reconnects, video out resolution, fps and bitrate, codec and codec recovery, errors thrown by your event listeners) and a stats snapshot every 10 s in memory. They are uploaded, at most about once per call when you leave or are disconnected (and once 60 s into a call), only when:
- the call had a problem: a codec fallback, a viewer reporting no picture, an encoder failure, a failed join, or repeated reconnects;
- AppLooma support turned log collection on for this device model or your project (for a limited time);
- your app asked for it, for example from a "Report a problem" button.
engine.uploadDiagnostics('user_report');
Never collected: audio, video, screen content, chat or data messages, display names, metadata or attribute values. User ids are replaced by a short one-way hash, tokens and URL parameters are removed, and only lines the SDK itself produced are included. If the phone is offline, the latest upload is kept in memory and sent at the next join in the same app session (there is no storage dependency, so it does not survive an app restart). Uploads are stored for 14 days and then deleted. Since 0.5.7.
To turn every upload off, set remoteDiagnostics: false:
const engine = AppEngine.create({ appId: 'YOUR_APP_ID', remoteDiagnostics: false });
Smart routing#
joinChannel asks AppLooma which media server is nearest the device and connects there automatically. There is no backend change to make: keep passing the token and URL your server returns. A room stays on one server, so everyone in it meets in the same place even when they join from far apart. It only applies to applooma.dev URLs; any other URL is used as given. Since 0.5.13.
To connect to exactly the URL you passed, opt out:
await engine.joinChannel(token, wsUrl, { role: 'host', camera: true, smartRouting: false });
Network test#
startNetworkTest(serverUrl, token, callback?) checks the link before a call. It opens a short, separate test connection (your current channel, if any, is not touched), samples it for about five seconds without publishing anything, disconnects, and reports once. Mint the token on your server for a throwaway channel such as nettest-<uid>; use the wsUrl from the same token response as serverUrl. It is best run before joining. Since 0.5.7.
const r = await engine.startNetworkTest(wsUrl, testToken);if (r.error) console.warn(r.error);else console.log(r.uplinkQuality, r.downlinkQuality, r.rttMs, r.downlinkLoss);
| Field | Meaning |
|---|---|
| uplinkQuality / downlinkQuality | 'excellent' | 'good' | 'poor' | 'lost' | 'unknown' |
| rttMs | Round trip to the server in ms, -1 when unknown |
| jitterMs | Packet timing variation in ms |
| uplinkLoss / downlinkLoss | Fraction of packets lost, 0 to 1 |
| error | null, or why the test did not finish (it gives up after 10 s) |
Because nothing is published, loss figures are only meaningful when packets actually flowed; the quality score and round trip always are.stopNetworkTest() ends a running test, which then reports error: 'Network test stopped'.
Note
Fallback on a poor network#
Tell the SDK what to give up when the network cannot carry everything. Both default to 'none'. A direction falls back after four seconds of poor or lost quality (or more than 10% packet loss) and is restored after ten seconds of good quality. Since 0.5.7.
engine.setRemoteSubscribeFallback('audio-only'); // cameras you receiveengine.setLocalPublishFallback('video-low-quality'); // your own cameraengine.on('fallback-state-changed', (direction, state) => {// direction: 'local' | 'remote'; state: the option in effect, 'none' = restored});
setRemoteSubscribeFallback('video-low-quality')asks for the smallest layer of every remote camera. While adaptive stream chooses layers by itself this is best effort.setRemoteSubscribeFallback('audio-only')pauses every remote camera and resumes them on recovery. Cameras your app paused itself are left alone.setLocalPublishFallback('video-low-quality')lowers your camera’s bitrate cap to 30% (at least 300 kbps);'audio-only'mutes your camera while keeping it published, and unmutes it on recovery.
Restrictive networks#
Some office, hotel and school networks block UDP and every port but 443. The cloud proxy carries the call through AppLooma's relay over TLS on port 443 so it still connects. Set it with cloudProxy:
'auto'(default): connect directly; if that join fails with a connection or network timeout, retry once through the relay on TLS 443. A refused token is not retried.'force-tls-443': always relay on TLS 443. Use it when you already know the network is locked down; it adds a little latency.'off': never relay.
The 'proxy-state-changed' event (state, autoRetry) reports the state: 'direct' (not relayed, also after leaving), 'connecting' (joining through the relay) and 'connected' (in the channel through the relay), with a flag that is true when the relay is used because the direct join failed. The SDK also logs the retry. Since 0.5.8.
const engine = AppEngine.create({ appId: 'YOUR_APP_ID', cloudProxy: 'force-tls-443' });engine.on('proxy-state-changed', (state, autoRetry) => {if (state === 'connected') setBadge('Connected through the cloud proxy');});
Video enhancement#
await engine.setVideoEnhance(options) turns on built-in camera enhancement: skin smoothing (beauty 0 to 1), lowLight boost, sharpen (0 to 1) and warmth (-1 cooler to 1 warmer). It runs on the capture path, so your own preview and what viewers receive are both enhanced. It survives camera off/on, camera switches and republishes; set it before the camera is on and it applies as soon as the camera starts. Pass null to turn it off; engine.videoEnhance reads the options in force. The SDK now includes a small native module (autolinked): rebuild the app and run pod install after upgrading. Without it the call resolves false. Since 0.5.10.
import { AppVideoEnhancePortrait } from '@applooma/rtc-react-native';await engine.setVideoEnhance(AppVideoEnhancePortrait); // beauty 0.5 + low lightawait engine.setVideoEnhance({ beauty: 0.3, sharpen: 0.4, warmth: 0.2 });await engine.setVideoEnhance(null); // off
Keep audio running in background#
On iOS, a call or voice room stops as soon as the app leaves the screen unless the app declares background audio. Add UIBackgroundModes with audio (and voip if your app rings for incoming calls) to ios/YourApp/Info.plist:
<key>UIBackgroundModes</key><array><string>audio</string><string>voip</string></array>
In Xcode this is Signing & Capabilities → Background Modes → Audio, AirPlay, and Picture in Picture and Voice over IP. Only declare voip if you really handle calls: App Review rejects it otherwise. Android needs nothing extra while a call is active, but a foreground service keeps the microphone alive on long background sessions.
Audience latency#
An audience member does not talk back, so a little extra delay buys smoother playback on a bad network. audienceLatency sets the tier; it only applies while the local user is audience (publishing nothing) and switches back as soon as they go on stage. Since 0.5.9.
'ultra-low'(default): the lowest delay, the behaviour before this option.'low': same as'ultra-low'on React Native: no receive-buffer control is reachable here.'standard': prefers lower video layers under congestion: turns onsetRemoteSubscribeFallback('video-low-quality')unless you set a fallback yourself.
const engine = AppEngine.create({ appId: 'YOUR_APP_ID', audienceLatency: 'standard' });
Clear Voice#
audio.noiseSuppression chooses the noise suppression mode:'standard' (default) is the standard suppressor,'clear' is Clear Voice, 'off' turns suppression off. Since 0.5.9.
'clear' adds a stronger spectral suppressor on the microphone's post-processing stage, after the standard one: steady noise (fans, hiss, hum, traffic) is removed far more strongly while speech passes. It adds about 10 ms of latency. On Android it needs AppLoomaRtc.setup() in your Application.onCreate (already part of installation). Since 0.5.10 (before that, 'clear' ran the standard suppressor only); rebuild the app and run pod install after upgrading.
AppEngine.create({ appId: 'YOUR_APP_ID', audio: { noiseSuppression: 'clear' } });
Codec detection#
await AppEngine.getSupportedVideoCodecs() lists the video codecs this device can send and receive, each as mime, hardware, encoder anddecoder. The lists come from the phone's media engine. hardware is true for H.264 and H.265, which the phone only offers on a hardware encoder; VP8, VP9 and AV1 report false because JavaScript cannot tell. Since 0.5.9.
const codecs = await AppEngine.getSupportedVideoCodecs();
Token renewal#
About 30 seconds before the join token expires (at once, if less is left at join) the engine emits 'token-will-expire'. Fetch a new token from your server and hand it over with renewToken(token), which also re-arms the warning. Since 0.5.7.
engine.on('token-will-expire', async () => {const { token } = await fetch('/my-server/rtc-token?channel=' + channel).then((r) => r.json());engine.renewToken(token);});
While you are connected the server keeps the live connection’s own credential fresh; the renewed token is what the SDK uses for a later reconnect and for its own requests (chat history, device tuning). Calling it outside a channel just stores the token.
Rendering video#
AppVideoView renders either a remote user or the local preview. It returns null when there is no camera track yet, so it is safe to render before anyone has published.
| Prop | Type | Description |
|---|---|---|
user | AppRemoteUser | The remote user to render. Ignored when local is set. |
local | boolean | Render the local camera preview instead of a remote user. |
engine | AppEngine | Required when local is set. Ignored otherwise. |
style | StyleProp<ViewStyle> | Style for the video view. Give it explicit dimensions or flex — with no size, nothing is visible. |
mirror | boolean | Flips the image horizontally. Defaults to true for the local preview and false for remote users. |
// Remote user<AppVideoView user={user} style={{ flex: 1 }} />// Local preview — needs engine, not user<AppVideoView local engine={engine} style={{ width: 110, height: 150 }} />
Audio needs no component. Remote audio plays automatically once subscribed.
useAppAudioSession
Configures the audio session for the engine's audioScenario. Call it in the component that lives for the duration of the call. On iOS it handles routing, the speaker and interruptions from phone calls. On Android (since 0.5.0) the mediascenario keeps the loudspeaker and media volume keys while running in the communication audio mode, so the phone's echo canceller stays engaged — a plain media session has no canceller, and a loudspeaker session then feeds itself back after a minute or two.
import { useAppEngine, useAppAudioSession } from '@applooma/rtc-react-native';const { engine } = useAppEngine({ appId: 'YOUR_APP_ID' });useAppAudioSession(engine);
Lifecycle and cleanup#
useAppEngineremoves the listeners it added and leaves the channel when the component unmounts, so navigating off the call screen ends the call. Without that removal a remounted screen would stack listeners and fire each callback once per mount.leaveChannel()ends the call but keeps the engine and your listeners. Use it between calls.destroy()ends the call and releases the engine permanently, clearing every listener. The instance cannot be reused afterwards. The hook does not call it.- Remove listeners you registered yourself with
off(), passing the same function reference, in the cleanup of the effect that added them. - Leave the channel when the app goes to the background if you do not want the call to continue there. Listen to React Native's
AppStateand callleave(), or at leastengine.enableCamera(false).
Troubleshooting#
Joining fails immediately after a fresh install
initAppLooma() was not called, or was called too late. It must run once in your entry file, before any engine is created. Also confirm you rebuilt the native app after installing the package — a JavaScript reload does not link native modules.
Permission denied
On iOS, a missing NSCameraUsageDescription or NSMicrophoneUsageDescription makes the app fail to get devices. On Android, request CAMERA and RECORD_AUDIO with PermissionsAndroid before calling join; the manifest entries alone are not enough. If the user has permanently denied a permission, send them to the system settings — the prompt will not appear again.
No video appears at all
Check the role in the token. A participant whose token was issued with role audience cannot publish, so nobody sees them, and passing camera: true to join does not override it. Issue a host or cohost token instead.
AppVideoView shows nothing
Three common causes: the style has no size, so the view is zero pixels tall; the user has not published a camera yet, in which case the component returns null — check user.hasVideo; or you passed local without engine, which also renders nothing.
A device gets disconnected on its own
Two clients joined with the same identity. The newer connection wins and the older one is dropped. Give each participant a unique identity in the token, and add a device suffix when the same account may be signed in on a phone and a tablet at once.
The channel looks empty
Both sides must use the same channel name. Channel names must match /^[a-zA-Z0-9_-]{1,64}$/ — no spaces, dots or slashes — and are compared exactly, including case.
The other side hears themselves
You are on the loudspeaker without the phone's echo canceller. Call useAppAudioSession(engine) in the call screen; with audioScenario: 'media' it keeps the loudspeaker and still runs the session in the communication mode where the canceller lives.
What changed#
0.5.13
- Smart routing:
joinChannelconnects to the nearest AppLooma media server for the device; the room stays on one server. Only applies to applooma.dev URLs, with a 2 s limit and a silent fallback to the wsUrl you passed. Opt out withsmartRouting: false. See Smart routing.
0.5.12
stable-hdnow degradesbalancedby default, so a live stream is smooth from its first seconds instead of starting at a few frames per second while bandwidth ramps up.ultra-hd-4kstill keeps resolution, and an explicit degradation setting is honoured.
0.5.11
- Cloud proxy auto-retry no longer reports a disconnect to the app while it switches to the relay.
- UIKit 0.1.9 requires 0.5.11 or newer; no UIKit API changes.
0.5.10
- Video Enhance (
setVideoEnhance) and Clear Voice ('clear') now work on Android and iOS through a small autolinked native module — rebuild the app and runpod installafter upgrading. See the sections above.
0.5.9
setVideoEnhanceand Clear Voice modes added for API parity — see the sections above.- Audience latency tiers (
audienceLatency) — see the sections above. - Codec detection (
AppEngine.getSupportedVideoCodecs()) — see the sections above. - The iOS sample keeps audio running in the background.
0.5.8
- Cloud proxy for restrictive networks (
cloudProxy,'proxy-state-changed') — see the section above.
0.5.7
- Remote diagnostics (
uploadDiagnostics,remoteDiagnostics), region hint (region), network test (startNetworkTest), network fallback (setRemoteSubscribeFallback,setLocalPublishFallback,fallback-state-changed) and token renewal (token-will-expire,renewToken) — see the sections above.
0.5.6
- Automatic codec recovery: a camera that viewers cannot show is republished on a safer path by itself. New
video-codec-fallbackevent. Per-device video settings are fetched in the background at join.
0.5.3
video.degradation—'keep-resolution'/'keep-framerate'/'balanced'/'auto';video.minBitrate(reserved).- Default caps raised: 4 Mbps at 1080p, 2.2 Mbps at 720p when
maxBitrateis unset. Newheight: 1440preset (2560×1440, 5 Mbps, 30 fps). codec: 'h265'and'av1', used only where the phone reports an encoder, with the VP8 backup layer kept.video-quality-changed— what your own camera is sending (size, fps, bitrate, layer, reason).
0.5.2
- Android:
codec: 'auto'publishes VP8 so phones without an engine-supported H.264 encoder (MediaTek and others) never publish a black camera;'h264'is still honoured when set explicitly.
0.5.0
AppEngineConfig.audioScenario,.videoand.audio;codec: 'auto'publishes H.264 with a VP8 backup layer.engine.publishedCodec.- Every publisher always sends and every joiner receives all tracks — fixes co-hosts seeing a black frame on flaky links.
useAppAudioSessionnow configures Android too: themediascenario runs in the communication mode with media attributes so the echo canceller stays engaged on the loudspeaker.- The local
AppVideoViewfollows the camera through restarts;local-video-changed. remote-stats,first-remote-video-frame,connection-stage.- Guardrails:
maxBitrateunder 10 000 logs a units warning; a non-presetheightsnaps to the nearest preset with a warning.