@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.
Files changed (46) hide show
  1. package/apps/meet/_CallControls.tsx +119 -2
  2. package/apps/meet/_CallView.tsx +59 -122
  3. package/apps/meet/_DiagnosticsPanel.tsx +65 -7
  4. package/apps/meet/_ParticipantTile.tsx +82 -9
  5. package/apps/meet/_ParticipantsDrawer.tsx +10 -7
  6. package/apps/meet/_icons.tsx +10 -0
  7. package/apps/shared/relay/AudioPlayer.ts +29 -0
  8. package/apps/shared/relay/AudioSender.ts +23 -0
  9. package/apps/shared/relay/RelayClient.ts +52 -1
  10. package/apps/shared/relay/RelayReceiver.ts +43 -2
  11. package/apps/shared/relay/RelaySender.ts +23 -1
  12. package/apps/shared/relay/RelayTransport.ts +19 -0
  13. package/apps/shared/relay/SilenceMeter.ts +59 -0
  14. package/apps/shared/webrtc/MeshConnectionManager.ts +25 -3
  15. package/apps/shared/webrtc/types.ts +71 -2
  16. package/dist/apps/meet/_CallControls.d.ts +24 -2
  17. package/dist/apps/meet/_CallControls.js +34 -3
  18. package/dist/apps/meet/_CallView.d.ts +28 -15
  19. package/dist/apps/meet/_CallView.js +50 -64
  20. package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
  21. package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
  22. package/dist/apps/meet/_ParticipantTile.d.ts +9 -2
  23. package/dist/apps/meet/_ParticipantTile.js +36 -5
  24. package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
  25. package/dist/apps/meet/_icons.d.ts +4 -0
  26. package/dist/apps/meet/_icons.js +4 -0
  27. package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
  28. package/dist/apps/shared/relay/AudioPlayer.js +24 -4
  29. package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
  30. package/dist/apps/shared/relay/AudioSender.js +20 -4
  31. package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
  32. package/dist/apps/shared/relay/RelayClient.js +44 -1
  33. package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
  34. package/dist/apps/shared/relay/RelayReceiver.js +38 -6
  35. package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
  36. package/dist/apps/shared/relay/RelaySender.js +20 -5
  37. package/dist/apps/shared/relay/RelayTransport.d.ts +4 -0
  38. package/dist/apps/shared/relay/RelayTransport.js +14 -0
  39. package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
  40. package/dist/apps/shared/relay/SilenceMeter.js +55 -0
  41. package/dist/apps/shared/webrtc/MeshConnectionManager.js +20 -1
  42. package/dist/apps/shared/webrtc/types.d.ts +66 -2
  43. package/dist/lib/util/RelayHub.js +15 -2
  44. package/dist/lib/util/RelayHub.js.map +1 -1
  45. package/dist/types/util/RelayHub.d.ts +5 -2
  46. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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
@@ -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. 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.
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
- * ## The navbar's "…" menu
69
+ * ## Diagnostics, connection method and settings
56
70
  *
57
- * A catch-all for controls that don't need a dedicated button of their own: "Diagnostics" opens
58
- * `_DiagnosticsWindow.tsx` (a persistent, draggable window - not a popover like everything else here, since
59
- * troubleshooting a connection is something a participant wants to keep open while they poke at the rest of the
60
- * call, not something that closes the moment they click elsewhere); "Connection method" is a four-way choice
61
- * (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment) for forcing how *this tab's*
62
- * media reaches everyone else, independent of what anyone else has chosen; "Settings" (host-only) opens
63
- * `_SettingsModal.tsx`, which used to be the participants drawer's own header (mute-on-join, the join password, the
64
- * waiting-room toggle) - moved out because none of those are about *who* is in the call, which is what the drawer
65
- * is for. The menu itself closes on Escape, a click outside it, or picking an item; the windows it opens do not
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, OverflowIcon } from "./_icons.js";
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 this module's doc comment on the "…" menu for why this is a personal,
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
- 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")) }));
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 }), _jsxs("div", { className: "relative", ref: overflowMenuRef, children: [_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 ${overflowMenuOpen ? "bg-[#4b4f53]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`, "aria-label": "More options", "aria-haspopup": "menu", "aria-expanded": overflowMenuOpen, onClick: () => setOverflowMenuOpen((prev) => !prev), children: _jsx(OverflowIcon, {}) }), overflowMenuOpen && (_jsxs("div", { role: "menu", "aria-label": "More options", className: "absolute top-full right-0 mt-2 w-56 rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: [_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: () => {
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) and always
10
- * absent for a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll there) - both cases show
11
- * "Not available" rather than a blank space, so it never looks like the panel forgot to load.
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") })] }), p.transport === "websocket" || !p.diagnostics ? (_jsxs("p", { className: `${LABEL} py-0.5`, children: ["Not available", p.transport !== "websocket" && " yet", "."] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "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.uid));
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
- import { useEffect, useRef } from "react";
20
- import { MicOffIcon } from "./_icons.js";
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
- 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 && (_jsx("span", { className: "absolute top-2 right-2 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, {}) }) })), 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)" : ""] })] }));
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: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Give the talking stick to ${name}`, onClick: onGiveStick, children: "Give stick" })), canModerate && !micMuted && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Mute ${name}`, onClick: onMute, children: "Mute" })), canModerate && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Remove ${name}`, onClick: () => {
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: "Remove" }))] }));
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;
@@ -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;