livekit-client 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.github/workflows/{lint.yaml → test.yaml} +7 -4
  2. package/.gitmodules +3 -0
  3. package/README.md +46 -14
  4. package/dist/api/SignalClient.d.ts +7 -32
  5. package/dist/api/SignalClient.js +37 -8
  6. package/dist/api/SignalClient.js.map +1 -1
  7. package/dist/connect.d.ts +1 -1
  8. package/dist/connect.js +73 -59
  9. package/dist/connect.js.map +1 -1
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +4 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/logger.d.ts +10 -0
  14. package/dist/logger.js +15 -0
  15. package/dist/logger.js.map +1 -1
  16. package/dist/options.d.ts +83 -19
  17. package/dist/options.js +0 -10
  18. package/dist/options.js.map +1 -1
  19. package/dist/proto/livekit_models.d.ts +27 -1
  20. package/dist/proto/livekit_models.js +188 -3
  21. package/dist/proto/livekit_models.js.map +1 -1
  22. package/dist/proto/livekit_rtc.d.ts +114 -7
  23. package/dist/proto/livekit_rtc.js +783 -30
  24. package/dist/proto/livekit_rtc.js.map +1 -1
  25. package/dist/room/RTCEngine.d.ts +4 -2
  26. package/dist/room/RTCEngine.js +31 -35
  27. package/dist/room/RTCEngine.js.map +1 -1
  28. package/dist/room/Room.d.ts +16 -17
  29. package/dist/room/Room.js +125 -77
  30. package/dist/room/Room.js.map +1 -1
  31. package/dist/room/events.d.ts +115 -6
  32. package/dist/room/events.js +114 -5
  33. package/dist/room/events.js.map +1 -1
  34. package/dist/room/participant/LocalParticipant.d.ts +29 -9
  35. package/dist/room/participant/LocalParticipant.js +220 -160
  36. package/dist/room/participant/LocalParticipant.js.map +1 -1
  37. package/dist/room/participant/Participant.js +2 -1
  38. package/dist/room/participant/Participant.js.map +1 -1
  39. package/dist/room/participant/RemoteParticipant.d.ts +1 -1
  40. package/dist/room/participant/RemoteParticipant.js +8 -5
  41. package/dist/room/participant/RemoteParticipant.js.map +1 -1
  42. package/dist/room/participant/publishUtils.d.ts +11 -0
  43. package/dist/room/participant/publishUtils.js +148 -0
  44. package/dist/room/participant/publishUtils.js.map +1 -0
  45. package/dist/room/participant/publishUtils.test.d.ts +1 -0
  46. package/dist/room/participant/publishUtils.test.js +79 -0
  47. package/dist/room/participant/publishUtils.test.js.map +1 -0
  48. package/dist/room/stats.d.ts +21 -6
  49. package/dist/room/stats.js +22 -1
  50. package/dist/room/stats.js.map +1 -1
  51. package/dist/room/track/LocalAudioTrack.d.ts +8 -3
  52. package/dist/room/track/LocalAudioTrack.js +49 -3
  53. package/dist/room/track/LocalAudioTrack.js.map +1 -1
  54. package/dist/room/track/LocalTrack.d.ts +4 -3
  55. package/dist/room/track/LocalTrack.js +6 -48
  56. package/dist/room/track/LocalTrack.js.map +1 -1
  57. package/dist/room/track/LocalVideoTrack.d.ts +13 -4
  58. package/dist/room/track/LocalVideoTrack.js +150 -57
  59. package/dist/room/track/LocalVideoTrack.js.map +1 -1
  60. package/dist/room/track/LocalVideoTrack.test.d.ts +1 -0
  61. package/dist/room/track/LocalVideoTrack.test.js +68 -0
  62. package/dist/room/track/LocalVideoTrack.test.js.map +1 -0
  63. package/dist/room/track/RemoteAudioTrack.d.ts +7 -0
  64. package/dist/room/track/RemoteAudioTrack.js +61 -0
  65. package/dist/room/track/RemoteAudioTrack.js.map +1 -1
  66. package/dist/room/track/RemoteTrackPublication.d.ts +2 -3
  67. package/dist/room/track/RemoteTrackPublication.js +11 -10
  68. package/dist/room/track/RemoteTrackPublication.js.map +1 -1
  69. package/dist/room/track/RemoteVideoTrack.d.ts +10 -6
  70. package/dist/room/track/RemoteVideoTrack.js +97 -44
  71. package/dist/room/track/RemoteVideoTrack.js.map +1 -1
  72. package/dist/room/track/Track.d.ts +10 -2
  73. package/dist/room/track/Track.js +29 -2
  74. package/dist/room/track/Track.js.map +1 -1
  75. package/dist/room/track/create.d.ts +4 -6
  76. package/dist/room/track/create.js +10 -57
  77. package/dist/room/track/create.js.map +1 -1
  78. package/dist/room/track/defaults.d.ts +4 -0
  79. package/dist/room/track/defaults.js +21 -0
  80. package/dist/room/track/defaults.js.map +1 -0
  81. package/dist/room/track/options.d.ts +15 -65
  82. package/dist/room/track/options.js +14 -13
  83. package/dist/room/track/options.js.map +1 -1
  84. package/dist/room/track/utils.d.ts +3 -0
  85. package/dist/room/track/utils.js +68 -0
  86. package/dist/room/track/utils.js.map +1 -0
  87. package/dist/room/track/utils.test.d.ts +1 -0
  88. package/dist/room/track/utils.test.js +85 -0
  89. package/dist/room/track/utils.test.js.map +1 -0
  90. package/dist/room/utils.d.ts +7 -1
  91. package/dist/room/utils.js +29 -6
  92. package/dist/room/utils.js.map +1 -1
  93. package/dist/version.d.ts +1 -1
  94. package/dist/version.js +1 -1
  95. package/example/index.html +194 -178
  96. package/example/sample.ts +454 -325
  97. package/example/styles.css +144 -0
  98. package/example/webpack.config.js +1 -1
  99. package/jest.config.js +6 -0
  100. package/package.json +10 -7
  101. package/tsconfig.eslint.json +8 -1
  102. package/dist/room/defaults.d.ts +0 -5
  103. package/dist/room/defaults.js +0 -32
  104. package/dist/room/defaults.js.map +0 -1
package/example/sample.ts CHANGED
@@ -1,82 +1,253 @@
1
1
  import {
2
- connect, CreateVideoTrackOptions, DataPacket_Kind, LocalTrack, LocalTrackPublication, LogLevel,
2
+ DataPacket_Kind, LocalParticipant,
3
+ LocalTrack,
3
4
  MediaDeviceFailure,
4
5
  Participant,
5
6
  ParticipantEvent,
6
- RemoteParticipant,
7
- RemoteTrack,
8
- RemoteTrackPublication,
9
- Room,
10
- RoomEvent,
11
- Track, TrackPublication, VideoPresets,
7
+ RemoteAudioTrack,
8
+ RemoteParticipant, RemoteVideoTrack, Room, RoomConnectOptions, RoomEvent,
9
+ RoomOptions, RoomState, setLogLevel, Track, TrackPublication,
10
+ VideoCaptureOptions, VideoPresets,
12
11
  } from '../src/index';
13
12
  import { ConnectionQuality } from '../src/room/participant/Participant';
14
13
 
15
14
  const $ = (id: string) => document.getElementById(id);
16
15
 
17
- declare global {
18
- interface Window {
19
- connectWithFormInput: any;
20
- connectToRoom: any;
21
- handleDeviceSelected: any;
22
- shareScreen: any;
23
- toggleVideo: any;
24
- toggleAudio: any;
25
- enterText: any;
26
- disconnectSignal: any;
27
- disconnectRoom: any;
28
- currentRoom: any;
29
- startAudio: any;
30
- flipVideo: any;
31
- }
32
- }
16
+ const state = {
17
+ isFrontFacing: false,
18
+ encoder: new TextEncoder(),
19
+ decoder: new TextDecoder(),
20
+ defaultDevices: new Map<MediaDeviceKind, string>(),
21
+ bitrateInterval: undefined as any,
22
+ };
23
+ let currentRoom: Room | undefined;
24
+
25
+ // handles actions from the HTML
26
+ const appActions = {
27
+ connectWithFormInput: async () => {
28
+ const url = (<HTMLInputElement>$('url')).value;
29
+ const token = (<HTMLInputElement>$('token')).value;
30
+ const simulcast = (<HTMLInputElement>$('simulcast')).checked;
31
+ const dynacast = (<HTMLInputElement>$('dynacast')).checked;
32
+ const forceTURN = (<HTMLInputElement>$('force-turn')).checked;
33
+ const adaptiveStream = (<HTMLInputElement>$('adaptive-stream')).checked;
34
+ const shouldPublish = (<HTMLInputElement>$('publish-option')).checked;
35
+
36
+ setLogLevel('debug');
37
+
38
+ const roomOpts: RoomOptions = {
39
+ adaptiveStream,
40
+ dynacast,
41
+ publishDefaults: {
42
+ simulcast,
43
+ },
44
+ videoCaptureDefaults: {
45
+ resolution: VideoPresets.hd.resolution,
46
+ },
47
+ };
33
48
 
34
- function appendLog(...args: any[]) {
35
- const logger = $('log')!;
36
- for (let i = 0; i < arguments.length; i += 1) {
37
- if (typeof args[i] === 'object') {
38
- logger.innerHTML
39
- += `${JSON && JSON.stringify
40
- ? JSON.stringify(args[i], undefined, 2)
41
- : args[i]} `;
49
+ const connectOpts: RoomConnectOptions = {};
50
+ if (forceTURN) {
51
+ connectOpts.rtcConfig = {
52
+ iceTransportPolicy: 'relay',
53
+ };
54
+ }
55
+ const room = await appActions.connectToRoom(url, token, roomOpts, connectOpts);
56
+
57
+ if (room && shouldPublish) {
58
+ await room.localParticipant.enableCameraAndMicrophone();
59
+ updateButtonsForPublishState();
60
+ }
61
+
62
+ state.bitrateInterval = setInterval(renderBitrate, 1000);
63
+ },
64
+
65
+ connectToRoom: async (
66
+ url: string,
67
+ token: string,
68
+ roomOptions?: RoomOptions,
69
+ connectOptions?: RoomConnectOptions,
70
+ ): Promise<Room | undefined> => {
71
+ const room = new Room(roomOptions);
72
+ room
73
+ .on(RoomEvent.ParticipantConnected, participantConnected)
74
+ .on(RoomEvent.ParticipantDisconnected, participantDisconnected)
75
+ .on(RoomEvent.DataReceived, handleData)
76
+ .on(RoomEvent.Disconnected, handleRoomDisconnect)
77
+ .on(RoomEvent.Reconnecting, () => appendLog('Reconnecting to room'))
78
+ .on(RoomEvent.Reconnected, () => appendLog('Successfully reconnected!'))
79
+ .on(RoomEvent.LocalTrackPublished, () => {
80
+ renderParticipant(room.localParticipant);
81
+ updateButtonsForPublishState();
82
+ renderScreenShare();
83
+ })
84
+ .on(RoomEvent.LocalTrackUnpublished, () => {
85
+ renderParticipant(room.localParticipant);
86
+ updateButtonsForPublishState();
87
+ renderScreenShare();
88
+ })
89
+ .on(RoomEvent.RoomMetadataChanged, (metadata) => {
90
+ appendLog('new metadata for room', metadata);
91
+ })
92
+ .on(RoomEvent.MediaDevicesChanged, handleDevicesChanged)
93
+ .on(RoomEvent.AudioPlaybackStatusChanged, () => {
94
+ if (room.canPlaybackAudio) {
95
+ $('start-audio-button')?.setAttribute('disabled', 'true');
96
+ } else {
97
+ $('start-audio-button')?.removeAttribute('disabled');
98
+ }
99
+ })
100
+ .on(RoomEvent.MediaDevicesError, (e: Error) => {
101
+ const failure = MediaDeviceFailure.getFailure(e);
102
+ appendLog('media device failure', failure);
103
+ })
104
+ .on(RoomEvent.ConnectionQualityChanged,
105
+ (quality: ConnectionQuality, participant: Participant) => {
106
+ appendLog('connection quality changed', participant.identity, quality);
107
+ });
108
+
109
+ try {
110
+ await room.connect(url, token, connectOptions);
111
+ } catch (error) {
112
+ let message: any = error;
113
+ if (error.message) {
114
+ message = error.message;
115
+ }
116
+ appendLog('could not connect:', message);
117
+ return;
118
+ }
119
+
120
+ appendLog('connected to room', room.name);
121
+ currentRoom = room;
122
+ window.currentRoom = room;
123
+ setButtonsForState(true);
124
+
125
+ appendLog('room participants', room.participants.keys());
126
+ room.participants.forEach((participant) => {
127
+ participantConnected(participant);
128
+ });
129
+ participantConnected(room.localParticipant);
130
+
131
+ return room;
132
+ },
133
+
134
+ toggleAudio: async () => {
135
+ if (!currentRoom) return;
136
+ const enabled = currentRoom.localParticipant.isMicrophoneEnabled;
137
+ if (enabled) {
138
+ appendLog('disabling audio');
42
139
  } else {
43
- logger.innerHTML += `${args[i]} `;
140
+ appendLog('enabling audio');
44
141
  }
45
- }
46
- logger.innerHTML += '\n';
47
- (() => {
48
- logger.scrollTop = logger.scrollHeight;
49
- })();
50
- }
142
+ await currentRoom.localParticipant.setMicrophoneEnabled(!enabled);
143
+ updateButtonsForPublishState();
144
+ },
145
+
146
+ toggleVideo: async () => {
147
+ if (!currentRoom) return;
148
+ const enabled = currentRoom.localParticipant.isCameraEnabled;
149
+ if (enabled) {
150
+ appendLog('disabling video');
151
+ } else {
152
+ appendLog('enabling video');
153
+ }
154
+ await currentRoom.localParticipant.setCameraEnabled(!enabled);
155
+ renderParticipant(currentRoom.localParticipant);
51
156
 
52
- function trackSubscribed(
53
- div: HTMLDivElement,
54
- track: Track,
55
- participant: Participant,
56
- ): HTMLMediaElement | null {
57
- appendLog('track subscribed', track);
58
- const element = track.attach();
59
- div.appendChild(element);
60
- return element;
61
- }
157
+ // update display
158
+ updateButtonsForPublishState();
159
+ },
62
160
 
63
- function trackUnsubscribed(
64
- track: RemoteTrack | LocalTrack,
65
- pub?: RemoteTrackPublication,
66
- participant?: Participant,
67
- ) {
68
- let logName = track.name;
69
- if (track.sid) {
70
- logName = track.sid;
161
+ flipVideo: () => {
162
+ const videoPub = currentRoom?.localParticipant.getTrack(Track.Source.Camera);
163
+ if (!videoPub) {
164
+ return;
165
+ }
166
+ if (state.isFrontFacing) {
167
+ setButtonState('flip-video-button', 'Front Camera', false);
168
+ } else {
169
+ setButtonState('flip-video-button', 'Back Camera', false);
170
+ }
171
+ state.isFrontFacing = !state.isFrontFacing;
172
+ const options: VideoCaptureOptions = {
173
+ resolution: VideoPresets.qhd.resolution,
174
+ facingMode: state.isFrontFacing ? 'user' : 'environment',
175
+ };
176
+ videoPub.videoTrack?.restartTrack(options);
177
+ },
178
+
179
+ shareScreen: async () => {
180
+ if (!currentRoom) return;
181
+
182
+ const enabled = currentRoom.localParticipant.isScreenShareEnabled;
183
+ appendLog(`${enabled ? 'stopping' : 'starting'} screen share`);
184
+ await currentRoom.localParticipant.setScreenShareEnabled(!enabled);
185
+ updateButtonsForPublishState();
186
+ },
187
+
188
+ startAudio: () => {
189
+ currentRoom?.startAudio();
190
+ },
191
+
192
+ enterText: () => {
193
+ if (!currentRoom) return;
194
+ const textField = <HTMLInputElement>$('entry');
195
+ if (textField.value) {
196
+ const msg = state.encoder.encode(textField.value);
197
+ currentRoom.localParticipant.publishData(msg, DataPacket_Kind.RELIABLE);
198
+ (<HTMLTextAreaElement>(
199
+ $('chat')
200
+ )).value += `${currentRoom.localParticipant.identity} (me): ${textField.value}\n`;
201
+ textField.value = '';
202
+ }
203
+ },
204
+
205
+ disconnectRoom: () => {
206
+ if (currentRoom) {
207
+ currentRoom.disconnect();
208
+ }
209
+ if (state.bitrateInterval) {
210
+ clearInterval(state.bitrateInterval);
211
+ }
212
+ },
213
+
214
+ disconnectSignal: () => {
215
+ if (!currentRoom) return;
216
+ currentRoom.engine.client.close();
217
+ if (currentRoom.engine.client.onClose) {
218
+ currentRoom.engine.client.onClose('manual disconnect');
219
+ }
220
+ },
221
+
222
+ handleDeviceSelected: async (e: Event) => {
223
+ const deviceId = (<HTMLSelectElement>e.target).value;
224
+ const elementId = (<HTMLSelectElement>e.target).id;
225
+ const kind = elementMapping[elementId];
226
+ if (!kind) {
227
+ return;
228
+ }
229
+
230
+ state.defaultDevices.set(kind, deviceId);
231
+
232
+ if (currentRoom) {
233
+ await currentRoom.switchActiveDevice(kind, deviceId);
234
+ }
235
+ },
236
+ };
237
+
238
+ declare global {
239
+ interface Window {
240
+ currentRoom: any;
241
+ appActions: typeof appActions;
71
242
  }
72
- appendLog('track unsubscribed', logName);
73
- track.detach().forEach((element) => element.remove());
74
243
  }
75
244
 
76
- const encoder = new TextEncoder();
77
- const decoder = new TextDecoder();
245
+ window.appActions = appActions;
246
+
247
+ // --------------------------- event handlers ------------------------------- //
248
+
78
249
  function handleData(msg: Uint8Array, participant?: RemoteParticipant) {
79
- const str = decoder.decode(msg);
250
+ const str = state.decoder.decode(msg);
80
251
  const chat = <HTMLTextAreaElement>$('chat');
81
252
  let from = 'server';
82
253
  if (participant) {
@@ -85,316 +256,277 @@ function handleData(msg: Uint8Array, participant?: RemoteParticipant) {
85
256
  chat.value += `${from}: ${str}\n`;
86
257
  }
87
258
 
88
- function handleSpeakerChanged(speakers: Participant[]) {
89
- // remove tags from all
90
- currentRoom.participants.forEach((participant) => {
91
- setParticipantSpeaking(participant, speakers.includes(participant));
92
- });
93
-
94
- // do the same for local participant
95
- setParticipantSpeaking(
96
- currentRoom.localParticipant,
97
- speakers.includes(currentRoom.localParticipant),
98
- );
99
- }
100
-
101
- function setParticipantSpeaking(participant: Participant, speaking: boolean) {
102
- participant.videoTracks.forEach((publication) => {
103
- const { track } = publication;
104
- if (track && track.kind === Track.Kind.Video) {
105
- track.attachedElements.forEach((element) => {
106
- if (speaking) {
107
- element.classList.add('speaking');
108
- } else {
109
- element.classList.remove('speaking');
110
- }
111
- });
112
- }
113
- });
114
- }
115
-
116
- function participantConnected(participant: RemoteParticipant) {
117
- appendLog('participant', participant.sid, 'connected', participant.metadata);
118
-
119
- const div = document.createElement('div');
120
- div.id = participant.sid;
121
- div.innerText = participant.identity;
122
- div.className = 'col-md-6 video-container';
123
- $('remote-area')?.appendChild(div);
124
-
125
- participant.on(ParticipantEvent.TrackSubscribed, (track) => {
126
- trackSubscribed(div, track, participant);
127
- });
128
- participant.on(ParticipantEvent.TrackUnsubscribed, (track, pub) => {
129
- trackUnsubscribed(track, pub, participant);
130
- });
259
+ function participantConnected(participant: Participant) {
260
+ appendLog('participant', participant.identity, 'connected', participant.metadata);
261
+ participant
262
+ .on(ParticipantEvent.TrackSubscribed, (_, pub: TrackPublication) => {
263
+ appendLog('subscribed to track', pub.trackSid, participant.identity);
264
+ renderParticipant(participant);
265
+ renderScreenShare();
266
+ })
267
+ .on(ParticipantEvent.TrackUnsubscribed, (_, pub: TrackPublication) => {
268
+ appendLog('unsubscribed from track', pub.trackSid);
269
+ renderParticipant(participant);
270
+ renderScreenShare();
271
+ })
272
+ .on(ParticipantEvent.TrackMuted, (pub: TrackPublication) => {
273
+ appendLog('track was muted', pub.trackSid, participant.identity);
274
+ renderParticipant(participant);
275
+ })
276
+ .on(ParticipantEvent.TrackUnmuted, (pub: TrackPublication) => {
277
+ appendLog('track was unmuted', pub.trackSid, participant.identity);
278
+ renderParticipant(participant);
279
+ })
280
+ .on(ParticipantEvent.IsSpeakingChanged, () => {
281
+ renderParticipant(participant);
282
+ })
283
+ .on(ParticipantEvent.ConnectionQualityChanged, () => {
284
+ renderParticipant(participant);
285
+ });
131
286
  }
132
287
 
133
288
  function participantDisconnected(participant: RemoteParticipant) {
134
289
  appendLog('participant', participant.sid, 'disconnected');
135
290
 
136
- $(participant.sid)?.remove();
291
+ renderParticipant(participant, true);
137
292
  }
138
293
 
139
294
  function handleRoomDisconnect() {
295
+ if (!currentRoom) return;
140
296
  appendLog('disconnected from room');
141
297
  setButtonsForState(false);
142
- $('local-video')!.innerHTML = '';
143
-
144
- // clear the chat area on disconnect
145
- clearChat();
146
-
147
- // clear remote area on disconnect
148
- clearRemoteArea();
149
- }
150
-
151
- function setButtonState(buttonId: string, buttonText: string, isActive: boolean) {
152
- const el = $(buttonId);
153
- if (!el) return;
298
+ renderParticipant(currentRoom.localParticipant, true);
299
+ currentRoom.participants.forEach((p) => {
300
+ renderParticipant(p, true);
301
+ });
302
+ renderScreenShare();
154
303
 
155
- el.innerHTML = buttonText;
156
- if (isActive) {
157
- el.classList.add('active');
158
- } else {
159
- el.classList.remove('active');
304
+ const container = $('participants-area');
305
+ if (container) {
306
+ container.innerHTML = '';
160
307
  }
161
- }
162
308
 
163
- function clearChat() {
309
+ // clear the chat area on disconnect
164
310
  const chat = <HTMLTextAreaElement>$('chat');
165
311
  chat.value = '';
312
+
313
+ currentRoom = undefined;
314
+ window.currentRoom = undefined;
166
315
  }
167
316
 
168
- function clearRemoteArea() {
169
- const el = $('remote-area');
170
- if (!el) return;
317
+ // -------------------------- rendering helpers ----------------------------- //
171
318
 
172
- while (el.firstChild) {
173
- el.removeChild(el.firstChild);
319
+ function appendLog(...args: any[]) {
320
+ const logger = $('log')!;
321
+ for (let i = 0; i < arguments.length; i += 1) {
322
+ if (typeof args[i] === 'object') {
323
+ logger.innerHTML
324
+ += `${JSON && JSON.stringify
325
+ ? JSON.stringify(args[i], undefined, 2)
326
+ : args[i]} `;
327
+ } else {
328
+ logger.innerHTML += `${args[i]} `;
329
+ }
174
330
  }
331
+ logger.innerHTML += '\n';
332
+ (() => {
333
+ logger.scrollTop = logger.scrollHeight;
334
+ })();
175
335
  }
176
336
 
177
- let currentRoom: Room;
178
- window.connectWithFormInput = () => {
179
- const url = (<HTMLInputElement>$('url')).value;
180
- const token = (<HTMLInputElement>$('token')).value;
181
- const simulcast = (<HTMLInputElement>$('simulcast')).checked;
182
- const forceTURN = (<HTMLInputElement>$('force-turn')).checked;
183
-
184
- window.connectToRoom(url, token, simulcast, forceTURN);
185
- };
186
-
187
- window.connectToRoom = async (
188
- url: string,
189
- token: string,
190
- simulcast: boolean = false,
191
- forceTURN: boolean = false,
192
- ) => {
193
- let room: Room;
194
- const rtcConfig: RTCConfiguration = {};
195
- if (forceTURN) {
196
- rtcConfig.iceTransportPolicy = 'relay';
337
+ // updates participant UI
338
+ function renderParticipant(participant: Participant, remove: boolean = false) {
339
+ const container = $('participants-area');
340
+ if (!container) return;
341
+ let div = $(`participant-${participant.sid}`);
342
+ if (!div && !remove) {
343
+ div = document.createElement('div');
344
+ div.id = `participant-${participant.sid}`;
345
+ div.className = 'participant';
346
+ div.innerHTML = `
347
+ <video id="video-${participant.sid}"></video>
348
+ <audio id="audio-${participant.sid}"></audio>
349
+ <div class="info-bar">
350
+ <div id="name-${participant.sid}" class="name">
351
+ </div>
352
+ <div style="text-align: center;">
353
+ <span id="size-${participant.sid}" class="size">
354
+ </span>
355
+ <span id="bitrate-${participant.sid}" class="bitrate">
356
+ </span>
357
+ </div>
358
+ <div class="right">
359
+ <span id="signal-${participant.sid}"></span>
360
+ <span id="mic-${participant.sid}" class="mic-on"></span>
361
+ </div>
362
+ </div>
363
+ `;
364
+ container.appendChild(div);
365
+
366
+ const sizeElm = $(`size-${participant.sid}`);
367
+ const videoElm = <HTMLVideoElement>$(`video-${participant.sid}`);
368
+ videoElm.onresize = () => {
369
+ updateVideoSize(videoElm!, sizeElm!);
370
+ };
197
371
  }
198
- const shouldPublish = (<HTMLInputElement>$('publish-option')).checked;
199
-
200
- try {
201
- room = await connect(url, token, {
202
- logLevel: LogLevel.debug,
203
- rtcConfig,
204
- audio: shouldPublish,
205
- video: shouldPublish,
206
- autoManageVideo: true,
207
- publishDefaults: {
208
- simulcast,
209
- },
210
- });
211
- } catch (error) {
212
- let message: any = error;
213
- if (error.message) {
214
- message = error.message;
372
+ const videoElm = <HTMLVideoElement>$(`video-${participant.sid}`);
373
+ const audioELm = <HTMLAudioElement>$(`audio-${participant.sid}`);
374
+ if (remove) {
375
+ div?.remove();
376
+ if (videoElm) {
377
+ videoElm.srcObject = null;
378
+ videoElm.src = '';
379
+ }
380
+ if (audioELm) {
381
+ audioELm.srcObject = null;
382
+ audioELm.src = '';
215
383
  }
216
- appendLog('could not connect:', message);
217
384
  return;
218
385
  }
219
386
 
220
- appendLog('connected to room', room.name);
221
- currentRoom = room;
222
- window.currentRoom = room;
223
- setButtonsForState(true);
224
- updateButtonsForPublishState();
225
-
226
- room
227
- .on(RoomEvent.ParticipantConnected, participantConnected)
228
- .on(RoomEvent.ParticipantDisconnected, participantDisconnected)
229
- .on(RoomEvent.DataReceived, handleData)
230
- .on(RoomEvent.ActiveSpeakersChanged, handleSpeakerChanged)
231
- .on(RoomEvent.Disconnected, handleRoomDisconnect)
232
- .on(RoomEvent.Reconnecting, () => appendLog('Reconnecting to room'))
233
- .on(RoomEvent.Reconnected, () => appendLog('Successfully reconnected!'))
234
- .on(RoomEvent.TrackMuted, (pub: TrackPublication, p: Participant) => appendLog('track was muted', pub.trackSid, p.identity))
235
- .on(RoomEvent.TrackUnmuted, (pub: TrackPublication, p: Participant) => appendLog('track was unmuted', pub.trackSid, p.identity))
236
- .on(RoomEvent.LocalTrackPublished, (pub: LocalTrackPublication) => {
237
- if (pub.kind === Track.Kind.Video) {
238
- attachLocalVideo();
239
- }
240
- updateButtonsForPublishState();
241
- })
242
- .on(RoomEvent.RoomMetadataChanged, (metadata) => {
243
- appendLog('new metadata for room', metadata);
244
- })
245
- .on(RoomEvent.MediaDevicesChanged, handleDevicesChanged)
246
- .on(RoomEvent.AudioPlaybackStatusChanged, () => {
247
- if (room.canPlaybackAudio) {
248
- $('start-audio-button')?.setAttribute('disabled', 'true');
249
- } else {
250
- $('start-audio-button')?.removeAttribute('disabled');
251
- }
252
- })
253
- .on(RoomEvent.MediaDevicesError, (e: Error) => {
254
- const failure = MediaDeviceFailure.getFailure(e);
255
- appendLog('media device failure', failure);
256
- })
257
- .on(RoomEvent.ConnectionQualityChanged,
258
- (quality: ConnectionQuality, participant: Participant) => {
259
- appendLog('connection quality changed', participant.identity, quality);
260
- });
261
-
262
- appendLog('room participants', room.participants.keys());
263
- room.participants.forEach((participant) => {
264
- participantConnected(participant);
265
- });
266
-
267
- $('local-video')!.innerHTML = `${room.localParticipant.identity} (me)`;
268
- };
269
-
270
- window.toggleVideo = async () => {
271
- if (!currentRoom) return;
272
- const video = getMyVideo();
273
- if (currentRoom.localParticipant.isCameraEnabled) {
274
- appendLog('disabling video');
275
- await currentRoom.localParticipant.setCameraEnabled(false);
276
- // hide from display
277
- if (video) {
278
- video.style.display = 'none';
279
- }
387
+ // update properties
388
+ $(`name-${participant.sid}`)!.innerHTML = participant.identity;
389
+ const micElm = $(`mic-${participant.sid}`)!;
390
+ const signalElm = $(`signal-${participant.sid}`)!;
391
+ const cameraPub = participant.getTrack(Track.Source.Camera);
392
+ const micPub = participant.getTrack(Track.Source.Microphone);
393
+ if (participant.isSpeaking) {
394
+ div!.classList.add('speaking');
280
395
  } else {
281
- appendLog('enabling video');
282
- await currentRoom.localParticipant.setCameraEnabled(true);
283
- attachLocalVideo();
284
- if (video) {
285
- video.style.display = '';
286
- }
396
+ div!.classList.remove('speaking');
287
397
  }
288
- updateButtonsForPublishState();
289
- };
290
398
 
291
- window.toggleAudio = async () => {
292
- if (!currentRoom) return;
293
- if (currentRoom.localParticipant.isMicrophoneEnabled) {
294
- appendLog('disabling audio');
295
- await currentRoom.localParticipant.setMicrophoneEnabled(false);
399
+ const cameraEnabled = cameraPub && cameraPub.isSubscribed && !cameraPub.isMuted;
400
+ if (cameraEnabled) {
401
+ if (participant instanceof LocalParticipant) {
402
+ // flip
403
+ videoElm.style.transform = 'scale(-1, 1)';
404
+ } else if (!cameraPub?.videoTrack?.attachedElements.includes(videoElm)) {
405
+ const startTime = Date.now();
406
+ // measure time to render
407
+ videoElm.addEventListener('loadeddata', () => {
408
+ const elapsed = Date.now() - startTime;
409
+ appendLog(`RemoteVideoTrack ${cameraPub?.trackSid} rendered in ${elapsed}ms`);
410
+ });
411
+ }
412
+ cameraPub?.videoTrack?.attach(videoElm);
413
+ } else if (cameraPub?.videoTrack) {
414
+ // detach manually whenever possible
415
+ cameraPub.videoTrack?.detach(videoElm);
296
416
  } else {
297
- appendLog('enabling audio');
298
- await currentRoom.localParticipant.setMicrophoneEnabled(true);
417
+ videoElm.src = '';
418
+ videoElm.srcObject = null;
299
419
  }
300
- updateButtonsForPublishState();
301
- };
302
420
 
303
- window.enterText = () => {
304
- const textField = <HTMLInputElement>$('entry');
305
- if (textField.value) {
306
- const msg = encoder.encode(textField.value);
307
- currentRoom.localParticipant.publishData(msg, DataPacket_Kind.RELIABLE);
308
- (<HTMLTextAreaElement>(
309
- $('chat')
310
- )).value += `${currentRoom.localParticipant.identity} (me): ${textField.value}\n`;
311
- textField.value = '';
312
- }
313
- };
314
-
315
- window.shareScreen = async () => {
316
- if (!currentRoom) return;
317
-
318
- if (currentRoom.localParticipant.isScreenShareEnabled) {
319
- appendLog('stopping screen share');
320
- await currentRoom.localParticipant.setScreenShareEnabled(false);
421
+ const micEnabled = micPub && micPub.isSubscribed && !micPub.isMuted;
422
+ if (micEnabled) {
423
+ if (!(participant instanceof LocalParticipant)) {
424
+ // don't attach local audio
425
+ micPub?.audioTrack?.attach(audioELm);
426
+ }
427
+ micElm.className = 'mic-on';
428
+ micElm.innerHTML = '<i class="fas fa-microphone"></i>';
321
429
  } else {
322
- appendLog('starting screen share');
323
- await currentRoom.localParticipant.setScreenShareEnabled(true);
324
- appendLog('started screen share');
325
- }
326
- updateButtonsForPublishState();
327
- };
328
-
329
- window.disconnectSignal = () => {
330
- if (!currentRoom) return;
331
- currentRoom.engine.client.close();
332
- if (currentRoom.engine.client.onClose) {
333
- currentRoom.engine.client.onClose('manual disconnect');
430
+ micElm.className = 'mic-off';
431
+ micElm.innerHTML = '<i class="fas fa-microphone-slash"></i>';
334
432
  }
335
- };
336
433
 
337
- window.disconnectRoom = () => {
338
- if (currentRoom) {
339
- currentRoom.disconnect();
434
+ switch (participant.connectionQuality) {
435
+ case ConnectionQuality.Excellent:
436
+ case ConnectionQuality.Good:
437
+ case ConnectionQuality.Poor:
438
+ signalElm.className = `connection-${participant.connectionQuality}`;
439
+ signalElm.innerHTML = '<i class="fas fa-circle"></i>';
440
+ break;
441
+ default:
442
+ signalElm.innerHTML = '';
443
+ // do nothing
340
444
  }
341
- };
342
-
343
- window.startAudio = () => {
344
- currentRoom.startAudio();
345
- };
445
+ }
346
446
 
347
- let isFrontFacing = true;
348
- window.flipVideo = () => {
349
- const videoPub = currentRoom.localParticipant.getTrack(Track.Source.Camera);
350
- if (!videoPub) {
447
+ function renderScreenShare() {
448
+ const div = $('screenshare-area')!;
449
+ if (!currentRoom || currentRoom.state !== RoomState.Connected) {
450
+ div.style.display = 'none';
351
451
  return;
352
452
  }
353
- if (isFrontFacing) {
354
- setButtonState('flip-video-button', 'Front Camera', false);
453
+ let participant: Participant | undefined;
454
+ let screenSharePub: TrackPublication | undefined = currentRoom.localParticipant.getTrack(
455
+ Track.Source.ScreenShare,
456
+ );
457
+ if (!screenSharePub) {
458
+ currentRoom.participants.forEach((p) => {
459
+ if (screenSharePub) {
460
+ return;
461
+ }
462
+ participant = p;
463
+ const pub = p.getTrack(Track.Source.ScreenShare);
464
+ if (pub?.isSubscribed) {
465
+ screenSharePub = pub;
466
+ }
467
+ });
355
468
  } else {
356
- setButtonState('flip-video-button', 'Back Camera', false);
469
+ participant = currentRoom.localParticipant;
357
470
  }
358
- isFrontFacing = !isFrontFacing;
359
- const options: CreateVideoTrackOptions = {
360
- resolution: VideoPresets.qhd.resolution,
361
- facingMode: isFrontFacing ? 'user' : 'environment',
362
- };
363
- videoPub.videoTrack?.restartTrack(options);
364
- };
365
471
 
366
- const defaultDevices = new Map<MediaDeviceKind, string>();
367
- window.handleDeviceSelected = async (e: Event) => {
368
- const deviceId = (<HTMLSelectElement>e.target).value;
369
- const elementId = (<HTMLSelectElement>e.target).id;
370
- const kind = elementMapping[elementId];
371
- if (!kind) {
372
- return;
472
+ if (screenSharePub && participant) {
473
+ div.style.display = 'block';
474
+ const videoElm = <HTMLVideoElement>$('screenshare-video');
475
+ screenSharePub.videoTrack?.attach(videoElm);
476
+ videoElm.onresize = () => {
477
+ updateVideoSize(videoElm, <HTMLSpanElement>$('screenshare-resolution'));
478
+ };
479
+ const infoElm = $('screenshare-info')!;
480
+ infoElm.innerHTML = `Screenshare from ${participant.identity}`;
481
+ } else {
482
+ div.style.display = 'none';
373
483
  }
484
+ }
374
485
 
375
- defaultDevices.set(kind, deviceId);
376
-
377
- if (currentRoom) {
378
- await currentRoom.switchActiveDevice(kind, deviceId);
486
+ function renderBitrate() {
487
+ if (!currentRoom || currentRoom.state !== RoomState.Connected) {
488
+ return;
379
489
  }
380
- };
490
+ const participants: Participant[] = [...currentRoom.participants.values()];
491
+ participants.push(currentRoom.localParticipant);
492
+
493
+ for (const p of participants) {
494
+ const elm = $(`bitrate-${p.sid}`);
495
+ let totalBitrate = 0;
496
+ for (const t of p.tracks.values()) {
497
+ if (t.track instanceof RemoteAudioTrack || t.track instanceof RemoteVideoTrack
498
+ || t.track instanceof LocalTrack) {
499
+ totalBitrate += t.track.currentBitrate;
500
+ }
501
+ }
502
+ let displayText = '';
503
+ if (totalBitrate > 0) {
504
+ displayText = `${Math.round(totalBitrate / 1024).toLocaleString()} kbps`;
505
+ }
506
+ if (elm) {
507
+ elm.innerHTML = displayText;
508
+ }
509
+ }
510
+ }
381
511
 
382
- setTimeout(handleDevicesChanged, 100);
512
+ function updateVideoSize(element: HTMLVideoElement, target: HTMLElement) {
513
+ target.innerHTML = `(${element.videoWidth}x${element.videoHeight})`;
514
+ }
383
515
 
384
- async function attachLocalVideo() {
385
- const videoPub = currentRoom.localParticipant.getTrack(Track.Source.Camera);
386
- const videoTrack = videoPub?.videoTrack;
387
- if (!videoTrack) {
388
- return;
389
- }
516
+ function setButtonState(buttonId: string, buttonText: string, isActive: boolean) {
517
+ const el = $(buttonId);
518
+ if (!el) return;
390
519
 
391
- if (videoTrack.attachedElements.length === 0) {
392
- const video = videoTrack.attach();
393
- video.style.transform = 'scale(-1, 1)';
394
- $('local-video')!.appendChild(video);
520
+ el.innerHTML = buttonText;
521
+ if (isActive) {
522
+ el.classList.add('active');
523
+ } else {
524
+ el.classList.remove('active');
395
525
  }
396
526
  }
397
527
 
528
+ setTimeout(handleDevicesChanged, 100);
529
+
398
530
  function setButtonsForState(connected: boolean) {
399
531
  const connectedSet = [
400
532
  'toggle-video-button',
@@ -403,6 +535,7 @@ function setButtonsForState(connected: boolean) {
403
535
  'disconnect-ws-button',
404
536
  'disconnect-room-button',
405
537
  'flip-video-button',
538
+ 'send-button',
406
539
  ];
407
540
  const disconnectedSet = ['connect-button'];
408
541
 
@@ -413,10 +546,6 @@ function setButtonsForState(connected: boolean) {
413
546
  toAdd.forEach((id) => $(id)?.setAttribute('disabled', 'true'));
414
547
  }
415
548
 
416
- function getMyVideo() {
417
- return <HTMLVideoElement>document.querySelector('#local-video video');
418
- }
419
-
420
549
  const elementMapping: { [k: string]: MediaDeviceKind } = {
421
550
  'video-input': 'videoinput',
422
551
  'audio-input': 'audioinput',
@@ -430,7 +559,7 @@ async function handleDevicesChanged() {
430
559
  }
431
560
  const devices = await Room.getLocalDevices(kind);
432
561
  const element = <HTMLSelectElement>$(id);
433
- populateSelect(kind, element, devices, defaultDevices.get(kind));
562
+ populateSelect(kind, element, devices, state.defaultDevices.get(kind));
434
563
  }));
435
564
  }
436
565