@rapidmx/meet-plugin 0.2.0 → 0.3.1

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 (46) hide show
  1. package/apps/meet/[token].tsx +51 -46
  2. package/apps/meet/_CallControls.tsx +255 -32
  3. package/apps/meet/_CallView.tsx +276 -114
  4. package/apps/meet/_MeetLobby.tsx +111 -142
  5. package/apps/meet/_ParticipantTile.tsx +51 -19
  6. package/apps/meet/_icons.tsx +54 -0
  7. package/apps/shared/media/chime.ts +74 -0
  8. package/apps/shared/media/deviceMedia.ts +3 -13
  9. package/apps/shared/media/useLocalMedia.ts +337 -0
  10. package/apps/shared/push/GuestSignalingClient.ts +2 -0
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +237 -55
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +58 -15
  14. package/dist/apps/meet/[token].js +31 -19
  15. package/dist/apps/meet/_CallControls.d.ts +20 -7
  16. package/dist/apps/meet/_CallControls.js +72 -3
  17. package/dist/apps/meet/_CallView.d.ts +24 -4
  18. package/dist/apps/meet/_CallView.js +142 -57
  19. package/dist/apps/meet/_MeetLobby.d.ts +16 -19
  20. package/dist/apps/meet/_MeetLobby.js +49 -99
  21. package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
  22. package/dist/apps/meet/_ParticipantTile.js +12 -9
  23. package/dist/apps/meet/_icons.d.ts +15 -0
  24. package/dist/apps/meet/_icons.js +16 -0
  25. package/dist/apps/shared/media/chime.d.ts +31 -0
  26. package/dist/apps/shared/media/chime.js +49 -0
  27. package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
  28. package/dist/apps/shared/media/deviceMedia.js +3 -12
  29. package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
  30. package/dist/apps/shared/media/useLocalMedia.js +259 -0
  31. package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +63 -18
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +232 -42
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +58 -15
  36. package/dist/apps/shared/webrtc/types.js +3 -1
  37. package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
  38. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  39. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
  40. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
  41. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
  42. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
  43. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
  44. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
  45. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
  46. package/package.json +181 -181
@@ -0,0 +1,337 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The local participant's camera and microphone, owned by one component for the whole visit (`apps/meet/[token].tsx`)
7
+ * and handed to both the lobby and the call - so the tracks the lobby previews are the very tracks the call sends.
8
+ * (The lobby used to own its stream and stop it when it unmounted, which is exactly when the call starts using it:
9
+ * everyone who joined had dead tracks.) Every track is stopped once, when the owner unmounts or `release()` is
10
+ * called.
11
+ *
12
+ * - **Permission**: `requestAccess()` asks for the camera and microphone in one browser prompt, and falls back to
13
+ * asking for each on its own if that fails for any reason other than an outright denial - a machine with no camera
14
+ * still gets its microphone. It is safe to call again (a "try again" button is a user gesture, which some browsers
15
+ * require before they will prompt at all), and the per-kind `status` says what is and isn't working.
16
+ * - **Mute**: the microphone track is kept and disabled (`micEnabled`), so unmuting is instant. If there is no
17
+ * microphone track yet, unmuting asks for one.
18
+ * - **Camera**: turning the camera off stops its track (the camera light goes out and the device is released);
19
+ * turning it on asks for a new one.
20
+ * - **Devices**: `selectDevice()` swaps in another camera or microphone; `devicechange` refreshes the lists.
21
+ * - **Level**: `audioLevel` (0-5) follows the microphone while it is unmuted, for the "your audio is being sent"
22
+ * indicator.
23
+ *
24
+ * Nothing here runs during SSR: browser APIs are only touched from effects and event handlers.
25
+ */
26
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
27
+ import {
28
+ type DeviceLists,
29
+ type MediaAccessError,
30
+ defaultMediaDevices,
31
+ isMediaDevicesSupported,
32
+ listDevices,
33
+ requestUserMedia,
34
+ } from "./deviceMedia.js";
35
+ import { startLevelMeter } from "./levelMeter.js";
36
+
37
+ export type MediaKind = "audio" | "video";
38
+
39
+ /** `pending`: not asked yet, or being asked. `live`: a track is running. `off`: the participant turned the camera
40
+ * off. `denied`/`unavailable`/`error`: the browser said no, there is no such device (or it was unplugged), or
41
+ * something else went wrong. */
42
+ export type TrackStatus = "pending" | "live" | "off" | "denied" | "unavailable" | "error";
43
+
44
+ const AUDIO_CONSTRAINTS: MediaTrackConstraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
45
+ const VIDEO_CONSTRAINTS: MediaTrackConstraints = { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } };
46
+
47
+ /** The mean deviation (the level meter's 0-100 scale) that fills the whole indicator - ordinary speech sits well
48
+ * below full scale. */
49
+ const LEVEL_FULL_SCALE = 20;
50
+ export const AUDIO_LEVEL_STEPS = 5;
51
+
52
+ export interface LocalMedia {
53
+ /** `false` when this browser (or page connection) has no `getUserMedia` at all. */
54
+ supported: boolean;
55
+ /** A permission request is in flight. */
56
+ requesting: boolean;
57
+ /** The message for the most recent failed request, ready to show. */
58
+ error: MediaAccessError | null;
59
+ audioTrack: MediaStreamTrack | null;
60
+ videoTrack: MediaStreamTrack | null;
61
+ /** A stream holding just the camera track, for a `<video>` element - `null` while the camera is off. */
62
+ videoStream: MediaStream | null;
63
+ /** The user wants the microphone live (unmuted). */
64
+ micEnabled: boolean;
65
+ /** The microphone is live and unmuted: there is a track and `micEnabled`. */
66
+ micOn: boolean;
67
+ cameraOn: boolean;
68
+ status: Record<MediaKind, TrackStatus>;
69
+ devices: DeviceLists;
70
+ selectedDeviceIds: Partial<Record<MediaKind, string>>;
71
+ /** 0-`AUDIO_LEVEL_STEPS`: how loud the microphone is right now - always 0 while muted. */
72
+ audioLevel: number;
73
+ /** Asks for the camera and microphone (or just `kind`), see this module's doc comment. */
74
+ requestAccess(kind?: MediaKind): Promise<void>;
75
+ toggleMic(): Promise<void>;
76
+ toggleCamera(): Promise<void>;
77
+ selectDevice(kind: MediaKind, deviceId: string): Promise<void>;
78
+ /** Stops both tracks. */
79
+ release(): void;
80
+ }
81
+
82
+ function statusFor(error: MediaAccessError): TrackStatus {
83
+ switch (error.kind) {
84
+ case "permission-denied":
85
+ return "denied";
86
+ case "not-found":
87
+ return "unavailable";
88
+ default:
89
+ return "error";
90
+ }
91
+ }
92
+
93
+ export function useLocalMedia(): LocalMedia {
94
+ const [supported, setSupported] = useState(true);
95
+ const [requesting, setRequesting] = useState(false);
96
+ const [error, setError] = useState<MediaAccessError | null>(null);
97
+ const [tracks, setTracks] = useState<Record<MediaKind, MediaStreamTrack | null>>({ audio: null, video: null });
98
+ const [status, setStatus] = useState<Record<MediaKind, TrackStatus>>({ audio: "pending", video: "pending" });
99
+ const [micEnabled, setMicEnabled] = useState(true);
100
+ const [devices, setDevices] = useState<DeviceLists>({ cameras: [], microphones: [] });
101
+ const [audioLevel, setAudioLevel] = useState(0);
102
+
103
+ const tracksRef = useRef<Record<MediaKind, MediaStreamTrack | null>>({ audio: null, video: null });
104
+ const micEnabledRef = useRef(true);
105
+ const mountedRef = useRef(true);
106
+
107
+ const refreshDevices = useCallback(async () => {
108
+ const list = await listDevices();
109
+ if (list.ok && mountedRef.current) {
110
+ setDevices(list.value);
111
+ }
112
+ }, []);
113
+
114
+ /** Makes `track` the current track of its kind, stopping whatever it replaces - or stops it at once if the
115
+ * owner has already gone away (a request that resolved after unmount). */
116
+ const adoptTrack = useCallback((track: MediaStreamTrack) => {
117
+ const kind = track.kind as MediaKind;
118
+ if (!mountedRef.current) {
119
+ track.stop();
120
+ return;
121
+ }
122
+ const previous = tracksRef.current[kind];
123
+ if (previous && previous !== track) {
124
+ previous.onended = null;
125
+ previous.stop();
126
+ }
127
+ if (kind === "audio") {
128
+ track.enabled = micEnabledRef.current;
129
+ }
130
+ track.onended = () => {
131
+ // The device went away (unplugged, or the browser revoked permission).
132
+ if (tracksRef.current[kind] === track) {
133
+ tracksRef.current = { ...tracksRef.current, [kind]: null };
134
+ setTracks(tracksRef.current);
135
+ setStatus((prev) => ({ ...prev, [kind]: "unavailable" }));
136
+ }
137
+ };
138
+ tracksRef.current = { ...tracksRef.current, [kind]: track };
139
+ setTracks(tracksRef.current);
140
+ setStatus((prev) => ({ ...prev, [kind]: "live" }));
141
+ }, []);
142
+
143
+ const adoptStream = useCallback(
144
+ (stream: MediaStream) => {
145
+ for (const track of stream.getTracks()) {
146
+ adoptTrack(track);
147
+ }
148
+ },
149
+ [adoptTrack],
150
+ );
151
+
152
+ /** Asks for one kind on its own - a specific device when `deviceId` is given. */
153
+ const acquire = useCallback(
154
+ async (kind: MediaKind, deviceId?: string): Promise<boolean> => {
155
+ const base = kind === "audio" ? AUDIO_CONSTRAINTS : VIDEO_CONSTRAINTS;
156
+ const constraint: MediaTrackConstraints = deviceId ? { ...base, deviceId: { exact: deviceId } } : base;
157
+ const result = await requestUserMedia({ [kind]: constraint });
158
+ if (!result.ok) {
159
+ if (mountedRef.current) {
160
+ setError(result.error);
161
+ setStatus((prev) => ({ ...prev, [kind]: statusFor(result.error) }));
162
+ }
163
+ return false;
164
+ }
165
+ adoptStream(result.value);
166
+ if (mountedRef.current) {
167
+ setError(null);
168
+ }
169
+ return true;
170
+ },
171
+ [adoptStream],
172
+ );
173
+
174
+ /** Asks for the camera and the microphone in a single prompt. */
175
+ const acquireBoth = useCallback(async () => {
176
+ const both = await requestUserMedia({ audio: AUDIO_CONSTRAINTS, video: VIDEO_CONSTRAINTS });
177
+ if (both.ok) {
178
+ adoptStream(both.value);
179
+ } else if (both.error.kind === "permission-denied") {
180
+ if (mountedRef.current) {
181
+ setError(both.error);
182
+ setStatus({ audio: "denied", video: "denied" });
183
+ }
184
+ } else {
185
+ // No camera, no microphone, or the two together couldn't be satisfied: ask for each on its own, so one
186
+ // missing device doesn't cost the participant the other.
187
+ await acquire("audio");
188
+ await acquire("video");
189
+ }
190
+ }, [acquire, adoptStream]);
191
+
192
+ const requestAccess = useCallback(
193
+ async (kind?: MediaKind) => {
194
+ if (!isMediaDevicesSupported()) {
195
+ setSupported(false);
196
+ return;
197
+ }
198
+ setRequesting(true);
199
+ setError(null);
200
+ await (kind ? acquire(kind) : acquireBoth());
201
+ await refreshDevices();
202
+ if (mountedRef.current) {
203
+ setRequesting(false);
204
+ }
205
+ },
206
+ [acquire, acquireBoth, refreshDevices],
207
+ );
208
+
209
+ const toggleMic = useCallback(async () => {
210
+ const track = tracksRef.current.audio;
211
+ if (!track) {
212
+ micEnabledRef.current = true;
213
+ setMicEnabled(true);
214
+ await acquire("audio");
215
+ await refreshDevices();
216
+ return;
217
+ }
218
+ const next = !micEnabledRef.current;
219
+ micEnabledRef.current = next;
220
+ track.enabled = next;
221
+ setMicEnabled(next);
222
+ }, [acquire, refreshDevices]);
223
+
224
+ const toggleCamera = useCallback(async () => {
225
+ const track = tracksRef.current.video;
226
+ if (!track) {
227
+ await acquire("video");
228
+ await refreshDevices();
229
+ return;
230
+ }
231
+ track.onended = null;
232
+ track.stop();
233
+ tracksRef.current = { ...tracksRef.current, video: null };
234
+ setTracks(tracksRef.current);
235
+ setStatus((prev) => ({ ...prev, video: "off" }));
236
+ }, [acquire, refreshDevices]);
237
+
238
+ const selectDevice = useCallback(
239
+ async (kind: MediaKind, deviceId: string) => {
240
+ if (kind === "video") {
241
+ // Some phones can't open a second camera while the first is still running.
242
+ const current = tracksRef.current.video;
243
+ if (current) {
244
+ current.onended = null;
245
+ current.stop();
246
+ tracksRef.current = { ...tracksRef.current, video: null };
247
+ setTracks(tracksRef.current);
248
+ }
249
+ }
250
+ await acquire(kind, deviceId);
251
+ await refreshDevices();
252
+ },
253
+ [acquire, refreshDevices],
254
+ );
255
+
256
+ const release = useCallback(() => {
257
+ for (const track of Object.values(tracksRef.current)) {
258
+ if (track) {
259
+ track.onended = null;
260
+ track.stop();
261
+ }
262
+ }
263
+ tracksRef.current = { audio: null, video: null };
264
+ setTracks(tracksRef.current);
265
+ setStatus({ audio: "pending", video: "pending" });
266
+ }, []);
267
+
268
+ // Stops everything when the owner goes away.
269
+ useEffect(() => {
270
+ mountedRef.current = true;
271
+ return () => {
272
+ mountedRef.current = false;
273
+ for (const track of Object.values(tracksRef.current)) {
274
+ track?.stop();
275
+ }
276
+ tracksRef.current = { audio: null, video: null };
277
+ };
278
+ }, []);
279
+
280
+ // Keeps the device lists current as devices are plugged in and out.
281
+ useEffect(() => {
282
+ const devicesApi = defaultMediaDevices() as (MediaDevices | undefined);
283
+ if (!devicesApi?.addEventListener) {
284
+ return;
285
+ }
286
+ const onChange = () => void refreshDevices();
287
+ devicesApi.addEventListener("devicechange", onChange);
288
+ return () => devicesApi.removeEventListener("devicechange", onChange);
289
+ }, [refreshDevices]);
290
+
291
+ // The "your audio is being sent" level: follows the microphone while it is live and unmuted.
292
+ useEffect(() => {
293
+ if (!tracks.audio || !micEnabled) {
294
+ setAudioLevel(0);
295
+ return;
296
+ }
297
+ const handle = startLevelMeter(new MediaStream([tracks.audio]), (level) => {
298
+ const step = Math.round(Math.min(1, level / LEVEL_FULL_SCALE) * AUDIO_LEVEL_STEPS);
299
+ setAudioLevel((prev) => (prev === step ? prev : step));
300
+ });
301
+ return () => {
302
+ handle?.stop();
303
+ setAudioLevel(0);
304
+ };
305
+ }, [tracks.audio, micEnabled]);
306
+
307
+ const videoStream = useMemo(() => (tracks.video ? new MediaStream([tracks.video]) : null), [tracks.video]);
308
+
309
+ const selectedDeviceIds = useMemo(
310
+ () => ({
311
+ audio: tracks.audio?.getSettings?.().deviceId,
312
+ video: tracks.video?.getSettings?.().deviceId,
313
+ }),
314
+ [tracks.audio, tracks.video],
315
+ );
316
+
317
+ return {
318
+ supported,
319
+ requesting,
320
+ error,
321
+ audioTrack: tracks.audio,
322
+ videoTrack: tracks.video,
323
+ videoStream,
324
+ micEnabled,
325
+ micOn: !!tracks.audio && micEnabled,
326
+ cameraOn: !!tracks.video,
327
+ status,
328
+ devices,
329
+ selectedDeviceIds,
330
+ audioLevel,
331
+ requestAccess,
332
+ toggleMic,
333
+ toggleCamera,
334
+ selectDevice,
335
+ release,
336
+ };
337
+ }
@@ -192,6 +192,8 @@ export class GuestSignalingClient implements SignalingChannel {
192
192
  method: "POST",
193
193
  headers,
194
194
  body: JSON.stringify(message),
195
+ // Lets a `bye` sent as the page unloads still go out.
196
+ keepalive: true,
195
197
  }).catch(() => {
196
198
  // Best-effort - see this method's doc comment.
197
199
  });