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:
- See who's in the room.
- Pick a camera, mic, or speaker.
- Send small data messages.
- 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'use client';
import { useLocalParticipant, useRemoteParticipants, useParticipants } from '@livqeno/react';
function Roster() {
const local = useLocalParticipant();
const remote = useRemoteParticipants();
const everyone = useParticipants(); // local first, then remote — the order a grid renders in
return everyone.map((p) => <p key={p.identity}>{p.identity}</p>);
}Each hook only re-renders when its own data changes. For example,
useRemoteParticipants() doesn't re-render when you mute yourself.
import { useParticipants, useRemoteParticipants } from '@livqeno/react-native';
function Roster({ room }) {
const everyone = useParticipants(room);
return everyone.map((p) => <Text key={p.identity}>{p.identity}</Text>);
}You pass room to the hook directly (no context). See
React Native SDK for why.
room.localParticipant.identity;
room.remoteParticipants; // List<LivqenoParticipant>
room.participants; // local first, then remote
room.participantChanges.listen((participants) {
// rebuild — fires on join/leave and on any track change
});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, likehand: "raised"orseat: "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 keysimport { useParticipantMetadata } from '@livqeno/react-native';
function HandIcon({ participant }) {
const { attributes } = useParticipantMetadata(participant);
return attributes.hand === 'raised' ? <Text>✋</Text> : null;
}Set your own with room.localParticipant.setMetadata() /
setAttributes(), as on the web.
room.participantMetadataChanges.listen((change) {
change.participant.attributes['hand'];
change.previousMetadata;
});
await room.setMetadata('{"name":"Ada"}');
await room.setAttributes({'hand': 'raised'}); // merges these keysfor await event in room.events() {
if case let .participantMetadataChanged(participant, previousMetadata, _) = event {
print(participant.identity, participant.attributes["hand"] ?? "", previousMetadata ?? "")
}
}
try await room.localParticipant.setMetadata(#"{"name":"Ada"}"#)
try await room.localParticipant.setAttributes(["hand": "raised"]) // merges these keysroom.events.filterIsInstance<RoomEvent.ParticipantMetadataChanged>().collect { e ->
e.participant.attributes["hand"]; e.previousMetadata
}
room.localParticipant.setMetadata(JSONObject(mapOf("name" to "Ada")).toString())
room.localParticipant.setAttributes(mapOf("hand" to "raised")) // merges these keysChanging 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 } });token = livqeno.tokens.create(CreateTokenParams(
room=room_id,
identity="ada",
metadata='{"name":"Ada"}',
attributes={"role": "host"},
permissions=TokenPermissions(join=True, subscribe=True, publish=True, update_own_metadata=True),
))
# Later, while Ada is connected. None deletes an attribute.
livqeno.rooms.update_participant(room_id, "ada", attributes={"hand": "raised", "seat": None})curl -X PATCH "$LIVQENO_API_URL/v1/rooms/$ROOM_ID/participants/ada" \
-H "Authorization: Bearer $LIVQENO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "attributes": { "hand": "raised", "seat": null } }'metadatareplaces the whole string.attributesmerges: a string sets that key,nulldeletes 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 a400. Each change is recorded in your project's audit log asroom.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
PATCHinstead.
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(), andstop()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());
});import { ParticipantView } from '@livqeno/react';
<ParticipantView participant={participant} /><ParticipantView> does attach and detach for you. It's optional. The
hooks work with any UI you build.
import { LivqenoVideoView } from '@livqeno/react-native';
<LivqenoVideoView participant={participant} room={room} style={{ flex: 1 }} />Pass room, and the view updates itself when tracks change.
LivqenoVideoView(
participant: participant,
room: room, // enables automatic updates
kind: LivqenoTrackKind.camera,
fit: LivqenoVideoFit.cover,
)Always pass room. Without it, a scrolling grid leaks a native view each
time it rebuilds.
Common errors
| Error | Cause | Fix |
|---|---|---|
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.