@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.
Files changed (74) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -483
  4. package/apps/meet/_CallView.tsx +1060 -924
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_MeetLobby.tsx +224 -224
  8. package/apps/meet/_ParticipantTile.tsx +243 -242
  9. package/apps/meet/_Whiteboard.tsx +577 -0
  10. package/apps/meet/_icons.tsx +135 -90
  11. package/apps/meet/_meetApi.ts +213 -212
  12. package/apps/shared/collab/CollabSession.ts +675 -0
  13. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  14. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  15. package/apps/shared/collab/color.ts +67 -0
  16. package/apps/shared/collab/protocol.ts +321 -0
  17. package/apps/shared/collab/whiteboardRender.ts +311 -0
  18. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  19. package/apps/shared/media/filters/mlModels.ts +174 -174
  20. package/apps/shared/relay/RelayClient.ts +2 -1
  21. package/apps/shared/relay/RelayTransport.ts +2 -1
  22. package/apps/shared/relay/relayEnv.ts +1 -1
  23. package/apps/shared/signaling/SignalingClient.ts +335 -0
  24. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
  25. package/apps/shared/webrtc/types.ts +335 -316
  26. package/dist/apps/meet/[token].js +1 -1
  27. package/dist/apps/meet/_CallControls.d.ts +12 -4
  28. package/dist/apps/meet/_CallControls.js +5 -5
  29. package/dist/apps/meet/_CallView.d.ts +19 -4
  30. package/dist/apps/meet/_CallView.js +100 -11
  31. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  32. package/dist/apps/meet/_ChatPanel.js +116 -0
  33. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  34. package/dist/apps/meet/_ColorWheel.js +82 -0
  35. package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
  36. package/dist/apps/meet/_ParticipantTile.js +1 -1
  37. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  38. package/dist/apps/meet/_Whiteboard.js +312 -0
  39. package/dist/apps/meet/_icons.d.ts +12 -0
  40. package/dist/apps/meet/_icons.js +12 -0
  41. package/dist/apps/meet/_meetApi.d.ts +8 -7
  42. package/dist/apps/meet/_meetApi.js +2 -2
  43. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  44. package/dist/apps/shared/collab/CollabSession.js +559 -0
  45. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  46. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  47. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  48. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  49. package/dist/apps/shared/collab/color.d.ts +18 -0
  50. package/dist/apps/shared/collab/color.js +59 -0
  51. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  52. package/dist/apps/shared/collab/protocol.js +222 -0
  53. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  54. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  55. package/dist/apps/shared/relay/RelayClient.js +2 -1
  56. package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
  57. package/dist/apps/shared/relay/RelayTransport.js +2 -1
  58. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  59. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  60. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  61. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  62. package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
  63. package/dist/apps/shared/webrtc/types.d.ts +38 -24
  64. package/dist/apps/shared/webrtc/types.js +3 -4
  65. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  66. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  67. package/dist/lib/util/SignalingHub.js +339 -0
  68. package/dist/lib/util/SignalingHub.js.map +1 -0
  69. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  70. package/dist/types/util/SignalingHub.d.ts +112 -0
  71. package/package.json +208 -208
  72. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  73. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  74. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -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
+ }