@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
@@ -1,18 +1,31 @@
1
- /** The in-call bottom control bar: mute/unmute, camera on/off, share screen, grid/focus toggle, leave. Deliberately
2
- * minimal per this plugin's Phase 2 spec ("no chat, no recording, no reactions"). */
1
+ /**
2
+ * The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
3
+ * them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
4
+ * hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
5
+ * screen, reactions, raise hand, grid/focus and leave.
6
+ *
7
+ * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
8
+ * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
9
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
10
+ */
3
11
  import React from "react";
12
+ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
4
13
  export type CallViewMode = "grid" | "focus";
5
14
  export interface CallControlsProps {
6
- micOn: boolean;
7
- onToggleMic: () => void;
8
- cameraOn: boolean;
9
- onToggleCamera: () => void;
15
+ media: LocalMedia;
10
16
  isPresenting: boolean;
11
17
  /** Someone else is presenting - the share button is disabled and explains why. */
12
18
  presentingElsewhereName?: string;
13
19
  onToggleShare: () => void;
20
+ handRaised: boolean;
21
+ onToggleHand: () => void;
22
+ onReaction: (emoji: string) => void;
14
23
  viewMode: CallViewMode;
15
24
  onToggleViewMode: () => void;
16
25
  onLeave: () => void;
17
26
  }
18
- export default function CallControls({ micOn, onToggleMic, cameraOn, onToggleCamera, isPresenting, presentingElsewhereName, onToggleShare, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
27
+ /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
28
+ export declare function LevelBars({ level }: {
29
+ level: number;
30
+ }): React.JSX.Element;
31
+ export default function CallControls({ media, isPresenting, presentingElsewhereName, onToggleShare, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
@@ -1,6 +1,75 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
3
- export default function CallControls({ micOn, onToggleMic, cameraOn, onToggleCamera, isPresenting, presentingElsewhereName, onToggleShare, viewMode, onToggleViewMode, onLeave, }) {
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
8
+ * them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
9
+ * hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
10
+ * screen, reactions, raise hand, grid/focus and leave.
11
+ *
12
+ * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
13
+ * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
14
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
15
+ */
16
+ import { useEffect, useRef, useState } from "react";
17
+ import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
18
+ import { ChevronUpIcon, EmojiIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
19
+ const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
20
+ const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
21
+ const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
22
+ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
23
+ /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
24
+ export function LevelBars({ level }) {
25
+ return (_jsx("span", { className: "flex items-center gap-[2px] h-5", "data-testid": "mic-level", "data-level": level, "aria-hidden": "true", children: [0.6, 1, 0.6].map((weight, i) => (_jsx("span", { className: `w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`, style: { height: `${4 + weight * level * 3}px` } }, i))) }));
26
+ }
27
+ export default function CallControls({ media, isPresenting, presentingElsewhereName, onToggleShare, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }) {
28
+ const [openMenu, setOpenMenu] = useState(null);
29
+ const barRef = useRef(null);
4
30
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
5
- return (_jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2 py-3 px-4 bg-surface border-t border-border", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleMic, children: micOn ? "Mute" : "Unmute" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleCamera, children: cameraOn ? "Turn camera off" : "Turn camera on" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: isPresenting ? "Stop sharing" : "Share screen" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onToggleViewMode, children: viewMode === "grid" ? "Focused view" : "Grid view" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: onLeave, children: "Leave" })] }));
31
+ // A menu closes on Escape or a press anywhere outside the bar.
32
+ useEffect(() => {
33
+ if (!openMenu) {
34
+ return;
35
+ }
36
+ const onPointerDown = (event) => {
37
+ if (!barRef.current?.contains(event.target)) {
38
+ setOpenMenu(null);
39
+ }
40
+ };
41
+ const onKeyDown = (event) => {
42
+ if (event.key === "Escape") {
43
+ setOpenMenu(null);
44
+ }
45
+ };
46
+ document.addEventListener("pointerdown", onPointerDown);
47
+ document.addEventListener("keydown", onKeyDown);
48
+ return () => {
49
+ document.removeEventListener("pointerdown", onPointerDown);
50
+ document.removeEventListener("keydown", onKeyDown);
51
+ };
52
+ }, [openMenu]);
53
+ function toggleMenu(menu) {
54
+ setOpenMenu((prev) => (prev === menu ? null : menu));
55
+ }
56
+ return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
57
+ onReaction(emoji);
58
+ setOpenMenu(null);
59
+ }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: onLeave, children: _jsx(LeaveIcon, {}) })] }));
60
+ }
61
+ /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
62
+ * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
63
+ function DeviceMenu({ kind, media, onClose }) {
64
+ const list = kind === "audio" ? media.devices.microphones : media.devices.cameras;
65
+ const noun = kind === "audio" ? "microphone" : "camera";
66
+ const selected = media.selectedDeviceIds[kind];
67
+ const denied = media.status[kind] === "denied";
68
+ return (_jsxs("div", { role: "menu", "aria-label": kind === "audio" ? "Microphones" : "Cameras", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 sm:left-0 sm:translate-x-0 w-max min-w-64 max-w-[calc(100vw-1rem)] p-1 rounded-lg bg-[#2b2d30] text-white text-sm shadow-xl", children: [list.map((device, index) => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": device.deviceId === selected, className: "flex w-full items-center gap-2 px-3 py-2 rounded-md text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
69
+ void media.selectDevice(kind, device.deviceId);
70
+ onClose();
71
+ }, children: [_jsx("span", { className: "w-4 text-[#8ab4f8]", "aria-hidden": "true", children: device.deviceId === selected ? "✓" : "" }), _jsx("span", { className: "truncate", children: device.label || `${noun[0].toUpperCase()}${noun.slice(1)} ${index + 1}` })] }, device.deviceId))), list.length === 0 && (_jsx("p", { className: "px-3 py-2 text-white/70", children: denied ? `Access to your ${noun} was blocked.` : `No ${noun} found.` })), (list.length === 0 || denied) && (_jsxs("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 rounded-md text-left text-[#8ab4f8] hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
72
+ void media.requestAccess(kind);
73
+ onClose();
74
+ }, children: ["Allow access to ", noun] }))] }));
6
75
  }
@@ -7,6 +7,21 @@
7
7
  * program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
8
8
  * identical `apps/shared/` convention for reusable frontend-only code.
9
9
  *
10
+ * ## Fitting the window
11
+ *
12
+ * The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
13
+ * is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
14
+ * the branded page shell, whose header, footer and padding pushed it off the screen. The local participant's own
15
+ * tile is a small tile in the bottom-right corner while anyone else is in the call (above the control bar on a narrower
16
+ * window, and at the top on a phone, where the bar wraps onto a second row), and fills the tile area while they are
17
+ * alone.
18
+ *
19
+ * ## Who is heard
20
+ *
21
+ * A tile never plays sound. Each remote stream is played by its own hidden `<audio>` element, so a participant is
22
+ * heard whether or not their camera is on and whichever layout is showing. If the browser refuses to start the
23
+ * audio (an autoplay policy), a banner asks for a click, which is the gesture that allows it.
24
+ *
10
25
  * ## Layout precedence
11
26
  *
12
27
  * `computeMainUid()` decides who is shown large, in this order: an active presenter always wins (presentation
@@ -16,6 +31,7 @@
16
31
  * empty main slot once someone else has joined.
17
32
  */
18
33
  import React from "react";
34
+ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
19
35
  export interface CallViewProps {
20
36
  channel: string;
21
37
  /** Omitted when the caller already authenticated as a real RapidMX identity in `join()` - there is then no
@@ -24,10 +40,10 @@ export interface CallViewProps {
24
40
  token?: string;
25
41
  selfUid: string;
26
42
  selfName: string;
43
+ meetingTitle: string;
27
44
  iceServers: RTCIceServer[];
28
- initialStream: MediaStream;
29
- initialMicOn: boolean;
30
- initialCameraOn: boolean;
45
+ /** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
46
+ media: LocalMedia;
31
47
  onLeave: () => void;
32
48
  }
33
49
  /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
@@ -38,4 +54,7 @@ export declare function computeMainUid(options: {
38
54
  activeSpeakerUid?: string;
39
55
  otherUids: readonly string[];
40
56
  }): string | undefined;
41
- export default function CallView({ channel, token, selfUid, selfName, iceServers, initialStream, initialMicOn, initialCameraOn, onLeave, }: CallViewProps): React.JSX.Element;
57
+ /** This tab's identity on the signaling channel: the account (or guest) uid plus a random suffix, so the same
58
+ * account joining from two devices - or two tabs - is two participants rather than one that ignores itself. */
59
+ export declare function newPeerId(selfUid: string): string;
60
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }: CallViewProps): React.JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
4
  // SPDX-License-Identifier: MPL-2.0
@@ -12,6 +12,21 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
12
12
  * program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
13
13
  * identical `apps/shared/` convention for reusable frontend-only code.
14
14
  *
15
+ * ## Fitting the window
16
+ *
17
+ * The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
18
+ * is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
19
+ * the branded page shell, whose header, footer and padding pushed it off the screen. The local participant's own
20
+ * tile is a small tile in the bottom-right corner while anyone else is in the call (above the control bar on a narrower
21
+ * window, and at the top on a phone, where the bar wraps onto a second row), and fills the tile area while they are
22
+ * alone.
23
+ *
24
+ * ## Who is heard
25
+ *
26
+ * A tile never plays sound. Each remote stream is played by its own hidden `<audio>` element, so a participant is
27
+ * heard whether or not their camera is on and whichever layout is showing. If the browser refuses to start the
28
+ * audio (an autoplay policy), a banner asks for a click, which is the gesture that allows it.
29
+ *
15
30
  * ## Layout precedence
16
31
  *
17
32
  * `computeMainUid()` decides who is shown large, in this order: an active presenter always wins (presentation
@@ -20,16 +35,20 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
20
35
  * slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
21
36
  * empty main slot once someone else has joined.
22
37
  */
23
- import { useEffect, useRef, useState } from "react";
38
+ import { useCallback, useEffect, useRef, useState } from "react";
39
+ import { playRaisedHandChime } from "../shared/media/chime.js";
24
40
  import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
25
41
  import { startLevelMeter } from "../shared/media/levelMeter.js";
26
- import { requestDisplayMedia, setTracksEnabled, stopStream } from "../shared/media/deviceMedia.js";
42
+ import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
27
43
  import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
28
44
  import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
29
45
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
30
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
46
  import CallControls from "./_CallControls.js";
32
47
  import ParticipantTile from "./_ParticipantTile.js";
48
+ /** How long a reaction floats on screen. */
49
+ const REACTION_MS = 4000;
50
+ /** The most reactions shown at once - a burst beyond this drops the oldest. */
51
+ const MAX_REACTIONS = 12;
33
52
  /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
34
53
  * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
35
54
  export function computeMainUid(options) {
@@ -44,36 +63,54 @@ export function computeMainUid(options) {
44
63
  }
45
64
  return options.otherUids[0];
46
65
  }
47
- export default function CallView({ channel, token, selfUid, selfName, iceServers, initialStream, initialMicOn, initialCameraOn, onLeave, }) {
66
+ /** This tab's identity on the signaling channel: the account (or guest) uid plus a random suffix, so the same
67
+ * account joining from two devices - or two tabs - is two participants rather than one that ignores itself. */
68
+ export function newPeerId(selfUid) {
69
+ return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
70
+ }
71
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }) {
72
+ const [peerId] = useState(() => newPeerId(selfUid));
48
73
  const [connectError, setConnectError] = useState(null);
49
74
  const [participants, setParticipants] = useState([]);
50
75
  const [remoteStreams, setRemoteStreams] = useState({});
51
76
  const [levels, setLevels] = useState({});
52
77
  const [presenterUid, setPresenterUid] = useState(undefined);
53
- const [micOn, setMicOn] = useState(initialMicOn);
54
- const [cameraOn, setCameraOn] = useState(initialCameraOn);
55
- const [isPresenting, setIsPresenting] = useState(false);
78
+ const [screenStream, setScreenStream] = useState(null);
79
+ const [handRaised, setHandRaised] = useState(false);
56
80
  const [viewMode, setViewMode] = useState("grid");
57
81
  const [pinnedUid, setPinnedUid] = useState(undefined);
58
82
  const [activeSpeakerUid, setActiveSpeakerUid] = useState(undefined);
83
+ const [reactions, setReactions] = useState([]);
84
+ const [announcement, setAnnouncement] = useState("");
85
+ const [audioBlocked, setAudioBlocked] = useState(false);
86
+ const [audioNonce, setAudioNonce] = useState(0);
59
87
  const managerRef = useRef(null);
60
- const clientRef = useRef(null);
61
- const localStreamRef = useRef(initialStream);
62
88
  const screenStreamRef = useRef(null);
63
- const cameraTrackRef = useRef(initialStream.getVideoTracks()[0] ?? null);
64
89
  const levelMetersRef = useRef({});
65
- const isPresentingRef = useRef(false);
90
+ const reactionSeqRef = useRef(0);
91
+ const reactionTimersRef = useRef(new Set());
92
+ const isPresenting = !!screenStream;
93
+ const showReaction = useCallback((emoji, name) => {
94
+ const id = ++reactionSeqRef.current;
95
+ setReactions((prev) => [...prev.slice(-(MAX_REACTIONS - 1)), { id, emoji, name }]);
96
+ const timer = setTimeout(() => {
97
+ reactionTimersRef.current.delete(timer);
98
+ setReactions((prev) => prev.filter((reaction) => reaction.id !== id));
99
+ }, REACTION_MS);
100
+ reactionTimersRef.current.add(timer);
101
+ }, []);
66
102
  useEffect(() => {
67
103
  let cancelled = false;
68
104
  const client = new GuestSignalingClient({ channel, token });
69
- clientRef.current = client;
70
105
  const manager = new MeshConnectionManager({
71
- selfUid,
106
+ selfUid: peerId,
72
107
  selfName,
73
108
  iceServers,
74
109
  channel: client,
75
110
  createPeerConnection: createBrowserPeerConnection,
76
- localStream: localStreamRef.current,
111
+ localAudioTrack: media.audioTrack,
112
+ localVideoTrack: media.videoTrack,
113
+ localState: { audioOn: media.micOn, videoOn: media.cameraOn },
77
114
  });
78
115
  managerRef.current = manager;
79
116
  const unsubscribe = manager.onEvent((event) => {
@@ -81,6 +118,9 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
81
118
  case "participant-joined":
82
119
  setParticipants((prev) => [...prev.filter((p) => p.uid !== event.participant.uid), event.participant]);
83
120
  return;
121
+ case "participant-updated":
122
+ setParticipants((prev) => prev.map((p) => (p.uid === event.participant.uid ? event.participant : p)));
123
+ return;
84
124
  case "participant-left":
85
125
  setParticipants((prev) => prev.filter((p) => p.uid !== event.uid));
86
126
  setRemoteStreams((prev) => {
@@ -98,9 +138,16 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
98
138
  setRemoteStreams((prev) => ({ ...prev, [event.uid]: event.stream }));
99
139
  startTrackingLevel(levelMetersRef, event.uid, event.stream, (level) => setLevels((prev) => ({ ...prev, [event.uid]: level })));
100
140
  return;
141
+ case "hand-raised":
142
+ playRaisedHandChime();
143
+ setAnnouncement(`${event.name} raised a hand`);
144
+ return;
145
+ case "reaction":
146
+ showReaction(event.emoji, event.name);
147
+ return;
101
148
  case "presenter-changed":
102
149
  setPresenterUid(event.uid);
103
- if (event.uid !== selfUid && isPresentingRef.current) {
150
+ if (event.uid !== peerId && screenStreamRef.current) {
104
151
  // Lost a presenter-claim collision (see `MeshConnectionManager`'s doc comment) - stop our
105
152
  // own capture without re-sending a release the manager already handled internally.
106
153
  stopLocalPresentation(false);
@@ -120,8 +167,14 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
120
167
  setConnectError(err.message);
121
168
  }
122
169
  });
170
+ // A closing tab doesn't unmount React, so say goodbye explicitly - otherwise everyone else keeps a tile for a
171
+ // participant who is gone.
172
+ const onPageHide = () => manager.stop();
173
+ window.addEventListener("pagehide", onPageHide);
174
+ const timers = reactionTimersRef.current;
123
175
  return () => {
124
176
  cancelled = true;
177
+ window.removeEventListener("pagehide", onPageHide);
125
178
  unsubscribe();
126
179
  manager.stop();
127
180
  client.close();
@@ -129,32 +182,43 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
129
182
  handle.stop();
130
183
  }
131
184
  levelMetersRef.current = {};
132
- stopStream(localStreamRef.current);
185
+ // Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
186
+ // releases them when the participant leaves.
133
187
  stopStream(screenStreamRef.current);
188
+ for (const timer of timers) {
189
+ clearTimeout(timer);
190
+ }
191
+ timers.clear();
134
192
  };
135
193
  // Deliberately runs once - the call's identity (channel/token/selfUid) never changes for the life of this
136
194
  // component; a real identity change is a new call, which unmounts/remounts this view from `[token].tsx`.
137
195
  }, []);
196
+ // What is sent follows what the participant has: the camera or the shared screen, and the microphone.
197
+ useEffect(() => {
198
+ managerRef.current?.setLocalTrack("audio", media.audioTrack);
199
+ }, [media.audioTrack]);
200
+ useEffect(() => {
201
+ managerRef.current?.setLocalTrack("video", screenStream?.getVideoTracks()[0] ?? media.videoTrack);
202
+ }, [media.videoTrack, screenStream]);
203
+ useEffect(() => {
204
+ managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
205
+ }, [media.micOn, media.cameraOn, isPresenting, handRaised]);
138
206
  useEffect(() => {
139
207
  if (!presenterUid) {
140
208
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
141
209
  }
142
210
  }, [levels, presenterUid]);
143
211
  function stopLocalPresentation(alsoRelease) {
144
- isPresentingRef.current = false;
145
- setIsPresenting(false);
146
212
  if (alsoRelease) {
147
213
  managerRef.current?.releasePresenter();
148
214
  }
149
215
  stopStream(screenStreamRef.current);
150
216
  screenStreamRef.current = null;
151
- // Restores the camera track as the outgoing video sender - its own `enabled` flag (toggled by
152
- // `handleToggleCamera`, untouched by presenting) already governs whether it actually sends frames, so
153
- // this is correct whether or not the camera happens to be off right now.
154
- managerRef.current?.replaceLocalVideoTrack(cameraTrackRef.current);
217
+ // The sync effect above swaps the camera back in as the outgoing video track.
218
+ setScreenStream(null);
155
219
  }
156
220
  async function handleToggleShare() {
157
- if (isPresenting) {
221
+ if (screenStreamRef.current) {
158
222
  stopLocalPresentation(true);
159
223
  return;
160
224
  }
@@ -169,48 +233,67 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
169
233
  return;
170
234
  }
171
235
  screenStreamRef.current = result.value;
172
- const [screenTrack] = result.value.getVideoTracks();
173
- managerRef.current?.replaceLocalVideoTrack(screenTrack);
174
- isPresentingRef.current = true;
175
- setIsPresenting(true);
176
- screenTrack.onended = () => stopLocalPresentation(true);
177
- }
178
- function handleToggleMic() {
179
- setMicOn((prev) => {
180
- const next = !prev;
181
- setTracksEnabled(localStreamRef.current, "audio", next);
182
- return next;
183
- });
236
+ setScreenStream(result.value);
237
+ result.value.getVideoTracks()[0].onended = () => stopLocalPresentation(true);
184
238
  }
185
- function handleToggleCamera() {
186
- setCameraOn((prev) => {
187
- const next = !prev;
188
- // Just the track's own `enabled` flag (see `deviceMedia.ts`'s `setTracksEnabled()` doc comment) -
189
- // whether that track is actually the one being sent (camera) or has been swapped out for a screen
190
- // share (`replaceLocalVideoTrack()`) is an orthogonal concern this toggle never touches.
191
- setTracksEnabled(localStreamRef.current, "video", next);
192
- return next;
193
- });
239
+ function handleToggleHand() {
240
+ setHandRaised((prev) => !prev);
194
241
  }
195
- function handleLeave() {
196
- onLeave();
242
+ function handleReaction(emoji) {
243
+ if (managerRef.current?.sendReaction(emoji)) {
244
+ showReaction(emoji, "You");
245
+ }
197
246
  }
198
247
  function togglePin(uid) {
199
248
  setPinnedUid((prev) => (prev === uid ? undefined : uid));
200
249
  }
250
+ function handleAudioBlocked() {
251
+ setAudioBlocked(true);
252
+ }
253
+ function handleResumeAudio() {
254
+ setAudioBlocked(false);
255
+ setAudioNonce((prev) => prev + 1);
256
+ }
201
257
  const otherUids = participants.map((p) => p.uid);
258
+ // A pin or an active-speaker pick can name someone who has just left, until the state catches up.
259
+ const stillHere = (uid) => (uid && otherUids.includes(uid) ? uid : undefined);
202
260
  const showFocusLayout = !!presenterUid || viewMode === "focus";
203
- const mainUid = showFocusLayout ? computeMainUid({ presenterUid, pinnedUid, activeSpeakerUid, otherUids }) : undefined;
204
- const presenterName = presenterUid ? (presenterUid === selfUid ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
205
- const localTile = (_jsx(ParticipantTile, { name: selfName, stream: isPresenting ? null : localStreamRef.current, isLocal: true, cameraOff: !cameraOn && !isPresenting, micMuted: !micOn, isFocused: mainUid === selfUid, onClick: () => togglePin(selfUid) }, "__self"));
206
- const remoteTiles = participants.map((p) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !remoteStreams[p.uid], isFocused: mainUid === p.uid, onClick: () => togglePin(p.uid) }, p.uid)));
207
- const allTiles = [localTile, ...remoteTiles];
208
- // The presenter's own screen content is rendered from their local capture, not their (replaced) outgoing
209
- // video sender - `mainStream` picks whichever is right for `mainUid`.
210
- const mainStream = mainUid === selfUid ? (isPresenting ? screenStreamRef.current : localStreamRef.current) : mainUid ? (remoteStreams[mainUid] ?? null) : null;
211
- const mainName = mainUid === selfUid ? selfName : (participants.find((p) => p.uid === mainUid)?.name ?? "");
212
- const thumbnailTiles = showFocusLayout ? allTiles.filter((tile) => tile.key !== (mainUid === selfUid ? "__self" : mainUid)) : allTiles;
213
- return (_jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [connectError && _jsx(Alert, { children: connectError }), _jsx("div", { className: "flex-1 p-3 flex flex-col gap-3 min-h-0", children: showFocusLayout && mainUid ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "flex-1 min-h-0", children: _jsx(ParticipantTile, { name: mainName, stream: mainStream, isLocal: mainUid === selfUid, isFocused: true, className: "h-full" }) }), _jsx("div", { className: "flex gap-2 overflow-x-auto h-24 shrink-0", children: thumbnailTiles.map((tile) => (_jsx("div", { className: "w-32 shrink-0", children: tile }, tile.key))) })] })) : (_jsx("div", { className: "flex-1 grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]", children: allTiles })) }), _jsx(CallControls, { micOn: micOn, onToggleMic: handleToggleMic, cameraOn: cameraOn, onToggleCamera: handleToggleCamera, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== selfUid ? presenterName : undefined, onToggleShare: handleToggleShare, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), onLeave: handleLeave })] }));
261
+ const mainUid = showFocusLayout
262
+ ? computeMainUid({ presenterUid, pinnedUid: stillHere(pinnedUid), activeSpeakerUid: stillHere(activeSpeakerUid), otherUids })
263
+ : undefined;
264
+ const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
265
+ const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
266
+ const alone = participants.length === 0;
267
+ const remoteTile = (p, className) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !p.videoOn, micMuted: !p.audioOn, handRaised: p.handRaised, isFocused: mainUid === p.uid, contain: presenterUid === p.uid, className: className, onClick: () => togglePin(p.uid) }, p.uid));
268
+ const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, className: className }));
269
+ const mainParticipant = participants.find((p) => p.uid === mainUid);
270
+ const thumbnails = participants.filter((p) => p.uid !== mainUid);
271
+ let stage;
272
+ if (alone) {
273
+ // Nobody else yet: the local participant fills the tile area, presenting or not.
274
+ stage = isPresenting ? (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) : (selfTile("h-full"));
275
+ }
276
+ else if (showFocusLayout && (mainParticipant || isPresenting)) {
277
+ stage = (_jsxs("div", { className: "h-full flex flex-col gap-2", children: [_jsx("div", { className: "flex-1 min-h-0", "data-testid": "main-tile", children: mainParticipant ? (remoteTile(mainParticipant, "h-full")) : (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) }), thumbnails.length > 0 && (_jsx("div", { className: "flex gap-2 overflow-x-auto h-24 shrink-0", "data-testid": "thumbnails", children: thumbnails.map((p) => (_jsx("div", { className: "w-36 shrink-0", children: remoteTile(p, "h-full") }, p.uid))) }))] }));
278
+ }
279
+ else {
280
+ stage = (_jsx("div", { className: "h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]", children: participants.map((p) => remoteTile(p, "h-full")) }));
281
+ }
282
+ return (_jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })), _jsx("span", { className: "px-3 py-1.5 rounded-full bg-[#3c4043] text-sm", "aria-label": `${participants.length + 1} participants`, children: participants.length + 1 })] })] }), connectError && (_jsx("div", { role: "alert", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm", children: connectError })), audioBlocked && (_jsx("button", { type: "button", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", onClick: handleResumeAudio, children: "Click here to turn on sound" })), _jsxs("main", { className: "relative flex-1 min-h-0 px-3 pb-3", children: [stage, presenterName && presenterUid !== peerId && (_jsxs("p", { className: "absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm", children: [presenterName, " is presenting"] }))] }), _jsx("footer", { className: "shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: _jsx(CallControls, { media: media, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), onLeave: onLeave }) }), !alone && (_jsx("div", { className: "absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4", "data-testid": "self-view", children: selfTile("h-full") })), _jsx("div", { className: "pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]", "aria-hidden": "true", children: reactions.map((reaction) => (_jsxs("div", { className: "absolute bottom-0 flex flex-col items-center", style: { left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }, "data-testid": "reaction", children: [_jsx("span", { className: "text-4xl leading-none", children: reaction.emoji }), _jsx("span", { className: "mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs", children: reaction.name })] }, reaction.id))) }), participants.map((p) => {
283
+ const stream = remoteStreams[p.uid];
284
+ return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
285
+ }), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })] }));
286
+ }
287
+ /** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
288
+ * refuses to start it (an autoplay policy) - the view then asks for a click. */
289
+ function RemoteAudio({ stream, onBlocked }) {
290
+ const audioRef = useRef(null);
291
+ useEffect(() => {
292
+ const element = audioRef.current;
293
+ element.srcObject = stream;
294
+ void Promise.resolve(element.play()).catch(onBlocked);
295
+ }, [stream, onBlocked]);
296
+ return _jsx("audio", { ref: audioRef, autoPlay: true, "data-testid": "remote-audio" });
214
297
  }
215
298
  function stopLevelMeter(ref, uid) {
216
299
  ref.current[uid]?.stop();
@@ -1,29 +1,26 @@
1
1
  /**
2
- * The join/lobby step: name entry, device preview and pickers, mute/camera-off starting preferences, and the
3
- * "Join" button. Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled*
4
- * when a session is genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment
5
- * on why that never actually happens on this page today).
2
+ * The join/lobby step: name entry, a preview of the camera, the microphone and camera on/off buttons and pickers,
3
+ * and the "Join" button. The camera and microphone are `LocalMedia` (`apps/shared/media/useLocalMedia.ts`), owned by
4
+ * the page (`[token].tsx`) rather than by this component - the tracks previewed here are the tracks the call sends,
5
+ * so leaving the lobby for the call must not (and does not) stop them.
6
6
  *
7
- * Device support/permission errors (`navigator.mediaDevices` missing entirely - e.g. a non-HTTPS context in some
8
- * browsers - a denied prompt, or no camera/microphone at all) are shown as a plain message rather than crashing;
9
- * joining is still allowed with no local media in that case (a signaling-only participant - see this plugin's
10
- * Phase 2 report's known-limitations note on why that participant's peer connections can carry no media at all
11
- * without this design's simpler, renegotiation-free mesh needing to grow one).
7
+ * Access is requested when the lobby opens, and again from the "Allow" / "Try again" button: a click is a user
8
+ * gesture, which some browsers require before they will show a permission prompt at all. When access is refused, or
9
+ * there is no camera or microphone, the lobby says so and why, and joining is still allowed - a participant with
10
+ * nothing to send can still see and hear the call, and turn a camera or microphone on later from the call controls.
11
+ *
12
+ * Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled* when a session is
13
+ * genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment on why that never
14
+ * actually happens on this page today).
12
15
  */
13
16
  import React from "react";
17
+ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
14
18
  import type { PublicVideoMeeting } from "./_meetApi.js";
15
- export interface JoinPreferences {
16
- name: string;
17
- micOn: boolean;
18
- cameraOn: boolean;
19
- /** Never `null` - an empty `MediaStream` when no device access was ever obtained, so the call view always has
20
- * a stream to work with. */
21
- stream: MediaStream;
22
- }
23
19
  export interface MeetLobbyProps {
24
20
  meeting: PublicVideoMeeting;
21
+ media: LocalMedia;
25
22
  /** Prefilled from `Profile.givenName` when a session was detected - still always editable. */
26
23
  initialName?: string;
27
- onJoin: (preferences: JoinPreferences) => void;
24
+ onJoin: (name: string) => void;
28
25
  }
29
- export default function MeetLobby({ meeting, initialName, onJoin }: MeetLobbyProps): React.JSX.Element;
26
+ export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetLobbyProps): React.JSX.Element;