@rapidmx/meet-plugin 0.13.0 → 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 -206
@@ -0,0 +1,80 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * A draggable, persistent window around `_DiagnosticsPanel.tsx` - unlike every other popover in this call (which
7
+ * closes the moment focus moves elsewhere), this one stays open and visible over the call for as long as a
8
+ * participant is actively troubleshooting a connection, until they close it themselves (the title bar's own ✕).
9
+ * Opened from the navbar's "…" menu (`_CallView.tsx`).
10
+ *
11
+ * Dragging is tracked on `document`, not the title bar itself, so a fast drag that outruns the pointer never gets
12
+ * "stuck" the way listening only on the title bar's own element would - the standard way to implement a drag
13
+ * without relying on `setPointerCapture()` (unimplemented in the jsdom this is tested under). The window's position
14
+ * is owned by this component (`position` state), not the caller - closing and reopening it starts over at
15
+ * `initialPosition` rather than remembering where it was left, since there is nothing durable to anchor that to
16
+ * across the call ending and starting again.
17
+ */
18
+ import React, { useEffect, useRef, useState } from "react";
19
+
20
+ export interface DiagnosticsWindowProps {
21
+ onClose: () => void;
22
+ /** Where the window first appears - defaults to a spot near the top-left corner, clear of the header's own
23
+ * chips and buttons. */
24
+ initialPosition?: { x: number; y: number };
25
+ children: React.ReactNode;
26
+ }
27
+
28
+ const DEFAULT_POSITION = { x: 12, y: 56 };
29
+
30
+ export default function DiagnosticsWindow({ onClose, initialPosition = DEFAULT_POSITION, children }: DiagnosticsWindowProps) {
31
+ const [position, setPosition] = useState(initialPosition);
32
+ const [dragging, setDragging] = useState(false);
33
+ const dragOffsetRef = useRef({ x: 0, y: 0 });
34
+
35
+ function handleTitleBarPointerDown(event: React.PointerEvent) {
36
+ dragOffsetRef.current = { x: event.clientX - position.x, y: event.clientY - position.y };
37
+ setDragging(true);
38
+ }
39
+
40
+ useEffect(() => {
41
+ if (!dragging) {
42
+ return;
43
+ }
44
+ const onMove = (event: PointerEvent) => {
45
+ setPosition({ x: event.clientX - dragOffsetRef.current.x, y: event.clientY - dragOffsetRef.current.y });
46
+ };
47
+ const onUp = () => setDragging(false);
48
+ document.addEventListener("pointermove", onMove);
49
+ document.addEventListener("pointerup", onUp);
50
+ return () => {
51
+ document.removeEventListener("pointermove", onMove);
52
+ document.removeEventListener("pointerup", onUp);
53
+ };
54
+ }, [dragging]);
55
+
56
+ return (
57
+ <div
58
+ role="dialog"
59
+ aria-label="Call diagnostics"
60
+ className="fixed z-30 w-80 max-w-[calc(100vw-1rem)] max-h-[70vh] flex flex-col rounded-2xl bg-[#2b2d30]/95 text-white shadow-xl overflow-hidden"
61
+ style={{ left: position.x, top: position.y }}
62
+ >
63
+ <div
64
+ className="shrink-0 flex items-center justify-between gap-2 px-3 py-2 border-b border-white/10 cursor-move select-none"
65
+ onPointerDown={handleTitleBarPointerDown}
66
+ >
67
+ <span className="text-sm font-medium">Call diagnostics</span>
68
+ <button
69
+ type="button"
70
+ className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
71
+ aria-label="Close call diagnostics"
72
+ onClick={onClose}
73
+ >
74
+ ✕
75
+ </button>
76
+ </div>
77
+ <div className="flex-1 min-h-0 overflow-y-auto p-3">{children}</div>
78
+ </div>
79
+ );
80
+ }
@@ -1,224 +1,224 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /**
6
- * The join/lobby step: name entry, a preview of the camera, the microphone and camera on/off buttons and pickers,
7
- * and the "Join" button. The camera and microphone are `LocalMedia` (`apps/shared/media/useLocalMedia.ts`), owned by
8
- * the page (`[token].tsx`) rather than by this component - the tracks previewed here are the tracks the call sends,
9
- * so leaving the lobby for the call must not (and does not) stop them.
10
- *
11
- * The "Effects" section opens the video filter picker (`_EffectsPanel.tsx`), so the participant sees themselves with
12
- * a blurred or replaced background, or a fun accessory, before anyone else does. What they chose last time is
13
- * already applied when the lobby opens (`useLocalMedia()` remembers it).
14
- *
15
- * Access is requested when the lobby opens, and again from the "Allow" / "Try again" button: a click is a user
16
- * gesture, which some browsers require before they will show a permission prompt at all. When access is refused, or
17
- * there is no camera or microphone, the lobby says so and why, and joining is still allowed - a participant with
18
- * nothing to send can still see and hear the call, and turn a camera or microphone on later from the call controls.
19
- *
20
- * The preview is mirrored, like a real mirror, so it feels natural - except with a custom background image, a fixed
21
- * picture rather than a live reflection: mirroring it would show it backwards to no one but the participant
22
- * themselves (see `_ParticipantTile.tsx`'s doc comment, which the in-call tile follows for the same reason).
23
- *
24
- * Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled* when a session is
25
- * genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment on why that never
26
- * actually happens on this page today).
27
- */
28
- import React, { useEffect, useRef, useState } from "react";
29
- import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
30
- import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
31
- import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
32
- import type { LocalMedia, TrackStatus } from "../shared/media/useLocalMedia.js";
33
- import type { PublicVideoMeeting } from "./_meetApi.js";
34
- import { LevelBars } from "./_CallControls.js";
35
- import EffectsPanel from "./_EffectsPanel.js";
36
- import { filtersActive } from "../shared/media/filters/filterTypes.js";
37
- import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "./_icons.js";
38
-
39
- const INPUT_CLASS =
40
- "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
41
-
42
- const ROUND_BUTTON =
43
- "flex items-center justify-center gap-1.5 h-12 min-w-12 px-3 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
44
-
45
- export interface MeetLobbyProps {
46
- meeting: PublicVideoMeeting;
47
- media: LocalMedia;
48
- /** Prefilled from `Profile.givenName` when a session was detected - still always editable. */
49
- initialName?: string;
50
- onJoin: (name: string) => void;
51
- }
52
-
53
- /** What the preview says when there is no picture to show. */
54
- function previewMessage(media: LocalMedia): string {
55
- if (!media.supported) {
56
- return "This browser can't use a camera or microphone here.";
57
- }
58
- if (media.requesting) {
59
- return "Waiting for camera and microphone access…";
60
- }
61
- const status: TrackStatus = media.status.video;
62
- switch (status) {
63
- case "off":
64
- return "Camera is off";
65
- case "denied":
66
- return "Camera access is blocked";
67
- case "unavailable":
68
- return "No camera found";
69
- case "error":
70
- return "Camera couldn't start";
71
- default:
72
- return "No camera preview";
73
- }
74
- }
75
-
76
- export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetLobbyProps) {
77
- const [name, setName] = useState(initialName ?? "");
78
- const [showEffects, setShowEffects] = useState(false);
79
- const videoRef = useRef<HTMLVideoElement>(null);
80
- const { requestAccess } = media;
81
-
82
- // Asks once, when the lobby opens - see this module's doc comment for the button that asks again.
83
- useEffect(() => {
84
- void requestAccess();
85
- // Only on mount: `requestAccess` is stable, and a later change of devices is the participant's own doing.
86
- }, []);
87
-
88
- // The `<video>` element only exists while there is a stream, so its stream is bound whenever it (re)appears.
89
- useEffect(() => {
90
- if (videoRef.current) {
91
- videoRef.current.srcObject = media.videoStream;
92
- }
93
- }, [media.videoStream]);
94
-
95
- const cameraOk = media.status.video === "live" || media.status.video === "off";
96
- const needsAccess = media.supported && !media.requesting && (media.status.audio !== "live" || !cameraOk);
97
- const nothingToSend = !media.audioTrack && !media.videoTrack && !media.requesting;
98
-
99
- return (
100
- <div>
101
- <h1 className="text-2xl font-bold tracking-tight">{meeting.title}</h1>
102
- {meeting.hostDisplayName && <p className="text-sm text-text-muted mt-1">Hosted by {meeting.hostDisplayName}</p>}
103
-
104
- <div className="mt-6 grid sm:grid-cols-2 gap-6">
105
- <div>
106
- <div className="relative aspect-video bg-[#202124] rounded-lg overflow-hidden flex items-center justify-center">
107
- {media.videoStream ? (
108
- <video
109
- ref={videoRef}
110
- autoPlay
111
- playsInline
112
- muted
113
- className={`w-full h-full object-cover ${media.filters.background !== "image" ? "[transform:scaleX(-1)]" : ""}`}
114
- />
115
- ) : (
116
- <p className="px-4 text-center text-white/70 text-sm">{previewMessage(media)}</p>
117
- )}
118
- <div className="absolute bottom-3 inset-x-0 flex justify-center gap-3">
119
- <button
120
- type="button"
121
- className={`${ROUND_BUTTON} ${media.micOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`}
122
- aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
123
- aria-pressed={!media.micOn}
124
- onClick={() => void media.toggleMic()}
125
- >
126
- {media.micOn && <LevelBars level={media.audioLevel} />}
127
- {media.micOn ? <MicIcon /> : <MicOffIcon />}
128
- </button>
129
- <button
130
- type="button"
131
- className={`${ROUND_BUTTON} ${media.cameraOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`}
132
- aria-label={media.cameraOn ? "Turn off camera" : "Turn on camera"}
133
- aria-pressed={!media.cameraOn}
134
- onClick={() => void media.toggleCamera()}
135
- >
136
- {media.cameraOn ? <VideoIcon /> : <VideoOffIcon />}
137
- </button>
138
- </div>
139
- </div>
140
-
141
- {!media.supported && (
142
- <Alert>
143
- This browser (or this page's connection) doesn't support camera/microphone access. Try a current browser
144
- over HTTPS - a link opened inside another app often needs to be opened in the browser itself.
145
- </Alert>
146
- )}
147
- {media.error && <Alert>{media.error.message}</Alert>}
148
- {needsAccess && (
149
- <Button type="button" variant="secondary" className="!w-auto mt-3" onClick={() => void requestAccess()}>
150
- {media.error ? "Try again" : "Allow camera and microphone"}
151
- </Button>
152
- )}
153
- {nothingToSend && <p className="text-sm text-text-muted mt-3">You'll join without a camera or microphone.</p>}
154
-
155
- {media.devices.cameras.length > 1 && (
156
- <label className="block text-sm mt-3">
157
- Camera
158
- <select
159
- className={`${INPUT_CLASS} mt-1`}
160
- value={media.selectedDeviceIds.video ?? ""}
161
- onChange={(e) => void media.selectDevice("video", e.target.value)}
162
- >
163
- {!media.selectedDeviceIds.video && <option value="">Choose a camera</option>}
164
- {media.devices.cameras.map((d, i) => (
165
- <option key={d.deviceId} value={d.deviceId}>
166
- {d.label || `Camera ${i + 1}`}
167
- </option>
168
- ))}
169
- </select>
170
- </label>
171
- )}
172
- <div className="mt-3">
173
- <button
174
- type="button"
175
- className="text-sm text-primary underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
176
- aria-expanded={showEffects}
177
- onClick={() => setShowEffects((shown) => !shown)}
178
- >
179
- {filtersActive(media.filters) ? "Effects (on)" : "Effects"}
180
- </button>
181
- {showEffects && (
182
- <div className="mt-2">
183
- <EffectsPanel media={media} tone="light" />
184
- </div>
185
- )}
186
- </div>
187
- {media.devices.microphones.length > 1 && (
188
- <label className="block text-sm mt-3">
189
- Microphone
190
- <select
191
- className={`${INPUT_CLASS} mt-1`}
192
- value={media.selectedDeviceIds.audio ?? ""}
193
- onChange={(e) => void media.selectDevice("audio", e.target.value)}
194
- >
195
- {!media.selectedDeviceIds.audio && <option value="">Choose a microphone</option>}
196
- {media.devices.microphones.map((d, i) => (
197
- <option key={d.deviceId} value={d.deviceId}>
198
- {d.label || `Microphone ${i + 1}`}
199
- </option>
200
- ))}
201
- </select>
202
- </label>
203
- )}
204
- </div>
205
-
206
- <div className="flex flex-col justify-center gap-3">
207
- <FormField label="Your name" htmlFor="meet-name">
208
- <input
209
- id="meet-name"
210
- type="text"
211
- className={INPUT_CLASS}
212
- value={name}
213
- onChange={(e) => setName(e.target.value)}
214
- placeholder="Enter your name"
215
- />
216
- </FormField>
217
- <Button type="button" className="!w-auto" disabled={!name.trim()} onClick={() => onJoin(name.trim())}>
218
- Join meeting
219
- </Button>
220
- </div>
221
- </div>
222
- </div>
223
- );
224
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The join/lobby step: name entry, a preview of the camera, the microphone and camera on/off buttons and pickers,
7
+ * and the "Join" button. The camera and microphone are `LocalMedia` (`apps/shared/media/useLocalMedia.ts`), owned by
8
+ * the page (`[token].tsx`) rather than by this component - the tracks previewed here are the tracks the call sends,
9
+ * so leaving the lobby for the call must not (and does not) stop them.
10
+ *
11
+ * The "Effects" section opens the video filter picker (`_EffectsPanel.tsx`), so the participant sees themselves with
12
+ * a blurred or replaced background, or a fun accessory, before anyone else does. What they chose last time is
13
+ * already applied when the lobby opens (`useLocalMedia()` remembers it).
14
+ *
15
+ * Access is requested when the lobby opens, and again from the "Allow" / "Try again" button: a click is a user
16
+ * gesture, which some browsers require before they will show a permission prompt at all. When access is refused, or
17
+ * there is no camera or microphone, the lobby says so and why, and joining is still allowed - a participant with
18
+ * nothing to send can still see and hear the call, and turn a camera or microphone on later from the call controls.
19
+ *
20
+ * The preview is mirrored, like a real mirror, so it feels natural - except with a custom background image, a fixed
21
+ * picture rather than a live reflection: mirroring it would show it backwards to no one but the participant
22
+ * themselves (see `_ParticipantTile.tsx`'s doc comment, which the in-call tile follows for the same reason).
23
+ *
24
+ * Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled* when a session is
25
+ * genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment on why that never
26
+ * actually happens on this page today).
27
+ */
28
+ import React, { useEffect, useRef, useState } from "react";
29
+ import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
30
+ import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
31
+ import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
32
+ import type { LocalMedia, TrackStatus } from "../shared/media/useLocalMedia.js";
33
+ import type { PublicVideoMeeting } from "./_meetApi.js";
34
+ import { LevelBars } from "./_CallControls.js";
35
+ import EffectsPanel from "./_EffectsPanel.js";
36
+ import { filtersActive } from "../shared/media/filters/filterTypes.js";
37
+ import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "./_icons.js";
38
+
39
+ const INPUT_CLASS =
40
+ "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
41
+
42
+ const ROUND_BUTTON =
43
+ "flex items-center justify-center gap-1.5 h-12 min-w-12 px-3 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
44
+
45
+ export interface MeetLobbyProps {
46
+ meeting: PublicVideoMeeting;
47
+ media: LocalMedia;
48
+ /** Prefilled from `Profile.givenName` when a session was detected - still always editable. */
49
+ initialName?: string;
50
+ onJoin: (name: string) => void;
51
+ }
52
+
53
+ /** What the preview says when there is no picture to show. */
54
+ function previewMessage(media: LocalMedia): string {
55
+ if (!media.supported) {
56
+ return "This browser can't use a camera or microphone here.";
57
+ }
58
+ if (media.requesting) {
59
+ return "Waiting for camera and microphone access…";
60
+ }
61
+ const status: TrackStatus = media.status.video;
62
+ switch (status) {
63
+ case "off":
64
+ return "Camera is off";
65
+ case "denied":
66
+ return "Camera access is blocked";
67
+ case "unavailable":
68
+ return "No camera found";
69
+ case "error":
70
+ return "Camera couldn't start";
71
+ default:
72
+ return "No camera preview";
73
+ }
74
+ }
75
+
76
+ export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetLobbyProps) {
77
+ const [name, setName] = useState(initialName ?? "");
78
+ const [showEffects, setShowEffects] = useState(false);
79
+ const videoRef = useRef<HTMLVideoElement>(null);
80
+ const { requestAccess } = media;
81
+
82
+ // Asks once, when the lobby opens - see this module's doc comment for the button that asks again.
83
+ useEffect(() => {
84
+ void requestAccess();
85
+ // Only on mount: `requestAccess` is stable, and a later change of devices is the participant's own doing.
86
+ }, []);
87
+
88
+ // The `<video>` element only exists while there is a stream, so its stream is bound whenever it (re)appears.
89
+ useEffect(() => {
90
+ if (videoRef.current) {
91
+ videoRef.current.srcObject = media.videoStream;
92
+ }
93
+ }, [media.videoStream]);
94
+
95
+ const cameraOk = media.status.video === "live" || media.status.video === "off";
96
+ const needsAccess = media.supported && !media.requesting && (media.status.audio !== "live" || !cameraOk);
97
+ const nothingToSend = !media.audioTrack && !media.videoTrack && !media.requesting;
98
+
99
+ return (
100
+ <div>
101
+ <h1 className="text-2xl font-bold tracking-tight">{meeting.title}</h1>
102
+ {meeting.hostDisplayName && <p className="text-sm text-text-muted mt-1">Hosted by {meeting.hostDisplayName}</p>}
103
+
104
+ <div className="mt-6 grid sm:grid-cols-2 gap-6">
105
+ <div>
106
+ <div className="relative aspect-video bg-[#202124] rounded-lg overflow-hidden flex items-center justify-center">
107
+ {media.videoStream ? (
108
+ <video
109
+ ref={videoRef}
110
+ autoPlay
111
+ playsInline
112
+ muted
113
+ className={`w-full h-full object-cover ${media.filters.background !== "image" ? "[transform:scaleX(-1)]" : ""}`}
114
+ />
115
+ ) : (
116
+ <p className="px-4 text-center text-white/70 text-sm">{previewMessage(media)}</p>
117
+ )}
118
+ <div className="absolute bottom-3 inset-x-0 flex justify-center gap-3">
119
+ <button
120
+ type="button"
121
+ className={`${ROUND_BUTTON} ${media.micOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`}
122
+ aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
123
+ aria-pressed={!media.micOn}
124
+ onClick={() => void media.toggleMic()}
125
+ >
126
+ {media.micOn && <LevelBars level={media.audioLevel} />}
127
+ {media.micOn ? <MicIcon /> : <MicOffIcon />}
128
+ </button>
129
+ <button
130
+ type="button"
131
+ className={`${ROUND_BUTTON} ${media.cameraOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`}
132
+ aria-label={media.cameraOn ? "Turn off camera" : "Turn on camera"}
133
+ aria-pressed={!media.cameraOn}
134
+ onClick={() => void media.toggleCamera()}
135
+ >
136
+ {media.cameraOn ? <VideoIcon /> : <VideoOffIcon />}
137
+ </button>
138
+ </div>
139
+ </div>
140
+
141
+ {!media.supported && (
142
+ <Alert>
143
+ This browser (or this page's connection) doesn't support camera/microphone access. Try a current browser
144
+ over HTTPS - a link opened inside another app often needs to be opened in the browser itself.
145
+ </Alert>
146
+ )}
147
+ {media.error && <Alert>{media.error.message}</Alert>}
148
+ {needsAccess && (
149
+ <Button type="button" variant="secondary" className="!w-auto mt-3" onClick={() => void requestAccess()}>
150
+ {media.error ? "Try again" : "Allow camera and microphone"}
151
+ </Button>
152
+ )}
153
+ {nothingToSend && <p className="text-sm text-text-muted mt-3">You'll join without a camera or microphone.</p>}
154
+
155
+ {media.devices.cameras.length > 1 && (
156
+ <label className="block text-sm mt-3">
157
+ Camera
158
+ <select
159
+ className={`${INPUT_CLASS} mt-1`}
160
+ value={media.selectedDeviceIds.video ?? ""}
161
+ onChange={(e) => void media.selectDevice("video", e.target.value)}
162
+ >
163
+ {!media.selectedDeviceIds.video && <option value="">Choose a camera</option>}
164
+ {media.devices.cameras.map((d, i) => (
165
+ <option key={d.deviceId} value={d.deviceId}>
166
+ {d.label || `Camera ${i + 1}`}
167
+ </option>
168
+ ))}
169
+ </select>
170
+ </label>
171
+ )}
172
+ <div className="mt-3">
173
+ <button
174
+ type="button"
175
+ className="text-sm text-primary underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
176
+ aria-expanded={showEffects}
177
+ onClick={() => setShowEffects((shown) => !shown)}
178
+ >
179
+ {filtersActive(media.filters) ? "Effects (on)" : "Effects"}
180
+ </button>
181
+ {showEffects && (
182
+ <div className="mt-2">
183
+ <EffectsPanel media={media} tone="light" />
184
+ </div>
185
+ )}
186
+ </div>
187
+ {media.devices.microphones.length > 1 && (
188
+ <label className="block text-sm mt-3">
189
+ Microphone
190
+ <select
191
+ className={`${INPUT_CLASS} mt-1`}
192
+ value={media.selectedDeviceIds.audio ?? ""}
193
+ onChange={(e) => void media.selectDevice("audio", e.target.value)}
194
+ >
195
+ {!media.selectedDeviceIds.audio && <option value="">Choose a microphone</option>}
196
+ {media.devices.microphones.map((d, i) => (
197
+ <option key={d.deviceId} value={d.deviceId}>
198
+ {d.label || `Microphone ${i + 1}`}
199
+ </option>
200
+ ))}
201
+ </select>
202
+ </label>
203
+ )}
204
+ </div>
205
+
206
+ <div className="flex flex-col justify-center gap-3">
207
+ <FormField label="Your name" htmlFor="meet-name">
208
+ <input
209
+ id="meet-name"
210
+ type="text"
211
+ className={INPUT_CLASS}
212
+ value={name}
213
+ onChange={(e) => setName(e.target.value)}
214
+ placeholder="Enter your name"
215
+ />
216
+ </FormField>
217
+ <Button type="button" className="!w-auto" disabled={!name.trim()} onClick={() => onJoin(name.trim())}>
218
+ Join meeting
219
+ </Button>
220
+ </div>
221
+ </div>
222
+ </div>
223
+ );
224
+ }