@rapidmx/meet-plugin 0.13.1 → 0.14.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.
Files changed (41) hide show
  1. package/README.md +55 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +366 -394
  4. package/apps/meet/_CallView.tsx +987 -848
  5. package/apps/meet/_DiagnosticsWindow.tsx +80 -0
  6. package/apps/meet/_MeetLobby.tsx +224 -224
  7. package/apps/meet/_ParticipantTile.tsx +169 -169
  8. package/apps/meet/_ParticipantsDrawer.tsx +10 -104
  9. package/apps/meet/_SettingsModal.tsx +152 -0
  10. package/apps/meet/_icons.tsx +80 -82
  11. package/apps/meet/_meetApi.ts +212 -212
  12. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  13. package/apps/shared/media/filters/mlModels.ts +174 -174
  14. package/apps/shared/push/GuestSignalingClient.ts +359 -359
  15. package/apps/shared/relay/RelayClient.ts +7 -2
  16. package/apps/shared/webrtc/MeshConnectionManager.ts +949 -795
  17. package/apps/shared/webrtc/types.ts +247 -222
  18. package/dist/apps/meet/_CallControls.d.ts +3 -8
  19. package/dist/apps/meet/_CallControls.js +3 -10
  20. package/dist/apps/meet/_CallView.d.ts +15 -2
  21. package/dist/apps/meet/_CallView.js +70 -4
  22. package/dist/apps/meet/_DiagnosticsWindow.d.ts +25 -0
  23. package/dist/apps/meet/_DiagnosticsWindow.js +45 -0
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +10 -21
  25. package/dist/apps/meet/_ParticipantsDrawer.js +9 -39
  26. package/dist/apps/meet/_SettingsModal.d.ts +28 -0
  27. package/dist/apps/meet/_SettingsModal.js +59 -0
  28. package/dist/apps/meet/_icons.d.ts +1 -1
  29. package/dist/apps/meet/_icons.js +1 -1
  30. package/dist/apps/shared/relay/RelayClient.d.ts +6 -1
  31. package/dist/apps/shared/relay/RelayClient.js +7 -2
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +55 -2
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +153 -9
  34. package/dist/apps/shared/webrtc/types.d.ts +25 -1
  35. package/dist/lib/util/RedisRelayBus.js +61 -5
  36. package/dist/lib/util/RedisRelayBus.js.map +1 -1
  37. package/dist/lib/util/RelayHub.js +10 -3
  38. package/dist/lib/util/RelayHub.js.map +1 -1
  39. package/dist/types/util/RedisRelayBus.d.ts +45 -6
  40. package/dist/types/util/RelayHub.d.ts +9 -2
  41. package/package.json +208 -208
@@ -1,394 +1,366 @@
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 diagnostics button is the one exception to "opens near its own button": its panel is `fixed` to the
13
- * viewport's top-left corner rather than anchored above the button, since it reads better as a HUD-style overlay
14
- * sitting over the call than as a dropdown tucked into the bottom control bar. The outside-click/Escape handling
15
- * (`barRef`) still closes it the same way, since it's still a DOM descendant of the bar - only its visual position
16
- * moved, not its open/close wiring.
17
- *
18
- * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
19
- * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
20
- * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
21
- * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
22
- * `_CallView.tsx`'s doc comment.
23
- */
24
- import React, { useEffect, useRef, useState } from "react";
25
- import { filtersActive } from "../shared/media/filters/filterTypes.js";
26
- import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
27
- import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
28
- import { REACTION_EMOJIS, type MeshParticipant } from "../shared/webrtc/types.js";
29
- import DiagnosticsPanel from "./_DiagnosticsPanel.js";
30
- import EffectsPanel from "./_EffectsPanel.js";
31
- import {
32
- ChevronUpIcon,
33
- DiagnosticsIcon,
34
- EffectsIcon,
35
- EmojiIcon,
36
- FlipIcon,
37
- FocusIcon,
38
- GridIcon,
39
- HandIcon,
40
- LeaveIcon,
41
- MicIcon,
42
- MicOffIcon,
43
- RotateIcon,
44
- ScreenShareIcon,
45
- VideoIcon,
46
- VideoOffIcon,
47
- } from "./_icons.js";
48
-
49
- export type CallViewMode = "grid" | "focus";
50
-
51
- export interface CallControlsProps {
52
- media: LocalMedia;
53
- selfName: string;
54
- participants: MeshParticipant[];
55
- isPresenting: boolean;
56
- /** Someone else is presenting - the share button is disabled and explains why. */
57
- presentingElsewhereName?: string;
58
- onToggleShare: () => void;
59
- /** The current presenter's own rotate/flip correction - shown (and changeable) only while `isPresenting`. */
60
- screenTransform: ScreenTransformState;
61
- onRotateScreen: () => void;
62
- onFlipScreen: () => void;
63
- handRaised: boolean;
64
- onToggleHand: () => void;
65
- onReaction: (emoji: string) => void;
66
- viewMode: CallViewMode;
67
- onToggleViewMode: () => void;
68
- /** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
69
- * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
70
- * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
71
- micLocked?: boolean;
72
- onLeave: () => void;
73
- }
74
-
75
- type OpenMenu = MediaKind | "emoji" | "effects" | "diagnostics" | null;
76
-
77
- 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";
78
- const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
79
- const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
80
- const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
81
-
82
- /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
83
- export function LevelBars({ level }: { level: number }) {
84
- return (
85
- <span className="flex items-center gap-[2px] h-5" data-testid="mic-level" data-level={level} aria-hidden="true">
86
- {[0.6, 1, 0.6].map((weight, i) => (
87
- <span
88
- key={i}
89
- className={`w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`}
90
- style={{ height: `${4 + weight * level * 3}px` }}
91
- />
92
- ))}
93
- </span>
94
- );
95
- }
96
-
97
- export default function CallControls({
98
- media,
99
- selfName,
100
- participants,
101
- isPresenting,
102
- presentingElsewhereName,
103
- onToggleShare,
104
- screenTransform,
105
- onRotateScreen,
106
- onFlipScreen,
107
- handRaised,
108
- onToggleHand,
109
- onReaction,
110
- viewMode,
111
- onToggleViewMode,
112
- micLocked,
113
- onLeave,
114
- }: CallControlsProps) {
115
- const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
116
- const barRef = useRef<HTMLDivElement>(null);
117
- const shareDisabled = !isPresenting && !!presentingElsewhereName;
118
-
119
- // A menu closes on Escape or a press anywhere outside the bar.
120
- useEffect(() => {
121
- if (!openMenu) {
122
- return;
123
- }
124
- const onPointerDown = (event: PointerEvent) => {
125
- if (!barRef.current?.contains(event.target as Node)) {
126
- setOpenMenu(null);
127
- }
128
- };
129
- const onKeyDown = (event: KeyboardEvent) => {
130
- if (event.key === "Escape") {
131
- setOpenMenu(null);
132
- }
133
- };
134
- document.addEventListener("pointerdown", onPointerDown);
135
- document.addEventListener("keydown", onKeyDown);
136
- return () => {
137
- document.removeEventListener("pointerdown", onPointerDown);
138
- document.removeEventListener("keydown", onKeyDown);
139
- };
140
- }, [openMenu]);
141
-
142
- function toggleMenu(menu: Exclude<OpenMenu, null>) {
143
- setOpenMenu((prev) => (prev === menu ? null : menu));
144
- }
145
-
146
- return (
147
- <div ref={barRef} className="relative flex flex-wrap items-center justify-center gap-2 px-3" role="toolbar" aria-label="Call controls">
148
- <div className="sm:relative flex items-stretch gap-px">
149
- <button
150
- type="button"
151
- className={`${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`}
152
- aria-label="Choose microphone"
153
- aria-haspopup="menu"
154
- aria-expanded={openMenu === "audio"}
155
- onClick={() => toggleMenu("audio")}
156
- >
157
- <ChevronUpIcon />
158
- </button>
159
- <button
160
- type="button"
161
- className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`}
162
- aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
163
- aria-pressed={!media.micOn}
164
- disabled={micLocked}
165
- title={micLocked ? "Only the current talking-stick holder can unmute." : undefined}
166
- onClick={() => void media.toggleMic()}
167
- >
168
- {media.micOn && <LevelBars level={media.audioLevel} />}
169
- {media.micOn ? <MicIcon /> : <MicOffIcon />}
170
- </button>
171
- {openMenu === "audio" && <DeviceMenu kind="audio" media={media} onClose={() => setOpenMenu(null)} />}
172
- </div>
173
-
174
- <div className="sm:relative flex items-stretch gap-px">
175
- <button
176
- type="button"
177
- className={`${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`}
178
- aria-label="Choose camera"
179
- aria-haspopup="menu"
180
- aria-expanded={openMenu === "video"}
181
- onClick={() => toggleMenu("video")}
182
- >
183
- <ChevronUpIcon />
184
- </button>
185
- <button
186
- type="button"
187
- className={`${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`}
188
- aria-label={media.cameraOn ? "Turn off camera" : "Turn on camera"}
189
- aria-pressed={!media.cameraOn}
190
- onClick={() => void media.toggleCamera()}
191
- >
192
- {media.cameraOn ? <VideoIcon /> : <VideoOffIcon />}
193
- {media.cameraOn && (
194
- <span
195
- className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse"
196
- data-testid="camera-live"
197
- title="Your camera is sending"
198
- />
199
- )}
200
- </button>
201
- {openMenu === "video" && <DeviceMenu kind="video" media={media} onClose={() => setOpenMenu(null)} />}
202
- </div>
203
-
204
- <div className="sm:relative">
205
- <button
206
- type="button"
207
- className={`${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`}
208
- aria-label="Video effects"
209
- aria-haspopup="dialog"
210
- aria-expanded={openMenu === "effects"}
211
- onClick={() => toggleMenu("effects")}
212
- >
213
- <EffectsIcon />
214
- </button>
215
- {openMenu === "effects" && (
216
- <div
217
- role="dialog"
218
- aria-label="Video effects"
219
- 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"
220
- >
221
- <EffectsPanel media={media} tone="dark" />
222
- </div>
223
- )}
224
- </div>
225
-
226
- <button
227
- type="button"
228
- className={`${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`}
229
- aria-label={isPresenting ? "Stop sharing" : "Share screen"}
230
- aria-pressed={isPresenting}
231
- disabled={shareDisabled}
232
- title={shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined}
233
- onClick={onToggleShare}
234
- >
235
- <ScreenShareIcon />
236
- </button>
237
-
238
- {isPresenting && (
239
- <>
240
- <button
241
- type="button"
242
- className={`${BUTTON} w-12 ${NEUTRAL}`}
243
- aria-label="Rotate shared screen"
244
- 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."
245
- onClick={onRotateScreen}
246
- >
247
- <RotateIcon />
248
- </button>
249
- <button
250
- type="button"
251
- className={`${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`}
252
- aria-label={screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen"}
253
- aria-pressed={screenTransform.flipped}
254
- onClick={onFlipScreen}
255
- >
256
- <FlipIcon />
257
- </button>
258
- </>
259
- )}
260
-
261
- <div className="sm:relative">
262
- <button
263
- type="button"
264
- className={`${BUTTON} w-12 ${NEUTRAL}`}
265
- aria-label="Send a reaction"
266
- aria-haspopup="menu"
267
- aria-expanded={openMenu === "emoji"}
268
- onClick={() => toggleMenu("emoji")}
269
- >
270
- <EmojiIcon />
271
- </button>
272
- {openMenu === "emoji" && (
273
- <div
274
- role="menu"
275
- aria-label="Reactions"
276
- 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"
277
- >
278
- {REACTION_EMOJIS.map((emoji) => (
279
- <button
280
- key={emoji}
281
- type="button"
282
- role="menuitem"
283
- 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"
284
- aria-label={`Send ${emoji}`}
285
- onClick={() => {
286
- onReaction(emoji);
287
- setOpenMenu(null);
288
- }}
289
- >
290
- {emoji}
291
- </button>
292
- ))}
293
- </div>
294
- )}
295
- </div>
296
-
297
- <button
298
- type="button"
299
- className={`${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`}
300
- aria-label={handRaised ? "Lower hand" : "Raise hand"}
301
- aria-pressed={handRaised}
302
- onClick={onToggleHand}
303
- >
304
- <HandIcon />
305
- </button>
306
-
307
- <button
308
- type="button"
309
- className={`${BUTTON} w-12 ${NEUTRAL}`}
310
- aria-label={viewMode === "grid" ? "Switch to focused view" : "Switch to grid view"}
311
- onClick={onToggleViewMode}
312
- >
313
- {viewMode === "grid" ? <FocusIcon /> : <GridIcon />}
314
- </button>
315
-
316
- <div className="sm:relative">
317
- <button
318
- type="button"
319
- className={`${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`}
320
- aria-label="Call diagnostics"
321
- aria-haspopup="dialog"
322
- aria-expanded={openMenu === "diagnostics"}
323
- onClick={() => toggleMenu("diagnostics")}
324
- >
325
- <DiagnosticsIcon />
326
- </button>
327
- {openMenu === "diagnostics" && (
328
- <div
329
- role="dialog"
330
- aria-label="Call diagnostics"
331
- className="fixed z-30 top-3 left-3 w-80 max-w-[calc(100vw-1rem)] max-h-[70vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30]/95 text-white shadow-xl"
332
- >
333
- <DiagnosticsPanel selfName={selfName} micOn={media.micOn} cameraOn={media.cameraOn} participants={participants} />
334
- </div>
335
- )}
336
- </div>
337
-
338
- <button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={() => onLeave()}>
339
- <LeaveIcon />
340
- </button>
341
- </div>
342
- );
343
- }
344
-
345
- /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
346
- * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
347
- function DeviceMenu({ kind, media, onClose }: { kind: MediaKind; media: LocalMedia; onClose: () => void }) {
348
- const list = kind === "audio" ? media.devices.microphones : media.devices.cameras;
349
- const noun = kind === "audio" ? "microphone" : "camera";
350
- const selected = media.selectedDeviceIds[kind];
351
- const denied = media.status[kind] === "denied";
352
- return (
353
- <div
354
- role="menu"
355
- aria-label={kind === "audio" ? "Microphones" : "Cameras"}
356
- 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"
357
- >
358
- {list.map((device, index) => (
359
- <button
360
- key={device.deviceId}
361
- type="button"
362
- role="menuitemradio"
363
- aria-checked={device.deviceId === selected}
364
- 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"
365
- onClick={() => {
366
- void media.selectDevice(kind, device.deviceId);
367
- onClose();
368
- }}
369
- >
370
- <span className="w-4 text-[#8ab4f8]" aria-hidden="true">
371
- {device.deviceId === selected ? "✓" : ""}
372
- </span>
373
- <span className="truncate">{device.label || `${noun[0].toUpperCase()}${noun.slice(1)} ${index + 1}`}</span>
374
- </button>
375
- ))}
376
- {list.length === 0 && (
377
- <p className="px-3 py-2 text-white/70">{denied ? `Access to your ${noun} was blocked.` : `No ${noun} found.`}</p>
378
- )}
379
- {(list.length === 0 || denied) && (
380
- <button
381
- type="button"
382
- role="menuitem"
383
- 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"
384
- onClick={() => {
385
- void media.requestAccess(kind);
386
- onClose();
387
- }}
388
- >
389
- Allow access to {noun}
390
- </button>
391
- )}
392
- </div>
393
- );
394
- }
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
+ import React, { useEffect, useRef, useState } from "react";
19
+ import { filtersActive } from "../shared/media/filters/filterTypes.js";
20
+ import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
21
+ import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
22
+ import { REACTION_EMOJIS, type MeshParticipant } from "../shared/webrtc/types.js";
23
+ import EffectsPanel from "./_EffectsPanel.js";
24
+ import {
25
+ ChevronUpIcon,
26
+ EffectsIcon,
27
+ EmojiIcon,
28
+ FlipIcon,
29
+ FocusIcon,
30
+ GridIcon,
31
+ HandIcon,
32
+ LeaveIcon,
33
+ MicIcon,
34
+ MicOffIcon,
35
+ RotateIcon,
36
+ ScreenShareIcon,
37
+ VideoIcon,
38
+ VideoOffIcon,
39
+ } from "./_icons.js";
40
+
41
+ export type CallViewMode = "grid" | "focus";
42
+
43
+ export interface CallControlsProps {
44
+ media: LocalMedia;
45
+ participants: MeshParticipant[];
46
+ isPresenting: boolean;
47
+ /** Someone else is presenting - the share button is disabled and explains why. */
48
+ presentingElsewhereName?: string;
49
+ onToggleShare: () => void;
50
+ /** The current presenter's own rotate/flip correction - shown (and changeable) only while `isPresenting`. */
51
+ screenTransform: ScreenTransformState;
52
+ onRotateScreen: () => void;
53
+ onFlipScreen: () => void;
54
+ handRaised: boolean;
55
+ onToggleHand: () => void;
56
+ onReaction: (emoji: string) => void;
57
+ viewMode: CallViewMode;
58
+ /** Disabled whenever `participants.length < 2` - grid and focused view render the exact same single tile with
59
+ * only one other participant (or none), so there is nothing for this to visibly toggle yet. */
60
+ onToggleViewMode: () => void;
61
+ /** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
62
+ * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
63
+ * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
64
+ micLocked?: boolean;
65
+ onLeave: () => void;
66
+ }
67
+
68
+ type OpenMenu = MediaKind | "emoji" | "effects" | null;
69
+
70
+ 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";
71
+ const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
72
+ const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
73
+ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
74
+
75
+ /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
76
+ export function LevelBars({ level }: { level: number }) {
77
+ return (
78
+ <span className="flex items-center gap-[2px] h-5" data-testid="mic-level" data-level={level} aria-hidden="true">
79
+ {[0.6, 1, 0.6].map((weight, i) => (
80
+ <span
81
+ key={i}
82
+ className={`w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`}
83
+ style={{ height: `${4 + weight * level * 3}px` }}
84
+ />
85
+ ))}
86
+ </span>
87
+ );
88
+ }
89
+
90
+ export default function CallControls({
91
+ media,
92
+ participants,
93
+ isPresenting,
94
+ presentingElsewhereName,
95
+ onToggleShare,
96
+ screenTransform,
97
+ onRotateScreen,
98
+ onFlipScreen,
99
+ handRaised,
100
+ onToggleHand,
101
+ onReaction,
102
+ viewMode,
103
+ onToggleViewMode,
104
+ micLocked,
105
+ onLeave,
106
+ }: CallControlsProps) {
107
+ const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
108
+ const barRef = useRef<HTMLDivElement>(null);
109
+ const shareDisabled = !isPresenting && !!presentingElsewhereName;
110
+
111
+ // A menu closes on Escape or a press anywhere outside the bar.
112
+ useEffect(() => {
113
+ if (!openMenu) {
114
+ return;
115
+ }
116
+ const onPointerDown = (event: PointerEvent) => {
117
+ if (!barRef.current?.contains(event.target as Node)) {
118
+ setOpenMenu(null);
119
+ }
120
+ };
121
+ const onKeyDown = (event: KeyboardEvent) => {
122
+ if (event.key === "Escape") {
123
+ setOpenMenu(null);
124
+ }
125
+ };
126
+ document.addEventListener("pointerdown", onPointerDown);
127
+ document.addEventListener("keydown", onKeyDown);
128
+ return () => {
129
+ document.removeEventListener("pointerdown", onPointerDown);
130
+ document.removeEventListener("keydown", onKeyDown);
131
+ };
132
+ }, [openMenu]);
133
+
134
+ function toggleMenu(menu: Exclude<OpenMenu, null>) {
135
+ setOpenMenu((prev) => (prev === menu ? null : menu));
136
+ }
137
+
138
+ return (
139
+ <div ref={barRef} className="relative flex flex-wrap items-center justify-center gap-2 px-3" role="toolbar" aria-label="Call controls">
140
+ <div className="sm:relative flex items-stretch gap-px">
141
+ <button
142
+ type="button"
143
+ className={`${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`}
144
+ aria-label="Choose microphone"
145
+ aria-haspopup="menu"
146
+ aria-expanded={openMenu === "audio"}
147
+ onClick={() => toggleMenu("audio")}
148
+ >
149
+ <ChevronUpIcon />
150
+ </button>
151
+ <button
152
+ type="button"
153
+ className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`}
154
+ aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
155
+ aria-pressed={!media.micOn}
156
+ disabled={micLocked}
157
+ title={micLocked ? "Only the current talking-stick holder can unmute." : undefined}
158
+ onClick={() => void media.toggleMic()}
159
+ >
160
+ {media.micOn && <LevelBars level={media.audioLevel} />}
161
+ {media.micOn ? <MicIcon /> : <MicOffIcon />}
162
+ </button>
163
+ {openMenu === "audio" && <DeviceMenu kind="audio" media={media} onClose={() => setOpenMenu(null)} />}
164
+ </div>
165
+
166
+ <div className="sm:relative flex items-stretch gap-px">
167
+ <button
168
+ type="button"
169
+ className={`${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`}
170
+ aria-label="Choose camera"
171
+ aria-haspopup="menu"
172
+ aria-expanded={openMenu === "video"}
173
+ onClick={() => toggleMenu("video")}
174
+ >
175
+ <ChevronUpIcon />
176
+ </button>
177
+ <button
178
+ type="button"
179
+ className={`${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`}
180
+ aria-label={media.cameraOn ? "Turn off camera" : "Turn on camera"}
181
+ aria-pressed={!media.cameraOn}
182
+ onClick={() => void media.toggleCamera()}
183
+ >
184
+ {media.cameraOn ? <VideoIcon /> : <VideoOffIcon />}
185
+ {media.cameraOn && (
186
+ <span
187
+ className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse"
188
+ data-testid="camera-live"
189
+ title="Your camera is sending"
190
+ />
191
+ )}
192
+ </button>
193
+ {openMenu === "video" && <DeviceMenu kind="video" media={media} onClose={() => setOpenMenu(null)} />}
194
+ </div>
195
+
196
+ <div className="sm:relative">
197
+ <button
198
+ type="button"
199
+ className={`${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`}
200
+ aria-label="Video effects"
201
+ aria-haspopup="dialog"
202
+ aria-expanded={openMenu === "effects"}
203
+ onClick={() => toggleMenu("effects")}
204
+ >
205
+ <EffectsIcon />
206
+ </button>
207
+ {openMenu === "effects" && (
208
+ <div
209
+ role="dialog"
210
+ aria-label="Video effects"
211
+ 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"
212
+ >
213
+ <EffectsPanel media={media} tone="dark" />
214
+ </div>
215
+ )}
216
+ </div>
217
+
218
+ <button
219
+ type="button"
220
+ className={`${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`}
221
+ aria-label={isPresenting ? "Stop sharing" : "Share screen"}
222
+ aria-pressed={isPresenting}
223
+ disabled={shareDisabled}
224
+ title={shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined}
225
+ onClick={onToggleShare}
226
+ >
227
+ <ScreenShareIcon />
228
+ </button>
229
+
230
+ {isPresenting && (
231
+ <>
232
+ <button
233
+ type="button"
234
+ className={`${BUTTON} w-12 ${NEUTRAL}`}
235
+ aria-label="Rotate shared screen"
236
+ 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."
237
+ onClick={onRotateScreen}
238
+ >
239
+ <RotateIcon />
240
+ </button>
241
+ <button
242
+ type="button"
243
+ className={`${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`}
244
+ aria-label={screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen"}
245
+ aria-pressed={screenTransform.flipped}
246
+ onClick={onFlipScreen}
247
+ >
248
+ <FlipIcon />
249
+ </button>
250
+ </>
251
+ )}
252
+
253
+ <div className="sm:relative">
254
+ <button
255
+ type="button"
256
+ className={`${BUTTON} w-12 ${NEUTRAL}`}
257
+ aria-label="Send a reaction"
258
+ aria-haspopup="menu"
259
+ aria-expanded={openMenu === "emoji"}
260
+ onClick={() => toggleMenu("emoji")}
261
+ >
262
+ <EmojiIcon />
263
+ </button>
264
+ {openMenu === "emoji" && (
265
+ <div
266
+ role="menu"
267
+ aria-label="Reactions"
268
+ 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"
269
+ >
270
+ {REACTION_EMOJIS.map((emoji) => (
271
+ <button
272
+ key={emoji}
273
+ type="button"
274
+ role="menuitem"
275
+ 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"
276
+ aria-label={`Send ${emoji}`}
277
+ onClick={() => {
278
+ onReaction(emoji);
279
+ setOpenMenu(null);
280
+ }}
281
+ >
282
+ {emoji}
283
+ </button>
284
+ ))}
285
+ </div>
286
+ )}
287
+ </div>
288
+
289
+ <button
290
+ type="button"
291
+ className={`${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`}
292
+ aria-label={handRaised ? "Lower hand" : "Raise hand"}
293
+ aria-pressed={handRaised}
294
+ onClick={onToggleHand}
295
+ >
296
+ <HandIcon />
297
+ </button>
298
+
299
+ <button
300
+ type="button"
301
+ className={`${BUTTON} w-12 ${NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`}
302
+ aria-label={viewMode === "grid" ? "Switch to focused view" : "Switch to grid view"}
303
+ disabled={participants.length < 2}
304
+ title={participants.length < 2 ? "Grid and focused view look the same with only one other participant." : undefined}
305
+ onClick={onToggleViewMode}
306
+ >
307
+ {viewMode === "grid" ? <FocusIcon /> : <GridIcon />}
308
+ </button>
309
+
310
+ <button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={() => onLeave()}>
311
+ <LeaveIcon />
312
+ </button>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
318
+ * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
319
+ function DeviceMenu({ kind, media, onClose }: { kind: MediaKind; media: LocalMedia; onClose: () => void }) {
320
+ const list = kind === "audio" ? media.devices.microphones : media.devices.cameras;
321
+ const noun = kind === "audio" ? "microphone" : "camera";
322
+ const selected = media.selectedDeviceIds[kind];
323
+ const denied = media.status[kind] === "denied";
324
+ return (
325
+ <div
326
+ role="menu"
327
+ aria-label={kind === "audio" ? "Microphones" : "Cameras"}
328
+ 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"
329
+ >
330
+ {list.map((device, index) => (
331
+ <button
332
+ key={device.deviceId}
333
+ type="button"
334
+ role="menuitemradio"
335
+ aria-checked={device.deviceId === selected}
336
+ 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"
337
+ onClick={() => {
338
+ void media.selectDevice(kind, device.deviceId);
339
+ onClose();
340
+ }}
341
+ >
342
+ <span className="w-4 text-[#8ab4f8]" aria-hidden="true">
343
+ {device.deviceId === selected ? "✓" : ""}
344
+ </span>
345
+ <span className="truncate">{device.label || `${noun[0].toUpperCase()}${noun.slice(1)} ${index + 1}`}</span>
346
+ </button>
347
+ ))}
348
+ {list.length === 0 && (
349
+ <p className="px-3 py-2 text-white/70">{denied ? `Access to your ${noun} was blocked.` : `No ${noun} found.`}</p>
350
+ )}
351
+ {(list.length === 0 || denied) && (
352
+ <button
353
+ type="button"
354
+ role="menuitem"
355
+ 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"
356
+ onClick={() => {
357
+ void media.requestAccess(kind);
358
+ onClose();
359
+ }}
360
+ >
361
+ Allow access to {noun}
362
+ </button>
363
+ )}
364
+ </div>
365
+ );
366
+ }