@rapidmx/meet-plugin 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/meet/_CallControls.tsx +119 -2
- package/apps/meet/_CallView.tsx +59 -122
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_ParticipantTile.tsx +82 -9
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_icons.tsx +10 -0
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +52 -1
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +19 -0
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +25 -3
- package/apps/shared/webrtc/types.ts +71 -2
- package/dist/apps/meet/_CallControls.d.ts +24 -2
- package/dist/apps/meet/_CallControls.js +34 -3
- package/dist/apps/meet/_CallView.d.ts +28 -15
- package/dist/apps/meet/_CallView.js +50 -64
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +9 -2
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_icons.d.ts +4 -0
- package/dist/apps/meet/_icons.js +4 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +44 -1
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +4 -0
- package/dist/apps/shared/relay/RelayTransport.js +14 -0
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +20 -1
- package/dist/apps/shared/webrtc/types.d.ts +66 -2
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
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
|
|
@@ -16,10 +16,24 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
16
16
|
*
|
|
17
17
|
* The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
|
|
18
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.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* the branded page shell, whose header, footer and padding pushed it off the screen.
|
|
20
|
+
*
|
|
21
|
+
* ## Where the local tile appears
|
|
22
|
+
*
|
|
23
|
+
* In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
|
|
24
|
+
* everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
|
|
25
|
+
* genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
|
|
26
|
+
* tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
|
|
27
|
+
* is reserved for focus/presentation layouts, where there's already a single large tile the local one would
|
|
28
|
+
* otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
|
|
29
|
+
* (see the `alone` branch of the `stage` computation).
|
|
30
|
+
*
|
|
31
|
+
* `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
|
|
32
|
+
* it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
|
|
33
|
+
* directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
|
|
34
|
+
* "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
|
|
35
|
+
* nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
|
|
36
|
+
* just blank it, so the local tile ignores `selfHidden` there and always shows.
|
|
23
37
|
*
|
|
24
38
|
* ## Who is heard
|
|
25
39
|
*
|
|
@@ -52,18 +66,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
52
66
|
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
53
67
|
* the floor, until the host picks someone (or themselves) again.
|
|
54
68
|
*
|
|
55
|
-
* ##
|
|
69
|
+
* ## Diagnostics, connection method and settings
|
|
56
70
|
*
|
|
57
|
-
*
|
|
58
|
-
* `_DiagnosticsWindow.tsx` (a persistent, draggable
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* `_SettingsModal.tsx`, which used to be the participants drawer's
|
|
64
|
-
* waiting-room toggle) - moved out because none of those are
|
|
65
|
-
*
|
|
66
|
-
* close along with it.
|
|
71
|
+
* All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
|
|
72
|
+
* callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
|
|
73
|
+
* window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
|
|
74
|
+
* participant wants to keep open while they poke at the rest of the call, not something that closes the moment
|
|
75
|
+
* they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
|
|
76
|
+
* else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
|
|
77
|
+
* anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
|
|
78
|
+
* own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
|
|
79
|
+
* about *who* is in the call, which is what the drawer is for.
|
|
67
80
|
*/
|
|
68
81
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
69
82
|
import { playRaisedHandChime } from "../shared/media/chime.js";
|
|
@@ -76,7 +89,7 @@ import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js
|
|
|
76
89
|
import { createRelayTransport } from "../shared/relay/RelayTransport.js";
|
|
77
90
|
import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
|
|
78
91
|
import CallControls from "./_CallControls.js";
|
|
79
|
-
import { BatonIcon
|
|
92
|
+
import { BatonIcon } from "./_icons.js";
|
|
80
93
|
import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
|
|
81
94
|
import DiagnosticsPanel from "./_DiagnosticsPanel.js";
|
|
82
95
|
import DiagnosticsWindow from "./_DiagnosticsWindow.js";
|
|
@@ -89,13 +102,6 @@ const REACTION_MS = 4000;
|
|
|
89
102
|
const MAX_REACTIONS = 12;
|
|
90
103
|
/** How often the host's drawer refreshes its own waiting-room list while open. */
|
|
91
104
|
const DEFAULT_WAITING_POLL_MS = 3000;
|
|
92
|
-
/** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
|
|
93
|
-
const TRANSPORT_MODE_OPTIONS = [
|
|
94
|
-
{ mode: "auto", label: "Auto" },
|
|
95
|
-
{ mode: "p2p", label: "P2P" },
|
|
96
|
-
{ mode: "relay", label: "Relay" },
|
|
97
|
-
{ mode: "websocket", label: "WebSocket Relay" },
|
|
98
|
-
];
|
|
99
105
|
/** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
|
|
100
106
|
* joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
|
|
101
107
|
export function computeMainUid(options) {
|
|
@@ -150,12 +156,11 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
150
156
|
const [waitingParticipants, setWaitingParticipants] = useState([]);
|
|
151
157
|
const [talkingStickActive, setTalkingStickActive] = useState(false);
|
|
152
158
|
const [talkingStickHolder, setTalkingStickHolder] = useState(undefined);
|
|
153
|
-
const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
|
|
154
159
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
|
155
160
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
156
161
|
const [transportMode, setTransportModeState] = useState("auto");
|
|
162
|
+
const [selfHidden, setSelfHidden] = useState(false);
|
|
157
163
|
const managerRef = useRef(null);
|
|
158
|
-
const overflowMenuRef = useRef(null);
|
|
159
164
|
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
160
165
|
* indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
|
|
161
166
|
* actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
|
|
@@ -319,30 +324,6 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
319
324
|
void mediaRef.current.toggleMic();
|
|
320
325
|
}
|
|
321
326
|
}, [talkingStickActive, selfHasTalkingStick]);
|
|
322
|
-
// The "…" menu closes on Escape or a press anywhere outside it - matching `_CallControls.tsx`'s own menus -
|
|
323
|
-
// but never the windows it opens (diagnostics, settings), which stay open independently (see this module's
|
|
324
|
-
// doc comment on the "…" menu).
|
|
325
|
-
useEffect(() => {
|
|
326
|
-
if (!overflowMenuOpen) {
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
const onPointerDown = (event) => {
|
|
330
|
-
if (!overflowMenuRef.current?.contains(event.target)) {
|
|
331
|
-
setOverflowMenuOpen(false);
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
const onKeyDown = (event) => {
|
|
335
|
-
if (event.key === "Escape") {
|
|
336
|
-
setOverflowMenuOpen(false);
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
document.addEventListener("pointerdown", onPointerDown);
|
|
340
|
-
document.addEventListener("keydown", onKeyDown);
|
|
341
|
-
return () => {
|
|
342
|
-
document.removeEventListener("pointerdown", onPointerDown);
|
|
343
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
344
|
-
};
|
|
345
|
-
}, [overflowMenuOpen]);
|
|
346
327
|
useEffect(() => {
|
|
347
328
|
if (!presenterUid) {
|
|
348
329
|
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
@@ -409,6 +390,15 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
409
390
|
function handleToggleHand() {
|
|
410
391
|
setHandRaised((prev) => !prev);
|
|
411
392
|
}
|
|
393
|
+
/** Hides the local tile - the tile's own "…" menu calls this directly (one-directional: there is no "show
|
|
394
|
+
* self" from in-grid, since a hidden tile isn't there to click on). Re-showing it is the navbar "…" menu's
|
|
395
|
+
* `handleToggleSelfHidden()` instead. */
|
|
396
|
+
function handleHideSelf() {
|
|
397
|
+
setSelfHidden(true);
|
|
398
|
+
}
|
|
399
|
+
function handleToggleSelfHidden() {
|
|
400
|
+
setSelfHidden((prev) => !prev);
|
|
401
|
+
}
|
|
412
402
|
function handleReaction(emoji) {
|
|
413
403
|
if (managerRef.current?.sendReaction(emoji)) {
|
|
414
404
|
showReaction(emoji, "You");
|
|
@@ -468,12 +458,11 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
468
458
|
managerRef.current?.setTalkingStick(true, uid);
|
|
469
459
|
}
|
|
470
460
|
/** Forces this tab's own transport policy - see `MeshConnectionManager.setTransportMode()`'s doc comment for
|
|
471
|
-
* what each mode actually does, and
|
|
472
|
-
* per-tab choice rather than a meeting setting. */
|
|
461
|
+
* what each mode actually does, and `_CallControls.tsx`'s doc comment on its "…" menu for why this is a
|
|
462
|
+
* personal, per-tab choice rather than a meeting setting. */
|
|
473
463
|
function handleSetTransportMode(mode) {
|
|
474
464
|
setTransportModeState(mode);
|
|
475
465
|
managerRef.current?.setTransportMode(mode);
|
|
476
|
-
setOverflowMenuOpen(false);
|
|
477
466
|
}
|
|
478
467
|
/** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
|
|
479
468
|
* anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
|
|
@@ -545,30 +534,27 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
545
534
|
// so someone already in the call is not told they are "connecting" each time a newcomer arrives.
|
|
546
535
|
const selfConnecting = !connectError && (!signalingReady || (!alone && participants.every((p) => p.transport === "connecting")));
|
|
547
536
|
const remoteTile = (p, className) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !p.videoOn, micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, status: p.transport === "connecting" ? "Awaiting connection…" : undefined, isFocused: mainUid === p.uid, contain: presenterUid === p.uid, className: className, onClick: () => togglePin(p.uid) }, p.uid));
|
|
548
|
-
const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, status: selfConnecting ? "Connecting…" : undefined,
|
|
537
|
+
const selfTile = (className, canHide) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, status: selfConnecting ? "Connecting…" : undefined,
|
|
549
538
|
// A custom background is a fixed picture, not a live reflection - mirroring it would show it backwards
|
|
550
539
|
// to no one but the participant themselves (see `_ParticipantTile.tsx`'s doc comment).
|
|
551
|
-
mirrored: media.filters.background !== "image", className: className }));
|
|
540
|
+
mirrored: media.filters.background !== "image", onHideSelf: canHide ? handleHideSelf : undefined, className: className }));
|
|
552
541
|
const mainParticipant = participants.find((p) => p.uid === mainUid);
|
|
553
542
|
const thumbnails = participants.filter((p) => p.uid !== mainUid);
|
|
554
543
|
let stage;
|
|
555
544
|
if (alone) {
|
|
556
|
-
// Nobody else yet: the local participant fills the tile area, presenting or not
|
|
545
|
+
// Nobody else yet: the local participant fills the tile area, presenting or not - and is never hideable,
|
|
546
|
+
// since there'd be nothing left to look at (see this module's doc comment on hiding the local tile).
|
|
557
547
|
stage = isPresenting ? (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) : (selfTile("h-full"));
|
|
558
548
|
}
|
|
559
549
|
else if (showFocusLayout && (mainParticipant || isPresenting)) {
|
|
560
550
|
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))) }))] }));
|
|
561
551
|
}
|
|
562
552
|
else {
|
|
563
|
-
|
|
553
|
+
// Plain grid, nobody presenting: the local tile joins the grid as an ordinary tile (unless hidden) instead
|
|
554
|
+
// of floating in a corner - see this module's doc comment.
|
|
555
|
+
stage = (_jsxs("div", { className: "h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]", children: [!selfHidden && selfTile("h-full", true), participants.map((p) => remoteTile(p, "h-full"))] }));
|
|
564
556
|
}
|
|
565
|
-
return (_jsxs("div", { className: "fixed inset-0 z-50 flex 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("div", { className: "relative flex-1 min-w-0 flex flex-col overflow-hidden", children: [_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(", ")] })), talkingStickActive && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "talking-stick-status", children: ["\uD83C\uDF99\uFE0F ", talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"] })), isHost && (_jsx("button", { type: "button", className: `w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${talkingStickActive ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`, "aria-label": talkingStickActive ? "End talking stick" : "Talking stick", "aria-pressed": talkingStickActive, title: talkingStickActive ? "End talking stick" : "Start talking stick mode", onClick: handleToggleTalkingStick, children: _jsx(BatonIcon, {}) })), _jsx("button", { type: "button", className: "px-3 py-1.5 rounded-full bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `${participants.length + 1} participants`, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen((prev) => !prev), children: participants.length + 1 }),
|
|
566
|
-
setDiagnosticsOpen(true);
|
|
567
|
-
setOverflowMenuOpen(false);
|
|
568
|
-
}, children: "Diagnostics" }), _jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("p", { className: "px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60", children: "Connection method" }), TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": transportMode === mode, className: "flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => handleSetTransportMode(mode), children: [_jsx("span", { className: "w-4 text-[#8ab4f8]", "aria-hidden": "true", children: transportMode === mode ? "✓" : "" }), label] }, mode))), isHost && (_jsxs(_Fragment, { children: [_jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
569
|
-
setSettingsOpen(true);
|
|
570
|
-
setOverflowMenuOpen(false);
|
|
571
|
-
}, children: "Settings" })] }))] }))] })] })] }), 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, participants: participants, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), screenTransform: screenTransform, onRotateScreen: handleRotateScreen, onFlipScreen: handleFlipScreen, handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), micLocked: micLocked, 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) => {
|
|
557
|
+
return (_jsxs("div", { className: "fixed inset-0 z-50 flex 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("div", { className: "relative flex-1 min-w-0 flex flex-col overflow-hidden", children: [_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(", ")] })), talkingStickActive && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "talking-stick-status", children: ["\uD83C\uDF99\uFE0F ", talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"] })), isHost && (_jsx("button", { type: "button", className: `w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${talkingStickActive ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`, "aria-label": talkingStickActive ? "End talking stick" : "Talking stick", "aria-pressed": talkingStickActive, title: talkingStickActive ? "End talking stick" : "Start talking stick mode", onClick: handleToggleTalkingStick, children: _jsx(BatonIcon, {}) })), _jsx("button", { type: "button", className: "px-3 py-1.5 rounded-full bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `${participants.length + 1} participants`, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen((prev) => !prev), 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, participants: participants, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), screenTransform: screenTransform, onRotateScreen: handleRotateScreen, onFlipScreen: handleFlipScreen, handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), micLocked: micLocked, selfHidden: selfHidden, onToggleSelfHidden: handleToggleSelfHidden, isHost: isHost, transportMode: transportMode, onSetTransportMode: handleSetTransportMode, onOpenDiagnostics: () => setDiagnosticsOpen(true), onOpenSettings: () => setSettingsOpen(true), onLeave: onLeave }) }), !alone && !selfHidden && showFocusLayout && (_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", true) })), _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) => {
|
|
572
558
|
const stream = remoteStreams[p.uid];
|
|
573
559
|
return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
|
|
574
560
|
}), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })] }), drawerOpen && (_jsx(ParticipantsDrawer, { selfName: selfName, micOn: media.micOn, handRaised: handRaised, participants: participants, isSelfHost: isHost, isParticipantHost: isParticipantHost, onMute: handleMuteParticipant, onKick: handleKickParticipant, waitingRoomEnabled: waitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) })), diagnosticsOpen && (_jsx(DiagnosticsWindow, { onClose: () => setDiagnosticsOpen(false), children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) })), settingsOpen && isHost && (_jsx(SettingsModal, { forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, onClose: () => setSettingsOpen(false) }))] }));
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* (`MeshParticipant.diagnostics`, refreshed every few seconds by `MeshConnectionManager`'s own poller - see its doc
|
|
7
7
|
* comment). Purely informational and entirely local: nothing here is sent anywhere.
|
|
8
8
|
*
|
|
9
|
-
* `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up, or the pair moves
|
|
10
|
+
* to the server relay), shown as "Not available yet" rather than a blank space, so it never looks like the panel
|
|
11
|
+
* forgot to load. For a `"websocket"`-relayed participant the round trip is this browser's own leg to the relay
|
|
12
|
+
* server (the relay has no direct path to the peer), and the relay's own counters follow (`RelayRows`): how much of this browser's microphone audio the capture path
|
|
13
|
+
* delivered and how much of it was digital silence, and how this peer's audio is playing back here.
|
|
12
14
|
*/
|
|
13
15
|
import React from "react";
|
|
14
16
|
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
@@ -24,9 +24,25 @@ function StreamRow({ kind, packetsLost, jitter, bytesSent, bytesReceived }) {
|
|
|
24
24
|
return (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: kind }), _jsxs("span", { children: [packetsLost !== undefined && `${packetsLost} lost`, jitter !== undefined && ` · ${formatMs(jitter)} jitter`, (bytesSent !== undefined || bytesReceived !== undefined) &&
|
|
25
25
|
` · ${[bytesSent !== undefined && `↑${formatBytes(bytesSent)}`, bytesReceived !== undefined && `↓${formatBytes(bytesReceived)}`].filter(Boolean).join(" ")}`] })] }));
|
|
26
26
|
}
|
|
27
|
+
/** `part` as a whole-number percentage of `whole`, or "—" when there is no whole yet. */
|
|
28
|
+
function percent(part, whole) {
|
|
29
|
+
return whole > 0 ? `${Math.round((part / whole) * 100)}%` : "—";
|
|
30
|
+
}
|
|
31
|
+
function plural(count, noun) {
|
|
32
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
33
|
+
}
|
|
34
|
+
/** The server relay's own counters (see `RelayDiagnostics`). The capture row is this browser's own microphone, the
|
|
35
|
+
* playback row this peer's audio as heard here - so a mostly silent capture points at the sender, silence arriving in
|
|
36
|
+
* playback with no capture problem points at the other end's capture, and gaps point at the network. */
|
|
37
|
+
function RelayRows({ relay }) {
|
|
38
|
+
const { send, receive } = relay;
|
|
39
|
+
const notSent = send.audio.framesDropped + send.video.framesDropped;
|
|
40
|
+
return (_jsxs(_Fragment, { children: [send.audioCaptureWallMs > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Your mic capture" }), _jsxs("span", { children: [percent(send.audioCapturedMs, send.audioCaptureWallMs), " of real time \u00B7 ", percent(send.audioSilentMs, send.audioCapturedMs), " silent", send.audioEncoderSkippedMs > 0 && ` · ${formatMs(send.audioEncoderSkippedMs / 1000)} skipped`] })] })), notSent > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Not sent" }), _jsxs("span", { children: [send.audio.framesDropped, " audio \u00B7 ", send.video.framesDropped, " video frames"] })] })), receive && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Playback" }), _jsx("span", { children: `${percent(receive.audio.silentMs, receive.audio.playedMs)} silent · ${plural(receive.audio.gaps, "gap")} ` +
|
|
41
|
+
`(${formatMs(receive.audio.gapMs / 1000)}) · ${formatMs(receive.audio.bufferedMs / 1000)} buffered` })] })), receive && receive.audio.droppedLate > 0 && (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Dropped late" }), _jsx("span", { children: plural(receive.audio.droppedLate, "packet") })] }))] }));
|
|
42
|
+
}
|
|
27
43
|
export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }) {
|
|
28
44
|
return (_jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5", children: "This browser" }), browserCapabilities().map(({ label, supported }) => (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: label }), _jsx("span", { className: supported ? "text-[#8ab4f8]" : "text-[#f2b8b5]", children: supported ? "Supported" : "Not supported" })] }, label))), _jsxs("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: [selfName, " ", _jsx("span", { className: "text-white/60", children: "(you)" })] }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Sending" }), _jsx("span", { children: [micOn && "audio", cameraOn && "video"].filter(Boolean).join(" + ") || "Nothing" })] }), participants.length === 0 && _jsx("p", { className: "text-white/60 mt-3", children: "Nobody else is in the call yet." }), participants.map((p) => {
|
|
29
45
|
const badge = p.transport !== "connecting" && p.transport !== "p2p" ? TRANSPORT_BADGES[p.transport] : undefined;
|
|
30
|
-
return (_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: p.name }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Connection" }), _jsx("span", { title: badge?.title, children: badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct") })] }),
|
|
46
|
+
return (_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: p.name }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Connection" }), _jsx("span", { title: badge?.title, children: badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct") })] }), !p.diagnostics ? (_jsx("p", { className: `${LABEL} py-0.5`, children: "Not available yet." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, title: p.diagnostics.relay ? "From this browser to the relay server and back" : undefined, children: p.diagnostics.relay ? "Round-trip to server" : "Round-trip time" }), _jsx("span", { children: formatMs(p.diagnostics.roundTripTimeSeconds) })] }), _jsx(StreamRow, { kind: "Audio", ...p.diagnostics.audio }), _jsx(StreamRow, { kind: "Video", ...p.diagnostics.video }), p.diagnostics.relay && _jsx(RelayRows, { relay: p.diagnostics.relay })] }))] }, p.uid));
|
|
31
47
|
})] }));
|
|
32
48
|
}
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* natural - this is a local display transform only, never applied to the video actually sent to anyone, so
|
|
11
11
|
* everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
|
|
12
12
|
* background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
|
|
13
|
-
* reflection, and mirroring it would show it backwards to no one but themselves.
|
|
13
|
+
* reflection, and mirroring it would show it backwards to no one but themselves.
|
|
14
|
+
*
|
|
15
|
+
* `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
|
|
16
|
+
* hiding the local tile from grid view. */
|
|
14
17
|
import React from "react";
|
|
15
18
|
import type { MediaTransport } from "../shared/webrtc/types.js";
|
|
16
19
|
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
@@ -41,7 +44,11 @@ export interface ParticipantTileProps {
|
|
|
41
44
|
/** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
|
|
42
45
|
* mirrored. Default `true`; see this module's doc comment. */
|
|
43
46
|
mirrored?: boolean;
|
|
47
|
+
/** Offers a small "…" menu in the tile's top-right corner with one item, "Hide self" - see `_CallView.tsx`'s
|
|
48
|
+
* doc comment on hiding the local tile. Omitted everywhere that shouldn't offer it: every remote tile, and the
|
|
49
|
+
* local tile while alone in the call, where hiding it would just blank the only thing on screen. */
|
|
50
|
+
onHideSelf?: () => void;
|
|
44
51
|
onClick?: () => void;
|
|
45
52
|
className?: string;
|
|
46
53
|
}
|
|
47
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored, onClick, className, }: ParticipantTileProps): React.JSX.Element;
|
|
54
|
+
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored, onHideSelf, onClick, className, }: ParticipantTileProps): React.JSX.Element;
|
|
@@ -15,9 +15,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
15
15
|
* natural - this is a local display transform only, never applied to the video actually sent to anyone, so
|
|
16
16
|
* everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
|
|
17
17
|
* background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
|
|
18
|
-
* reflection, and mirroring it would show it backwards to no one but themselves.
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
* reflection, and mirroring it would show it backwards to no one but themselves.
|
|
19
|
+
*
|
|
20
|
+
* `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
|
|
21
|
+
* hiding the local tile from grid view. */
|
|
22
|
+
import { useEffect, useRef, useState } from "react";
|
|
23
|
+
import { MicOffIcon, OverflowIcon } from "./_icons.js";
|
|
21
24
|
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
22
25
|
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
23
26
|
* Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
|
|
@@ -44,15 +47,43 @@ function StatusPill({ status, className }) {
|
|
|
44
47
|
function initialOf(name) {
|
|
45
48
|
return (Array.from(name.trim())[0] ?? "?").toUpperCase();
|
|
46
49
|
}
|
|
47
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored = true, onClick, className, }) {
|
|
50
|
+
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored = true, onHideSelf, onClick, className, }) {
|
|
48
51
|
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
49
52
|
const videoRef = useRef(null);
|
|
50
53
|
const showVideo = !!stream && !cameraOff;
|
|
54
|
+
const [hideMenuOpen, setHideMenuOpen] = useState(false);
|
|
55
|
+
const hideMenuRef = useRef(null);
|
|
51
56
|
// The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
|
|
52
57
|
useEffect(() => {
|
|
53
58
|
if (videoRef.current) {
|
|
54
59
|
videoRef.current.srcObject = stream ?? null;
|
|
55
60
|
}
|
|
56
61
|
}, [stream, showVideo]);
|
|
57
|
-
|
|
62
|
+
// The tile's own "…" menu closes on Escape or a press anywhere outside it, same shape as every other menu in
|
|
63
|
+
// this app.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!hideMenuOpen) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const onPointerDown = (event) => {
|
|
69
|
+
if (!hideMenuRef.current?.contains(event.target)) {
|
|
70
|
+
setHideMenuOpen(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const onKeyDown = (event) => {
|
|
74
|
+
if (event.key === "Escape") {
|
|
75
|
+
setHideMenuOpen(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
79
|
+
document.addEventListener("keydown", onKeyDown);
|
|
80
|
+
return () => {
|
|
81
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
82
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
83
|
+
};
|
|
84
|
+
}, [hideMenuOpen]);
|
|
85
|
+
return (_jsxs("div", { className: `relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-[#8ab4f8]" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined, children: [showVideo ? (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}` }), status && _jsx(StatusPill, { status: status, className: "absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" })] })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 max-w-full px-1", children: [_jsx("div", { className: "w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold", "aria-hidden": "true", children: initialOf(name) }), status && _jsx(StatusPill, { status: status })] })), handRaised && (_jsx("span", { className: "absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base", role: "img", "aria-label": "Hand raised", children: "\u270B" })), (micMuted || onHideSelf) && (_jsxs("div", { className: "absolute top-2 right-2 flex items-center gap-1", children: [micMuted && (_jsx("span", { className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white", role: "img", "aria-label": "Muted", children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(MicOffIcon, {}) }) })), onHideSelf && (_jsxs("div", { className: "relative", ref: hideMenuRef, children: [_jsx("button", { type: "button", className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 hover:bg-black/80 text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Local video options", "aria-haspopup": "menu", "aria-expanded": hideMenuOpen, onClick: () => setHideMenuOpen((prev) => !prev), children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(OverflowIcon, {}) }) }), hideMenuOpen && (_jsx("div", { role: "menu", "aria-label": "Local video options", className: "absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-1.5 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
86
|
+
setHideMenuOpen(false);
|
|
87
|
+
onHideSelf();
|
|
88
|
+
}, children: "Hide self" }) }))] }))] })), badge && (_jsx("span", { className: `absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"}`, title: badge.title, "data-testid": "transport-badge", children: badge.label })), _jsxs("div", { className: "absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm", children: [name, isLocal ? " (you)" : ""] })] }));
|
|
58
89
|
}
|
|
@@ -34,7 +34,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
34
34
|
*/
|
|
35
35
|
import { useEffect } from "react";
|
|
36
36
|
import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
|
|
37
|
-
import { MicOffIcon } from "./_icons.js";
|
|
37
|
+
import { BatonIcon, MicOffIcon, RemoveIcon } from "./_icons.js";
|
|
38
38
|
/** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
|
|
39
39
|
* anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
|
|
40
40
|
* indistinguishable from the list just not having loaded yet). */
|
|
@@ -43,11 +43,11 @@ function WaitingSection({ waitingParticipants, onAdmit, onDeny, }) {
|
|
|
43
43
|
}
|
|
44
44
|
function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, hasStick, canGiveStick, onGiveStick, }) {
|
|
45
45
|
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
46
|
-
return (_jsxs("li", { className: "flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5", children: [_jsxs("span", { className: "flex-1 min-w-0 truncate", children: [name, isSelf ? " (you)" : ""] }), isHost && _jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", children: "Host" }), hasStick && (_jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", role: "img", "aria-label": "Holding the talking stick", children: "\uD83C\uDF99\uFE0F" })), handRaised && (_jsx("span", { role: "img", "aria-label": "Hand raised", children: "\u270B" })), micMuted && (_jsx("span", { className: "w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4", role: "img", "aria-label": "Muted", children: _jsx(MicOffIcon, {}) })), badge && (_jsx("span", { className: `px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`, title: badge.title, children: badge.label })), canGiveStick && !hasStick && (_jsx("button", { type: "button", className: "
|
|
46
|
+
return (_jsxs("li", { className: "flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5", children: [_jsxs("span", { className: "flex-1 min-w-0 truncate", children: [name, isSelf ? " (you)" : ""] }), isHost && _jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", children: "Host" }), hasStick && (_jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", role: "img", "aria-label": "Holding the talking stick", children: "\uD83C\uDF99\uFE0F" })), handRaised && (_jsx("span", { role: "img", "aria-label": "Hand raised", children: "\u270B" })), micMuted && (_jsx("span", { className: "w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4", role: "img", "aria-label": "Muted", children: _jsx(MicOffIcon, {}) })), badge && (_jsx("span", { className: `px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`, title: badge.title, children: badge.label })), canGiveStick && !hasStick && (_jsx("button", { type: "button", className: "w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4", "aria-label": `Give the talking stick to ${name}`, title: `Give the talking stick to ${name}`, onClick: onGiveStick, children: _jsx(BatonIcon, {}) })), canModerate && !micMuted && (_jsx("button", { type: "button", className: "w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4", "aria-label": `Mute ${name}`, title: `Mute ${name}`, onClick: onMute, children: _jsx(MicOffIcon, {}) })), canModerate && (_jsx("button", { type: "button", className: "w-7 h-7 flex items-center justify-center rounded-full bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4", "aria-label": `Remove ${name}`, title: `Remove ${name}`, onClick: () => {
|
|
47
47
|
if (window.confirm(`Remove ${name} from the call?`)) {
|
|
48
48
|
onKick?.();
|
|
49
49
|
}
|
|
50
|
-
}, children:
|
|
50
|
+
}, children: _jsx(RemoveIcon, {}) }))] }));
|
|
51
51
|
}
|
|
52
52
|
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, waitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }) {
|
|
53
53
|
useEffect(() => {
|
|
@@ -21,3 +21,7 @@ export declare const FlipIcon: () => React.JSX.Element;
|
|
|
21
21
|
* needs to light up (`currentColor`) the same way every other control icon here does. */
|
|
22
22
|
export declare const BatonIcon: () => React.JSX.Element;
|
|
23
23
|
export declare const OverflowIcon: () => React.JSX.Element;
|
|
24
|
+
/** A plain "X" - removing a participant from the call (the participants drawer's "Remove" row button). Drawn with
|
|
25
|
+
* strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
|
|
26
|
+
* `BatonIcon`. */
|
|
27
|
+
export declare const RemoveIcon: () => React.JSX.Element;
|
package/dist/apps/meet/_icons.js
CHANGED
|
@@ -22,3 +22,7 @@ export const FlipIcon = () => (_jsx(Icon, { path: "M15 21h2v-2h-2v2zm4-12h2V7h-2
|
|
|
22
22
|
* needs to light up (`currentColor`) the same way every other control icon here does. */
|
|
23
23
|
export const BatonIcon = () => (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "currentColor", "aria-hidden": "true", focusable: "false", children: _jsxs("g", { transform: "rotate(45 12 12)", children: [_jsx("rect", { x: "5", y: "10.5", width: "13", height: "3", rx: "1.5" }), _jsx("circle", { cx: "19", cy: "12", r: "3" })] }) }));
|
|
24
24
|
export const OverflowIcon = () => _jsx(Icon, { path: "M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" });
|
|
25
|
+
/** A plain "X" - removing a participant from the call (the participants drawer's "Remove" row button). Drawn with
|
|
26
|
+
* strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
|
|
27
|
+
* `BatonIcon`. */
|
|
28
|
+
export const RemoveIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }), _jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })] }));
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { RelayReceiveDiagnostics } from "../webrtc/types.js";
|
|
1
2
|
import { type Frame } from "./frames.js";
|
|
2
3
|
import type { AudioContextLike, RelayEnv } from "./relayEnv.js";
|
|
4
|
+
/** The playback half of `RelayReceiveDiagnostics.audio`. */
|
|
5
|
+
export type AudioPlaybackStats = Pick<RelayReceiveDiagnostics["audio"], "playedMs" | "silentMs" | "gaps" | "gapMs" | "droppedLate" | "bufferedMs">;
|
|
3
6
|
export declare const PLAYBACK_SAMPLE_RATE = 48000;
|
|
4
7
|
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
5
8
|
* packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
|
|
@@ -38,8 +41,14 @@ export declare class AudioPlayer {
|
|
|
38
41
|
private decoder;
|
|
39
42
|
private nextTime;
|
|
40
43
|
private readonly timestamps;
|
|
44
|
+
private readonly played;
|
|
45
|
+
private gaps;
|
|
46
|
+
private gapSeconds;
|
|
47
|
+
private droppedLate;
|
|
41
48
|
/** `destination` is the node whose stream the peer's `MediaStream` carries. */
|
|
42
49
|
constructor(env: RelayEnv, ctx: AudioContextLike, destination: unknown);
|
|
50
|
+
/** What playback has done so far - see `RelayReceiveDiagnostics`. */
|
|
51
|
+
stats(): AudioPlaybackStats;
|
|
43
52
|
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
44
53
|
push(frame: Frame): void;
|
|
45
54
|
close(): void;
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
1
|
import { TimestampUnwrapper } from "./frames.js";
|
|
6
2
|
import { safeClose } from "./senderCommon.js";
|
|
3
|
+
import { SilenceMeter } from "./SilenceMeter.js";
|
|
7
4
|
export const PLAYBACK_SAMPLE_RATE = 48000;
|
|
8
5
|
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
9
6
|
* packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
|
|
@@ -43,11 +40,27 @@ export class AudioPlayer {
|
|
|
43
40
|
this.destination = destination;
|
|
44
41
|
this.nextTime = 0;
|
|
45
42
|
this.timestamps = new TimestampUnwrapper();
|
|
43
|
+
this.played = new SilenceMeter();
|
|
44
|
+
this.gaps = 0;
|
|
45
|
+
this.gapSeconds = 0;
|
|
46
|
+
this.droppedLate = 0;
|
|
47
|
+
}
|
|
48
|
+
/** What playback has done so far - see `RelayReceiveDiagnostics`. */
|
|
49
|
+
stats() {
|
|
50
|
+
return {
|
|
51
|
+
playedMs: this.played.totalMs,
|
|
52
|
+
silentMs: this.played.silentMs,
|
|
53
|
+
gaps: this.gaps,
|
|
54
|
+
gapMs: this.gapSeconds * 1000,
|
|
55
|
+
droppedLate: this.droppedLate,
|
|
56
|
+
bufferedMs: Math.max(0, this.nextTime - this.ctx.currentTime) * 1000,
|
|
57
|
+
};
|
|
46
58
|
}
|
|
47
59
|
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
48
60
|
push(frame) {
|
|
49
61
|
const decoder = this.decoder ?? this.createDecoder();
|
|
50
62
|
if (!decoder || decoder.decodeQueueSize > AUDIO_MAX_DECODE_QUEUE) {
|
|
63
|
+
this.droppedLate += 1;
|
|
51
64
|
return;
|
|
52
65
|
}
|
|
53
66
|
try {
|
|
@@ -91,6 +104,7 @@ export class AudioPlayer {
|
|
|
91
104
|
try {
|
|
92
105
|
const now = this.ctx.currentTime;
|
|
93
106
|
if (this.nextTime - now > MAX_AHEAD_SECONDS) {
|
|
107
|
+
this.droppedLate += 1;
|
|
94
108
|
return;
|
|
95
109
|
}
|
|
96
110
|
const buffer = this.ctx.createBuffer(1, data.numberOfFrames, data.sampleRate);
|
|
@@ -118,6 +132,12 @@ export class AudioPlayer {
|
|
|
118
132
|
source.onended = () => source.disconnect();
|
|
119
133
|
}
|
|
120
134
|
source.start(start);
|
|
135
|
+
this.played.feed(pcm, data.sampleRate);
|
|
136
|
+
if (start > this.nextTime && this.nextTime > 0) {
|
|
137
|
+
// Playback ran dry: everything between the end of the last block and this one is silence.
|
|
138
|
+
this.gaps += 1;
|
|
139
|
+
this.gapSeconds += start - this.nextTime;
|
|
140
|
+
}
|
|
121
141
|
this.nextTime = start + buffer.duration;
|
|
122
142
|
}
|
|
123
143
|
catch {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RelaySendDiagnostics } from "../webrtc/types.js";
|
|
1
2
|
import { type SenderDeps } from "./senderCommon.js";
|
|
2
3
|
export declare const AUDIO_SAMPLE_RATE = 48000;
|
|
3
4
|
/** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
|
|
@@ -35,7 +36,14 @@ export declare class AudioSender {
|
|
|
35
36
|
private encoder;
|
|
36
37
|
private baseMs;
|
|
37
38
|
private samples;
|
|
39
|
+
/** Diagnostics, cumulative across capture sessions: what the capture path delivered (and how much of it was
|
|
40
|
+
* silent), the wall-clock time it was attached for, and what the encoder had to skip. */
|
|
41
|
+
private readonly captured;
|
|
42
|
+
private wallMs;
|
|
43
|
+
private encoderSkippedSamples;
|
|
38
44
|
constructor(deps: SenderDeps);
|
|
45
|
+
/** The capture half of `RelaySendDiagnostics`. */
|
|
46
|
+
captureStats(): Pick<RelaySendDiagnostics, "audioCapturedMs" | "audioCaptureWallMs" | "audioSilentMs" | "audioEncoderSkippedMs">;
|
|
39
47
|
setActive(active: boolean): void;
|
|
40
48
|
setTrack(track: MediaStreamTrack | null): void;
|
|
41
49
|
/** Brings the capture graph in line with (`active`, `track`): torn down when either is missing, rebuilt when
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
1
|
import { resumeAudioContext } from "./audioResume.js";
|
|
6
2
|
import { KIND_AUDIO } from "./frames.js";
|
|
7
3
|
import { safeClose } from "./senderCommon.js";
|
|
4
|
+
import { SilenceMeter } from "./SilenceMeter.js";
|
|
8
5
|
export const AUDIO_SAMPLE_RATE = 48000;
|
|
9
6
|
/** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
|
|
10
7
|
* thread is interrupted. */
|
|
@@ -36,6 +33,20 @@ export class AudioSender {
|
|
|
36
33
|
this.attachedTrack = null;
|
|
37
34
|
this.baseMs = 0;
|
|
38
35
|
this.samples = 0;
|
|
36
|
+
/** Diagnostics, cumulative across capture sessions: what the capture path delivered (and how much of it was
|
|
37
|
+
* silent), the wall-clock time it was attached for, and what the encoder had to skip. */
|
|
38
|
+
this.captured = new SilenceMeter();
|
|
39
|
+
this.wallMs = 0;
|
|
40
|
+
this.encoderSkippedSamples = 0;
|
|
41
|
+
}
|
|
42
|
+
/** The capture half of `RelaySendDiagnostics`. */
|
|
43
|
+
captureStats() {
|
|
44
|
+
return {
|
|
45
|
+
audioCapturedMs: this.captured.totalMs,
|
|
46
|
+
audioCaptureWallMs: this.wallMs + (this.attachedTrack ? this.deps.env.now() - this.baseMs : 0),
|
|
47
|
+
audioSilentMs: this.captured.silentMs,
|
|
48
|
+
audioEncoderSkippedMs: (this.encoderSkippedSamples * 1000) / AUDIO_SAMPLE_RATE,
|
|
49
|
+
};
|
|
39
50
|
}
|
|
40
51
|
setActive(active) {
|
|
41
52
|
this.active = active;
|
|
@@ -84,6 +95,9 @@ export class AudioSender {
|
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
97
|
detach() {
|
|
98
|
+
if (this.attachedTrack) {
|
|
99
|
+
this.wallMs += this.deps.env.now() - this.baseMs;
|
|
100
|
+
}
|
|
87
101
|
this.attachedTrack = null;
|
|
88
102
|
this.stopResume?.();
|
|
89
103
|
this.stopResume = undefined;
|
|
@@ -109,11 +123,13 @@ export class AudioSender {
|
|
|
109
123
|
const samples = input.length;
|
|
110
124
|
const timestampUs = Math.round((this.samples * 1000000) / AUDIO_SAMPLE_RATE);
|
|
111
125
|
this.samples += samples;
|
|
126
|
+
this.captured.feed(input, AUDIO_SAMPLE_RATE);
|
|
112
127
|
if (!this.deps.canSend()) {
|
|
113
128
|
return;
|
|
114
129
|
}
|
|
115
130
|
const encoder = this.encoder ?? this.createEncoder();
|
|
116
131
|
if (!encoder || encoder.encodeQueueSize > AUDIO_MAX_ENCODE_QUEUE) {
|
|
132
|
+
this.encoderSkippedSamples += samples;
|
|
117
133
|
return;
|
|
118
134
|
}
|
|
119
135
|
let audio;
|