@rapidmx/meet-plugin 0.15.0 → 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 -483
- package/apps/meet/_CallView.tsx +1060 -924
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -242
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -90
- 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/RelayClient.ts +2 -1
- package/apps/shared/relay/RelayTransport.ts +2 -1
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
- package/apps/shared/webrtc/types.ts +335 -316
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +12 -4
- package/dist/apps/meet/_CallControls.js +5 -5
- package/dist/apps/meet/_CallView.d.ts +19 -4
- package/dist/apps/meet/_CallView.js +100 -11
- 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/_ParticipantTile.d.ts +2 -1
- package/dist/apps/meet/_ParticipantTile.js +1 -1
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +12 -0
- package/dist/apps/meet/_icons.js +12 -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/RelayClient.js +2 -1
- package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
- package/dist/apps/shared/relay/RelayTransport.js +2 -1
- 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 +8 -8
- package/dist/apps/shared/webrtc/types.d.ts +38 -24
- 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/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/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
|
@@ -1,483 +1,508 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
/**
|
|
6
|
-
* The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
|
|
7
|
-
* them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
|
|
8
|
-
* hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
|
|
9
|
-
* screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
|
|
10
|
-
* (`_EffectsPanel.tsx`) and lights up while any filter is on.
|
|
11
|
-
*
|
|
12
|
-
* The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
|
|
13
|
-
* picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
|
|
14
|
-
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
|
|
15
|
-
* is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
|
|
16
|
-
* `_CallView.tsx`'s doc comment.
|
|
17
|
-
*
|
|
18
|
-
* ## The "…" menu
|
|
19
|
-
*
|
|
20
|
-
* Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
|
|
21
|
-
* their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
|
|
22
|
-
* persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
|
|
23
|
-
* `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
|
|
24
|
-
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
|
|
25
|
-
* "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
|
|
26
|
-
* grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
|
|
27
|
-
* where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
|
|
28
|
-
* Escape/outside click like every other one here).
|
|
29
|
-
*/
|
|
30
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
31
|
-
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
32
|
-
import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
33
|
-
import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
|
|
34
|
-
import { REACTION_EMOJIS, type MeshParticipant, type TransportMode } from "../shared/webrtc/types.js";
|
|
35
|
-
import EffectsPanel from "./_EffectsPanel.js";
|
|
36
|
-
import {
|
|
37
|
-
ChevronUpIcon,
|
|
38
|
-
EffectsIcon,
|
|
39
|
-
EmojiIcon,
|
|
40
|
-
FlipIcon,
|
|
41
|
-
FocusIcon,
|
|
42
|
-
GridIcon,
|
|
43
|
-
HandIcon,
|
|
44
|
-
LeaveIcon,
|
|
45
|
-
MicIcon,
|
|
46
|
-
MicOffIcon,
|
|
47
|
-
OverflowIcon,
|
|
48
|
-
RotateIcon,
|
|
49
|
-
ScreenShareIcon,
|
|
50
|
-
VideoIcon,
|
|
51
|
-
VideoOffIcon,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
{ mode: "
|
|
58
|
-
{ mode: "
|
|
59
|
-
{ mode: "
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
/** Whether
|
|
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
|
-
</button>
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
>
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
{
|
|
223
|
-
{
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
>
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
|
|
7
|
+
* them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
|
|
8
|
+
* hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
|
|
9
|
+
* screen, whiteboard, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
|
|
10
|
+
* (`_EffectsPanel.tsx`) and lights up while any filter is on.
|
|
11
|
+
*
|
|
12
|
+
* The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
|
|
13
|
+
* picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
|
|
14
|
+
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
|
|
15
|
+
* is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
|
|
16
|
+
* `_CallView.tsx`'s doc comment.
|
|
17
|
+
*
|
|
18
|
+
* ## The "…" menu
|
|
19
|
+
*
|
|
20
|
+
* Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
|
|
21
|
+
* their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
|
|
22
|
+
* persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
|
|
23
|
+
* `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
|
|
24
|
+
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
|
|
25
|
+
* "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
|
|
26
|
+
* grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
|
|
27
|
+
* where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
|
|
28
|
+
* Escape/outside click like every other one here).
|
|
29
|
+
*/
|
|
30
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
31
|
+
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
32
|
+
import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
33
|
+
import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
|
|
34
|
+
import { REACTION_EMOJIS, type MeshParticipant, type TransportMode } from "../shared/webrtc/types.js";
|
|
35
|
+
import EffectsPanel from "./_EffectsPanel.js";
|
|
36
|
+
import {
|
|
37
|
+
ChevronUpIcon,
|
|
38
|
+
EffectsIcon,
|
|
39
|
+
EmojiIcon,
|
|
40
|
+
FlipIcon,
|
|
41
|
+
FocusIcon,
|
|
42
|
+
GridIcon,
|
|
43
|
+
HandIcon,
|
|
44
|
+
LeaveIcon,
|
|
45
|
+
MicIcon,
|
|
46
|
+
MicOffIcon,
|
|
47
|
+
OverflowIcon,
|
|
48
|
+
RotateIcon,
|
|
49
|
+
ScreenShareIcon,
|
|
50
|
+
VideoIcon,
|
|
51
|
+
VideoOffIcon,
|
|
52
|
+
WhiteboardIcon,
|
|
53
|
+
} from "./_icons.js";
|
|
54
|
+
|
|
55
|
+
/** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
|
|
56
|
+
const TRANSPORT_MODE_OPTIONS: { mode: TransportMode; label: string }[] = [
|
|
57
|
+
{ mode: "auto", label: "Auto" },
|
|
58
|
+
{ mode: "p2p", label: "P2P" },
|
|
59
|
+
{ mode: "relay", label: "Relay" },
|
|
60
|
+
{ mode: "websocket", label: "WebSocket Relay" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export type CallViewMode = "grid" | "focus";
|
|
64
|
+
|
|
65
|
+
export interface CallControlsProps {
|
|
66
|
+
media: LocalMedia;
|
|
67
|
+
participants: MeshParticipant[];
|
|
68
|
+
isPresenting: boolean;
|
|
69
|
+
/** Someone else is presenting - the share button is disabled and explains why. */
|
|
70
|
+
presentingElsewhereName?: string;
|
|
71
|
+
onToggleShare: () => void;
|
|
72
|
+
/** The current presenter's own rotate/flip correction - shown (and changeable) only while `isPresenting`. */
|
|
73
|
+
screenTransform: ScreenTransformState;
|
|
74
|
+
onRotateScreen: () => void;
|
|
75
|
+
onFlipScreen: () => void;
|
|
76
|
+
handRaised: boolean;
|
|
77
|
+
onToggleHand: () => void;
|
|
78
|
+
onReaction: (emoji: string) => void;
|
|
79
|
+
viewMode: CallViewMode;
|
|
80
|
+
/** Disabled while alone (`participants.length === 0`) - with nobody else here, grid and focused view both just
|
|
81
|
+
* show the local tile filling the stage, so there is nothing for this to visibly toggle yet. Enabled from the
|
|
82
|
+
* very first other participant: focus view corners the local tile and gives the other participant the main
|
|
83
|
+
* slot, which already looks different from grid view's two equal-sized tiles - see `_CallView.tsx`'s doc
|
|
84
|
+
* comment on where the local tile appears. */
|
|
85
|
+
onToggleViewMode: () => void;
|
|
86
|
+
/** Whether this participant has the shared whiteboard open - see `_CallView.tsx`'s doc comment. */
|
|
87
|
+
whiteboardOpen: boolean;
|
|
88
|
+
/** Someone drew on the whiteboard while it was closed here - its button shows a dot. */
|
|
89
|
+
whiteboardActivity?: boolean;
|
|
90
|
+
onToggleWhiteboard: () => void;
|
|
91
|
+
/** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
|
|
92
|
+
* doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
|
|
93
|
+
* doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
|
|
94
|
+
micLocked?: boolean;
|
|
95
|
+
/** Whether the "…" menu's "Settings" item is offered at all - see this module's doc comment on the menu. */
|
|
96
|
+
isHost: boolean;
|
|
97
|
+
transportMode: TransportMode;
|
|
98
|
+
onSetTransportMode: (mode: TransportMode) => void;
|
|
99
|
+
onOpenDiagnostics: () => void;
|
|
100
|
+
onOpenSettings: () => void;
|
|
101
|
+
/** Whether the local tile is currently hidden - the "…" menu's "Hide self"/"Show self" item reflects and
|
|
102
|
+
* toggles this; see `_CallView.tsx`'s doc comment on where the local tile appears. */
|
|
103
|
+
selfHidden: boolean;
|
|
104
|
+
onToggleSelfHidden: () => void;
|
|
105
|
+
onLeave: () => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type OpenMenu = MediaKind | "emoji" | "effects" | "overflow" | null;
|
|
109
|
+
|
|
110
|
+
const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
|
|
111
|
+
const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
|
|
112
|
+
const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
|
|
113
|
+
const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
|
|
114
|
+
|
|
115
|
+
/** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
|
|
116
|
+
export function LevelBars({ level }: { level: number }) {
|
|
117
|
+
return (
|
|
118
|
+
<span className="flex items-center gap-[2px] h-5" data-testid="mic-level" data-level={level} aria-hidden="true">
|
|
119
|
+
{[0.6, 1, 0.6].map((weight, i) => (
|
|
120
|
+
<span
|
|
121
|
+
key={i}
|
|
122
|
+
className={`w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`}
|
|
123
|
+
style={{ height: `${4 + weight * level * 3}px` }}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
</span>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default function CallControls({
|
|
131
|
+
media,
|
|
132
|
+
participants,
|
|
133
|
+
isPresenting,
|
|
134
|
+
presentingElsewhereName,
|
|
135
|
+
onToggleShare,
|
|
136
|
+
screenTransform,
|
|
137
|
+
onRotateScreen,
|
|
138
|
+
onFlipScreen,
|
|
139
|
+
handRaised,
|
|
140
|
+
onToggleHand,
|
|
141
|
+
onReaction,
|
|
142
|
+
viewMode,
|
|
143
|
+
onToggleViewMode,
|
|
144
|
+
whiteboardOpen,
|
|
145
|
+
whiteboardActivity,
|
|
146
|
+
onToggleWhiteboard,
|
|
147
|
+
micLocked,
|
|
148
|
+
isHost,
|
|
149
|
+
transportMode,
|
|
150
|
+
onSetTransportMode,
|
|
151
|
+
onOpenDiagnostics,
|
|
152
|
+
onOpenSettings,
|
|
153
|
+
selfHidden,
|
|
154
|
+
onToggleSelfHidden,
|
|
155
|
+
onLeave,
|
|
156
|
+
}: CallControlsProps) {
|
|
157
|
+
const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
|
|
158
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
159
|
+
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
160
|
+
|
|
161
|
+
// A menu closes on Escape or a press anywhere outside the bar.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!openMenu) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
167
|
+
if (!barRef.current?.contains(event.target as Node)) {
|
|
168
|
+
setOpenMenu(null);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
172
|
+
if (event.key === "Escape") {
|
|
173
|
+
setOpenMenu(null);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
177
|
+
document.addEventListener("keydown", onKeyDown);
|
|
178
|
+
return () => {
|
|
179
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
180
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
181
|
+
};
|
|
182
|
+
}, [openMenu]);
|
|
183
|
+
|
|
184
|
+
function toggleMenu(menu: Exclude<OpenMenu, null>) {
|
|
185
|
+
setOpenMenu((prev) => (prev === menu ? null : menu));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div ref={barRef} className="relative flex flex-wrap items-center justify-center gap-2 px-3" role="toolbar" aria-label="Call controls">
|
|
190
|
+
<div className="sm:relative flex items-stretch gap-px">
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
className={`${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`}
|
|
194
|
+
aria-label="Choose microphone"
|
|
195
|
+
aria-haspopup="menu"
|
|
196
|
+
aria-expanded={openMenu === "audio"}
|
|
197
|
+
onClick={() => toggleMenu("audio")}
|
|
198
|
+
>
|
|
199
|
+
<ChevronUpIcon />
|
|
200
|
+
</button>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
204
|
+
aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
|
|
205
|
+
aria-pressed={!media.micOn}
|
|
206
|
+
disabled={micLocked}
|
|
207
|
+
title={micLocked ? "Only the current talking-stick holder can unmute." : undefined}
|
|
208
|
+
onClick={() => void media.toggleMic()}
|
|
209
|
+
>
|
|
210
|
+
{media.micOn && <LevelBars level={media.audioLevel} />}
|
|
211
|
+
{media.micOn ? <MicIcon /> : <MicOffIcon />}
|
|
212
|
+
</button>
|
|
213
|
+
{openMenu === "audio" && <DeviceMenu kind="audio" media={media} onClose={() => setOpenMenu(null)} />}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div className="sm:relative flex items-stretch gap-px">
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className={`${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`}
|
|
220
|
+
aria-label="Choose camera"
|
|
221
|
+
aria-haspopup="menu"
|
|
222
|
+
aria-expanded={openMenu === "video"}
|
|
223
|
+
onClick={() => toggleMenu("video")}
|
|
224
|
+
>
|
|
225
|
+
<ChevronUpIcon />
|
|
226
|
+
</button>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className={`${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`}
|
|
230
|
+
aria-label={media.cameraOn ? "Turn off camera" : "Turn on camera"}
|
|
231
|
+
aria-pressed={!media.cameraOn}
|
|
232
|
+
onClick={() => void media.toggleCamera()}
|
|
233
|
+
>
|
|
234
|
+
{media.cameraOn ? <VideoIcon /> : <VideoOffIcon />}
|
|
235
|
+
{media.cameraOn && (
|
|
236
|
+
<span
|
|
237
|
+
className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse"
|
|
238
|
+
data-testid="camera-live"
|
|
239
|
+
title="Your camera is sending"
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
</button>
|
|
243
|
+
{openMenu === "video" && <DeviceMenu kind="video" media={media} onClose={() => setOpenMenu(null)} />}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div className="sm:relative">
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
className={`${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`}
|
|
250
|
+
aria-label="Video effects"
|
|
251
|
+
aria-haspopup="dialog"
|
|
252
|
+
aria-expanded={openMenu === "effects"}
|
|
253
|
+
onClick={() => toggleMenu("effects")}
|
|
254
|
+
>
|
|
255
|
+
<EffectsIcon />
|
|
256
|
+
</button>
|
|
257
|
+
{openMenu === "effects" && (
|
|
258
|
+
<div
|
|
259
|
+
role="dialog"
|
|
260
|
+
aria-label="Video effects"
|
|
261
|
+
className="absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl"
|
|
262
|
+
>
|
|
263
|
+
<EffectsPanel media={media} tone="dark" />
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
className={`${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
271
|
+
aria-label={isPresenting ? "Stop sharing" : "Share screen"}
|
|
272
|
+
aria-pressed={isPresenting}
|
|
273
|
+
disabled={shareDisabled}
|
|
274
|
+
title={shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined}
|
|
275
|
+
onClick={onToggleShare}
|
|
276
|
+
>
|
|
277
|
+
<ScreenShareIcon />
|
|
278
|
+
</button>
|
|
279
|
+
|
|
280
|
+
{isPresenting && (
|
|
281
|
+
<>
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
className={`${BUTTON} w-12 ${NEUTRAL}`}
|
|
285
|
+
aria-label="Rotate shared screen"
|
|
286
|
+
title="If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically."
|
|
287
|
+
onClick={onRotateScreen}
|
|
288
|
+
>
|
|
289
|
+
<RotateIcon />
|
|
290
|
+
</button>
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
className={`${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`}
|
|
294
|
+
aria-label={screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen"}
|
|
295
|
+
aria-pressed={screenTransform.flipped}
|
|
296
|
+
onClick={onFlipScreen}
|
|
297
|
+
>
|
|
298
|
+
<FlipIcon />
|
|
299
|
+
</button>
|
|
300
|
+
</>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
className={`${BUTTON} relative w-12 ${whiteboardOpen ? ACTIVE : NEUTRAL}`}
|
|
306
|
+
aria-label={whiteboardOpen ? "Close whiteboard" : whiteboardActivity ? "Open whiteboard, new drawing" : "Open whiteboard"}
|
|
307
|
+
aria-pressed={whiteboardOpen}
|
|
308
|
+
onClick={onToggleWhiteboard}
|
|
309
|
+
>
|
|
310
|
+
<WhiteboardIcon />
|
|
311
|
+
{whiteboardActivity && !whiteboardOpen && (
|
|
312
|
+
<span className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#fbbc04]" data-testid="whiteboard-activity" />
|
|
313
|
+
)}
|
|
314
|
+
</button>
|
|
315
|
+
|
|
316
|
+
<div className="sm:relative">
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
className={`${BUTTON} w-12 ${NEUTRAL}`}
|
|
320
|
+
aria-label="Send a reaction"
|
|
321
|
+
aria-haspopup="menu"
|
|
322
|
+
aria-expanded={openMenu === "emoji"}
|
|
323
|
+
onClick={() => toggleMenu("emoji")}
|
|
324
|
+
>
|
|
325
|
+
<EmojiIcon />
|
|
326
|
+
</button>
|
|
327
|
+
{openMenu === "emoji" && (
|
|
328
|
+
<div
|
|
329
|
+
role="menu"
|
|
330
|
+
aria-label="Reactions"
|
|
331
|
+
className="absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl"
|
|
332
|
+
>
|
|
333
|
+
{REACTION_EMOJIS.map((emoji) => (
|
|
334
|
+
<button
|
|
335
|
+
key={emoji}
|
|
336
|
+
type="button"
|
|
337
|
+
role="menuitem"
|
|
338
|
+
className="w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
339
|
+
aria-label={`Send ${emoji}`}
|
|
340
|
+
onClick={() => {
|
|
341
|
+
onReaction(emoji);
|
|
342
|
+
setOpenMenu(null);
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
{emoji}
|
|
346
|
+
</button>
|
|
347
|
+
))}
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
className={`${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`}
|
|
355
|
+
aria-label={handRaised ? "Lower hand" : "Raise hand"}
|
|
356
|
+
aria-pressed={handRaised}
|
|
357
|
+
onClick={onToggleHand}
|
|
358
|
+
>
|
|
359
|
+
<HandIcon />
|
|
360
|
+
</button>
|
|
361
|
+
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
className={`${BUTTON} w-12 ${NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
365
|
+
aria-label={viewMode === "grid" ? "Switch to focused view" : "Switch to grid view"}
|
|
366
|
+
disabled={participants.length === 0}
|
|
367
|
+
title={participants.length === 0 ? "Grid and focused view look the same while you're alone." : undefined}
|
|
368
|
+
onClick={onToggleViewMode}
|
|
369
|
+
>
|
|
370
|
+
{viewMode === "grid" ? <FocusIcon /> : <GridIcon />}
|
|
371
|
+
</button>
|
|
372
|
+
|
|
373
|
+
<div className="sm:relative">
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
className={`${BUTTON} w-12 ${openMenu === "overflow" ? ACTIVE : NEUTRAL}`}
|
|
377
|
+
aria-label="More options"
|
|
378
|
+
aria-haspopup="menu"
|
|
379
|
+
aria-expanded={openMenu === "overflow"}
|
|
380
|
+
onClick={() => toggleMenu("overflow")}
|
|
381
|
+
>
|
|
382
|
+
<OverflowIcon />
|
|
383
|
+
</button>
|
|
384
|
+
{openMenu === "overflow" && (
|
|
385
|
+
<div
|
|
386
|
+
role="menu"
|
|
387
|
+
aria-label="More options"
|
|
388
|
+
className="absolute bottom-full mb-3 right-0 w-56 max-w-[calc(100vw-1rem)] rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm"
|
|
389
|
+
>
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
role="menuitem"
|
|
393
|
+
className="w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
394
|
+
onClick={() => {
|
|
395
|
+
onOpenDiagnostics();
|
|
396
|
+
setOpenMenu(null);
|
|
397
|
+
}}
|
|
398
|
+
>
|
|
399
|
+
Diagnostics
|
|
400
|
+
</button>
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
role="menuitem"
|
|
404
|
+
className="w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
405
|
+
onClick={() => {
|
|
406
|
+
onToggleSelfHidden();
|
|
407
|
+
setOpenMenu(null);
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
{selfHidden ? "Show self" : "Hide self"}
|
|
411
|
+
</button>
|
|
412
|
+
<div className="my-1 border-t border-white/10" />
|
|
413
|
+
<p className="px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60">Connection method</p>
|
|
414
|
+
{TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (
|
|
415
|
+
<button
|
|
416
|
+
key={mode}
|
|
417
|
+
type="button"
|
|
418
|
+
role="menuitemradio"
|
|
419
|
+
aria-checked={transportMode === mode}
|
|
420
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
421
|
+
onClick={() => {
|
|
422
|
+
onSetTransportMode(mode);
|
|
423
|
+
setOpenMenu(null);
|
|
424
|
+
}}
|
|
425
|
+
>
|
|
426
|
+
<span className="w-4 text-[#8ab4f8]" aria-hidden="true">
|
|
427
|
+
{transportMode === mode ? "✓" : ""}
|
|
428
|
+
</span>
|
|
429
|
+
{label}
|
|
430
|
+
</button>
|
|
431
|
+
))}
|
|
432
|
+
{isHost && (
|
|
433
|
+
<>
|
|
434
|
+
<div className="my-1 border-t border-white/10" />
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
role="menuitem"
|
|
438
|
+
className="w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
439
|
+
onClick={() => {
|
|
440
|
+
onOpenSettings();
|
|
441
|
+
setOpenMenu(null);
|
|
442
|
+
}}
|
|
443
|
+
>
|
|
444
|
+
Settings
|
|
445
|
+
</button>
|
|
446
|
+
</>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={() => onLeave()}>
|
|
453
|
+
<LeaveIcon />
|
|
454
|
+
</button>
|
|
455
|
+
</div>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
|
|
460
|
+
* one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
|
|
461
|
+
function DeviceMenu({ kind, media, onClose }: { kind: MediaKind; media: LocalMedia; onClose: () => void }) {
|
|
462
|
+
const list = kind === "audio" ? media.devices.microphones : media.devices.cameras;
|
|
463
|
+
const noun = kind === "audio" ? "microphone" : "camera";
|
|
464
|
+
const selected = media.selectedDeviceIds[kind];
|
|
465
|
+
const denied = media.status[kind] === "denied";
|
|
466
|
+
return (
|
|
467
|
+
<div
|
|
468
|
+
role="menu"
|
|
469
|
+
aria-label={kind === "audio" ? "Microphones" : "Cameras"}
|
|
470
|
+
className="absolute bottom-full mb-3 left-1/2 -translate-x-1/2 sm:left-0 sm:translate-x-0 w-max min-w-64 max-w-[calc(100vw-1rem)] p-1 rounded-lg bg-[#2b2d30] text-white text-sm shadow-xl"
|
|
471
|
+
>
|
|
472
|
+
{list.map((device, index) => (
|
|
473
|
+
<button
|
|
474
|
+
key={device.deviceId}
|
|
475
|
+
type="button"
|
|
476
|
+
role="menuitemradio"
|
|
477
|
+
aria-checked={device.deviceId === selected}
|
|
478
|
+
className="flex w-full items-center gap-2 px-3 py-2 rounded-md text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
479
|
+
onClick={() => {
|
|
480
|
+
void media.selectDevice(kind, device.deviceId);
|
|
481
|
+
onClose();
|
|
482
|
+
}}
|
|
483
|
+
>
|
|
484
|
+
<span className="w-4 text-[#8ab4f8]" aria-hidden="true">
|
|
485
|
+
{device.deviceId === selected ? "✓" : ""}
|
|
486
|
+
</span>
|
|
487
|
+
<span className="truncate">{device.label || `${noun[0].toUpperCase()}${noun.slice(1)} ${index + 1}`}</span>
|
|
488
|
+
</button>
|
|
489
|
+
))}
|
|
490
|
+
{list.length === 0 && (
|
|
491
|
+
<p className="px-3 py-2 text-white/70">{denied ? `Access to your ${noun} was blocked.` : `No ${noun} found.`}</p>
|
|
492
|
+
)}
|
|
493
|
+
{(list.length === 0 || denied) && (
|
|
494
|
+
<button
|
|
495
|
+
type="button"
|
|
496
|
+
role="menuitem"
|
|
497
|
+
className="w-full px-3 py-2 rounded-md text-left text-[#8ab4f8] hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
498
|
+
onClick={() => {
|
|
499
|
+
void media.requestAccess(kind);
|
|
500
|
+
onClose();
|
|
501
|
+
}}
|
|
502
|
+
>
|
|
503
|
+
Allow access to {noun}
|
|
504
|
+
</button>
|
|
505
|
+
)}
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
}
|