@rapidmx/meet-plugin 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/meet/[token].tsx +51 -46
- package/apps/meet/_CallControls.tsx +255 -32
- package/apps/meet/_CallView.tsx +276 -114
- 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 +237 -55
- package/apps/shared/webrtc/realPeerConnection.ts +16 -3
- package/apps/shared/webrtc/types.ts +58 -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 +24 -4
- package/dist/apps/meet/_CallView.js +142 -57
- 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 +63 -18
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +232 -42
- package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
- package/dist/apps/shared/webrtc/types.d.ts +58 -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
|
@@ -33,20 +33,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
33
33
|
import React, { useState } from "react";
|
|
34
34
|
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
35
35
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
36
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
36
37
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
37
38
|
import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
|
|
38
|
-
import {
|
|
39
|
+
import { useLocalMedia } from "../shared/media/useLocalMedia.js";
|
|
40
|
+
import MeetLobby from "./_MeetLobby.js";
|
|
39
41
|
import CallView from "./_CallView.js";
|
|
40
42
|
import { joinMeeting } from "./_meetApi.js";
|
|
41
43
|
export default function MeetJoinPage({ params }) {
|
|
42
44
|
const { branding } = useBranding();
|
|
43
|
-
return
|
|
45
|
+
return _jsx(MeetJoinContent, { token: params.token, branding: branding });
|
|
44
46
|
}
|
|
45
|
-
|
|
47
|
+
/** Owns the camera and microphone (`useLocalMedia()`) for the whole visit, so the tracks the lobby previews are the
|
|
48
|
+
* ones the call sends. Every phase but the call is drawn inside the branded page shell; the call fills the whole
|
|
49
|
+
* window instead (see `_CallView.tsx`). */
|
|
50
|
+
function MeetJoinContent({ token, branding }) {
|
|
46
51
|
const [phase, setPhase] = useState("loading");
|
|
47
52
|
const [joinResult, setJoinResult] = useState(null);
|
|
48
|
-
const [
|
|
53
|
+
const [name, setName] = useState("");
|
|
49
54
|
const [loadError, setLoadError] = useState(null);
|
|
55
|
+
const media = useLocalMedia();
|
|
56
|
+
const { release } = media;
|
|
50
57
|
React.useEffect(() => {
|
|
51
58
|
let cancelled = false;
|
|
52
59
|
joinMeeting(token)
|
|
@@ -71,31 +78,36 @@ function MeetJoinContent({ token }) {
|
|
|
71
78
|
cancelled = true;
|
|
72
79
|
};
|
|
73
80
|
}, [token]);
|
|
74
|
-
function handleJoin(
|
|
75
|
-
|
|
81
|
+
function handleJoin(joinName) {
|
|
82
|
+
setName(joinName);
|
|
76
83
|
setPhase("in-call");
|
|
77
84
|
}
|
|
78
85
|
function handleLeave() {
|
|
86
|
+
release();
|
|
79
87
|
setPhase("ended");
|
|
80
88
|
}
|
|
81
|
-
if (phase === "
|
|
82
|
-
return (_jsx(MeetCard, { children: _jsx("p", { className: "text-base text-text-muted", children: "Loading\u2026" }) }));
|
|
83
|
-
}
|
|
84
|
-
if (phase === "not-found") {
|
|
85
|
-
return (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
|
|
86
|
-
}
|
|
87
|
-
if (phase === "ended") {
|
|
88
|
-
return (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted", children: "You can close this page now." })] }));
|
|
89
|
-
}
|
|
90
|
-
if (phase === "in-call" && joinResult && preferences) {
|
|
89
|
+
if (phase === "in-call" && joinResult) {
|
|
91
90
|
return (_jsx(CallView, { channel: joinResult.meeting.uid,
|
|
92
91
|
// `token` is present only for the common anonymous case (`authenticated: false`) - when the
|
|
93
92
|
// caller's own existing session already authenticated them (`authenticated: true`), there is no
|
|
94
93
|
// guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
|
|
95
94
|
// browser's own already-existing `jwt` session cookie instead (see its own doc comment and
|
|
96
95
|
// `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
|
|
97
|
-
token: joinResult.token, selfUid: joinResult.selfUid, selfName:
|
|
96
|
+
token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, media: media, onLeave: handleLeave }));
|
|
97
|
+
}
|
|
98
|
+
let content;
|
|
99
|
+
if (phase === "loading") {
|
|
100
|
+
content = (_jsx(MeetCard, { children: _jsx("p", { className: "text-base text-text-muted", children: "Loading\u2026" }) }));
|
|
101
|
+
}
|
|
102
|
+
else if (phase === "not-found") {
|
|
103
|
+
content = (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
|
|
104
|
+
}
|
|
105
|
+
else if (phase === "ended") {
|
|
106
|
+
content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted mb-4", children: "You can close this page now, or rejoin." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => setPhase("lobby"), children: "Rejoin meeting" })] }));
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// phase === "lobby" (joinResult is always set by then - see the effect above).
|
|
110
|
+
content = (_jsx(MeetCard, { maxWidth: "max-w-4xl", children: _jsx(MeetLobby, { meeting: joinResult.meeting, media: media, initialName: name, onJoin: handleJoin }) }));
|
|
98
111
|
}
|
|
99
|
-
|
|
100
|
-
return (_jsx(MeetCard, { maxWidth: "max-w-4xl", children: _jsx(MeetLobby, { meeting: joinResult.meeting, onJoin: handleJoin }) }));
|
|
112
|
+
return _jsx(MeetPageShell, { branding: branding, children: content });
|
|
101
113
|
}
|
|
@@ -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,8 @@ export declare function computeMainUid(options: {
|
|
|
38
54
|
activeSpeakerUid?: string;
|
|
39
55
|
otherUids: readonly string[];
|
|
40
56
|
}): string | undefined;
|
|
41
|
-
|
|
57
|
+
/** This tab's identity in the call: the account (or guest) uid plus a random suffix, so the same account joining
|
|
58
|
+
* from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
|
|
59
|
+
* `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
|
|
60
|
+
export declare function newPeerId(selfUid: string): string;
|
|
61
|
+
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,56 @@ export function computeMainUid(options) {
|
|
|
44
63
|
}
|
|
45
64
|
return options.otherUids[0];
|
|
46
65
|
}
|
|
47
|
-
|
|
66
|
+
/** This tab's identity in the call: the account (or guest) uid plus a random suffix, so the same account joining
|
|
67
|
+
* from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
|
|
68
|
+
* `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
|
|
69
|
+
export function newPeerId(selfUid) {
|
|
70
|
+
return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
71
|
+
}
|
|
72
|
+
export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }) {
|
|
73
|
+
const [peerId] = useState(() => newPeerId(selfUid));
|
|
48
74
|
const [connectError, setConnectError] = useState(null);
|
|
49
75
|
const [participants, setParticipants] = useState([]);
|
|
50
76
|
const [remoteStreams, setRemoteStreams] = useState({});
|
|
51
77
|
const [levels, setLevels] = useState({});
|
|
52
78
|
const [presenterUid, setPresenterUid] = useState(undefined);
|
|
53
|
-
const [
|
|
54
|
-
const [
|
|
55
|
-
const [isPresenting, setIsPresenting] = useState(false);
|
|
79
|
+
const [screenStream, setScreenStream] = useState(null);
|
|
80
|
+
const [handRaised, setHandRaised] = useState(false);
|
|
56
81
|
const [viewMode, setViewMode] = useState("grid");
|
|
57
82
|
const [pinnedUid, setPinnedUid] = useState(undefined);
|
|
58
83
|
const [activeSpeakerUid, setActiveSpeakerUid] = useState(undefined);
|
|
84
|
+
const [reactions, setReactions] = useState([]);
|
|
85
|
+
const [announcement, setAnnouncement] = useState("");
|
|
86
|
+
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
87
|
+
const [audioNonce, setAudioNonce] = useState(0);
|
|
59
88
|
const managerRef = useRef(null);
|
|
60
|
-
const clientRef = useRef(null);
|
|
61
|
-
const localStreamRef = useRef(initialStream);
|
|
62
89
|
const screenStreamRef = useRef(null);
|
|
63
|
-
const cameraTrackRef = useRef(initialStream.getVideoTracks()[0] ?? null);
|
|
64
90
|
const levelMetersRef = useRef({});
|
|
65
|
-
const
|
|
91
|
+
const reactionSeqRef = useRef(0);
|
|
92
|
+
const reactionTimersRef = useRef(new Set());
|
|
93
|
+
const isPresenting = !!screenStream;
|
|
94
|
+
const showReaction = useCallback((emoji, name) => {
|
|
95
|
+
const id = ++reactionSeqRef.current;
|
|
96
|
+
setReactions((prev) => [...prev.slice(-(MAX_REACTIONS - 1)), { id, emoji, name }]);
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
reactionTimersRef.current.delete(timer);
|
|
99
|
+
setReactions((prev) => prev.filter((reaction) => reaction.id !== id));
|
|
100
|
+
}, REACTION_MS);
|
|
101
|
+
reactionTimersRef.current.add(timer);
|
|
102
|
+
}, []);
|
|
66
103
|
useEffect(() => {
|
|
67
104
|
let cancelled = false;
|
|
68
105
|
const client = new GuestSignalingClient({ channel, token });
|
|
69
|
-
clientRef.current = client;
|
|
70
106
|
const manager = new MeshConnectionManager({
|
|
71
107
|
selfUid,
|
|
108
|
+
peerId,
|
|
72
109
|
selfName,
|
|
73
110
|
iceServers,
|
|
74
111
|
channel: client,
|
|
75
112
|
createPeerConnection: createBrowserPeerConnection,
|
|
76
|
-
|
|
113
|
+
localAudioTrack: media.audioTrack,
|
|
114
|
+
localVideoTrack: media.videoTrack,
|
|
115
|
+
localState: { audioOn: media.micOn, videoOn: media.cameraOn },
|
|
77
116
|
});
|
|
78
117
|
managerRef.current = manager;
|
|
79
118
|
const unsubscribe = manager.onEvent((event) => {
|
|
@@ -81,6 +120,9 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
|
|
|
81
120
|
case "participant-joined":
|
|
82
121
|
setParticipants((prev) => [...prev.filter((p) => p.uid !== event.participant.uid), event.participant]);
|
|
83
122
|
return;
|
|
123
|
+
case "participant-updated":
|
|
124
|
+
setParticipants((prev) => prev.map((p) => (p.uid === event.participant.uid ? event.participant : p)));
|
|
125
|
+
return;
|
|
84
126
|
case "participant-left":
|
|
85
127
|
setParticipants((prev) => prev.filter((p) => p.uid !== event.uid));
|
|
86
128
|
setRemoteStreams((prev) => {
|
|
@@ -98,9 +140,16 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
|
|
|
98
140
|
setRemoteStreams((prev) => ({ ...prev, [event.uid]: event.stream }));
|
|
99
141
|
startTrackingLevel(levelMetersRef, event.uid, event.stream, (level) => setLevels((prev) => ({ ...prev, [event.uid]: level })));
|
|
100
142
|
return;
|
|
143
|
+
case "hand-raised":
|
|
144
|
+
playRaisedHandChime();
|
|
145
|
+
setAnnouncement(`${event.name} raised a hand`);
|
|
146
|
+
return;
|
|
147
|
+
case "reaction":
|
|
148
|
+
showReaction(event.emoji, event.name);
|
|
149
|
+
return;
|
|
101
150
|
case "presenter-changed":
|
|
102
151
|
setPresenterUid(event.uid);
|
|
103
|
-
if (event.uid !==
|
|
152
|
+
if (event.uid !== peerId && screenStreamRef.current) {
|
|
104
153
|
// Lost a presenter-claim collision (see `MeshConnectionManager`'s doc comment) - stop our
|
|
105
154
|
// own capture without re-sending a release the manager already handled internally.
|
|
106
155
|
stopLocalPresentation(false);
|
|
@@ -120,8 +169,14 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
|
|
|
120
169
|
setConnectError(err.message);
|
|
121
170
|
}
|
|
122
171
|
});
|
|
172
|
+
// A closing tab doesn't unmount React, so say goodbye explicitly - otherwise everyone else keeps a tile for a
|
|
173
|
+
// participant who is gone.
|
|
174
|
+
const onPageHide = () => manager.stop();
|
|
175
|
+
window.addEventListener("pagehide", onPageHide);
|
|
176
|
+
const timers = reactionTimersRef.current;
|
|
123
177
|
return () => {
|
|
124
178
|
cancelled = true;
|
|
179
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
125
180
|
unsubscribe();
|
|
126
181
|
manager.stop();
|
|
127
182
|
client.close();
|
|
@@ -129,32 +184,43 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
|
|
|
129
184
|
handle.stop();
|
|
130
185
|
}
|
|
131
186
|
levelMetersRef.current = {};
|
|
132
|
-
|
|
187
|
+
// Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
|
|
188
|
+
// releases them when the participant leaves.
|
|
133
189
|
stopStream(screenStreamRef.current);
|
|
190
|
+
for (const timer of timers) {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
}
|
|
193
|
+
timers.clear();
|
|
134
194
|
};
|
|
135
195
|
// Deliberately runs once - the call's identity (channel/token/selfUid) never changes for the life of this
|
|
136
196
|
// component; a real identity change is a new call, which unmounts/remounts this view from `[token].tsx`.
|
|
137
197
|
}, []);
|
|
198
|
+
// What is sent follows what the participant has: the camera or the shared screen, and the microphone.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
managerRef.current?.setLocalTrack("audio", media.audioTrack);
|
|
201
|
+
}, [media.audioTrack]);
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
managerRef.current?.setLocalTrack("video", screenStream?.getVideoTracks()[0] ?? media.videoTrack);
|
|
204
|
+
}, [media.videoTrack, screenStream]);
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
|
|
207
|
+
}, [media.micOn, media.cameraOn, isPresenting, handRaised]);
|
|
138
208
|
useEffect(() => {
|
|
139
209
|
if (!presenterUid) {
|
|
140
210
|
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
141
211
|
}
|
|
142
212
|
}, [levels, presenterUid]);
|
|
143
213
|
function stopLocalPresentation(alsoRelease) {
|
|
144
|
-
isPresentingRef.current = false;
|
|
145
|
-
setIsPresenting(false);
|
|
146
214
|
if (alsoRelease) {
|
|
147
215
|
managerRef.current?.releasePresenter();
|
|
148
216
|
}
|
|
149
217
|
stopStream(screenStreamRef.current);
|
|
150
218
|
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);
|
|
219
|
+
// The sync effect above swaps the camera back in as the outgoing video track.
|
|
220
|
+
setScreenStream(null);
|
|
155
221
|
}
|
|
156
222
|
async function handleToggleShare() {
|
|
157
|
-
if (
|
|
223
|
+
if (screenStreamRef.current) {
|
|
158
224
|
stopLocalPresentation(true);
|
|
159
225
|
return;
|
|
160
226
|
}
|
|
@@ -169,48 +235,67 @@ export default function CallView({ channel, token, selfUid, selfName, iceServers
|
|
|
169
235
|
return;
|
|
170
236
|
}
|
|
171
237
|
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
|
-
});
|
|
238
|
+
setScreenStream(result.value);
|
|
239
|
+
result.value.getVideoTracks()[0].onended = () => stopLocalPresentation(true);
|
|
184
240
|
}
|
|
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
|
-
});
|
|
241
|
+
function handleToggleHand() {
|
|
242
|
+
setHandRaised((prev) => !prev);
|
|
194
243
|
}
|
|
195
|
-
function
|
|
196
|
-
|
|
244
|
+
function handleReaction(emoji) {
|
|
245
|
+
if (managerRef.current?.sendReaction(emoji)) {
|
|
246
|
+
showReaction(emoji, "You");
|
|
247
|
+
}
|
|
197
248
|
}
|
|
198
249
|
function togglePin(uid) {
|
|
199
250
|
setPinnedUid((prev) => (prev === uid ? undefined : uid));
|
|
200
251
|
}
|
|
252
|
+
function handleAudioBlocked() {
|
|
253
|
+
setAudioBlocked(true);
|
|
254
|
+
}
|
|
255
|
+
function handleResumeAudio() {
|
|
256
|
+
setAudioBlocked(false);
|
|
257
|
+
setAudioNonce((prev) => prev + 1);
|
|
258
|
+
}
|
|
201
259
|
const otherUids = participants.map((p) => p.uid);
|
|
260
|
+
// A pin or an active-speaker pick can name someone who has just left, until the state catches up.
|
|
261
|
+
const stillHere = (uid) => (uid && otherUids.includes(uid) ? uid : undefined);
|
|
202
262
|
const showFocusLayout = !!presenterUid || viewMode === "focus";
|
|
203
|
-
const mainUid = showFocusLayout
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
263
|
+
const mainUid = showFocusLayout
|
|
264
|
+
? computeMainUid({ presenterUid, pinnedUid: stillHere(pinnedUid), activeSpeakerUid: stillHere(activeSpeakerUid), otherUids })
|
|
265
|
+
: undefined;
|
|
266
|
+
const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
|
|
267
|
+
const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
|
|
268
|
+
const alone = participants.length === 0;
|
|
269
|
+
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));
|
|
270
|
+
const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, className: className }));
|
|
271
|
+
const mainParticipant = participants.find((p) => p.uid === mainUid);
|
|
272
|
+
const thumbnails = participants.filter((p) => p.uid !== mainUid);
|
|
273
|
+
let stage;
|
|
274
|
+
if (alone) {
|
|
275
|
+
// Nobody else yet: the local participant fills the tile area, presenting or not.
|
|
276
|
+
stage = isPresenting ? (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) : (selfTile("h-full"));
|
|
277
|
+
}
|
|
278
|
+
else if (showFocusLayout && (mainParticipant || isPresenting)) {
|
|
279
|
+
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))) }))] }));
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
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")) }));
|
|
283
|
+
}
|
|
284
|
+
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) => {
|
|
285
|
+
const stream = remoteStreams[p.uid];
|
|
286
|
+
return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
|
|
287
|
+
}), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })] }));
|
|
288
|
+
}
|
|
289
|
+
/** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
|
|
290
|
+
* refuses to start it (an autoplay policy) - the view then asks for a click. */
|
|
291
|
+
function RemoteAudio({ stream, onBlocked }) {
|
|
292
|
+
const audioRef = useRef(null);
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
const element = audioRef.current;
|
|
295
|
+
element.srcObject = stream;
|
|
296
|
+
void Promise.resolve(element.play()).catch(onBlocked);
|
|
297
|
+
}, [stream, onBlocked]);
|
|
298
|
+
return _jsx("audio", { ref: audioRef, autoPlay: true, "data-testid": "remote-audio" });
|
|
214
299
|
}
|
|
215
300
|
function stopLevelMeter(ref, uid) {
|
|
216
301
|
ref.current[uid]?.stop();
|