@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.
- package/apps/meet/[token].tsx +51 -46
- package/apps/meet/_CallControls.tsx +255 -32
- package/apps/meet/_CallView.tsx +275 -115
- package/apps/meet/_MeetLobby.tsx +111 -142
- package/apps/meet/_ParticipantTile.tsx +51 -19
- package/apps/meet/_icons.tsx +54 -0
- package/apps/shared/media/chime.ts +74 -0
- package/apps/shared/media/deviceMedia.ts +3 -13
- package/apps/shared/media/useLocalMedia.ts +337 -0
- package/apps/shared/push/GuestSignalingClient.ts +2 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +212 -44
- package/apps/shared/webrtc/realPeerConnection.ts +16 -3
- package/apps/shared/webrtc/types.ts +52 -15
- package/dist/apps/meet/[token].js +31 -19
- package/dist/apps/meet/_CallControls.d.ts +20 -7
- package/dist/apps/meet/_CallControls.js +72 -3
- package/dist/apps/meet/_CallView.d.ts +23 -4
- package/dist/apps/meet/_CallView.js +141 -58
- package/dist/apps/meet/_MeetLobby.d.ts +16 -19
- package/dist/apps/meet/_MeetLobby.js +49 -99
- package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
- package/dist/apps/meet/_ParticipantTile.js +12 -9
- package/dist/apps/meet/_icons.d.ts +15 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/shared/media/chime.d.ts +31 -0
- package/dist/apps/shared/media/chime.js +49 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
- package/dist/apps/shared/media/deviceMedia.js +3 -12
- package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
- package/dist/apps/shared/media/useLocalMedia.js +259 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +57 -17
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
- package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
- package/dist/apps/shared/webrtc/types.d.ts +52 -15
- package/dist/apps/shared/webrtc/types.js +3 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
- package/package.json +181 -181
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 [
|
|
54
|
-
const [
|
|
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
|
|
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
|
-
|
|
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 !==
|
|
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
|
-
|
|
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
|
-
//
|
|
152
|
-
|
|
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 (
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
186
|
-
|
|
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
|
|
196
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
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,
|
|
3
|
-
* "Join" button.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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: (
|
|
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;
|