@reactoo/watchtogether-sdk-js
v2.8.105
Published
Javascript SDK for Reactoo
Downloads
6,357
Maintainers
Readme
Reactoo Watch Together SDK for JavaScript
- Description
- Browser support
- Installation
- Quick start
- Lifecycle
- Sessions and roles
- Publishing media
- Rendering remote participants
- Events
- Messaging
- Real-time room state (IoT)
- Connection health and recovery
- Error handling
- API reference
- Flutter / mobile
- Questions and feedback
Description
The Reactoo Watch Together SDK provides client-side functionality that:
- Manages and joins Watch Together video rooms (WebRTC, via a Janus SFU)
- Calls the Reactoo API directly, with the surface built at runtime from the live OpenAPI spec
- Delivers real-time room state over AWS IoT (MQTT) and a WebRTC data channel
- Synchronises an OTT live stream to the room, and captures participant reactions
Reactoo Watch Together lets you create a video room and invite participants to share the emotion of watching live video together — seeing, hearing and chatting while the action plays.
Browser support
|
|
|
|
|
|
| --- | --- | --- | --- | --- |
| 56+ ✔ | 44+ ✔ | 43+ ✔ | 67+ ✔ | 12.1+ ✔ |
The REST surface is not bundled: the SDK fetches
the OpenAPI spec on load and builds
client.apis.<tag>.<operationId> from it, so new endpoints appear without an SDK upgrade.
Installation
npm install --save @reactoo/watchtogether-sdk-js<script src="https://unpkg.com/@reactoo/watchtogether-sdk-js"></script>Quick start
// 1. Create an SDK instance. Outer call configures the SDK, inner call the tenant.
const wt = WatchTogetherSDK({ debug: false })({ instanceType: 'reactooDemo' });
// 2. Authenticate. deviceLogin() needs no credentials — it derives a stable
// device id from a browser fingerprint and creates an anonymous user.
await wt.auth.deviceLogin();
// 3. Connect the real-time channel (room state, presence, attribute changes).
wt.iot.iotLogin();
// 4. Fetch the room, then subscribe to its IoT topic.
const room = (await wt.room.getRoomById(roomId)).data;
wt.iot.subscribe(room.iotTopic);
// 5. Create a session and wire your listeners BEFORE connecting — `connect()`
// emits joined/feed events while it is still resolving.
const session = await wt.room.createSession({
constructId: myUniqueId, // your handle for this session
roomId,
pinHash: room.pinHash, // only for pin-protected rooms
role: 'participant',
options: {},
});
session.$on('addRemoteParticipant', renderRemote);
session.$on('removeRemoteParticipant', dropRemote);
session.$on('error', (e) => console.error(e));
// 6. Join, then publish.
await session.connect();
await session.publishLocal(await wt.utils.getUserStream({ hasVideo: true, hasAudio: true }));A complete, production-shaped integration lives in example/room.js —
it is the bridge a shipping app uses, including the audio mixer, reconnection watchers and
room-attribute handling.
Lifecycle
The SDK has two nested lifecycles: an instance (auth + IoT, one per app) and a session (one per room join). Keeping them separate is what lets you leave and rejoin a room, or move between rooms, without re-authenticating.
instance: deviceLogin ──► iotLogin ──► getRoomById ──► iot.subscribe(room.iotTopic)
│
session: createSession ──► connect ──► publishLocal
│
disconnect ◄── leave │
destroySession ◄── teardown ◄──┘async function destroy() {
await session?.disconnect();
wt.iot.unsubscribe(room.iotTopic);
await wt.room.destroySession(constructId); // kills the session and unregisters it
}destroySession is idempotent for an unknown id. The SDK also tears every session down on
pagehide/beforeunload automatically.
Sessions and roles
role decides both what you publish and whose feeds you receive. The subscription
matrix is keyed by [yourRole][roomType]:
| Role | Publishes | Typically subscribes to |
| --- | --- | --- |
| participant | camera + mic | other participants, talkback (plus host/observer in studio) |
| talkback | camera + mic | participants, host, other talkback |
| monitor | — | participants, host |
| host | camera + mic | nothing (renders the program feed instead) |
| observer, observerSolo1…5 | — | participants |
Room types: watchparty, studio, template, commentary, intercom, videowall,
videowall-queue, videowall-queue-video. The room's own type arrives on the join
response and is applied automatically.
Override the matrix per session with options.userRoleSubscriptionRules, and narrow it at
runtime:
session.setRestrictToAudioOnly(true); // drop remote video, keep audio
session.setRestrictSubscribeToUserIds([userId]); // only these users ([] = all)
session.setListenIntercomChannels(['producers']); // which intercom groups you hear
session.setTalkIntercomChannels(['producers']); // which groups hear youPublishing media
A session can publish several named sources at once (camera0, screen0, …). Each
carries at most one audio and one video track.
const stream = await wt.utils.getUserStream({
hasVideo: true, hasAudio: true,
width: 1280, height: 720,
aDeviceId, vDeviceId, // optional, from enumerateDevices()
echoCancellation: true, noiseSuppression: true, autoGainControl: false,
muteAudio: false, muteVideo: false,
});
await session.publishLocal(stream); // defaults to source 'camera0'
await session.publishLocal(screenStream, 'screen0'); // a second source
await session.publishLocal(null, 'screen0'); // stop publishing that source
session.toggleAudio(false, 'camera0'); // false = mute, true = unmute, omit = flip
session.toggleVideo();
await session.unpublishLocal();publishLocal resolves once the data channel is open and the local feed events have been
emitted. Renegotiation with the SFU continues in the background; a failure there surfaces
as a restartConnection event rather than a rejected promise.
Screen sharing uses wt.utils.getDisplayMedia(). When the user stops sharing from the
browser's own UI the track ends, and the SDK republishes that source automatically.
Rendering remote participants
The identity model
A feed is not a user — it is a (userId, source) pair. One participant can publish
camera0 and screen0 at once, and each is an independent stream. Most apps key their
store on a composite id:
const userUid = `${participant.userId}|${source}`;streamMap is the authority on which sources currently exist for that publisher:
Object.keys(participant.streamMap) is the complete, current source list. Do not
infer "they stopped publishing" from tracks being empty — roster events legitimately
carry tracks: [] while streamMap is populated.
Reading an event
addRemote* fires for several different reasons, and the flags tell you which:
| Flags | Meaning |
| --- | --- |
| adding: true, source, track set | a track was added to that source — attach it |
| removing: true | a track went away |
| adding: false, removing: false, source: null | a roster event (someone joined / initial roster). tracks and sourceRelatedTracks are [] even if streamMap is populated |
Use sourceRelatedTracks — the SDK has already filtered tracks down to the source this
event concerns.
Pattern A — rebuild (simple, stateless renderers)
Fine when a feed's UI is cheap to recreate and you hold no per-stream state:
function onRemote(data) {
dropAllFeedsFor(data.id);
for (const source of Object.keys(data.streamMap)) {
const tracks = data.tracks.filter((t) => data.streamMap[source].includes(t.id));
if (!tracks.length) continue;
const stream = new MediaStream();
tracks.forEach((t) => stream.addTrack(t));
mount(`${data.id}|${source}`, stream);
}
}The cost: every event rebuilds every source for that publisher. When someone adds
screen0, the camera0 element is torn down and recreated too — a visible flicker, and
any per-stream state attached to it is lost.
Pattern B — incremental upsert (recommended)
Touch only the source the event is about, and derive removals by diffing the source list
against what you already hold. This is what production apps use, because per-stream state
— a <video> element, an audio GainNode, a background-removal pipeline — must survive
unrelated events:
function onRemote(participant) {
const { userId, source, streamMap } = participant;
const previousSources = store.filter((p) => p.userId === userId).map((p) => p.source);
const newSources = Object.keys(streamMap);
const sourcesToRemove = previousSources.filter((s) => !newSources.includes(s));
// 1. add/replace only the source this event is about
if (participant.adding) {
let stream = null;
const tracks = participant.sourceRelatedTracks;
if (tracks.length) {
stream = new MediaStream();
tracks.forEach((t) => stream.addTrack(t));
}
const entry = { ...participant, stream, source, userUid: `${userId}|${source}` };
const i = store.findIndex((p) => p.userId === userId && p.source === source);
if (i > -1) store.splice(i, 1, entry); // replace — keeps position
else store.push(entry);
}
// 2. drop sources that are no longer in streamMap
sourcesToRemove.forEach((gone) => {
teardownPerStreamState(userId, gone);
store.splice(store.findIndex((p) => p.userId === userId && p.source === gone), 1);
});
// 3. present but publishing nothing — keep a placeholder tile
if (newSources.length === 0 && !participant.removing) {
store.push({ ...participant, stream: null, source: 'camera0',
userUid: `${userId}|camera0` });
}
}removeRemote* is per-user, not per-source: drop every entry for that userId.
tid — the change token
Every feed event carries a fresh tid. It exists so you can force a re-render without a
real SDK event: when you swap a stream yourself (a filter pipeline finishing, a track
replacement), re-dispatch the same participant with a new tid and your view layer will
see it as changed.
onPipelineReady(() => onRemote({ ...participant, tid: crypto.randomUUID() }));constructId
Every payload carries the constructId of the session that emitted it. If your app can
hold more than one session at a time, namespace your store by it — otherwise two rooms
will write over each other.
Roles use different keys
The other feed events carry the same payload but are conventionally keyed differently, because their identity semantics differ:
| Event | Key on |
| --- | --- |
| addRemoteParticipant | userId + source |
| addRemoteTalkback, addRemoteObserver | feed id + userId — the same user can appear as more than one feed |
| addRemoteInstructor | fullUserId |
Events
Subscribe with session.$on(name, handler) / $off(name, handler). Session events and
room events share one namespace.
Connection
| Event | Payload |
| --- | --- |
| connecting | bool — the REST join + SFU connect is in flight |
| joining | bool |
| joined | (bool, constructId) |
| disconnect | bool — whether it had been connected |
| reconnecting | bool — a restart() is running |
| kicked | — |
| changeRoomId | roomId — the backend rerouted you to another room |
Publishing
| Event | Payload |
| --- | --- |
| publishing / published | bool |
| addLocalParticipant / removeLocalParticipant | feed payload (above) |
| localHasAudio / localHasVideo | (bool, source) |
| localMuted | {type, value, source, mid} — value: true means muted |
| dataChannel | bool |
Remote feeds — add…/remove… for RemoteParticipant, RemoteTalkback,
RemoteObserver, RemoteInstructor (host), RemoteCompanionTV, RemoteCompanionPhone.
Plus remoteTrackMuted {…, kind, muted}.
Diagnostics
| Event | Payload |
| --- | --- |
| connectionState / iceState | [handleId, isLocal, state] — W3C state strings |
| iceRestart | {state, origin: 'local'\|'remote', status} |
| reclaimingConnection | {state, status} |
| rtcStats | [{id, mid, userId, source, stats: {currentBandwidth, frameRate, framesDecoded, framesDropped, framesReceived, freezeCount, jitter, packetLoss, rtt, totalFreezesDuration, selectedSubstream}}], every 3 s |
| restartConnection | — the SDK asks you to restart() |
| error | see Error handling |
Application — chatMessage, userUpdate, remoteMuted, customData, scaling,
data, dataMessage.
Messaging
Two transports, both through the session:
// Over the SFU — reaches everyone in the room, survives renegotiation.
session.sendChatMessage('hello'); // → peers' `chatMessage`
session.sendChatMessage('psst', [userId]); // to specific users
session.sendSystemMessage('my_action', { any: 'json' });
// Over the WebRTC data channel — lower latency, peer-to-SFU.
session.sendMessageViaDataChannel(null, { hello: 'world' }); // → peers' `dataMessage`sendSystemMessage is what the SDK itself uses for mute state (remote_muted), so custom
actions should use a distinct action name.
Real-time room state (IoT)
Room metadata changes (layout, attributes, presence, recording) arrive over MQTT, not the SFU. Subscribe to the room's topic and filter:
wt.iot.iotLogin(); // connects; retries and refreshes credentials itself
wt.iot.subscribe(room.iotTopic);
wt.iot.$on('message', (msg) => {
if (msg.roomId !== room._id) return; // late delivery from a previous room
if (msg.event !== 'attribute_updated') return;
if (msg.operation === 'multi') msg.value.forEach(applyAttribute);
else applyAttribute(msg);
});
wt.iot.$on('connectionFailure', handleIotDown);
wt.iot.$on('connectionSuccess', handleIotUp);Write attributes back with wt.room.updateAttribute({ roomId, operation: 'set',
attributeName: 'layoutAttributes', value: {...}, id, deviceId, deduplicationId }).
The backend replaces the named attribute wholesale, so send it complete.
Connection health and recovery
The SDK recovers from most drops on its own: it reclaims the Janus session, restarts ICE, re-signs and reconnects IoT credentials, and resubscribes topics. What it cannot fix itself, it reports:
session.$on('restartConnection', () => session.restart());
const trouble = {};
const watch = (key) => (d) => {
if (d.state === true || d.status === 'failed') trouble[key] = true;
else if (d.status === 'success') trouble[key] = false;
render(Object.values(trouble).some(Boolean));
};
session.$on('iceRestart', watch('ice'));
session.$on('reclaimingConnection', watch('reclaim'));
session.$on('connectionState', ([, , state]) => {
trouble.pc = state === 'failed' || state === 'disconnected';
render(Object.values(trouble).some(Boolean));
});session.restart() disconnects, waits, reconnects and republishes whatever was live. It
is a no-op if a restart is already running.
Error handling
Errors are objects, not Error instances:
{ type: 'error' | 'warning', id: <number>, message: <string>, data: <any> }type: 'warning' is informational and recoverable; type: 'error' means the session is
not usable and the conventional response is to disconnect:
session.$on('error', (e) => { if (e.type === 'error') session.disconnect(); });The numeric id is stable, so you can branch on specific failures. Notable ones: 39
data channel did not open, 44 connection timeout, 53 reconnect failed, 54
subscription update failed, 60 unauthorized (403 — the SDK disconnects itself).
REST rejections are separate and carry an HTTP response:
catch (e) {
if (e?.response?.status === 404) … // no such room
if (e?.response?.data?.errorCode === 'INVALID_PASSWORD') … // wrong pin
}API reference
Factory — WatchTogetherSDK({ debug, language, storagePrefix, apiUrl })({ instanceType, playerFactory, providerAuth })
| Namespace | Selected members |
| --- | --- |
| wt.auth | deviceLogin(), login(user, pass), logout(), isLoggedIn(), setLanguage(), $on/$off |
| wt.user | getUserSelf(), updateUserSelf({...}), and the user endpoints |
| wt.room | getRoomById(id, pinHash), getRoomBySlug(slug, password), createRoom({...}), updateRoom({...}), getRoomsList({...}), updateAttribute({...}), updateLayout({...}), queue({...}), roomRecorder({...}), getInviteUrl({...}), setUser({...}), createSession({...}), getSessions(), getSessionBy*(), destroySession(id), destroySessions() |
| wt.iot | iotLogin(), iotLogout(), subscribe(topic), unsubscribe(topic), send(topic, msg), isConnected(), $on/$off/$once |
| wt.asset, wt.sport, wt.liveBarn, wt.system | the corresponding REST namespaces |
| wt.utils | getUserStream({...}), getHostStream({...}), getDisplayMedia(), applyConstraints({...}), decodeJanusDisplay(d), generateUUID() |
Session — connect({reactooRoomId}), disconnect(), restart({reactooRoomId}),
publishLocal(stream, source), unpublishLocal(), toggleAudio(value, source),
toggleVideo(value, source), selectSubStream(id, substream, source, mid),
setRestrictToAudioOnly(v), setRestrictSubscribeToUserIds([]),
setTalkIntercomChannels([]), setListenIntercomChannels([]),
getUserTalkIntercomChannels(userId), sendChatMessage(text, to),
sendSystemMessage(action, value, to), sendMessageViaDataChannel(label, data),
requestKeyFrame(handleId, mid), setBitrateCap(bitrate), changeUserRole(role),
kill(), destroy(), $on/$off/$clear. Getters: userId, roomId, sessionId,
constructId, role.
Everything not listed maps 1:1 onto
the OpenAPI spec — wt.<tag>.<operationId>.
Flutter / mobile
A Dart port for Android and iOS lives in flutter/ and mirrors this SDK
file-for-file, with the same namespaces, method names and event strings. See
flutter/README.md for the mobile-specific parts and
flutter/PLATFORM_GAPS.md for what the mobile WebRTC stack
cannot reproduce.
Questions and feedback
Open an issue, or reach the team at reactoo.com.
