@rapidmx/meet-plugin 0.14.1 → 0.16.0
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.
- package/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -463
- package/apps/meet/_CallView.tsx +1060 -891
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -80
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +54 -2
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +21 -1
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
- package/apps/shared/webrtc/types.ts +335 -247
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +21 -6
- package/dist/apps/meet/_CallControls.js +14 -8
- package/dist/apps/meet/_CallView.d.ts +36 -7
- package/dist/apps/meet/_CallView.js +132 -16
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +16 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +46 -2
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
- package/dist/apps/shared/relay/RelayTransport.js +16 -1
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
- package/dist/apps/shared/webrtc/types.d.ts +104 -26
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
- package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
package/apps/meet/_CallView.tsx
CHANGED
|
@@ -1,891 +1,1060 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
/**
|
|
6
|
-
* The in-call view: connects the full-mesh `MeshConnectionManager` to the meeting's signaling channel
|
|
7
|
-
* (`
|
|
8
|
-
* layouts - see `apps/shared/webrtc/MeshConnectionManager.ts`'s doc comment for the who-calls-whom and
|
|
9
|
-
* single-presenter rules this view relies on rather than re-deciding. These reusable, non-UI modules live under
|
|
10
|
-
* `apps/shared/` rather than this plugin's backend `src/` - `tsconfig.apps.json` builds `apps/` as its own
|
|
11
|
-
* program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
|
|
12
|
-
* identical `apps/shared/` convention for reusable frontend-only code.
|
|
13
|
-
*
|
|
14
|
-
* ## Fitting the window
|
|
15
|
-
*
|
|
16
|
-
* The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
|
|
17
|
-
* is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
|
|
18
|
-
* the branded page shell, whose header, footer and padding pushed it off the screen.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* ##
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* ##
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* own
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
)
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
<div
|
|
809
|
-
{
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
)
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The in-call view: connects the full-mesh `MeshConnectionManager` to the meeting's signaling channel
|
|
7
|
+
* (`SignalingClient`, this plugin's own WebSocket), mirrors its events into React state, and renders the grid/focused/presentation
|
|
8
|
+
* layouts - see `apps/shared/webrtc/MeshConnectionManager.ts`'s doc comment for the who-calls-whom and
|
|
9
|
+
* single-presenter rules this view relies on rather than re-deciding. These reusable, non-UI modules live under
|
|
10
|
+
* `apps/shared/` rather than this plugin's backend `src/` - `tsconfig.apps.json` builds `apps/` as its own
|
|
11
|
+
* program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
|
|
12
|
+
* identical `apps/shared/` convention for reusable frontend-only code.
|
|
13
|
+
*
|
|
14
|
+
* ## Fitting the window
|
|
15
|
+
*
|
|
16
|
+
* The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
|
|
17
|
+
* is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
|
|
18
|
+
* the branded page shell, whose header, footer and padding pushed it off the screen. In plain grid view, each tile
|
|
19
|
+
* keeps its capture device's aspect ratio (`aspect-video`) instead of being stretched to fill an equal share of the
|
|
20
|
+
* available height - the grid area itself may scroll (vertically, within that middle row) once enough
|
|
21
|
+
* aspect-ratio-sized tiles no longer all fit, rather than cropping or distorting any of them to force a fit.
|
|
22
|
+
*
|
|
23
|
+
* ## Where the local tile appears
|
|
24
|
+
*
|
|
25
|
+
* In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
|
|
26
|
+
* everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
|
|
27
|
+
* genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
|
|
28
|
+
* tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
|
|
29
|
+
* is reserved for focus/presentation layouts, where there's already a single large tile the local one would
|
|
30
|
+
* otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
|
|
31
|
+
* (see the `alone` branch of the `stage` computation).
|
|
32
|
+
*
|
|
33
|
+
* `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
|
|
34
|
+
* it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
|
|
35
|
+
* directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
|
|
36
|
+
* "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
|
|
37
|
+
* nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
|
|
38
|
+
* just blank it, so the local tile ignores `selfHidden` there and always shows.
|
|
39
|
+
*
|
|
40
|
+
* ## Who is heard
|
|
41
|
+
*
|
|
42
|
+
* A tile never plays sound. Each remote stream is played by its own hidden `<audio>` element, so a participant is
|
|
43
|
+
* heard whether or not their camera is on and whichever layout is showing. If the browser refuses to start the
|
|
44
|
+
* audio (an autoplay policy), a banner asks for a click, which is the gesture that allows it.
|
|
45
|
+
*
|
|
46
|
+
* ## Layout precedence
|
|
47
|
+
*
|
|
48
|
+
* `computeMainUid()` decides who is shown large, in this order: an active presenter always wins (presentation
|
|
49
|
+
* mode, forcing a focused-like layout regardless of `viewMode`); otherwise a manually pinned participant; else the
|
|
50
|
+
* auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
|
|
51
|
+
* slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
|
|
52
|
+
* empty main slot once someone else has joined.
|
|
53
|
+
*
|
|
54
|
+
* ## Talking stick mode
|
|
55
|
+
*
|
|
56
|
+
* A host-only navbar toggle (`handleToggleTalkingStick()`) that, once on, lets at most one participant's microphone
|
|
57
|
+
* be unmuted at a time - the host assigns who from the participants drawer (`handleGiveTalkingStick()`), everyone
|
|
58
|
+
* else sees a header chip naming who currently holds it. Purely an in-call runtime state signaled peer-to-peer over
|
|
59
|
+
* the mesh (`MeshConnectionManager.setTalkingStick()`/`"talking-stick-changed"`), the same shape as `mute-request`/
|
|
60
|
+
* `kicked`: no `VideoMeeting` field, no backend route, and only as "enforced" as a cooperating client makes it (see
|
|
61
|
+
* `types.ts`'s `SignalMessage` doc comment on why the signaling layer has no host identity to check against). What
|
|
62
|
+
* *is* new here versus `mute-request`'s one-time nudge: becoming or ceasing to be the holder force-toggles this
|
|
63
|
+
* tab's own mic to match (`selfHasTalkingStick`'s effect, below), and the mic button itself is disabled
|
|
64
|
+
* (`micLocked`, passed to `CallControls`) the whole time a non-holder would otherwise be able to tap it straight
|
|
65
|
+
* back on - a real restriction for as long as this tab's own UI is the one in front of the participant, not just a
|
|
66
|
+
* suggestion. Turning the mode off touches nobody's mic state, matching every other host toggle's "never
|
|
67
|
+
* retroactive" posture. A holder who leaves the call is not specially reassigned - see
|
|
68
|
+
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
69
|
+
* the floor, until the host picks someone (or themselves) again.
|
|
70
|
+
*
|
|
71
|
+
* ## Chat and whiteboard
|
|
72
|
+
*
|
|
73
|
+
* Both run on `CollabSession` (`apps/shared/collab/CollabSession.ts`), over the same signaling channel as the mesh,
|
|
74
|
+
* and last as long as the call. The chat opens in a drawer (`_ChatPanel.tsx`) from the header's chat button - in
|
|
75
|
+
* place of the participants drawer, since one side drawer at a time is all a narrow window has room for - which
|
|
76
|
+
* counts unread messages while it is closed. The whiteboard (`_Whiteboard.tsx`) takes the stage when opened from
|
|
77
|
+
* the control bar, with everyone's video in a strip below it and the local tile in its corner, like presenting; it
|
|
78
|
+
* is a per-participant view of one shared board, so opening or closing it changes nobody else's screen. Its button
|
|
79
|
+
* shows a dot when someone draws while it is closed. Everyone's pointer shows on everyone's board with their name;
|
|
80
|
+
* while talking-stick mode is on only the holder may draw (`CollabSession.setDrawPolicy()`), the same rule as the
|
|
81
|
+
* microphone. History a late joiner receives is shown but not counted as unread.
|
|
82
|
+
*
|
|
83
|
+
* ## Diagnostics, connection method and settings
|
|
84
|
+
*
|
|
85
|
+
* All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
|
|
86
|
+
* callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
|
|
87
|
+
* window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
|
|
88
|
+
* participant wants to keep open while they poke at the rest of the call, not something that closes the moment
|
|
89
|
+
* they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
|
|
90
|
+
* else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
|
|
91
|
+
* anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
|
|
92
|
+
* own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
|
|
93
|
+
* about *who* is in the call, which is what the drawer is for.
|
|
94
|
+
*/
|
|
95
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
96
|
+
import { playRaisedHandChime } from "../shared/media/chime.js";
|
|
97
|
+
import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
|
|
98
|
+
import { startLevelMeter, type LevelMeterHandle } from "../shared/media/levelMeter.js";
|
|
99
|
+
import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
|
|
100
|
+
import { NO_SCREEN_TRANSFORM, ScreenTransformProcessor, rotateClockwise, type ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
101
|
+
import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|
|
102
|
+
import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
|
|
103
|
+
import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
|
|
104
|
+
import type { MeshParticipant, RelayTransportLike, TransportMode } from "../shared/webrtc/types.js";
|
|
105
|
+
import { createRelayTransport } from "../shared/relay/RelayTransport.js";
|
|
106
|
+
import { SIGNALING_EVICTED_REASON, SignalingClient } from "../shared/signaling/SignalingClient.js";
|
|
107
|
+
import { CollabSession, type ChatEntry, type RemoteCursor } from "../shared/collab/CollabSession.js";
|
|
108
|
+
import CallControls, { type CallViewMode } from "./_CallControls.js";
|
|
109
|
+
import ChatPanel from "./_ChatPanel.js";
|
|
110
|
+
import { BatonIcon, ChatIcon } from "./_icons.js";
|
|
111
|
+
import {
|
|
112
|
+
admitParticipant,
|
|
113
|
+
denyParticipant,
|
|
114
|
+
kickParticipant,
|
|
115
|
+
listWaitingParticipants,
|
|
116
|
+
setForceMuteOnJoin as apiSetForceMuteOnJoin,
|
|
117
|
+
setMeetingPassword,
|
|
118
|
+
setWaitingRoomEnabled as apiSetWaitingRoomEnabled,
|
|
119
|
+
type WaitingParticipant,
|
|
120
|
+
} from "./_meetApi.js";
|
|
121
|
+
import DiagnosticsPanel from "./_DiagnosticsPanel.js";
|
|
122
|
+
import DiagnosticsWindow from "./_DiagnosticsWindow.js";
|
|
123
|
+
import ParticipantTile from "./_ParticipantTile.js";
|
|
124
|
+
import ParticipantsDrawer from "./_ParticipantsDrawer.js";
|
|
125
|
+
import SettingsModal from "./_SettingsModal.js";
|
|
126
|
+
import Whiteboard from "./_Whiteboard.js";
|
|
127
|
+
|
|
128
|
+
export interface CallViewProps {
|
|
129
|
+
channel: string;
|
|
130
|
+
/** Omitted when the caller already authenticated as a real RapidMX identity in `join()` - there is then no
|
|
131
|
+
* guest token to hand `SignalingClient`, which relies entirely on the browser's own already-existing `jwt`
|
|
132
|
+
* session cookie instead (see its own doc comment). */
|
|
133
|
+
token?: string;
|
|
134
|
+
selfUid: string;
|
|
135
|
+
selfName: string;
|
|
136
|
+
meetingTitle: string;
|
|
137
|
+
iceServers: RTCIceServer[];
|
|
138
|
+
/** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
|
|
139
|
+
* connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
|
|
140
|
+
relayEnabled?: boolean;
|
|
141
|
+
/** The meeting's host account uid (`PublicVideoMeeting.hostUid`) - this client's only source of a "host"
|
|
142
|
+
* identity, purely for deciding which controls to show (see `BaseVideoMeetingRoute`'s class doc comment on why
|
|
143
|
+
* this is a client-side-only check, not a server-enforced one for every moderation action). Absent when the
|
|
144
|
+
* server couldn't resolve one, in which case nobody sees host controls. */
|
|
145
|
+
hostUid?: string;
|
|
146
|
+
/** `PublicVideoMeeting.forceMuteOnJoin` as of this call's own `join()` - the starting value for the host's
|
|
147
|
+
* toggle in the settings modal, which this view then owns and keeps current itself (see
|
|
148
|
+
* `handleToggleForceMuteOnJoin()`). Does not update if changed elsewhere while this view is mounted - there is
|
|
149
|
+
* no signal for that today, matching every other meeting-settings field's lack of live sync. */
|
|
150
|
+
initialForceMuteOnJoin?: boolean;
|
|
151
|
+
/** `PublicVideoMeeting.hasPassword` as of this call's own `join()` - the starting value for the host's password
|
|
152
|
+
* section in the settings modal, same "owned and kept current by this view" shape as
|
|
153
|
+
* `initialForceMuteOnJoin`. */
|
|
154
|
+
initialHasPassword?: boolean;
|
|
155
|
+
/** `PublicVideoMeeting.waitingRoomEnabled` as of this call's own `join()` - the starting value for the host's
|
|
156
|
+
* waiting-room toggle, same "owned and kept current by this view" shape as `initialForceMuteOnJoin`. */
|
|
157
|
+
initialWaitingRoomEnabled?: boolean;
|
|
158
|
+
/** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
|
|
159
|
+
media: LocalMedia;
|
|
160
|
+
/** Called once the participant leaves, for any reason. `reason` is set only when the call ended without the
|
|
161
|
+
* participant's own action - currently, being kicked by the host - so the caller can show a distinct message
|
|
162
|
+
* instead of the ordinary "you left" one; omitted for an ordinary voluntary leave. */
|
|
163
|
+
onLeave: (reason?: string) => void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** How long a reaction floats on screen. */
|
|
167
|
+
const REACTION_MS = 4_000;
|
|
168
|
+
/** The most reactions shown at once - a burst beyond this drops the oldest. */
|
|
169
|
+
const MAX_REACTIONS = 12;
|
|
170
|
+
/** How often the host's drawer refreshes its own waiting-room list while open. */
|
|
171
|
+
const DEFAULT_WAITING_POLL_MS = 3_000;
|
|
172
|
+
|
|
173
|
+
interface Reaction {
|
|
174
|
+
id: number;
|
|
175
|
+
emoji: string;
|
|
176
|
+
name: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
|
|
180
|
+
* joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
|
|
181
|
+
export function computeMainUid(options: {
|
|
182
|
+
presenterUid?: string;
|
|
183
|
+
pinnedUid?: string;
|
|
184
|
+
activeSpeakerUid?: string;
|
|
185
|
+
otherUids: readonly string[];
|
|
186
|
+
}): string | undefined {
|
|
187
|
+
if (options.presenterUid) {
|
|
188
|
+
return options.presenterUid;
|
|
189
|
+
}
|
|
190
|
+
if (options.pinnedUid) {
|
|
191
|
+
return options.pinnedUid;
|
|
192
|
+
}
|
|
193
|
+
if (options.activeSpeakerUid) {
|
|
194
|
+
return options.activeSpeakerUid;
|
|
195
|
+
}
|
|
196
|
+
return options.otherUids[0];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** This tab's identity in the call: the account (or guest) uid plus a random suffix, so the same account joining
|
|
200
|
+
* from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
|
|
201
|
+
* `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
|
|
202
|
+
export function newPeerId(selfUid: string): string {
|
|
203
|
+
return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The real, server-enforced account uid behind a participant's tab-scoped `MeshParticipant.uid` (`newPeerId()`'s
|
|
207
|
+
* `<account uid>~<random>` shape) - what the kick route's ACL grant is actually keyed by, unlike the tab-scoped id
|
|
208
|
+
* this view otherwise addresses a specific tab with. Neither a real account uid nor a minted guest uid (see
|
|
209
|
+
* `GUEST_UID_PREFIX`/`mintGuestToken()` on the backend) can itself contain "~", so the last one found is always
|
|
210
|
+
* exactly this suffix's own separator. */
|
|
211
|
+
export function accountUidOf(peerUid: string): string {
|
|
212
|
+
const i = peerUid.lastIndexOf("~");
|
|
213
|
+
return i === -1 ? peerUid : peerUid.slice(0, i);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default function CallView({
|
|
217
|
+
channel,
|
|
218
|
+
token,
|
|
219
|
+
selfUid,
|
|
220
|
+
selfName,
|
|
221
|
+
meetingTitle,
|
|
222
|
+
iceServers,
|
|
223
|
+
relayEnabled,
|
|
224
|
+
hostUid,
|
|
225
|
+
initialForceMuteOnJoin,
|
|
226
|
+
initialHasPassword,
|
|
227
|
+
initialWaitingRoomEnabled,
|
|
228
|
+
media,
|
|
229
|
+
onLeave,
|
|
230
|
+
}: CallViewProps) {
|
|
231
|
+
const [peerId] = useState(() => newPeerId(selfUid));
|
|
232
|
+
const [connectError, setConnectError] = useState<string | null>(null);
|
|
233
|
+
const [participants, setParticipants] = useState<MeshParticipant[]>([]);
|
|
234
|
+
const [remoteStreams, setRemoteStreams] = useState<Record<string, MediaStream>>({});
|
|
235
|
+
const [levels, setLevels] = useState<Record<string, number>>({});
|
|
236
|
+
const [presenterUid, setPresenterUid] = useState<string | undefined>(undefined);
|
|
237
|
+
const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
|
|
238
|
+
const [screenTransform, setScreenTransform] = useState<ScreenTransformState>(NO_SCREEN_TRANSFORM);
|
|
239
|
+
const [handRaised, setHandRaised] = useState(false);
|
|
240
|
+
const [viewMode, setViewMode] = useState<CallViewMode>("grid");
|
|
241
|
+
const [pinnedUid, setPinnedUid] = useState<string | undefined>(undefined);
|
|
242
|
+
const [activeSpeakerUid, setActiveSpeakerUid] = useState<string | undefined>(undefined);
|
|
243
|
+
const [reactions, setReactions] = useState<Reaction[]>([]);
|
|
244
|
+
const [announcement, setAnnouncement] = useState("");
|
|
245
|
+
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
246
|
+
const [audioNonce, setAudioNonce] = useState(0);
|
|
247
|
+
const [signalingReady, setSignalingReady] = useState(false);
|
|
248
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
249
|
+
const [forceMuteOnJoin, setForceMuteOnJoinState] = useState(!!initialForceMuteOnJoin);
|
|
250
|
+
const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
|
|
251
|
+
const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
|
|
252
|
+
const [waitingParticipants, setWaitingParticipants] = useState<WaitingParticipant[]>([]);
|
|
253
|
+
const [talkingStickActive, setTalkingStickActive] = useState(false);
|
|
254
|
+
const [talkingStickHolder, setTalkingStickHolder] = useState<string | undefined>(undefined);
|
|
255
|
+
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
|
256
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
257
|
+
const [transportMode, setTransportModeState] = useState<TransportMode>("auto");
|
|
258
|
+
const [selfHidden, setSelfHidden] = useState(false);
|
|
259
|
+
const [chatOpen, setChatOpen] = useState(false);
|
|
260
|
+
const [chatEntries, setChatEntries] = useState<readonly ChatEntry[]>([]);
|
|
261
|
+
const [unreadChat, setUnreadChat] = useState(0);
|
|
262
|
+
const [whiteboardOpen, setWhiteboardOpen] = useState(false);
|
|
263
|
+
const [boardRevision, setBoardRevision] = useState(0);
|
|
264
|
+
const [whiteboardActivity, setWhiteboardActivity] = useState(false);
|
|
265
|
+
const [boardCursors, setBoardCursors] = useState<RemoteCursor[]>([]);
|
|
266
|
+
|
|
267
|
+
const managerRef = useRef<MeshConnectionManager | null>(null);
|
|
268
|
+
const collabRef = useRef<CollabSession | null>(null);
|
|
269
|
+
/** Whether the chat drawer / whiteboard is open, for the collab-event handler - set up once, like the mesh's. */
|
|
270
|
+
const chatOpenRef = useRef(chatOpen);
|
|
271
|
+
const whiteboardOpenRef = useRef(whiteboardOpen);
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
chatOpenRef.current = chatOpen;
|
|
274
|
+
whiteboardOpenRef.current = whiteboardOpen;
|
|
275
|
+
}, [chatOpen, whiteboardOpen]);
|
|
276
|
+
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
277
|
+
* indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
|
|
278
|
+
* actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
|
|
279
|
+
const screenStreamRef = useRef<MediaStream | null>(null);
|
|
280
|
+
const screenTransformRef = useRef<ScreenTransformProcessor | null>(null);
|
|
281
|
+
const levelMetersRef = useRef<Record<string, LevelMeterHandle>>({});
|
|
282
|
+
const reactionSeqRef = useRef(0);
|
|
283
|
+
const reactionTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
|
|
284
|
+
/** The latest `media`, for the mesh-event handler below - that effect runs once at mount (see its own comment),
|
|
285
|
+
* so it would otherwise see only the mic state/toggle function from that first render. */
|
|
286
|
+
const mediaRef = useRef(media);
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
mediaRef.current = media;
|
|
289
|
+
}, [media]);
|
|
290
|
+
const isPresenting = !!screenStream;
|
|
291
|
+
|
|
292
|
+
const showReaction = useCallback((emoji: string, name: string) => {
|
|
293
|
+
const id = ++reactionSeqRef.current;
|
|
294
|
+
setReactions((prev) => [...prev.slice(-(MAX_REACTIONS - 1)), { id, emoji, name }]);
|
|
295
|
+
const timer = setTimeout(() => {
|
|
296
|
+
reactionTimersRef.current.delete(timer);
|
|
297
|
+
setReactions((prev) => prev.filter((reaction) => reaction.id !== id));
|
|
298
|
+
}, REACTION_MS);
|
|
299
|
+
reactionTimersRef.current.add(timer);
|
|
300
|
+
}, []);
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
let cancelled = false;
|
|
304
|
+
const client = new SignalingClient({
|
|
305
|
+
meetingUid: channel,
|
|
306
|
+
peerId,
|
|
307
|
+
token,
|
|
308
|
+
// The server closed the socket for good: being removed reads as the host's doing (the cooperative `kicked`
|
|
309
|
+
// usually got here first), anything else - the meeting ended, the grant is gone - as a connection error.
|
|
310
|
+
onClosed: (reason) => (reason === SIGNALING_EVICTED_REASON ? onLeave("The host removed you from this call.") : setConnectError(reason)),
|
|
311
|
+
});
|
|
312
|
+
const manager = new MeshConnectionManager({
|
|
313
|
+
selfUid,
|
|
314
|
+
peerId,
|
|
315
|
+
selfName,
|
|
316
|
+
iceServers,
|
|
317
|
+
channel: client,
|
|
318
|
+
createPeerConnection: createBrowserPeerConnection,
|
|
319
|
+
relay: relayEnabled ? createRelay(channel, peerId) : undefined,
|
|
320
|
+
localAudioTrack: media.audioTrack,
|
|
321
|
+
localVideoTrack: media.videoTrack,
|
|
322
|
+
localState: { audioOn: media.micOn, videoOn: media.cameraOn },
|
|
323
|
+
});
|
|
324
|
+
managerRef.current = manager;
|
|
325
|
+
const collab = new CollabSession({ channel: client, selfUid, peerId, selfName });
|
|
326
|
+
collabRef.current = collab;
|
|
327
|
+
const unsubscribeCollab = collab.onEvent((event) => {
|
|
328
|
+
if (event.type === "board") {
|
|
329
|
+
setBoardRevision(collab.board.revision);
|
|
330
|
+
if (!whiteboardOpenRef.current) {
|
|
331
|
+
setWhiteboardActivity(true);
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (event.type === "cursors") {
|
|
336
|
+
setBoardCursors(collab.cursors);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
setChatEntries(collab.chat);
|
|
340
|
+
// What was said before this participant arrived (a catch-up), or a message only renamed, is not news to them.
|
|
341
|
+
if (!event.entry.self && !event.history && !event.renamed && !chatOpenRef.current) {
|
|
342
|
+
setUnreadChat((prev) => prev + 1);
|
|
343
|
+
setAnnouncement(`${event.entry.name}: ${event.entry.text.slice(0, 140)}`);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const unsubscribe = manager.onEvent((event) => {
|
|
348
|
+
switch (event.type) {
|
|
349
|
+
case "participant-joined":
|
|
350
|
+
setParticipants((prev) => [...prev.filter((p) => p.uid !== event.participant.uid), event.participant]);
|
|
351
|
+
return;
|
|
352
|
+
case "participant-updated":
|
|
353
|
+
setParticipants((prev) => prev.map((p) => (p.uid === event.participant.uid ? event.participant : p)));
|
|
354
|
+
return;
|
|
355
|
+
case "participant-left":
|
|
356
|
+
setParticipants((prev) => prev.filter((p) => p.uid !== event.uid));
|
|
357
|
+
setRemoteStreams((prev) => {
|
|
358
|
+
const { [event.uid]: _removed, ...rest } = prev;
|
|
359
|
+
return rest;
|
|
360
|
+
});
|
|
361
|
+
stopLevelMeter(levelMetersRef, event.uid);
|
|
362
|
+
setLevels((prev) => {
|
|
363
|
+
const { [event.uid]: _removed, ...rest } = prev;
|
|
364
|
+
return rest;
|
|
365
|
+
});
|
|
366
|
+
setPinnedUid((prev) => (prev === event.uid ? undefined : prev));
|
|
367
|
+
return;
|
|
368
|
+
case "remote-stream":
|
|
369
|
+
setRemoteStreams((prev) => ({ ...prev, [event.uid]: event.stream }));
|
|
370
|
+
startTrackingLevel(levelMetersRef, event.uid, event.stream, (level) =>
|
|
371
|
+
setLevels((prev) => ({ ...prev, [event.uid]: level })),
|
|
372
|
+
);
|
|
373
|
+
return;
|
|
374
|
+
case "hand-raised":
|
|
375
|
+
playRaisedHandChime();
|
|
376
|
+
setAnnouncement(`${event.name} raised a hand`);
|
|
377
|
+
return;
|
|
378
|
+
case "reaction":
|
|
379
|
+
showReaction(event.emoji, event.name);
|
|
380
|
+
return;
|
|
381
|
+
case "presenter-changed":
|
|
382
|
+
setPresenterUid(event.uid);
|
|
383
|
+
if (event.uid !== peerId && screenStreamRef.current) {
|
|
384
|
+
// Lost a presenter-claim collision (see `MeshConnectionManager`'s doc comment) - stop our
|
|
385
|
+
// own capture without re-sending a release the manager already handled internally.
|
|
386
|
+
stopLocalPresentation(false);
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
case "mute-requested":
|
|
390
|
+
if (mediaRef.current.micOn) {
|
|
391
|
+
void mediaRef.current.toggleMic();
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
case "kicked":
|
|
395
|
+
onLeave("The host removed you from this call.");
|
|
396
|
+
return;
|
|
397
|
+
case "talking-stick-changed":
|
|
398
|
+
setTalkingStickActive(event.active);
|
|
399
|
+
setTalkingStickHolder(event.holder);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
client
|
|
405
|
+
.connect()
|
|
406
|
+
.then(() => {
|
|
407
|
+
if (!cancelled) {
|
|
408
|
+
// Listening before the mesh says hello, so no answering hello is missed - see `CollabSession`.
|
|
409
|
+
collab.start();
|
|
410
|
+
manager.start();
|
|
411
|
+
setSignalingReady(true);
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
.catch((err: Error) => {
|
|
415
|
+
if (!cancelled) {
|
|
416
|
+
setConnectError(err.message);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// A closing tab doesn't unmount React, so say goodbye explicitly - otherwise everyone else keeps a tile for a
|
|
421
|
+
// participant who is gone.
|
|
422
|
+
const onPageHide = () => manager.stop();
|
|
423
|
+
window.addEventListener("pagehide", onPageHide);
|
|
424
|
+
|
|
425
|
+
const timers = reactionTimersRef.current;
|
|
426
|
+
return () => {
|
|
427
|
+
cancelled = true;
|
|
428
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
429
|
+
unsubscribe();
|
|
430
|
+
unsubscribeCollab();
|
|
431
|
+
collab.stop();
|
|
432
|
+
manager.stop();
|
|
433
|
+
client.close();
|
|
434
|
+
for (const handle of Object.values(levelMetersRef.current)) {
|
|
435
|
+
handle.stop();
|
|
436
|
+
}
|
|
437
|
+
levelMetersRef.current = {};
|
|
438
|
+
// Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
|
|
439
|
+
// releases them when the participant leaves.
|
|
440
|
+
stopStream(screenStreamRef.current);
|
|
441
|
+
screenTransformRef.current?.stop();
|
|
442
|
+
for (const timer of timers) {
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
}
|
|
445
|
+
timers.clear();
|
|
446
|
+
};
|
|
447
|
+
// Deliberately runs once - the call's identity (channel/token/selfUid) never changes for the life of this
|
|
448
|
+
// component; a real identity change is a new call, which unmounts/remounts this view from `[token].tsx`.
|
|
449
|
+
}, []);
|
|
450
|
+
|
|
451
|
+
// What is sent follows what the participant has: the camera or the shared screen, and the microphone.
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
managerRef.current?.setLocalTrack("audio", media.audioTrack);
|
|
454
|
+
}, [media.audioTrack]);
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
managerRef.current?.setLocalTrack("video", screenStream?.getVideoTracks()[0] ?? media.videoTrack);
|
|
457
|
+
}, [media.videoTrack, screenStream]);
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
|
|
460
|
+
}, [media.micOn, media.cameraOn, isPresenting, handRaised]);
|
|
461
|
+
|
|
462
|
+
// Talking-stick mode forces exactly one microphone on at a time: whoever just became the holder (including the
|
|
463
|
+
// host, on activation) is unmuted; everyone else is muted. Unlike `mute-requested`'s one-time nudge, this is
|
|
464
|
+
// paired with disabling the mic button itself (`micLocked`, passed to `CallControls` below) while it applies,
|
|
465
|
+
// so it isn't merely a suggestion - though still cooperative at the signaling layer, like every other
|
|
466
|
+
// moderation feature here (see `MeshConnectionManager`'s doc comment). Turning the mode off touches nobody's
|
|
467
|
+
// mic - states are left exactly as they are, the same "never retroactive" posture as every other host toggle.
|
|
468
|
+
const selfHasTalkingStick = talkingStickActive && talkingStickHolder === peerId;
|
|
469
|
+
const micLocked = talkingStickActive && !selfHasTalkingStick;
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
if (!talkingStickActive) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (selfHasTalkingStick && !mediaRef.current.micOn) {
|
|
475
|
+
void mediaRef.current.toggleMic();
|
|
476
|
+
} else if (!selfHasTalkingStick && mediaRef.current.micOn) {
|
|
477
|
+
void mediaRef.current.toggleMic();
|
|
478
|
+
}
|
|
479
|
+
}, [talkingStickActive, selfHasTalkingStick]);
|
|
480
|
+
|
|
481
|
+
// Talking stick also decides who may draw on the whiteboard: only the holder, while it is on - see `CollabSession`.
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
collabRef.current?.setDrawPolicy(talkingStickActive ? (peer) => peer === talkingStickHolder : undefined);
|
|
484
|
+
}, [talkingStickActive, talkingStickHolder]);
|
|
485
|
+
|
|
486
|
+
useEffect(() => {
|
|
487
|
+
if (!presenterUid) {
|
|
488
|
+
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
489
|
+
}
|
|
490
|
+
}, [levels, presenterUid]);
|
|
491
|
+
|
|
492
|
+
function stopLocalPresentation(alsoRelease: boolean): void {
|
|
493
|
+
if (alsoRelease) {
|
|
494
|
+
managerRef.current?.releasePresenter();
|
|
495
|
+
}
|
|
496
|
+
stopStream(screenStreamRef.current);
|
|
497
|
+
screenStreamRef.current = null;
|
|
498
|
+
screenTransformRef.current?.stop();
|
|
499
|
+
screenTransformRef.current = null;
|
|
500
|
+
// The sync effect above swaps the camera back in as the outgoing video track. The rotation/flip is reset
|
|
501
|
+
// for the next share - each share starts from the capture the browser hands back, not the last one's fix.
|
|
502
|
+
setScreenStream(null);
|
|
503
|
+
setScreenTransform(NO_SCREEN_TRANSFORM);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function handleToggleShare() {
|
|
507
|
+
if (screenStreamRef.current) {
|
|
508
|
+
stopLocalPresentation(true);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const result = await requestDisplayMedia();
|
|
512
|
+
if (!result.ok) {
|
|
513
|
+
setConnectError(result.error.message);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const claimed = managerRef.current?.claimPresenter() ?? false;
|
|
517
|
+
if (!claimed) {
|
|
518
|
+
stopStream(result.value);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
screenStreamRef.current = result.value;
|
|
522
|
+
const rawTrack = result.value.getVideoTracks()[0];
|
|
523
|
+
rawTrack.onended = () => stopLocalPresentation(true);
|
|
524
|
+
try {
|
|
525
|
+
// Runs the capture through the rotate/flip processor before anyone (presenter included) sees it - see
|
|
526
|
+
// ScreenTransform.ts's doc comment on why window capture sometimes needs this correction.
|
|
527
|
+
const processor = new ScreenTransformProcessor({ source: rawTrack, state: screenTransform });
|
|
528
|
+
screenTransformRef.current = processor;
|
|
529
|
+
setScreenStream(new MediaStream([processor.track]));
|
|
530
|
+
} catch {
|
|
531
|
+
// This browser can't draw the correction - share the raw capture rather than not sharing at all; the
|
|
532
|
+
// rotate/flip buttons simply won't do anything (screenTransformRef stays null).
|
|
533
|
+
setScreenStream(result.value);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function handleRotateScreen() {
|
|
538
|
+
setScreenTransform((prev) => {
|
|
539
|
+
const next = rotateClockwise(prev);
|
|
540
|
+
screenTransformRef.current?.setState(next);
|
|
541
|
+
return next;
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function handleFlipScreen() {
|
|
546
|
+
setScreenTransform((prev) => {
|
|
547
|
+
const next = { ...prev, flipped: !prev.flipped };
|
|
548
|
+
screenTransformRef.current?.setState(next);
|
|
549
|
+
return next;
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function handleToggleHand() {
|
|
554
|
+
setHandRaised((prev) => !prev);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Hides the local tile - the tile's own "…" menu calls this directly (one-directional: there is no "show
|
|
558
|
+
* self" from in-grid, since a hidden tile isn't there to click on). Re-showing it is the navbar "…" menu's
|
|
559
|
+
* `handleToggleSelfHidden()` instead. */
|
|
560
|
+
function handleHideSelf() {
|
|
561
|
+
setSelfHidden(true);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function handleToggleSelfHidden() {
|
|
565
|
+
setSelfHidden((prev) => !prev);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function handleReaction(emoji: string) {
|
|
569
|
+
if (managerRef.current?.sendReaction(emoji)) {
|
|
570
|
+
showReaction(emoji, "You");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function handleToggleChat() {
|
|
575
|
+
setChatOpen((prev) => !prev);
|
|
576
|
+
setDrawerOpen(false);
|
|
577
|
+
setUnreadChat(0);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function handleToggleParticipants() {
|
|
581
|
+
setDrawerOpen((prev) => !prev);
|
|
582
|
+
setChatOpen(false);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function handleToggleWhiteboard() {
|
|
586
|
+
setWhiteboardOpen((prev) => !prev);
|
|
587
|
+
setWhiteboardActivity(false);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function togglePin(uid: string) {
|
|
591
|
+
setPinnedUid((prev) => (prev === uid ? undefined : uid));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Asks `peerUid`'s participant to mute - the host-only button in the participants drawer calls this
|
|
595
|
+
* directly, with no confirmation (unlike removing someone, muting is easily undone by the participant
|
|
596
|
+
* themselves). */
|
|
597
|
+
function handleMuteParticipant(peerUid: string) {
|
|
598
|
+
managerRef.current?.sendMuteRequest(peerUid);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Removes `peerUid`'s participant from the call: the cooperative signal (immediate, so a cooperating client
|
|
602
|
+
* leaves without waiting on the network round trip below) and the enforced server-side revoke together - see
|
|
603
|
+
* `kickParticipant()`'s own doc comment for why both matter. The REST call's failure is swallowed rather than
|
|
604
|
+
* surfaced: the host already asked them to leave, and there is no useful recovery action to offer from here
|
|
605
|
+
* for what is almost always a transient network issue. */
|
|
606
|
+
function handleKickParticipant(peerUid: string) {
|
|
607
|
+
managerRef.current?.sendKick(peerUid);
|
|
608
|
+
kickParticipant(channel, accountUidOf(peerUid)).catch(() => undefined);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Flips the host's "mute new participants on join" setting: applied optimistically (the toggle itself is the
|
|
612
|
+
* only feedback this small a control needs) and reverted if the save fails, so the drawer never keeps claiming
|
|
613
|
+
* a setting that isn't actually persisted. Takes effect for whoever joins next - never retroactive, see
|
|
614
|
+
* `setForceMuteOnJoin()`'s own doc comment. */
|
|
615
|
+
function handleToggleForceMuteOnJoin() {
|
|
616
|
+
const next = !forceMuteOnJoin;
|
|
617
|
+
setForceMuteOnJoinState(next);
|
|
618
|
+
apiSetForceMuteOnJoin(channel, next).catch(() => setForceMuteOnJoinState(!next));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Sets, changes, or removes (`password: null`) the host's join password - unlike the force-mute toggle, this
|
|
622
|
+
* is not applied optimistically: the drawer's own `PasswordSection` awaits the result itself (clearing its
|
|
623
|
+
* input on success, showing an inline error on failure), so there is nothing here to revert. `hasPassword`
|
|
624
|
+
* updates only once the save actually succeeds. */
|
|
625
|
+
async function handleSetPassword(password: string | null): Promise<void> {
|
|
626
|
+
await setMeetingPassword(channel, password);
|
|
627
|
+
setHasPassword(!!password);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Flips the host's waiting-room setting - same optimistic-and-revert shape as `handleToggleForceMuteOnJoin()`,
|
|
631
|
+
* for the same reason. Takes effect for whoever joins next; nobody already admitted is retroactively gated. */
|
|
632
|
+
function handleToggleWaitingRoomEnabled() {
|
|
633
|
+
const next = !waitingRoomEnabled;
|
|
634
|
+
setWaitingRoomEnabledState(next);
|
|
635
|
+
apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Starts or stops talking-stick mode - host-only, see this module's doc comment. Starting makes the host the
|
|
639
|
+
* initial holder; stopping clears the holder too, leaving mic states exactly as they are rather than
|
|
640
|
+
* retroactively unmuting anyone. */
|
|
641
|
+
function handleToggleTalkingStick() {
|
|
642
|
+
managerRef.current?.setTalkingStick(!talkingStickActive, talkingStickActive ? undefined : peerId);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Hands the talking stick to `uid` (a participant's `MeshParticipant.uid`, or this tab's own `peerId` for the
|
|
646
|
+
* host taking it back) - host-only, called from the participants drawer's per-row "Give stick" button. */
|
|
647
|
+
function handleGiveTalkingStick(uid: string) {
|
|
648
|
+
managerRef.current?.setTalkingStick(true, uid);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Forces this tab's own transport policy - see `MeshConnectionManager.setTransportMode()`'s doc comment for
|
|
652
|
+
* what each mode actually does, and `_CallControls.tsx`'s doc comment on its "…" menu for why this is a
|
|
653
|
+
* personal, per-tab choice rather than a meeting setting. */
|
|
654
|
+
function handleSetTransportMode(mode: TransportMode) {
|
|
655
|
+
setTransportModeState(mode);
|
|
656
|
+
managerRef.current?.setTransportMode(mode);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
|
|
660
|
+
* anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
|
|
661
|
+
* delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
|
|
662
|
+
function handleAdmitParticipant(uid: string) {
|
|
663
|
+
setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
|
|
664
|
+
admitParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Denies one pending request - same optimistic-removal shape as `handleAdmitParticipant()`. */
|
|
668
|
+
function handleDenyParticipant(uid: string) {
|
|
669
|
+
setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
|
|
670
|
+
denyParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function handleAudioBlocked() {
|
|
674
|
+
setAudioBlocked(true);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function handleResumeAudio() {
|
|
678
|
+
setAudioBlocked(false);
|
|
679
|
+
setAudioNonce((prev) => prev + 1);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// A participant's `uid` is their tab-scoped peer id (`<account uid>~<random>` - see `newPeerId()`), while
|
|
683
|
+
// `hostUid` is the plain account uid `PublicVideoMeeting.hostUid` names - so telling the host's own tab(s) apart
|
|
684
|
+
// needs the prefix match, not equality. Purely a client-side check (see `hostUid`'s own doc comment on this
|
|
685
|
+
// prop): nobody's tab is prevented from claiming it, only from being shown host controls for it.
|
|
686
|
+
const isHost = !!hostUid && selfUid === hostUid;
|
|
687
|
+
const isParticipantHost = (uid: string): boolean => !!hostUid && uid.startsWith(`${hostUid}~`);
|
|
688
|
+
|
|
689
|
+
// Refreshes the drawer's own waiting-room list while the host has the drawer open - there is no push signal
|
|
690
|
+
// for a newly filed admission request, so polling is the only way the list stays current without the host
|
|
691
|
+
// having to close and reopen the drawer. Stops (and the list is dropped) the moment either condition ends.
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
if (!isHost || !drawerOpen) {
|
|
694
|
+
setWaitingParticipants([]);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
let cancelled = false;
|
|
698
|
+
const poll = () => {
|
|
699
|
+
listWaitingParticipants(channel).then((list) => {
|
|
700
|
+
if (!cancelled) {
|
|
701
|
+
setWaitingParticipants(list);
|
|
702
|
+
}
|
|
703
|
+
}, () => undefined);
|
|
704
|
+
};
|
|
705
|
+
poll();
|
|
706
|
+
const interval = setInterval(poll, DEFAULT_WAITING_POLL_MS);
|
|
707
|
+
return () => {
|
|
708
|
+
cancelled = true;
|
|
709
|
+
clearInterval(interval);
|
|
710
|
+
};
|
|
711
|
+
}, [isHost, drawerOpen, channel]);
|
|
712
|
+
const otherUids = participants.map((p) => p.uid);
|
|
713
|
+
// A pin or an active-speaker pick can name someone who has just left, until the state catches up.
|
|
714
|
+
const stillHere = (uid: string | undefined) => (uid && otherUids.includes(uid) ? uid : undefined);
|
|
715
|
+
const showFocusLayout = !!presenterUid || viewMode === "focus" || whiteboardOpen;
|
|
716
|
+
const mainUid = showFocusLayout
|
|
717
|
+
? computeMainUid({ presenterUid, pinnedUid: stillHere(pinnedUid), activeSpeakerUid: stillHere(activeSpeakerUid), otherUids })
|
|
718
|
+
: undefined;
|
|
719
|
+
const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
|
|
720
|
+
const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
|
|
721
|
+
// Absent (not "Someone") once the holder has left the call - see `MeshConnectionManager`'s doc comment on why
|
|
722
|
+
// nothing reassigns it automatically; the header then says so rather than naming someone no longer here.
|
|
723
|
+
const talkingStickHolderName = !talkingStickActive
|
|
724
|
+
? undefined
|
|
725
|
+
: selfHasTalkingStick
|
|
726
|
+
? "You"
|
|
727
|
+
: participants.find((p) => p.uid === talkingStickHolder)?.name;
|
|
728
|
+
const alone = participants.length === 0;
|
|
729
|
+
// This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
|
|
730
|
+
// and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
|
|
731
|
+
// so someone already in the call is not told they are "connecting" each time a newcomer arrives.
|
|
732
|
+
const selfConnecting = !connectError && (!signalingReady || (!alone && participants.every((p) => p.transport === "connecting")));
|
|
733
|
+
|
|
734
|
+
const remoteTile = (p: MeshParticipant, className?: string) => (
|
|
735
|
+
<ParticipantTile
|
|
736
|
+
key={p.uid}
|
|
737
|
+
name={p.name}
|
|
738
|
+
stream={remoteStreams[p.uid] ?? null}
|
|
739
|
+
cameraOff={!p.videoOn}
|
|
740
|
+
micMuted={!p.audioOn}
|
|
741
|
+
handRaised={p.handRaised}
|
|
742
|
+
transport={p.transport}
|
|
743
|
+
status={p.transport === "connecting" ? "Awaiting connection…" : undefined}
|
|
744
|
+
isFocused={mainUid === p.uid}
|
|
745
|
+
contain={presenterUid === p.uid}
|
|
746
|
+
className={className}
|
|
747
|
+
onClick={() => togglePin(p.uid)}
|
|
748
|
+
/>
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const selfTile = (className?: string, canHide?: boolean) => (
|
|
752
|
+
<ParticipantTile
|
|
753
|
+
name={selfName}
|
|
754
|
+
stream={media.videoStream}
|
|
755
|
+
isLocal
|
|
756
|
+
cameraOff={!media.cameraOn}
|
|
757
|
+
micMuted={!media.micOn}
|
|
758
|
+
handRaised={handRaised}
|
|
759
|
+
status={selfConnecting ? "Connecting…" : undefined}
|
|
760
|
+
// A custom background is a fixed picture, not a live reflection - mirroring it would show it backwards
|
|
761
|
+
// to no one but the participant themselves (see `_ParticipantTile.tsx`'s doc comment).
|
|
762
|
+
mirrored={media.filters.background !== "image"}
|
|
763
|
+
onHideSelf={canHide ? handleHideSelf : undefined}
|
|
764
|
+
className={className}
|
|
765
|
+
/>
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
const mainParticipant = participants.find((p) => p.uid === mainUid);
|
|
769
|
+
const thumbnails = participants.filter((p) => p.uid !== mainUid);
|
|
770
|
+
|
|
771
|
+
let stage: React.ReactNode;
|
|
772
|
+
if (whiteboardOpen && collabRef.current) {
|
|
773
|
+
// The board takes the main slot, everyone else's video the strip below it - see this module's doc comment.
|
|
774
|
+
stage = (
|
|
775
|
+
<div className="h-full flex flex-col gap-2">
|
|
776
|
+
<div className="flex-1 min-h-0">
|
|
777
|
+
<Whiteboard
|
|
778
|
+
session={collabRef.current}
|
|
779
|
+
revision={boardRevision}
|
|
780
|
+
cursors={boardCursors}
|
|
781
|
+
canDraw={!talkingStickActive || selfHasTalkingStick}
|
|
782
|
+
drawLockedTo={talkingStickHolderName}
|
|
783
|
+
onClose={handleToggleWhiteboard}
|
|
784
|
+
/>
|
|
785
|
+
</div>
|
|
786
|
+
{participants.length > 0 && (
|
|
787
|
+
<div className="flex gap-2 overflow-x-auto h-24 shrink-0" data-testid="thumbnails">
|
|
788
|
+
{participants.map((p) => (
|
|
789
|
+
<div key={p.uid} className="w-36 shrink-0">
|
|
790
|
+
{remoteTile(p, "h-full")}
|
|
791
|
+
</div>
|
|
792
|
+
))}
|
|
793
|
+
</div>
|
|
794
|
+
)}
|
|
795
|
+
</div>
|
|
796
|
+
);
|
|
797
|
+
} else if (alone) {
|
|
798
|
+
// Nobody else yet: the local participant fills the tile area, presenting or not - and is never hideable,
|
|
799
|
+
// since there'd be nothing left to look at (see this module's doc comment on hiding the local tile).
|
|
800
|
+
stage = isPresenting ? (
|
|
801
|
+
<ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
|
|
802
|
+
) : (
|
|
803
|
+
selfTile("h-full")
|
|
804
|
+
);
|
|
805
|
+
} else if (showFocusLayout && (mainParticipant || isPresenting)) {
|
|
806
|
+
stage = (
|
|
807
|
+
<div className="h-full flex flex-col gap-2">
|
|
808
|
+
<div className="flex-1 min-h-0" data-testid="main-tile">
|
|
809
|
+
{mainParticipant ? (
|
|
810
|
+
remoteTile(mainParticipant, "h-full")
|
|
811
|
+
) : (
|
|
812
|
+
<ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
|
|
813
|
+
)}
|
|
814
|
+
</div>
|
|
815
|
+
{thumbnails.length > 0 && (
|
|
816
|
+
<div className="flex gap-2 overflow-x-auto h-24 shrink-0" data-testid="thumbnails">
|
|
817
|
+
{thumbnails.map((p) => (
|
|
818
|
+
<div key={p.uid} className="w-36 shrink-0">
|
|
819
|
+
{remoteTile(p, "h-full")}
|
|
820
|
+
</div>
|
|
821
|
+
))}
|
|
822
|
+
</div>
|
|
823
|
+
)}
|
|
824
|
+
</div>
|
|
825
|
+
);
|
|
826
|
+
} else {
|
|
827
|
+
// Plain grid, nobody presenting: the local tile joins the grid as an ordinary tile (unless hidden) instead
|
|
828
|
+
// of floating in a corner - see this module's doc comment. Each tile keeps a video aspect ratio
|
|
829
|
+
// (`aspect-video`) rather than being stretched to fill whatever height an equal-row grid would otherwise
|
|
830
|
+
// force on it, so rows size to their own content (`content-start`, with a scrollbar if that overflows)
|
|
831
|
+
// instead of a fixed `auto-rows-fr` share of the stage.
|
|
832
|
+
stage = (
|
|
833
|
+
<div className="h-full content-start overflow-y-auto grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]">
|
|
834
|
+
{!selfHidden && selfTile("aspect-video", true)}
|
|
835
|
+
{participants.map((p) => remoteTile(p, "aspect-video"))}
|
|
836
|
+
</div>
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return (
|
|
841
|
+
<div className="fixed inset-0 z-50 flex bg-[#202124] text-white overflow-hidden">
|
|
842
|
+
<style>{`@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }`}</style>
|
|
843
|
+
<div className="relative flex-1 min-w-0 flex flex-col overflow-hidden">
|
|
844
|
+
<header className="shrink-0 flex items-center justify-between gap-3 px-4 py-3">
|
|
845
|
+
<h1 className="min-w-0 truncate text-base font-medium">{meetingTitle}</h1>
|
|
846
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
847
|
+
{raisedNames.length > 0 && (
|
|
848
|
+
<span className="max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium" data-testid="raised-hands">
|
|
849
|
+
✋ {raisedNames.join(", ")}
|
|
850
|
+
</span>
|
|
851
|
+
)}
|
|
852
|
+
{talkingStickActive && (
|
|
853
|
+
<span className="max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium" data-testid="talking-stick-status">
|
|
854
|
+
🎙️ {talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"}
|
|
855
|
+
</span>
|
|
856
|
+
)}
|
|
857
|
+
{isHost && (
|
|
858
|
+
<button
|
|
859
|
+
type="button"
|
|
860
|
+
className={`w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${talkingStickActive ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`}
|
|
861
|
+
aria-label={talkingStickActive ? "End talking stick" : "Talking stick"}
|
|
862
|
+
aria-pressed={talkingStickActive}
|
|
863
|
+
title={talkingStickActive ? "End talking stick" : "Start talking stick mode"}
|
|
864
|
+
onClick={handleToggleTalkingStick}
|
|
865
|
+
>
|
|
866
|
+
<BatonIcon />
|
|
867
|
+
</button>
|
|
868
|
+
)}
|
|
869
|
+
<button
|
|
870
|
+
type="button"
|
|
871
|
+
className={`relative w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${chatOpen ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`}
|
|
872
|
+
aria-label={unreadChat > 0 ? `Chat, ${unreadChat} unread` : "Chat"}
|
|
873
|
+
aria-haspopup="dialog"
|
|
874
|
+
aria-expanded={chatOpen}
|
|
875
|
+
onClick={handleToggleChat}
|
|
876
|
+
>
|
|
877
|
+
<ChatIcon />
|
|
878
|
+
{unreadChat > 0 && (
|
|
879
|
+
<span className="absolute -top-1 -right-1 min-w-5 h-5 px-1 flex items-center justify-center rounded-full bg-[#d93025] text-white text-[11px] font-semibold" data-testid="chat-unread">
|
|
880
|
+
{unreadChat > 99 ? "99+" : unreadChat}
|
|
881
|
+
</span>
|
|
882
|
+
)}
|
|
883
|
+
</button>
|
|
884
|
+
<button
|
|
885
|
+
type="button"
|
|
886
|
+
className="px-3 py-1.5 rounded-full bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
887
|
+
aria-label={`${participants.length + 1} participants`}
|
|
888
|
+
aria-haspopup="dialog"
|
|
889
|
+
aria-expanded={drawerOpen}
|
|
890
|
+
onClick={handleToggleParticipants}
|
|
891
|
+
>
|
|
892
|
+
{participants.length + 1}
|
|
893
|
+
</button>
|
|
894
|
+
</div>
|
|
895
|
+
</header>
|
|
896
|
+
{connectError && (
|
|
897
|
+
<div role="alert" className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm">
|
|
898
|
+
{connectError}
|
|
899
|
+
</div>
|
|
900
|
+
)}
|
|
901
|
+
{audioBlocked && (
|
|
902
|
+
<button
|
|
903
|
+
type="button"
|
|
904
|
+
className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium"
|
|
905
|
+
onClick={handleResumeAudio}
|
|
906
|
+
>
|
|
907
|
+
Click here to turn on sound
|
|
908
|
+
</button>
|
|
909
|
+
)}
|
|
910
|
+
<main className="relative flex-1 min-h-0 px-3 pb-3">
|
|
911
|
+
{stage}
|
|
912
|
+
{presenterName && presenterUid !== peerId && (
|
|
913
|
+
<p className="absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm">{presenterName} is presenting</p>
|
|
914
|
+
)}
|
|
915
|
+
</main>
|
|
916
|
+
<footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
|
917
|
+
<CallControls
|
|
918
|
+
media={media}
|
|
919
|
+
participants={participants}
|
|
920
|
+
isPresenting={isPresenting}
|
|
921
|
+
presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
|
|
922
|
+
onToggleShare={() => void handleToggleShare()}
|
|
923
|
+
screenTransform={screenTransform}
|
|
924
|
+
onRotateScreen={handleRotateScreen}
|
|
925
|
+
onFlipScreen={handleFlipScreen}
|
|
926
|
+
handRaised={handRaised}
|
|
927
|
+
onToggleHand={handleToggleHand}
|
|
928
|
+
onReaction={handleReaction}
|
|
929
|
+
viewMode={viewMode}
|
|
930
|
+
onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
|
|
931
|
+
whiteboardOpen={whiteboardOpen}
|
|
932
|
+
whiteboardActivity={whiteboardActivity}
|
|
933
|
+
onToggleWhiteboard={handleToggleWhiteboard}
|
|
934
|
+
micLocked={micLocked}
|
|
935
|
+
selfHidden={selfHidden}
|
|
936
|
+
onToggleSelfHidden={handleToggleSelfHidden}
|
|
937
|
+
isHost={isHost}
|
|
938
|
+
transportMode={transportMode}
|
|
939
|
+
onSetTransportMode={handleSetTransportMode}
|
|
940
|
+
onOpenDiagnostics={() => setDiagnosticsOpen(true)}
|
|
941
|
+
onOpenSettings={() => setSettingsOpen(true)}
|
|
942
|
+
onLeave={onLeave}
|
|
943
|
+
/>
|
|
944
|
+
</footer>
|
|
945
|
+
|
|
946
|
+
{!alone && !selfHidden && showFocusLayout && (
|
|
947
|
+
<div
|
|
948
|
+
className="absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4"
|
|
949
|
+
data-testid="self-view"
|
|
950
|
+
>
|
|
951
|
+
{selfTile("h-full", true)}
|
|
952
|
+
</div>
|
|
953
|
+
)}
|
|
954
|
+
|
|
955
|
+
<div className="pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]" aria-hidden="true">
|
|
956
|
+
{reactions.map((reaction) => (
|
|
957
|
+
<div
|
|
958
|
+
key={reaction.id}
|
|
959
|
+
className="absolute bottom-0 flex flex-col items-center"
|
|
960
|
+
style={{ left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }}
|
|
961
|
+
data-testid="reaction"
|
|
962
|
+
>
|
|
963
|
+
<span className="text-4xl leading-none">{reaction.emoji}</span>
|
|
964
|
+
<span className="mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs">{reaction.name}</span>
|
|
965
|
+
</div>
|
|
966
|
+
))}
|
|
967
|
+
</div>
|
|
968
|
+
|
|
969
|
+
{participants.map((p) => {
|
|
970
|
+
const stream = remoteStreams[p.uid];
|
|
971
|
+
return stream ? <RemoteAudio key={`${p.uid}:${audioNonce}`} stream={stream} onBlocked={handleAudioBlocked} /> : null;
|
|
972
|
+
})}
|
|
973
|
+
<div role="status" aria-live="polite" className="sr-only">
|
|
974
|
+
{announcement}
|
|
975
|
+
</div>
|
|
976
|
+
</div>
|
|
977
|
+
|
|
978
|
+
{drawerOpen && (
|
|
979
|
+
<ParticipantsDrawer
|
|
980
|
+
selfName={selfName}
|
|
981
|
+
micOn={media.micOn}
|
|
982
|
+
handRaised={handRaised}
|
|
983
|
+
participants={participants}
|
|
984
|
+
isSelfHost={isHost}
|
|
985
|
+
isParticipantHost={isParticipantHost}
|
|
986
|
+
onMute={handleMuteParticipant}
|
|
987
|
+
onKick={handleKickParticipant}
|
|
988
|
+
waitingRoomEnabled={waitingRoomEnabled}
|
|
989
|
+
waitingParticipants={waitingParticipants}
|
|
990
|
+
onAdmit={handleAdmitParticipant}
|
|
991
|
+
onDeny={handleDenyParticipant}
|
|
992
|
+
talkingStickActive={talkingStickActive}
|
|
993
|
+
selfPeerId={peerId}
|
|
994
|
+
talkingStickHolder={talkingStickHolder}
|
|
995
|
+
onGiveTalkingStick={handleGiveTalkingStick}
|
|
996
|
+
onClose={() => setDrawerOpen(false)}
|
|
997
|
+
/>
|
|
998
|
+
)}
|
|
999
|
+
|
|
1000
|
+
{chatOpen && collabRef.current && (
|
|
1001
|
+
<ChatPanel entries={chatEntries} onSend={(text) => collabRef.current!.sendChat(text)} onClose={handleToggleChat} />
|
|
1002
|
+
)}
|
|
1003
|
+
|
|
1004
|
+
{diagnosticsOpen && (
|
|
1005
|
+
<DiagnosticsWindow onClose={() => setDiagnosticsOpen(false)}>
|
|
1006
|
+
<DiagnosticsPanel selfName={selfName} micOn={media.micOn} cameraOn={media.cameraOn} participants={participants} />
|
|
1007
|
+
</DiagnosticsWindow>
|
|
1008
|
+
)}
|
|
1009
|
+
|
|
1010
|
+
{settingsOpen && isHost && (
|
|
1011
|
+
<SettingsModal
|
|
1012
|
+
forceMuteOnJoin={forceMuteOnJoin}
|
|
1013
|
+
onToggleForceMuteOnJoin={handleToggleForceMuteOnJoin}
|
|
1014
|
+
hasPassword={hasPassword}
|
|
1015
|
+
onSetPassword={handleSetPassword}
|
|
1016
|
+
waitingRoomEnabled={waitingRoomEnabled}
|
|
1017
|
+
onToggleWaitingRoomEnabled={handleToggleWaitingRoomEnabled}
|
|
1018
|
+
onClose={() => setSettingsOpen(false)}
|
|
1019
|
+
/>
|
|
1020
|
+
)}
|
|
1021
|
+
</div>
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
|
|
1026
|
+
* refuses to start it (an autoplay policy) - the view then asks for a click. */
|
|
1027
|
+
function RemoteAudio({ stream, onBlocked }: { stream: MediaStream; onBlocked: () => void }) {
|
|
1028
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
const element = audioRef.current!;
|
|
1031
|
+
element.srcObject = stream;
|
|
1032
|
+
void Promise.resolve(element.play()).catch(onBlocked);
|
|
1033
|
+
}, [stream, onBlocked]);
|
|
1034
|
+
return <audio ref={audioRef} autoPlay data-testid="remote-audio" />;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** The WebSocket media relay for this call, or `undefined` when this browser cannot run it (no WebCodecs) - the
|
|
1038
|
+
* mesh then never falls back to it, and says so by marking an unreachable participant `"failed"`. */
|
|
1039
|
+
function createRelay(meetingUid: string, peerId: string): RelayTransportLike | undefined {
|
|
1040
|
+
const relay = createRelayTransport({ meetingUid, peerId });
|
|
1041
|
+
return relay.supported ? relay : undefined;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function stopLevelMeter(ref: React.MutableRefObject<Record<string, LevelMeterHandle>>, uid: string): void {
|
|
1045
|
+
ref.current[uid]?.stop();
|
|
1046
|
+
delete ref.current[uid];
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function startTrackingLevel(
|
|
1050
|
+
ref: React.MutableRefObject<Record<string, LevelMeterHandle>>,
|
|
1051
|
+
uid: string,
|
|
1052
|
+
stream: MediaStream,
|
|
1053
|
+
onLevel: (level: number) => void,
|
|
1054
|
+
): void {
|
|
1055
|
+
stopLevelMeter(ref, uid);
|
|
1056
|
+
const handle = startLevelMeter(stream, onLevel);
|
|
1057
|
+
if (handle) {
|
|
1058
|
+
ref.current[uid] = handle;
|
|
1059
|
+
}
|
|
1060
|
+
}
|