@rapidmx/meet-plugin 0.1.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 (108) hide show
  1. package/LICENSE +382 -0
  2. package/README.md +38 -0
  3. package/apps/meet/[token].tsx +144 -0
  4. package/apps/meet/_CallControls.tsx +69 -0
  5. package/apps/meet/_CallView.tsx +338 -0
  6. package/apps/meet/_MeetChrome.tsx +30 -0
  7. package/apps/meet/_MeetLobby.tsx +223 -0
  8. package/apps/meet/_ParticipantTile.tsx +66 -0
  9. package/apps/meet/_layout.tsx +34 -0
  10. package/apps/meet/_meetApi.ts +66 -0
  11. package/apps/meet/index.tsx +22 -0
  12. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
  13. package/apps/settings-video-conferencing/_layout.tsx +47 -0
  14. package/apps/settings-video-conferencing/index.tsx +192 -0
  15. package/apps/shared/media/activeSpeaker.ts +45 -0
  16. package/apps/shared/media/deviceMedia.ts +141 -0
  17. package/apps/shared/media/levelMeter.ts +91 -0
  18. package/apps/shared/push/GuestSignalingClient.ts +338 -0
  19. package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
  20. package/apps/shared/webrtc/realPeerConnection.ts +37 -0
  21. package/apps/shared/webrtc/types.ts +90 -0
  22. package/dist/apps/meet/[token].d.ts +33 -0
  23. package/dist/apps/meet/[token].js +101 -0
  24. package/dist/apps/meet/_CallControls.d.ts +18 -0
  25. package/dist/apps/meet/_CallControls.js +6 -0
  26. package/dist/apps/meet/_CallView.d.ts +41 -0
  27. package/dist/apps/meet/_CallView.js +225 -0
  28. package/dist/apps/meet/_MeetChrome.d.ts +13 -0
  29. package/dist/apps/meet/_MeetChrome.js +10 -0
  30. package/dist/apps/meet/_MeetLobby.d.ts +29 -0
  31. package/dist/apps/meet/_MeetLobby.js +120 -0
  32. package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
  33. package/dist/apps/meet/_ParticipantTile.js +25 -0
  34. package/dist/apps/meet/_layout.d.ts +9 -0
  35. package/dist/apps/meet/_layout.js +8 -0
  36. package/dist/apps/meet/_meetApi.d.ts +47 -0
  37. package/dist/apps/meet/_meetApi.js +23 -0
  38. package/dist/apps/meet/index.d.ts +5 -0
  39. package/dist/apps/meet/index.js +8 -0
  40. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
  41. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
  42. package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
  43. package/dist/apps/settings-video-conferencing/_layout.js +8 -0
  44. package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
  45. package/dist/apps/settings-video-conferencing/index.js +92 -0
  46. package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
  47. package/dist/apps/shared/media/activeSpeaker.js +39 -0
  48. package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
  49. package/dist/apps/shared/media/deviceMedia.js +110 -0
  50. package/dist/apps/shared/media/levelMeter.d.ts +40 -0
  51. package/dist/apps/shared/media/levelMeter.js +61 -0
  52. package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
  53. package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
  54. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
  55. package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
  56. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  57. package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
  58. package/dist/apps/shared/webrtc/types.d.ts +94 -0
  59. package/dist/apps/shared/webrtc/types.js +9 -0
  60. package/dist/lib/index.js +25 -0
  61. package/dist/lib/index.js.map +1 -0
  62. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
  63. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
  64. package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
  65. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
  66. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
  67. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
  68. package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
  69. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
  70. package/dist/lib/models/types.js +19 -0
  71. package/dist/lib/models/types.js.map +1 -0
  72. package/dist/lib/mongo.js +13 -0
  73. package/dist/lib/mongo.js.map +1 -0
  74. package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
  75. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
  76. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
  77. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
  78. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
  79. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
  80. package/dist/lib/sql.js +13 -0
  81. package/dist/lib/sql.js.map +1 -0
  82. package/dist/lib/util/BookingIntegrationUtils.js +115 -0
  83. package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
  84. package/dist/lib/util/IceServerUtils.js +68 -0
  85. package/dist/lib/util/IceServerUtils.js.map +1 -0
  86. package/dist/lib/util/PublicUrlUtils.js +37 -0
  87. package/dist/lib/util/PublicUrlUtils.js.map +1 -0
  88. package/dist/lib/util/RouteAccessUtils.js +32 -0
  89. package/dist/lib/util/RouteAccessUtils.js.map +1 -0
  90. package/dist/lib/util/TokenUtils.js +33 -0
  91. package/dist/lib/util/TokenUtils.js.map +1 -0
  92. package/dist/types/index.d.ts +20 -0
  93. package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
  94. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
  95. package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
  96. package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
  97. package/dist/types/models/types.d.ts +131 -0
  98. package/dist/types/mongo.d.ts +8 -0
  99. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
  100. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
  101. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
  102. package/dist/types/sql.d.ts +8 -0
  103. package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
  104. package/dist/types/util/IceServerUtils.d.ts +57 -0
  105. package/dist/types/util/PublicUrlUtils.d.ts +11 -0
  106. package/dist/types/util/RouteAccessUtils.d.ts +8 -0
  107. package/dist/types/util/TokenUtils.d.ts +23 -0
  108. package/package.json +181 -0
@@ -0,0 +1,338 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The in-call view: connects the full-mesh `MeshConnectionManager` to the meeting's signaling channel
7
+ * (`GuestSignalingClient`), mirrors its events into React state, and renders the grid/focused/presentation
8
+ * layouts - see `apps/shared/webrtc/MeshConnectionManager.ts`'s doc comment for the who-calls-whom and
9
+ * single-presenter rules this view relies on rather than re-deciding. These reusable, non-UI modules live under
10
+ * `apps/shared/` rather than this plugin's backend `src/` - `tsconfig.apps.json` builds `apps/` as its own
11
+ * program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
12
+ * identical `apps/shared/` convention for reusable frontend-only code.
13
+ *
14
+ * ## Layout precedence
15
+ *
16
+ * `computeMainUid()` decides who is shown large, in this order: an active presenter always wins (presentation
17
+ * mode, forcing a focused-like layout regardless of `viewMode`); otherwise a manually pinned participant; else the
18
+ * auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
19
+ * slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
20
+ * empty main slot once someone else has joined.
21
+ */
22
+ import React, { useEffect, useRef, useState } from "react";
23
+ import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
24
+ import { startLevelMeter, type LevelMeterHandle } from "../shared/media/levelMeter.js";
25
+ import { requestDisplayMedia, setTracksEnabled, stopStream } from "../shared/media/deviceMedia.js";
26
+ import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
27
+ import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
28
+ import type { MeshParticipant } from "../shared/webrtc/types.js";
29
+ import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
30
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
+ import CallControls, { type CallViewMode } from "./_CallControls.js";
32
+ import ParticipantTile from "./_ParticipantTile.js";
33
+
34
+ export interface CallViewProps {
35
+ channel: string;
36
+ /** Omitted when the caller already authenticated as a real RapidMX identity in `join()` - there is then no
37
+ * guest token to hand `GuestSignalingClient`, which relies entirely on the browser's own already-existing
38
+ * `jwt` session cookie instead (see its own doc comment). */
39
+ token?: string;
40
+ selfUid: string;
41
+ selfName: string;
42
+ iceServers: RTCIceServer[];
43
+ initialStream: MediaStream;
44
+ initialMicOn: boolean;
45
+ initialCameraOn: boolean;
46
+ onLeave: () => void;
47
+ }
48
+
49
+ /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
50
+ * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
51
+ export function computeMainUid(options: {
52
+ presenterUid?: string;
53
+ pinnedUid?: string;
54
+ activeSpeakerUid?: string;
55
+ otherUids: readonly string[];
56
+ }): string | undefined {
57
+ if (options.presenterUid) {
58
+ return options.presenterUid;
59
+ }
60
+ if (options.pinnedUid) {
61
+ return options.pinnedUid;
62
+ }
63
+ if (options.activeSpeakerUid) {
64
+ return options.activeSpeakerUid;
65
+ }
66
+ return options.otherUids[0];
67
+ }
68
+
69
+ export default function CallView({
70
+ channel,
71
+ token,
72
+ selfUid,
73
+ selfName,
74
+ iceServers,
75
+ initialStream,
76
+ initialMicOn,
77
+ initialCameraOn,
78
+ onLeave,
79
+ }: CallViewProps) {
80
+ const [connectError, setConnectError] = useState<string | null>(null);
81
+ const [participants, setParticipants] = useState<MeshParticipant[]>([]);
82
+ const [remoteStreams, setRemoteStreams] = useState<Record<string, MediaStream>>({});
83
+ const [levels, setLevels] = useState<Record<string, number>>({});
84
+ const [presenterUid, setPresenterUid] = useState<string | undefined>(undefined);
85
+ const [micOn, setMicOn] = useState(initialMicOn);
86
+ const [cameraOn, setCameraOn] = useState(initialCameraOn);
87
+ const [isPresenting, setIsPresenting] = useState(false);
88
+ const [viewMode, setViewMode] = useState<CallViewMode>("grid");
89
+ const [pinnedUid, setPinnedUid] = useState<string | undefined>(undefined);
90
+ const [activeSpeakerUid, setActiveSpeakerUid] = useState<string | undefined>(undefined);
91
+
92
+ const managerRef = useRef<MeshConnectionManager | null>(null);
93
+ const clientRef = useRef<GuestSignalingClient | null>(null);
94
+ const localStreamRef = useRef(initialStream);
95
+ const screenStreamRef = useRef<MediaStream | null>(null);
96
+ const cameraTrackRef = useRef<MediaStreamTrack | null>(initialStream.getVideoTracks()[0] ?? null);
97
+ const levelMetersRef = useRef<Record<string, LevelMeterHandle>>({});
98
+ const isPresentingRef = useRef(false);
99
+
100
+ useEffect(() => {
101
+ let cancelled = false;
102
+ const client = new GuestSignalingClient({ channel, token });
103
+ clientRef.current = client;
104
+ const manager = new MeshConnectionManager({
105
+ selfUid,
106
+ selfName,
107
+ iceServers,
108
+ channel: client,
109
+ createPeerConnection: createBrowserPeerConnection,
110
+ localStream: localStreamRef.current,
111
+ });
112
+ managerRef.current = manager;
113
+
114
+ const unsubscribe = manager.onEvent((event) => {
115
+ switch (event.type) {
116
+ case "participant-joined":
117
+ setParticipants((prev) => [...prev.filter((p) => p.uid !== event.participant.uid), event.participant]);
118
+ return;
119
+ case "participant-left":
120
+ setParticipants((prev) => prev.filter((p) => p.uid !== event.uid));
121
+ setRemoteStreams((prev) => {
122
+ const { [event.uid]: _removed, ...rest } = prev;
123
+ return rest;
124
+ });
125
+ stopLevelMeter(levelMetersRef, event.uid);
126
+ setLevels((prev) => {
127
+ const { [event.uid]: _removed, ...rest } = prev;
128
+ return rest;
129
+ });
130
+ setPinnedUid((prev) => (prev === event.uid ? undefined : prev));
131
+ return;
132
+ case "remote-stream":
133
+ setRemoteStreams((prev) => ({ ...prev, [event.uid]: event.stream }));
134
+ startTrackingLevel(levelMetersRef, event.uid, event.stream, (level) =>
135
+ setLevels((prev) => ({ ...prev, [event.uid]: level })),
136
+ );
137
+ return;
138
+ case "presenter-changed":
139
+ setPresenterUid(event.uid);
140
+ if (event.uid !== selfUid && isPresentingRef.current) {
141
+ // Lost a presenter-claim collision (see `MeshConnectionManager`'s doc comment) - stop our
142
+ // own capture without re-sending a release the manager already handled internally.
143
+ stopLocalPresentation(false);
144
+ }
145
+ return;
146
+ }
147
+ });
148
+
149
+ client
150
+ .connect()
151
+ .then(() => {
152
+ if (!cancelled) {
153
+ manager.start();
154
+ }
155
+ })
156
+ .catch((err: Error) => {
157
+ if (!cancelled) {
158
+ setConnectError(err.message);
159
+ }
160
+ });
161
+
162
+ return () => {
163
+ cancelled = true;
164
+ unsubscribe();
165
+ manager.stop();
166
+ client.close();
167
+ for (const handle of Object.values(levelMetersRef.current)) {
168
+ handle.stop();
169
+ }
170
+ levelMetersRef.current = {};
171
+ stopStream(localStreamRef.current);
172
+ stopStream(screenStreamRef.current);
173
+ };
174
+ // Deliberately runs once - the call's identity (channel/token/selfUid) never changes for the life of this
175
+ // component; a real identity change is a new call, which unmounts/remounts this view from `[token].tsx`.
176
+ }, []);
177
+
178
+ useEffect(() => {
179
+ if (!presenterUid) {
180
+ setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
181
+ }
182
+ }, [levels, presenterUid]);
183
+
184
+ function stopLocalPresentation(alsoRelease: boolean): void {
185
+ isPresentingRef.current = false;
186
+ setIsPresenting(false);
187
+ if (alsoRelease) {
188
+ managerRef.current?.releasePresenter();
189
+ }
190
+ stopStream(screenStreamRef.current);
191
+ screenStreamRef.current = null;
192
+ // Restores the camera track as the outgoing video sender - its own `enabled` flag (toggled by
193
+ // `handleToggleCamera`, untouched by presenting) already governs whether it actually sends frames, so
194
+ // this is correct whether or not the camera happens to be off right now.
195
+ managerRef.current?.replaceLocalVideoTrack(cameraTrackRef.current);
196
+ }
197
+
198
+ async function handleToggleShare() {
199
+ if (isPresenting) {
200
+ stopLocalPresentation(true);
201
+ return;
202
+ }
203
+ const result = await requestDisplayMedia();
204
+ if (!result.ok) {
205
+ setConnectError(result.error.message);
206
+ return;
207
+ }
208
+ const claimed = managerRef.current?.claimPresenter() ?? false;
209
+ if (!claimed) {
210
+ stopStream(result.value);
211
+ return;
212
+ }
213
+ screenStreamRef.current = result.value;
214
+ const [screenTrack] = result.value.getVideoTracks();
215
+ managerRef.current?.replaceLocalVideoTrack(screenTrack);
216
+ isPresentingRef.current = true;
217
+ setIsPresenting(true);
218
+ screenTrack.onended = () => stopLocalPresentation(true);
219
+ }
220
+
221
+ function handleToggleMic() {
222
+ setMicOn((prev) => {
223
+ const next = !prev;
224
+ setTracksEnabled(localStreamRef.current, "audio", next);
225
+ return next;
226
+ });
227
+ }
228
+
229
+ function handleToggleCamera() {
230
+ setCameraOn((prev) => {
231
+ const next = !prev;
232
+ // Just the track's own `enabled` flag (see `deviceMedia.ts`'s `setTracksEnabled()` doc comment) -
233
+ // whether that track is actually the one being sent (camera) or has been swapped out for a screen
234
+ // share (`replaceLocalVideoTrack()`) is an orthogonal concern this toggle never touches.
235
+ setTracksEnabled(localStreamRef.current, "video", next);
236
+ return next;
237
+ });
238
+ }
239
+
240
+ function handleLeave() {
241
+ onLeave();
242
+ }
243
+
244
+ function togglePin(uid: string) {
245
+ setPinnedUid((prev) => (prev === uid ? undefined : uid));
246
+ }
247
+
248
+ const otherUids = participants.map((p) => p.uid);
249
+ const showFocusLayout = !!presenterUid || viewMode === "focus";
250
+ const mainUid = showFocusLayout ? computeMainUid({ presenterUid, pinnedUid, activeSpeakerUid, otherUids }) : undefined;
251
+ const presenterName = presenterUid ? (presenterUid === selfUid ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
252
+
253
+ const localTile = (
254
+ <ParticipantTile
255
+ key="__self"
256
+ name={selfName}
257
+ stream={isPresenting ? null : localStreamRef.current}
258
+ isLocal
259
+ cameraOff={!cameraOn && !isPresenting}
260
+ micMuted={!micOn}
261
+ isFocused={mainUid === selfUid}
262
+ onClick={() => togglePin(selfUid)}
263
+ />
264
+ );
265
+
266
+ const remoteTiles = participants.map((p) => (
267
+ <ParticipantTile
268
+ key={p.uid}
269
+ name={p.name}
270
+ stream={remoteStreams[p.uid] ?? null}
271
+ cameraOff={!remoteStreams[p.uid]}
272
+ isFocused={mainUid === p.uid}
273
+ onClick={() => togglePin(p.uid)}
274
+ />
275
+ ));
276
+
277
+ const allTiles = [localTile, ...remoteTiles];
278
+ // The presenter's own screen content is rendered from their local capture, not their (replaced) outgoing
279
+ // video sender - `mainStream` picks whichever is right for `mainUid`.
280
+ const mainStream =
281
+ mainUid === selfUid ? (isPresenting ? screenStreamRef.current : localStreamRef.current) : mainUid ? (remoteStreams[mainUid] ?? null) : null;
282
+ const mainName = mainUid === selfUid ? selfName : (participants.find((p) => p.uid === mainUid)?.name ?? "");
283
+ const thumbnailTiles = showFocusLayout ? allTiles.filter((tile) => tile.key !== (mainUid === selfUid ? "__self" : mainUid)) : allTiles;
284
+
285
+ return (
286
+ <div className="min-h-screen flex flex-col bg-surface-alt">
287
+ {connectError && <Alert>{connectError}</Alert>}
288
+ <div className="flex-1 p-3 flex flex-col gap-3 min-h-0">
289
+ {showFocusLayout && mainUid ? (
290
+ <>
291
+ <div className="flex-1 min-h-0">
292
+ <ParticipantTile name={mainName} stream={mainStream} isLocal={mainUid === selfUid} isFocused className="h-full" />
293
+ </div>
294
+ <div className="flex gap-2 overflow-x-auto h-24 shrink-0">
295
+ {thumbnailTiles.map((tile) => (
296
+ <div key={tile.key} className="w-32 shrink-0">
297
+ {tile}
298
+ </div>
299
+ ))}
300
+ </div>
301
+ </>
302
+ ) : (
303
+ <div className="flex-1 grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">{allTiles}</div>
304
+ )}
305
+ </div>
306
+ <CallControls
307
+ micOn={micOn}
308
+ onToggleMic={handleToggleMic}
309
+ cameraOn={cameraOn}
310
+ onToggleCamera={handleToggleCamera}
311
+ isPresenting={isPresenting}
312
+ presentingElsewhereName={presenterUid && presenterUid !== selfUid ? presenterName : undefined}
313
+ onToggleShare={handleToggleShare}
314
+ viewMode={viewMode}
315
+ onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
316
+ onLeave={handleLeave}
317
+ />
318
+ </div>
319
+ );
320
+ }
321
+
322
+ function stopLevelMeter(ref: React.MutableRefObject<Record<string, LevelMeterHandle>>, uid: string): void {
323
+ ref.current[uid]?.stop();
324
+ delete ref.current[uid];
325
+ }
326
+
327
+ function startTrackingLevel(
328
+ ref: React.MutableRefObject<Record<string, LevelMeterHandle>>,
329
+ uid: string,
330
+ stream: MediaStream,
331
+ onLevel: (level: number) => void,
332
+ ): void {
333
+ stopLevelMeter(ref, uid);
334
+ const handle = startLevelMeter(stream, onLevel);
335
+ if (handle) {
336
+ ref.current[uid] = handle;
337
+ }
338
+ }
@@ -0,0 +1,30 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** The frame shared by every page in this app: the deployment's branding header/footer around a centered card, the
6
+ * same `booking-plugin`'s `_BookingChrome.tsx` shell-component pattern this plugin's Phase 2 spec calls for. Not a
7
+ * page itself: `_`-prefixed files in `apps/` aren't routed. */
8
+ import React, { PropsWithChildren } from "react";
9
+ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
10
+ import { BrandingFooter, BrandingHeader } from "@rapidmx/web-client/shared/components/layout/BrandingChrome.js";
11
+
12
+ export function MeetPageShell({ branding, children }: PropsWithChildren<{ branding: Branding | null }>) {
13
+ return (
14
+ <div className="min-h-screen flex flex-col">
15
+ <BrandingHeader branding={branding} />
16
+ <main className="flex-1 bg-surface-alt px-4 py-6 sm:py-10">{children}</main>
17
+ <BrandingFooter branding={branding} />
18
+ </div>
19
+ );
20
+ }
21
+
22
+ /** The white card the lobby (and the "meeting not found" state) is drawn in - the in-call view (`_CallView.tsx`)
23
+ * deliberately does NOT use this: a video call wants the full viewport, not a centered card. */
24
+ export function MeetCard({ maxWidth = "max-w-2xl", children }: PropsWithChildren<{ maxWidth?: string }>) {
25
+ return (
26
+ <div className={`mx-auto w-full ${maxWidth} overflow-hidden rounded-lg border border-border bg-surface shadow-sm p-6 sm:p-10`}>
27
+ {children}
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,223 @@
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, device preview and pickers, mute/camera-off starting preferences, and the
7
+ * "Join" button. Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled*
8
+ * when a session is genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment
9
+ * on why that never actually happens on this page today).
10
+ *
11
+ * Device support/permission errors (`navigator.mediaDevices` missing entirely - e.g. a non-HTTPS context in some
12
+ * browsers - a denied prompt, or no camera/microphone at all) are shown as a plain message rather than crashing;
13
+ * joining is still allowed with no local media in that case (a signaling-only participant - see this plugin's
14
+ * Phase 2 report's known-limitations note on why that participant's peer connections can carry no media at all
15
+ * without this design's simpler, renegotiation-free mesh needing to grow one).
16
+ */
17
+ import React, { useEffect, useRef, useState } from "react";
18
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
19
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
20
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
21
+ import {
22
+ type DeviceLists,
23
+ type MediaAccessError,
24
+ isMediaDevicesSupported,
25
+ listDevices,
26
+ requestUserMedia,
27
+ setTracksEnabled,
28
+ stopStream,
29
+ } from "../shared/media/deviceMedia.js";
30
+ import type { PublicVideoMeeting } from "./_meetApi.js";
31
+
32
+ const INPUT_CLASS =
33
+ "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";
34
+
35
+ export interface JoinPreferences {
36
+ name: string;
37
+ micOn: boolean;
38
+ cameraOn: boolean;
39
+ /** Never `null` - an empty `MediaStream` when no device access was ever obtained, so the call view always has
40
+ * a stream to work with. */
41
+ stream: MediaStream;
42
+ }
43
+
44
+ export interface MeetLobbyProps {
45
+ meeting: PublicVideoMeeting;
46
+ /** Prefilled from `Profile.givenName` when a session was detected - still always editable. */
47
+ initialName?: string;
48
+ onJoin: (preferences: JoinPreferences) => void;
49
+ }
50
+
51
+ export default function MeetLobby({ meeting, initialName, onJoin }: MeetLobbyProps) {
52
+ const [name, setName] = useState(initialName ?? "");
53
+ const [micOn, setMicOn] = useState(true);
54
+ const [cameraOn, setCameraOn] = useState(true);
55
+ const [stream, setStream] = useState<MediaStream | null>(null);
56
+ const [devices, setDevices] = useState<DeviceLists>({ cameras: [], microphones: [] });
57
+ const [error, setError] = useState<MediaAccessError | null>(null);
58
+ const [supported, setSupported] = useState(true);
59
+ const videoRef = useRef<HTMLVideoElement>(null);
60
+
61
+ // Requests device access once, on mount - never re-requested just because `micOn`/`cameraOn` toggle (those
62
+ // toggles disable/enable the already-granted tracks instead, see `handleToggleMic`/`handleToggleCamera`).
63
+ useEffect(() => {
64
+ let cancelled = false;
65
+ if (!isMediaDevicesSupported()) {
66
+ setSupported(false);
67
+ return;
68
+ }
69
+ void requestUserMedia({ audio: true, video: true }).then(async (result) => {
70
+ if (cancelled) {
71
+ return;
72
+ }
73
+ if (!result.ok) {
74
+ setError(result.error);
75
+ return;
76
+ }
77
+ setStream(result.value);
78
+ const list = await listDevices();
79
+ if (!cancelled && list.ok) {
80
+ setDevices(list.value);
81
+ }
82
+ });
83
+ return () => {
84
+ cancelled = true;
85
+ };
86
+ // Intentionally empty deps - see this effect's own comment on why this never re-runs.
87
+ }, []);
88
+
89
+ // Releases the camera/microphone the moment this component unmounts (leaving the lobby without joining, or
90
+ // transitioning into the call - `[token].tsx` requests its own continuation of these tracks first).
91
+ useEffect(() => stopOnUnmount(stream), [stream]);
92
+
93
+ useEffect(() => {
94
+ if (videoRef.current) {
95
+ videoRef.current.srcObject = stream;
96
+ }
97
+ }, [stream]);
98
+
99
+ function handleToggleMic() {
100
+ setMicOn((prev) => {
101
+ const next = !prev;
102
+ setTracksEnabled(stream, "audio", next);
103
+ return next;
104
+ });
105
+ }
106
+
107
+ function handleToggleCamera() {
108
+ setCameraOn((prev) => {
109
+ const next = !prev;
110
+ setTracksEnabled(stream, "video", next);
111
+ return next;
112
+ });
113
+ }
114
+
115
+ async function handleSwitchDevice(kind: "audio" | "video", deviceId: string) {
116
+ const result = await requestUserMedia({ [kind]: { deviceId: { exact: deviceId } }, [kind === "audio" ? "video" : "audio"]: false });
117
+ if (!result.ok) {
118
+ setError(result.error);
119
+ return;
120
+ }
121
+ const [newTrack] = kind === "audio" ? result.value.getAudioTracks() : result.value.getVideoTracks();
122
+ // `stream` is always set by the time this can be called - the device picker this is wired to only ever
123
+ // renders once `devices` is populated, which itself only happens after an initial successful
124
+ // `requestUserMedia()` call has already set `stream` (see the mount effect above).
125
+ if (!newTrack) {
126
+ return;
127
+ }
128
+ const [oldTrack] = kind === "audio" ? stream!.getAudioTracks() : stream!.getVideoTracks();
129
+ if (oldTrack) {
130
+ stream!.removeTrack(oldTrack);
131
+ oldTrack.stop();
132
+ }
133
+ newTrack.enabled = kind === "audio" ? micOn : cameraOn;
134
+ stream!.addTrack(newTrack);
135
+ // Re-triggers the preview `<video>` binding and any downstream consumer without minting a whole new
136
+ // `MediaStream` object (the mesh's already-negotiated connections keep the same stream identity).
137
+ setStream((current) => current);
138
+ }
139
+
140
+ function handleJoin() {
141
+ onJoin({
142
+ name: name.trim(),
143
+ micOn,
144
+ cameraOn,
145
+ stream: stream ?? new MediaStream(),
146
+ });
147
+ }
148
+
149
+ return (
150
+ <div>
151
+ <h1 className="text-2xl font-bold tracking-tight">{meeting.title}</h1>
152
+ {meeting.hostDisplayName && <p className="text-sm text-text-muted mt-1">Hosted by {meeting.hostDisplayName}</p>}
153
+
154
+ <div className="mt-6 grid sm:grid-cols-2 gap-6">
155
+ <div>
156
+ <div className="aspect-video bg-black/80 rounded-md overflow-hidden flex items-center justify-center">
157
+ {stream && cameraOn ? (
158
+ <video ref={videoRef} autoPlay playsInline muted className="w-full h-full object-cover" />
159
+ ) : (
160
+ <p className="text-white/70 text-sm">{cameraOn ? "No camera preview" : "Camera is off"}</p>
161
+ )}
162
+ </div>
163
+ <div className="flex gap-2 mt-3">
164
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleToggleMic} disabled={!stream}>
165
+ {micOn ? "Mute mic" : "Unmute mic"}
166
+ </Button>
167
+ <Button type="button" variant="secondary" className="!w-auto" onClick={handleToggleCamera} disabled={!stream}>
168
+ {cameraOn ? "Turn camera off" : "Turn camera on"}
169
+ </Button>
170
+ </div>
171
+ {!supported && <Alert>This browser (or this page's connection) doesn't support camera/microphone access.</Alert>}
172
+ {error && <Alert>{error.message}</Alert>}
173
+ {devices.cameras.length > 1 && (
174
+ <label className="block text-sm mt-3">
175
+ Camera
176
+ <select className={`${INPUT_CLASS} mt-1`} onChange={(e) => handleSwitchDevice("video", e.target.value)}>
177
+ {devices.cameras.map((d) => (
178
+ <option key={d.deviceId} value={d.deviceId}>
179
+ {d.label || "Camera"}
180
+ </option>
181
+ ))}
182
+ </select>
183
+ </label>
184
+ )}
185
+ {devices.microphones.length > 1 && (
186
+ <label className="block text-sm mt-3">
187
+ Microphone
188
+ <select className={`${INPUT_CLASS} mt-1`} onChange={(e) => handleSwitchDevice("audio", e.target.value)}>
189
+ {devices.microphones.map((d) => (
190
+ <option key={d.deviceId} value={d.deviceId}>
191
+ {d.label || "Microphone"}
192
+ </option>
193
+ ))}
194
+ </select>
195
+ </label>
196
+ )}
197
+ </div>
198
+
199
+ <div className="flex flex-col justify-center gap-3">
200
+ <FormField label="Your name" htmlFor="meet-name">
201
+ <input
202
+ id="meet-name"
203
+ type="text"
204
+ className={INPUT_CLASS}
205
+ value={name}
206
+ onChange={(e) => setName(e.target.value)}
207
+ placeholder="Enter your name"
208
+ />
209
+ </FormField>
210
+ <Button type="button" className="!w-auto" disabled={!name.trim()} onClick={handleJoin}>
211
+ Join meeting
212
+ </Button>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ );
217
+ }
218
+
219
+ /** `useEffect` cleanup helper for stopping a lobby preview stream on unmount/replacement - a named function
220
+ * (rather than an inline arrow) purely so its intent reads clearly at the call site. */
221
+ function stopOnUnmount(stream: MediaStream | null): () => void {
222
+ return () => stopStream(stream);
223
+ }
@@ -0,0 +1,66 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** One participant's video tile - used by both the grid and the focused/thumbnail-strip layouts in `_CallView.tsx`.
6
+ * Renders a placeholder (the participant's initial) instead of a `<video>` element whenever there's no stream or
7
+ * their camera is off, rather than showing a black/frozen frame. */
8
+ import React, { useEffect, useRef } from "react";
9
+
10
+ export interface ParticipantTileProps {
11
+ name: string;
12
+ stream?: MediaStream | null;
13
+ /** The local participant's own tile must be `muted` (never plays back its own audio) - a real remote tile
14
+ * never sets this. */
15
+ isLocal?: boolean;
16
+ cameraOff?: boolean;
17
+ micMuted?: boolean;
18
+ /** Highlights this tile as the current presenter/focus. */
19
+ isFocused?: boolean;
20
+ onClick?: () => void;
21
+ className?: string;
22
+ }
23
+
24
+ function initialOf(name: string): string {
25
+ return (Array.from(name.trim())[0] ?? "?").toUpperCase();
26
+ }
27
+
28
+ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, isFocused, onClick, className }: ParticipantTileProps) {
29
+ const videoRef = useRef<HTMLVideoElement>(null);
30
+ const showVideo = !!stream && !cameraOff;
31
+
32
+ useEffect(() => {
33
+ if (videoRef.current) {
34
+ /* v8 ignore next -- unreachable via real usage: the `<video>` element only ever renders at all when
35
+ `showVideo` (`!!stream && !cameraOff`) is true, so `stream` is always truthy whenever `videoRef.current`
36
+ is non-null here; the `?? null` exists purely to satisfy `srcObject`'s type, not for a real code path. */
37
+ videoRef.current.srcObject = stream ?? null;
38
+ }
39
+ }, [stream]);
40
+
41
+ return (
42
+ <div
43
+ className={`relative overflow-hidden rounded-md bg-black/80 flex items-center justify-center min-h-[90px] ${
44
+ isFocused ? "ring-2 ring-accent" : ""
45
+ } ${className ?? ""}`}
46
+ onClick={onClick}
47
+ role={onClick ? "button" : undefined}
48
+ tabIndex={onClick ? 0 : undefined}
49
+ >
50
+ {showVideo ? (
51
+ <video ref={videoRef} autoPlay playsInline muted={isLocal} className="w-full h-full object-cover" />
52
+ ) : (
53
+ <div className="w-14 h-14 rounded-full bg-primary text-white flex items-center justify-center text-xl font-bold" aria-hidden="true">
54
+ {initialOf(name)}
55
+ </div>
56
+ )}
57
+ <div className="absolute bottom-1.5 left-1.5 flex items-center gap-1.5 px-2 py-0.5 rounded bg-black/60 text-white text-xs">
58
+ {micMuted && <span title="Muted">(muted)</span>}
59
+ <span>
60
+ {name}
61
+ {isLocal ? " (you)" : ""}
62
+ </span>
63
+ </div>
64
+ </div>
65
+ );
66
+ }