livqenoDocs

RTCRooms & Participants

Rooms & Participants

See who's in the room, pick devices, send data, and show video — on Web, React, React Native, and Flutter.

Once your app has joined a room, it needs to build the call screen: a tile for each person, a device picker, maybe a "raise hand" button. This page covers the four pieces most call screens need:

  1. See who's in the room.
  2. Pick a camera, mic, or speaker.
  3. Send small data messages.
  4. Show video on screen.

Before you start: your app should already join a room. See RTC overview or the Quickstart. All code on this page runs in your app, not your backend.

Participants

A participant is one person (or device) in the room. You are the local participant. Everyone else is a remote participant. Each one has an identity: the user id your backend put in their token, like alice. Use it as the key for their tile, and to look up their name and avatar in your own database.

room.localParticipant.identity; // this session's own identity
room.localParticipant.tracks;   // LocalTrack[] currently published
 
room.remoteParticipants;        // RemoteParticipant[]
participant.identity;
participant.metadata;           // opaque string, from the token, then live
participant.attributes;         // Record<string, string>, from the token, then live
participant.tracks;             // RemoteTrack[] currently subscribed
participant.isSpeaking;         // the server's active-speaker verdict; also audioLevel (0..1)
participant.connectionQuality;  // 'excellent' | 'good' | 'poor' | 'lost' | 'unknown'
 
room.on('participantJoined', (participant) => {});
room.on('activeSpeakersChanged', (speakers) => {}); // see Events
room.on('participantLeft', (participant) => {});
room.on('participantMetadataChanged', (participant, prev) => {}); // see below

Metadata and attributes

Each participant carries two pieces of state that your app defines and Livqeno never reads:

  • metadata: one opaque string, up to 4 KiB. Often JSON, like {"name":"Ada","role":"host"}.
  • attributes: a small string-to-string map, up to 32 keys (keys up to 64 characters, values up to 1 KiB). Good for flags you change one at a time, like hand: "raised" or seat: "3".

Your backend sets the starting values when it mints the token (metadata and attributes). Both can then change during the call, and everyone in the room, including the participant itself, gets the change as an event.

room.on('participantMetadataChanged', (participant, prev) => {
  // prev: { metadata, attributes } before the change
  console.log(participant.identity, participant.attributes.hand);
});
 
await room.localParticipant.setMetadata(JSON.stringify({ name: 'Ada' }));
await room.localParticipant.setAttributes({ hand: 'raised' }); // merges these keys

Changing your own needs a grant. Mint the token with updateOwnMetadata: true in its permissions (it's off by default). Without it, setMetadata() and setAttributes() throw PERMISSION_DENIED. Values over the limits throw INVALID_ARGUMENT before anything is sent.

Nothing changes until the server confirms. Your own metadata and attributes update when the server sends the change back, at the same moment everyone else sees it. Your backend can also change anyone's at any time with PATCH /v1/rooms/:roomId/participants/:identity.

From your backend

Set the starting values when you mint the token, and change them during the call with an API key:

const token = await livqeno.tokens.create({
  room: roomId,
  identity: 'ada',
  metadata: JSON.stringify({ name: 'Ada' }),
  attributes: { role: 'host' },
  permissions: { join: true, subscribe: true, publish: true, updateOwnMetadata: true },
});
 
// Later, while Ada is connected. `null` deletes an attribute.
await livqeno.rooms.updateParticipant(roomId, 'ada', { attributes: { hand: 'raised', seat: null } });
  • metadata replaces the whole string. attributes merges: a string sets that key, null deletes it, and keys you don't name are kept.
  • The response is the result, { "identity", "metadata", "attributes" }, and the whole room gets the change as the event above.
  • The participant must be connected: otherwise it's 404 RAVEN_PARTICIPANT_NOT_CONNECTED. A value over the limits is a 400. Each change is recorded in your project's audit log as room.participant_updated.
  • The values live as long as the participant's session. A reconnect that resumes the session keeps them. A fresh join starts again from the token's values.
  • Token values are signed into the token, which travels in the signaling URL. Keep them small, and set anything large with the PATCH instead.

Use metadata and attributes for state (who is a host, whose hand is up). A late joiner sees the current values. For one-off events (a reaction, a cursor move) use data messages.

Device selection

Use this to build a "Settings" menu where a user picks which camera, mic, or speaker to use, for example a headset instead of the laptop mic.

Browser only. On React Native and Flutter, the phone's OS picks devices.

const devices = await client.getDevices();
// { deviceId, label, kind }[] — kind: 'videoinput' | 'audioinput' | 'audiooutput'
// labels are populated only once permission has been granted at least once
 
await room.setCameraDevice(deviceId);
await room.setMicrophoneDevice(deviceId);
await room.setSpeakerDevice(deviceId); // where the browser supports setSinkId — not Safari; throws DEVICE_NOT_FOUND there
 
const unsubscribe = client.onDeviceChange(() => {
  // re-enumerate — a camera or mic was connected or disconnected
});

Sending data

Send small messages (text or bytes) to everyone in the room, to a few people, or under a topic:

await room.sendData('hello'); // string or Uint8Array
 
room.on('dataReceived', (payload, participant, topic) => {
  console.log(topic, participant?.identity, new TextDecoder().decode(payload));
});

The token needs publishData. Add the dataReceived listener right after joining: it's what opens your data channel. Topics, sending to specific people, the lossy channel, limits and every platform's API are on Data Messages.

Track model

A track is one stream of audio or video. You publish your own tracks, and you receive (subscribe to) other people's. To show someone on screen, you attach their video track to a video element.

Track   (base: kind, mediaStreamTrack, mediaStream, isMuted, attach(), detach())
  ├── LocalTrack   (+ mute(), unmute(), stop())
  └── RemoteTrack
  • LocalTrack: your own camera, mic, or screen. You can mute(), unmute(), and stop() it.
  • RemoteTrack: someone else's media.

Show video on screen

Use track.attach() (or track.attach(existingElement)) and track.detach(). These are the only UI helpers in @livqeno/rtc.

room.on('trackSubscribed', (track, participant) => {
  videoElement.appendChild(track.attach());
});
 
room.on('trackUnsubscribed', (track) => {
  track.detach().forEach((el) => el.remove());
});

Common errors

ErrorCauseFix
PERMISSION_DENIED on sendData()The token has no publishData.Set publishData: true in tokens.create().
PERMISSION_DENIED on setMetadata() / setAttributes()The token has no updateOwnMetadata.Set updateOwnMetadata: true in the token's permissions, or update from your backend.
DEVICE_NOT_FOUND on setSpeakerDevice()The browser doesn't support setSinkId (Safari).Let the user pick output in their system settings. There's no workaround.

Next steps

You can now show everyone in the call. Next:

  • Audio & Video: add camera and mic buttons, whose tracks show up here.
  • Reconnection: keep the screen correct when the network drops.

Related:

  • Events: the full event list.
  • Data Messages: topics, targeting and the lossy channel.
  • Chat: for messages that need more than data messages offer.