@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.
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 +276 -114
  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 +237 -55
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +58 -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 +24 -4
  18. package/dist/apps/meet/_CallView.js +142 -57
  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 +63 -18
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +232 -42
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +58 -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
@@ -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 { default as MeetLobby } from "./_MeetLobby.js";
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 (_jsx(MeetPageShell, { branding: branding, children: _jsx(MeetJoinContent, { token: params.token }) }));
45
+ return _jsx(MeetJoinContent, { token: params.token, branding: branding });
44
46
  }
45
- function MeetJoinContent({ token }) {
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 [preferences, setPreferences] = useState(null);
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(prefs) {
75
- setPreferences(prefs);
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 === "loading") {
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: preferences.name, iceServers: joinResult.iceServers, initialStream: preferences.stream, initialMicOn: preferences.micOn, initialCameraOn: preferences.cameraOn, onLeave: handleLeave }));
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
- // phase === "lobby" (join Result is always set by then - see the effect above).
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
- /** 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,8 @@ 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 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, 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,56 @@ 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 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 [micOn, setMicOn] = useState(initialMicOn);
54
- const [cameraOn, setCameraOn] = useState(initialCameraOn);
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 isPresentingRef = useRef(false);
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
- localStream: localStreamRef.current,
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 !== selfUid && isPresentingRef.current) {
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
- stopStream(localStreamRef.current);
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
- // 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);
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 (isPresenting) {
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
- 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
- });
238
+ setScreenStream(result.value);
239
+ result.value.getVideoTracks()[0].onended = () => stopLocalPresentation(true);
184
240
  }
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
- });
241
+ function handleToggleHand() {
242
+ setHandRaised((prev) => !prev);
194
243
  }
195
- function handleLeave() {
196
- onLeave();
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 ? 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 })] }));
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();