Web SDK
@applooma/rtc-web is the browser client for AppLooma RTC. It gives you one class, AppEngine, that connects to a channel, publishes your camera and microphone, and hands you remote tracks you can attach to a DOM element.
Install#
npm install @applooma/rtc-web
The package ships ESM and CommonJS builds with TypeScript types. It runs in the browser only — it needs navigator.mediaDevices and a secure context (https:// or http://localhost).
A complete example#
Your server issues the token. The browser never sees your API secret. See Quickstart for the server side.
import AppEngine from '@applooma/rtc-web';const engine = AppEngine.create({ appId: 'YOUR_APP_ID' });// Render remote media as it arrives.engine.on('track-subscribed', (track, user) => {const el = track.attach(); // <video> or <audio>el.id = `peer-${user.uid}-${track.kind}`;document.getElementById('remote')!.append(el);});engine.on('track-unsubscribed', (track, user) => {document.getElementById(`peer-${user.uid}-${track.kind}`)?.remove();track.detach();});engine.on('user-left', (user) => {document.querySelectorAll(`[id^="peer-${user.uid}-"]`).forEach((el) => el.remove());});engine.on('error', (err) => console.error('[rtc]', err.message));// Token comes from YOUR server.const res = await fetch('/api/rtc-token', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ channel: 'lobby' }),});const { token, wsUrl } = await res.json();await engine.joinChannel(token, wsUrl, { role: 'host', camera: true });// Local preview.engine.attachLocalVideo(document.getElementById('local') as HTMLVideoElement);
<video id="local" autoplay playsinline muted></video><div id="remote"></div>
Mute the local preview
The local <video> element must carry muted and playsinline. Without muted the browser blocks autoplay, and you get a black preview with no error.
AppEngine.create#
AppEngine.create(config: AppEngineConfig): AppEngine — builds an engine. Throws if appId is missing. Creating an engine does not open a connection or ask for device permissions; joinChannel does both.
| Field | Type | Default | Description |
|---|---|---|---|
appId | string | required | Your project App ID from the console. Safe to ship in your bundle. |
adaptiveStream | boolean | true | Adjusts the received video quality to the network and to the on-screen size of the element the track is attached to. Set false to always receive full quality. |
video | AppVideoConfig | 1080p / 30 fps / auto | Your own camera: height (360 / 540 / 720 / 1080 / 1440), fps, maxBitrate and minBitrate in bits per second, simulcast, codec, degradation. See below. Since 0.4.1. |
audio | AppAudioOptions | all on | Microphone processing: echoCancellation, noiseSuppression, autoGainControl — each switchable on its own. Since 0.4.1. |
AppVideoConfig
| Field | Default | Description |
|---|---|---|
mode | 'stable-hd' | stable-hd, ultra-hd-4k or adaptive — see Video modes below. Fields you set explicitly win over the mode. Since 0.4.4. |
height | by mode | One of 360 / 540 / 720 / 1080 / 1440 / 2160 (1440 since 0.4.3, 2160 since 0.4.4). Anything else snaps to the nearest preset with a console warning. |
fps | 30 | Capture and publish frame rate. |
maxBitrate | by height | Bits per second — 1_900_000 for 1.9 Mbps. Unset: chosen by the mode — 3.5 Mbps at 1080p and 1.8 Mbps at 720p in stable-hd, 16 / 25 Mbps in ultra-hd-4k; in adaptive 5 Mbps at 1440p, 4 Mbps at 1080p, 2.2 Mbps at 720p, the preset default below that (since 0.4.3; was 3 / 1.7 Mbps). A value under 10 000 is almost certainly kbps by mistake and logs a warning. |
minBitrate | 0 | Bits per second; 0 = engine default. Reserved: browsers do not honour a per-sender floor today, so it is kept but does not change what is sent. Since 0.4.3. |
simulcast | by mode | Three layers so the server can hand each viewer what their link can take. Unset: on in adaptive, off in stable-hd and ultra-hd-4k. |
codec | 'auto' | auto publishes H.264 with a VP8 backup layer, so a browser that cannot decode H.264 is served VP8 by the server instead of a black frame. vp8 or h264 force one. h265 and av1 (since 0.4.3) are used only when this browser can encode them — otherwise auto applies with a console warning — and keep the VP8 backup layer. engine.publishedCodec tells you what was chosen. |
degradation | 'auto' | What the encoder gives up first under pressure: auto (keep-resolution in stable-hd and ultra-hd-4k; in adaptive the engine's choice for a camera: frame rate stays, resolution drops), keep-resolution (sharp picture, frames dropped), keep-framerate, balanced. Since 0.4.3. |
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.4.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 browser can encode it, else H.264 at 25 Mbps. Browsers do not report encoder limits up front, so isUltraHdSupported() is true wherever the browser can send video; a machine that cannot keep up drops frames rather than size. |
'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 |
|---|---|---|
| Desktop with 8 or more cores | 1080p30 | 3.5 Mbps |
| Fewer cores, or a phone / tablet browser | 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.const uhd = AppEngine.create({ appId: 'YOUR_APP_ID', video: { mode: 'ultra-hd-4k' } });// 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' },});// Watch what is actually going out. If reason is 'bandwidth' the uplink is the// limit; if it is 'cpu', the machine is — drop to 720p or lower fps.engine.on('video-quality-changed', (q) => {console.log(`${q.width}x${q.height} @${q.fps} ${q.bitrateKbps} kbps layer=${q.layer} reason=${q.reason}`);});
On Wi-Fi with a weak uplink, keep-resolution shows as a lower frame rate rather than a soft picture; if that is worse for your content, leavedegradation on auto. H.265 (codec: 'h265') gives the same picture in roughly 30 % fewer bits where the browser has a hardware encoder — check engine.publishedCodec to see whether it was taken.
Joining and leaving#
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. Resolves once the connection is established and the requested tracks are published. Rejects if the token is invalid or expired, if the connection fails, or if the user denies camera or microphone access.
The channel name, identity and role all come from the token, not from the client. wsUrl is returned alongside the token by your server. Calling joinChannel 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. When set to audience, no camera or microphone is published regardless of the other options. 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.4.12. |
Everyone already in the channel is reported to you
On join, the SDK emits user-joined and track-subscribed for every participant who was already present, so a viewer arriving mid-stream sees the host immediately. The same happens after a reconnect.
leaveChannel
leaveChannel(): Promise<void> — disconnects, stops local capture and clears the remote user list. The engine stays usable: you can call joinChannel again with a new token. Your event listeners remain registered.
destroy
destroy(): Promise<void> — disconnects and releases everything: internal listeners, your listeners, and the remote user list. The instance cannot be reused afterwards. Call it when the user leaves the call screen for good.
Local media#
| Method | Returns | Description |
|---|---|---|
enableCamera(on = true) | Promise<void> | Publishes or unpublishes the camera. The first call prompts for permission. |
enableMicrophone(on = true) | Promise<void> | Publishes or unpublishes the microphone. |
enableScreenShare(on = true) | Promise<void> | Starts or stops a screen share. The browser shows its own picker; rejecting it rejects the promise. |
switchCamera() | Promise<void> | Flips between the front and back camera. On a device with only one camera the call keeps the current camera and emits an error event instead of failing. |
setDevice(kind, deviceId) | Promise<boolean> | Switches to a specific camera ('videoinput'), microphone ('audioinput') or speaker ('audiooutput') by the id from enumerateDevices(). Works mid-call; the published track is restarted. |
AppEngine.listDevices(kind?) | Promise<MediaDeviceInfo[]> | The devices the browser will let you choose from, optionally filtered by kind. Labels are empty until permission has been granted once. |
attachLocalVideo(el) | void | Attaches the local camera track to a media element and keeps it attached through every restart — camera off→on, switchCamera, a device change. Since 0.4.1 you can call it before the camera is on; the element fills in when it is. |
detachLocalVideo(el) | void | Stops the element following the local camera. |
sendData(data, reliable = true) | Promise<void> | Broadcasts a Uint8Array or string to every participant. Strings are UTF-8 encoded. Pass false for lossy delivery of data you can afford to drop, such as cursor positions. |
The self-preview follows your camera
Before 0.4.1, attachLocalVideo bound the element to the track that existed at that moment, so a camera restart left the preview on the last frame of a dead track. It now re-binds on every local track change; the local-video-changed event fires at the same time for anything else that holds your preview.
Choosing a device
Desktops have more than one of everything. List them once permission has been granted, and switch whenever the user picks one — the call does not drop.
const mics = await AppEngine.listDevices('audioinput');select.replaceChildren(...mics.map((d) => new Option(d.label || 'Microphone', d.deviceId)));select.onchange = () => engine.setDevice('audioinput', select.value);
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. A fresh array on every read. |
Events#
on(event, callback): this registers a listener and off(event, callback): this removes it. Both return the engine, so calls chain. Pass the same function reference to off that you passed to on. An exception thrown inside a listener is caught and logged; it does not break the engine.
| 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 | (track: AppTrack, user: AppRemoteUser) | A remote audio or video track is ready to render. |
track-unsubscribed | (track: AppTrack, user: AppRemoteUser) | A remote track stopped. Remove its element. |
connection-state-changed | (state) | The connection state changed. Useful for a reconnecting banner. |
disconnected | (reason?: string) | The connection ended. The reason is a short code such as DUPLICATE_IDENTITY or PARTICIPANT_REMOVED, and may be undefined for a clean local leave. |
data-received | (data: Uint8Array, from?: AppRemoteUser) | Someone called sendData. Decode with TextDecoder if you sent a string. |
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 currently speaking identities changed. May include your own localUid. |
error | (err: Error) | A device error, such as permission denied or a camera in use by another application. |
local-video-changed | () | Your own camera track was (re)created — camera off→on, switchCamera, a device change. Elements attached with attachLocalVideo re-bind by themselves. Since 0.4.1. |
first-remote-video-frame | (uid, elapsedMs) | The first decoded frame of that user’s camera reached the page; elapsedMs counts from joinChannel. Since 0.4.1. |
connection-stage | (stage, uid?, elapsedMs) | 'connected' when the channel is up, 'first-remote-video-frame' per user — each with ms since joinChannel. Since 0.4.1. |
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.4.1. |
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; the first sample is a baseline and not reported. Since 0.4.3. |
const onJoined = (user) => addTile(user.uid);engine.on('user-joined', onJoined).on('user-left', onLeft);// laterengine.off('user-joined', onJoined).off('user-left', onLeft);
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 and AppTrack#
Both are read-only wrappers you receive from events. You never construct them yourself.
AppRemoteUser
| Property | Type | Description |
|---|---|---|
uid | string | The identity your server assigned in the token. Unique in the channel. |
displayName | string | undefined | The name field from the token, if your server set one. |
metadata | string | undefined | The metadata your server attached to the token, as a string. Parse it yourself if you stored JSON. |
isSpeaking | boolean | True while this user is talking. |
audioEnabled | boolean | The user is publishing an unmuted microphone. |
videoEnabled | boolean | The user is publishing a camera. |
These properties are live, not a snapshot
isSpeaking, audioEnabled and videoEnabled read the current state each time you access them. React to active-speakers-changed or track-subscribed to know when to re-read them.
AppTrack
| Member | Signature | Description |
|---|---|---|
kind | 'audio' | 'video' | What this track carries. |
attach | attach(el?: HTMLMediaElement): HTMLMediaElement | Attaches the track to the given element and returns it. With no argument it creates a new <video> or <audio> element, already configured for autoplay, and returns that for you to insert into the DOM. |
detach | detach(): void | Detaches the track from every element it was attached to. |
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.4.5.
- 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 browser is offline, the latest upload is kept in localStorage and sent at the next join. Where the browser supports it the upload is gzip-compressed. Uploads are stored for 14 days and then deleted. Since 0.4.6.
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.4.12.
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.4.6.
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'.
Echo test
startEchoTest(seconds?, onDone?) records the microphone for a few seconds (default 5, 2 to 10) and plays it back through the speaker, so the user can hear how they sound. It runs entirely in the browser; no server or token is needed. It resolves with null, or with an error message when the microphone is denied or the browser cannot record. stopEchoTest() ends it early. Since 0.4.6.
const err = await engine.startEchoTest(5);if (err) console.warn('echo test:', err);
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.4.6.
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.4.7.
const engine = AppEngine.create({ appId: 'YOUR_APP_ID', cloudProxy: 'force-tls-443' });engine.on('proxy-state-changed', (state, autoRetry) => {if (state === 'connected') showBadge('Connected through the cloud proxy');});
Video enhancement#
await engine.setVideoEnhance(options) improves your camera before it is sent: beauty (0 to 1, edge-preserving skin smoothing), lowLight (adaptive brightening), sharpen (0 to 1) and warmth (-1 cooler to 1 warmer). Pass null to turn it off. It runs on the GPU (WebGL), through insertable streams where the browser has them and a canvas pipeline elsewhere, and follows the camera through off/on and switchCamera(). It resolves false when the browser has no WebGL. Since 0.4.8.
import { AppVideoEnhancePortrait } from '@applooma/rtc-web';await engine.setVideoEnhance({ beauty: 0.6, lowLight: true });await engine.setVideoEnhance(AppVideoEnhancePortrait);await engine.setVideoEnhance(null); // off
Receivers and super-resolution
Enhancement runs on the sender, so every viewer sees the improved picture at no cost to them. Upscaling a low-resolution stream on the receiving phone (super-resolution) is not offered: doing it well needs machine-learning models that are too heavy for an in-call path on mid-range phones. For viewers on weak networks, use the existing receiver quality controls instead (network fallback and the video quality layers) so they get the best layer their connection carries.
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.4.8.
'ultra-low'(default): the lowest delay, the behaviour before this option.'low': asks the browser for a receive buffer of about 400 ms on every remote track, for smoother playback on a bad network.'standard': about 1000 ms, and prefers lower video layers under congestion (turns onsetRemoteSubscribeFallback('video-low-quality')unless you set a fallback yourself). A browser without a receive-buffer control ignores the delay part.
const engine = AppEngine.create({ appId: 'YOUR_APP_ID', audienceLatency: 'low' });
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.4.8.
'clear' runs a neural noise suppression model in an audio worklet on top of the browser's own suppressor: keyboard clicks, fans and background chatter drop out while speech stays natural. Its code and model load the first time Clear Voice is turned on, so apps that never use it download nothing extra. true and false still work and mean 'standard' and 'off'. Switch at runtime with await engine.setNoiseSuppressionMode(mode), which resolves false if the browser cannot run it (no audio worklets or WebAssembly).
const engine = AppEngine.create({ appId: 'YOUR_APP_ID', audio: { noiseSuppression: 'clear' } });await engine.setNoiseSuppressionMode('standard');
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 browser; hardwareis the browser's own "power efficient" report for 720p, false when it does not say. Since 0.4.8.
const codecs = await AppEngine.getSupportedVideoCodecs();console.log(codecs.filter((c) => c.hardware).map((c) => c.mime));
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.4.6.
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#
There is no video component in this SDK — you attach tracks to your own elements. Two patterns work.
engine.on('track-subscribed', (track, user) => {const el = track.attach();el.style.width = '320px';document.getElementById('remote')!.append(el);});
engine.on('track-subscribed', (track, user) => {if (track.kind !== 'video') return;const el = document.querySelector<HTMLVideoElement>(`#tile-${user.uid} video`);if (el) track.attach(el);});
Audio tracks need attaching too, even though nothing is visible. If you only attach video tracks, the call is silent.
Lifecycle and cleanup#
leaveChannel()ends the call but keeps the engine and your listeners. Use it between calls, when the user hangs up and might join another channel.destroy()ends the call and releases the engine permanently. Use it when the page or view is going away. Without it the connection and its internal listeners live for the lifetime of the page.- Remove your own listeners with
off()when the code that registered them goes away.destroy()clears them all;leaveChannel()does not. - Call
detach()on a track when you remove its element, so the element stops holding the media.
window.addEventListener('pagehide', () => {engine.destroy();});
Troubleshooting#
Permission denied
joinChannel rejects, or the error event fires, with a message about permission. The user dismissed the browser prompt, or the site is not on a secure origin. Serve over HTTPS, and re-prompt by calling enableCamera(true) from a button click — browsers ignore permission requests that are not tied to a user gesture after the first refusal.
Video plays but there is no sound
Browsers block audio playback until the user interacts with the page. An element created by attach()may therefore stay paused. Play the elements from inside a click handler, and keep a visible "unmute" button for the case where join happened without a gesture.
document.getElementById('unmute')!.addEventListener('click', () => {document.querySelectorAll('video, audio').forEach((el) => {(el as HTMLMediaElement).play().catch(() => {});});});
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 joinChannel does not override it. Issue a host or cohost token instead.
The first tab gets kicked
Two clients joined with the same identity. The newer connection wins and the older one receives disconnected with reason DUPLICATE_IDENTITY. Give each participant a unique identity in the token, and add a suffix when the same account may be in two places.
The channel looks empty
Confirm both sides used 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.
A remote picture is stuck or black
Listen to remote-stats. A user whose videoFps is 0 while their videoWidth is non-zero is a stalled decode or a paused publisher; a rising videoFreezeCount with packet loss is the network. Since 0.4.1 every publisher always sends and every joiner receives all tracks, so a black tile is no longer a subscription that never started.
What changed#
0.4.12
- 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.4.11
engine.setVideoProcessor(processor): plug a video processor (for example Looma Beauty Camera,@applooma/beauty-web) into the local camera. It replaces the built-in enhance while set; passnullto remove it.
0.4.10
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.4.9
- Cloud proxy auto-retry no longer reports a disconnect to the app while it switches to the relay.
0.4.8
- Video enhancement (
setVideoEnhance) on the GPU — see the sections above. - Clear Voice noise suppression (
audio.noiseSuppression: 'clear',setNoiseSuppressionMode) — see the sections above. - Audience latency tiers (
audienceLatency) — see the sections above. - Codec detection (
AppEngine.getSupportedVideoCodecs()) — see the sections above.
0.4.7
- Cloud proxy for restrictive networks (
cloudProxy,'proxy-state-changed') — see the section above.
0.4.6
- Remote diagnostics (
uploadDiagnostics,remoteDiagnostics), region hint (region), network test (startNetworkTest) and echo test (startEchoTest), network fallback (setRemoteSubscribeFallback,setLocalPublishFallback,fallback-state-changed) and token renewal (token-will-expire,renewToken) — see the sections above.
0.4.5
- 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.4.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 browser can encode them, with the VP8 backup layer kept.video-quality-changed— what your own camera is sending (size, fps, bitrate, layer, reason).
0.4.1
AppEngineConfig.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.
attachLocalVideofollows the camera through restarts;detachLocalVideo;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.
0.3.1
setDevice,AppEngine.listDevices. HD by default: 1080p with three simulcast layers, 96 kbps voice.