@rapidmx/meet-plugin 0.2.0 → 0.3.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 (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 +275 -115
  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 +212 -44
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +52 -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 +23 -4
  18. package/dist/apps/meet/_CallView.js +141 -58
  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 +57 -17
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +52 -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,259 @@
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 { defaultMediaDevices, isMediaDevicesSupported, listDevices, requestUserMedia, } from "./deviceMedia.js";
28
+ import { startLevelMeter } from "./levelMeter.js";
29
+ const AUDIO_CONSTRAINTS = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
30
+ const VIDEO_CONSTRAINTS = { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } };
31
+ /** The mean deviation (the level meter's 0-100 scale) that fills the whole indicator - ordinary speech sits well
32
+ * below full scale. */
33
+ const LEVEL_FULL_SCALE = 20;
34
+ export const AUDIO_LEVEL_STEPS = 5;
35
+ function statusFor(error) {
36
+ switch (error.kind) {
37
+ case "permission-denied":
38
+ return "denied";
39
+ case "not-found":
40
+ return "unavailable";
41
+ default:
42
+ return "error";
43
+ }
44
+ }
45
+ export function useLocalMedia() {
46
+ const [supported, setSupported] = useState(true);
47
+ const [requesting, setRequesting] = useState(false);
48
+ const [error, setError] = useState(null);
49
+ const [tracks, setTracks] = useState({ audio: null, video: null });
50
+ const [status, setStatus] = useState({ audio: "pending", video: "pending" });
51
+ const [micEnabled, setMicEnabled] = useState(true);
52
+ const [devices, setDevices] = useState({ cameras: [], microphones: [] });
53
+ const [audioLevel, setAudioLevel] = useState(0);
54
+ const tracksRef = useRef({ audio: null, video: null });
55
+ const micEnabledRef = useRef(true);
56
+ const mountedRef = useRef(true);
57
+ const refreshDevices = useCallback(async () => {
58
+ const list = await listDevices();
59
+ if (list.ok && mountedRef.current) {
60
+ setDevices(list.value);
61
+ }
62
+ }, []);
63
+ /** Makes `track` the current track of its kind, stopping whatever it replaces - or stops it at once if the
64
+ * owner has already gone away (a request that resolved after unmount). */
65
+ const adoptTrack = useCallback((track) => {
66
+ const kind = track.kind;
67
+ if (!mountedRef.current) {
68
+ track.stop();
69
+ return;
70
+ }
71
+ const previous = tracksRef.current[kind];
72
+ if (previous && previous !== track) {
73
+ previous.onended = null;
74
+ previous.stop();
75
+ }
76
+ if (kind === "audio") {
77
+ track.enabled = micEnabledRef.current;
78
+ }
79
+ track.onended = () => {
80
+ // The device went away (unplugged, or the browser revoked permission).
81
+ if (tracksRef.current[kind] === track) {
82
+ tracksRef.current = { ...tracksRef.current, [kind]: null };
83
+ setTracks(tracksRef.current);
84
+ setStatus((prev) => ({ ...prev, [kind]: "unavailable" }));
85
+ }
86
+ };
87
+ tracksRef.current = { ...tracksRef.current, [kind]: track };
88
+ setTracks(tracksRef.current);
89
+ setStatus((prev) => ({ ...prev, [kind]: "live" }));
90
+ }, []);
91
+ const adoptStream = useCallback((stream) => {
92
+ for (const track of stream.getTracks()) {
93
+ adoptTrack(track);
94
+ }
95
+ }, [adoptTrack]);
96
+ /** Asks for one kind on its own - a specific device when `deviceId` is given. */
97
+ const acquire = useCallback(async (kind, deviceId) => {
98
+ const base = kind === "audio" ? AUDIO_CONSTRAINTS : VIDEO_CONSTRAINTS;
99
+ const constraint = deviceId ? { ...base, deviceId: { exact: deviceId } } : base;
100
+ const result = await requestUserMedia({ [kind]: constraint });
101
+ if (!result.ok) {
102
+ if (mountedRef.current) {
103
+ setError(result.error);
104
+ setStatus((prev) => ({ ...prev, [kind]: statusFor(result.error) }));
105
+ }
106
+ return false;
107
+ }
108
+ adoptStream(result.value);
109
+ if (mountedRef.current) {
110
+ setError(null);
111
+ }
112
+ return true;
113
+ }, [adoptStream]);
114
+ /** Asks for the camera and the microphone in a single prompt. */
115
+ const acquireBoth = useCallback(async () => {
116
+ const both = await requestUserMedia({ audio: AUDIO_CONSTRAINTS, video: VIDEO_CONSTRAINTS });
117
+ if (both.ok) {
118
+ adoptStream(both.value);
119
+ }
120
+ else if (both.error.kind === "permission-denied") {
121
+ if (mountedRef.current) {
122
+ setError(both.error);
123
+ setStatus({ audio: "denied", video: "denied" });
124
+ }
125
+ }
126
+ else {
127
+ // No camera, no microphone, or the two together couldn't be satisfied: ask for each on its own, so one
128
+ // missing device doesn't cost the participant the other.
129
+ await acquire("audio");
130
+ await acquire("video");
131
+ }
132
+ }, [acquire, adoptStream]);
133
+ const requestAccess = useCallback(async (kind) => {
134
+ if (!isMediaDevicesSupported()) {
135
+ setSupported(false);
136
+ return;
137
+ }
138
+ setRequesting(true);
139
+ setError(null);
140
+ await (kind ? acquire(kind) : acquireBoth());
141
+ await refreshDevices();
142
+ if (mountedRef.current) {
143
+ setRequesting(false);
144
+ }
145
+ }, [acquire, acquireBoth, refreshDevices]);
146
+ const toggleMic = useCallback(async () => {
147
+ const track = tracksRef.current.audio;
148
+ if (!track) {
149
+ micEnabledRef.current = true;
150
+ setMicEnabled(true);
151
+ await acquire("audio");
152
+ await refreshDevices();
153
+ return;
154
+ }
155
+ const next = !micEnabledRef.current;
156
+ micEnabledRef.current = next;
157
+ track.enabled = next;
158
+ setMicEnabled(next);
159
+ }, [acquire, refreshDevices]);
160
+ const toggleCamera = useCallback(async () => {
161
+ const track = tracksRef.current.video;
162
+ if (!track) {
163
+ await acquire("video");
164
+ await refreshDevices();
165
+ return;
166
+ }
167
+ track.onended = null;
168
+ track.stop();
169
+ tracksRef.current = { ...tracksRef.current, video: null };
170
+ setTracks(tracksRef.current);
171
+ setStatus((prev) => ({ ...prev, video: "off" }));
172
+ }, [acquire, refreshDevices]);
173
+ const selectDevice = useCallback(async (kind, deviceId) => {
174
+ if (kind === "video") {
175
+ // Some phones can't open a second camera while the first is still running.
176
+ const current = tracksRef.current.video;
177
+ if (current) {
178
+ current.onended = null;
179
+ current.stop();
180
+ tracksRef.current = { ...tracksRef.current, video: null };
181
+ setTracks(tracksRef.current);
182
+ }
183
+ }
184
+ await acquire(kind, deviceId);
185
+ await refreshDevices();
186
+ }, [acquire, refreshDevices]);
187
+ const release = useCallback(() => {
188
+ for (const track of Object.values(tracksRef.current)) {
189
+ if (track) {
190
+ track.onended = null;
191
+ track.stop();
192
+ }
193
+ }
194
+ tracksRef.current = { audio: null, video: null };
195
+ setTracks(tracksRef.current);
196
+ setStatus({ audio: "pending", video: "pending" });
197
+ }, []);
198
+ // Stops everything when the owner goes away.
199
+ useEffect(() => {
200
+ mountedRef.current = true;
201
+ return () => {
202
+ mountedRef.current = false;
203
+ for (const track of Object.values(tracksRef.current)) {
204
+ track?.stop();
205
+ }
206
+ tracksRef.current = { audio: null, video: null };
207
+ };
208
+ }, []);
209
+ // Keeps the device lists current as devices are plugged in and out.
210
+ useEffect(() => {
211
+ const devicesApi = defaultMediaDevices();
212
+ if (!devicesApi?.addEventListener) {
213
+ return;
214
+ }
215
+ const onChange = () => void refreshDevices();
216
+ devicesApi.addEventListener("devicechange", onChange);
217
+ return () => devicesApi.removeEventListener("devicechange", onChange);
218
+ }, [refreshDevices]);
219
+ // The "your audio is being sent" level: follows the microphone while it is live and unmuted.
220
+ useEffect(() => {
221
+ if (!tracks.audio || !micEnabled) {
222
+ setAudioLevel(0);
223
+ return;
224
+ }
225
+ const handle = startLevelMeter(new MediaStream([tracks.audio]), (level) => {
226
+ const step = Math.round(Math.min(1, level / LEVEL_FULL_SCALE) * AUDIO_LEVEL_STEPS);
227
+ setAudioLevel((prev) => (prev === step ? prev : step));
228
+ });
229
+ return () => {
230
+ handle?.stop();
231
+ setAudioLevel(0);
232
+ };
233
+ }, [tracks.audio, micEnabled]);
234
+ const videoStream = useMemo(() => (tracks.video ? new MediaStream([tracks.video]) : null), [tracks.video]);
235
+ const selectedDeviceIds = useMemo(() => ({
236
+ audio: tracks.audio?.getSettings?.().deviceId,
237
+ video: tracks.video?.getSettings?.().deviceId,
238
+ }), [tracks.audio, tracks.video]);
239
+ return {
240
+ supported,
241
+ requesting,
242
+ error,
243
+ audioTrack: tracks.audio,
244
+ videoTrack: tracks.video,
245
+ videoStream,
246
+ micEnabled,
247
+ micOn: !!tracks.audio && micEnabled,
248
+ cameraOn: !!tracks.video,
249
+ status,
250
+ devices,
251
+ selectedDeviceIds,
252
+ audioLevel,
253
+ requestAccess,
254
+ toggleMic,
255
+ toggleCamera,
256
+ selectDevice,
257
+ release,
258
+ };
259
+ }
@@ -150,6 +150,8 @@ export class GuestSignalingClient {
150
150
  method: "POST",
151
151
  headers,
152
152
  body: JSON.stringify(message),
153
+ // Lets a `bye` sent as the page unloads still go out.
154
+ keepalive: true,
153
155
  }).catch(() => {
154
156
  // Best-effort - see this method's doc comment.
155
157
  });
@@ -11,6 +11,20 @@
11
11
  * both sides independently compute the same answer with no coordination round trip, and exactly one side ever
12
12
  * calls `createOffer()` for a given pair.
13
13
  *
14
+ * ## Media: two permanent transceivers, tracks swapped in and out
15
+ *
16
+ * Every connection carries one send-and-receive audio transceiver and one send-and-receive video transceiver,
17
+ * whether or not the local participant has a microphone or camera to send yet. The offerer adds them before its
18
+ * offer; the answerer takes the ones the offer created (`claimTransceivers()`) - a browser does not match an offer's
19
+ * m-lines to transceivers the answerer added itself, so adding them up front on both sides leaves the answerer
20
+ * sending nothing at all. A participant who declined permission, has no camera, or turns the camera on halfway
21
+ * through therefore still negotiates both directions from the first offer, and later `setLocalTrack()` calls just
22
+ * `replaceTrack()` on the existing senders - never a renegotiation. (Attaching tracks once at connection creation,
23
+ * as this manager used to, meant a participant with nothing to send at that moment negotiated no media in either
24
+ * direction, for the whole call.)
25
+ * The remote side's tracks are collected into one `MediaStream` per peer (`remote-stream` events), rather than
26
+ * read from `event.streams[0]`: a transceiver whose sender has no track yet announces no stream to the far end.
27
+ *
14
28
  * ## Join announcement and roster discovery
15
29
  *
16
30
  * The channel has no history (a push event published while a socket was down is never replayed - see
@@ -22,6 +36,17 @@
22
36
  * roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
23
37
  * ever discovers) rather than a risk of runaway flooding.
24
38
  *
39
+ * Each message is its own `POST`, so nothing guarantees they arrive in the order they were sent. Two consequences
40
+ * are handled here: an `offer` that beats its sender's `hello` creates the peer under a placeholder name that the
41
+ * `hello` then replaces (`participant-updated`), and an ICE candidate that beats its offer is held until the peer
42
+ * exists rather than dropped.
43
+ *
44
+ * ## Participant state, hands and reactions
45
+ *
46
+ * What each participant is sending (`audioOn`/`videoOn`) and whether their hand is up travels in `hello` and in a
47
+ * `state` message on every change (`setLocalState()`), because a remote track gives no dependable signal that its
48
+ * sender stopped. `reaction` messages carry one emoji from `REACTION_EMOJIS`; anything else is ignored.
49
+ *
25
50
  * ## Presenter (single-writer screen share)
26
51
  *
27
52
  * `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
@@ -31,29 +56,35 @@
31
56
  * smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
32
57
  * `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
33
58
  *
34
- * ## Presentation mode's screen share
35
- *
36
- * Sharing a screen never adds a second video track or renegotiates a connection - `replaceLocalVideoTrack()`
37
- * simply swaps each peer connection's existing outgoing video `RTCRtpSender`'s track (`sender.replaceTrack()`),
38
- * the same track object already flowing to everyone from the moment they joined. Stopping a share replaces it
39
- * back to the camera track the same way.
59
+ * Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
40
60
  */
41
- import type { MeshEvent, MeshParticipant, RTCPeerConnectionFactory, SignalingChannel } from "./types.js";
61
+ import { type MeshEvent, type MeshParticipant, type ParticipantState, type RTCPeerConnectionFactory, type SignalingChannel } from "./types.js";
42
62
  /** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
43
63
  export declare function isOfferer(selfUid: string, peerUid: string): boolean;
64
+ type MediaKind = "audio" | "video";
44
65
  export interface MeshConnectionManagerOptions {
45
66
  selfUid: string;
46
67
  selfName: string;
47
68
  iceServers: RTCIceServer[];
48
69
  channel: SignalingChannel;
49
70
  createPeerConnection: RTCPeerConnectionFactory;
50
- /** The local camera/microphone stream, attached to every new peer connection as it's created. */
51
- localStream: MediaStream;
71
+ /** The tracks to send from the start - either may be absent (no permission, no device) and supplied or
72
+ * replaced later with `setLocalTrack()`. */
73
+ localAudioTrack?: MediaStreamTrack | null;
74
+ localVideoTrack?: MediaStreamTrack | null;
75
+ /** What to announce at first - defaults to "sending whatever tracks were given, hand down". */
76
+ localState?: Partial<ParticipantState>;
77
+ /** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
78
+ * `new MediaStream()`; a test supplies a fake. */
79
+ createMediaStream?: () => MediaStream;
52
80
  }
53
81
  export declare class MeshConnectionManager {
54
82
  private readonly options;
55
83
  private readonly peers;
56
84
  private readonly listeners;
85
+ private readonly orphanCandidates;
86
+ private readonly localTracks;
87
+ private localState;
57
88
  private unsubscribe;
58
89
  private started;
59
90
  private stopped;
@@ -65,15 +96,18 @@ export declare class MeshConnectionManager {
65
96
  /** Subscribes to the channel and announces this participant. Idempotent - a second call is a no-op. */
66
97
  start(): void;
67
98
  /** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
68
- * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping `localStream`'s
69
- * own tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns that
70
- * stream's lifecycle, only attaches it. */
99
+ * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
100
+ * tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
101
+ * lifecycle, only sends them. */
71
102
  stop(): void;
72
- /** Swaps every peer connection's outgoing video track (presentation mode) - see this module's doc comment.
73
- * `track` is `null` to stop sending video at all (never used by this plugin's UI today, which always has a
74
- * camera-or-screen track to fall back to, but kept correct since `RTCRtpSender.replaceTrack()` itself allows
75
- * it). */
76
- replaceLocalVideoTrack(track: MediaStreamTrack | null): void;
103
+ /** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
104
+ * joins later - see this module's doc comment. */
105
+ setLocalTrack(kind: MediaKind, track: MediaStreamTrack | null): void;
106
+ /** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
107
+ * actually changed. Before `start()` it only records the state, which `hello` then carries. */
108
+ setLocalState(partial: Partial<ParticipantState>): void;
109
+ /** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
110
+ sendReaction(emoji: string): boolean;
77
111
  /** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
78
112
  * else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
79
113
  * rather than let a claim silently do nothing. */
@@ -85,6 +119,11 @@ export declare class MeshConnectionManager {
85
119
  private emit;
86
120
  private handleMessage;
87
121
  private handleHello;
122
+ private handleState;
123
+ private handleReaction;
124
+ /** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
125
+ * hand going up) only when something actually changed. */
126
+ private updatePeer;
88
127
  private handleBye;
89
128
  private createPeer;
90
129
  private initiateOffer;
@@ -95,3 +134,4 @@ export declare class MeshConnectionManager {
95
134
  private handlePresenterClaim;
96
135
  private handlePresenterRelease;
97
136
  }
137
+ export {};