@rapidmx/meet-plugin 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +55 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +463 -394
  4. package/apps/meet/_CallView.tsx +891 -848
  5. package/apps/meet/_DiagnosticsWindow.tsx +80 -0
  6. package/apps/meet/_MeetLobby.tsx +224 -224
  7. package/apps/meet/_ParticipantTile.tsx +169 -169
  8. package/apps/meet/_ParticipantsDrawer.tsx +10 -104
  9. package/apps/meet/_SettingsModal.tsx +152 -0
  10. package/apps/meet/_icons.tsx +80 -82
  11. package/apps/meet/_meetApi.ts +212 -212
  12. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  13. package/apps/shared/media/filters/mlModels.ts +174 -174
  14. package/apps/shared/push/GuestSignalingClient.ts +359 -359
  15. package/apps/shared/relay/RelayClient.ts +7 -2
  16. package/apps/shared/webrtc/MeshConnectionManager.ts +949 -795
  17. package/apps/shared/webrtc/types.ts +247 -222
  18. package/dist/apps/meet/_CallControls.d.ts +19 -9
  19. package/dist/apps/meet/_CallControls.js +28 -10
  20. package/dist/apps/meet/_CallView.d.ts +14 -2
  21. package/dist/apps/meet/_CallView.js +27 -2
  22. package/dist/apps/meet/_DiagnosticsWindow.d.ts +25 -0
  23. package/dist/apps/meet/_DiagnosticsWindow.js +45 -0
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +10 -21
  25. package/dist/apps/meet/_ParticipantsDrawer.js +9 -39
  26. package/dist/apps/meet/_SettingsModal.d.ts +28 -0
  27. package/dist/apps/meet/_SettingsModal.js +59 -0
  28. package/dist/apps/meet/_icons.d.ts +1 -1
  29. package/dist/apps/meet/_icons.js +1 -1
  30. package/dist/apps/shared/relay/RelayClient.d.ts +6 -1
  31. package/dist/apps/shared/relay/RelayClient.js +7 -2
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +55 -2
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +153 -9
  34. package/dist/apps/shared/webrtc/types.d.ts +25 -1
  35. package/dist/lib/util/RedisRelayBus.js +61 -5
  36. package/dist/lib/util/RedisRelayBus.js.map +1 -1
  37. package/dist/lib/util/RelayHub.js +10 -3
  38. package/dist/lib/util/RelayHub.js.map +1 -1
  39. package/dist/types/util/RedisRelayBus.d.ts +45 -6
  40. package/dist/types/util/RelayHub.d.ts +9 -2
  41. package/package.json +208 -208
@@ -10,24 +10,33 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
11
11
  * (`_EffectsPanel.tsx`) and lights up while any filter is on.
12
12
  *
13
- * The diagnostics button is the one exception to "opens near its own button": its panel is `fixed` to the
14
- * viewport's top-left corner rather than anchored above the button, since it reads better as a HUD-style overlay
15
- * sitting over the call than as a dropdown tucked into the bottom control bar. The outside-click/Escape handling
16
- * (`barRef`) still closes it the same way, since it's still a DOM descendant of the bar - only its visual position
17
- * moved, not its open/close wiring.
18
- *
19
13
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
20
14
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
21
15
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
22
16
  * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
23
17
  * `_CallView.tsx`'s doc comment.
18
+ *
19
+ * ## The "…" menu
20
+ *
21
+ * Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
22
+ * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
23
+ * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
24
+ * `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
25
+ * forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen. Otherwise an
26
+ * ordinary menu of this bar's own shape (`openMenu`, closes on Escape/outside click like every other one here).
24
27
  */
25
28
  import { useEffect, useRef, useState } from "react";
26
29
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
27
30
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
28
- import DiagnosticsPanel from "./_DiagnosticsPanel.js";
29
31
  import EffectsPanel from "./_EffectsPanel.js";
30
- import { ChevronUpIcon, DiagnosticsIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
32
+ import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, OverflowIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
33
+ /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
34
+ const TRANSPORT_MODE_OPTIONS = [
35
+ { mode: "auto", label: "Auto" },
36
+ { mode: "p2p", label: "P2P" },
37
+ { mode: "relay", label: "Relay" },
38
+ { mode: "websocket", label: "WebSocket Relay" },
39
+ ];
31
40
  const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
32
41
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
33
42
  const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
@@ -36,7 +45,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
36
45
  export function LevelBars({ level }) {
37
46
  return (_jsx("span", { className: "flex items-center gap-[2px] h-5", "data-testid": "mic-level", "data-level": level, "aria-hidden": "true", children: [0.6, 1, 0.6].map((weight, i) => (_jsx("span", { className: `w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`, style: { height: `${4 + weight * level * 3}px` } }, i))) }));
38
47
  }
39
- export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }) {
48
+ export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, onLeave, }) {
40
49
  const [openMenu, setOpenMenu] = useState(null);
41
50
  const barRef = useRef(null);
42
51
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
@@ -68,7 +77,16 @@ export default function CallControls({ media, selfName, participants, isPresenti
68
77
  return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, disabled: micLocked, title: micLocked ? "Only the current talking-stick holder can unmute." : undefined, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), isPresenting && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Rotate shared screen", title: "If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically.", onClick: onRotateScreen, children: _jsx(RotateIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`, "aria-label": screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen", "aria-pressed": screenTransform.flipped, onClick: onFlipScreen, children: _jsx(FlipIcon, {}) })] })), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
69
78
  onReaction(emoji);
70
79
  setOpenMenu(null);
71
- }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`, "aria-label": "Call diagnostics", "aria-haspopup": "dialog", "aria-expanded": openMenu === "diagnostics", onClick: () => toggleMenu("diagnostics"), children: _jsx(DiagnosticsIcon, {}) }), openMenu === "diagnostics" && (_jsx("div", { role: "dialog", "aria-label": "Call diagnostics", className: "fixed z-30 top-3 left-3 w-80 max-w-[calc(100vw-1rem)] max-h-[70vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30]/95 text-white shadow-xl", children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
80
+ }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", disabled: participants.length < 2, title: participants.length < 2 ? "Grid and focused view look the same with only one other participant." : undefined, onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "overflow" ? ACTIVE : NEUTRAL}`, "aria-label": "More options", "aria-haspopup": "menu", "aria-expanded": openMenu === "overflow", onClick: () => toggleMenu("overflow"), children: _jsx(OverflowIcon, {}) }), openMenu === "overflow" && (_jsxs("div", { role: "menu", "aria-label": "More options", className: "absolute bottom-full mb-3 right-0 w-56 max-w-[calc(100vw-1rem)] 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: () => {
81
+ onOpenDiagnostics();
82
+ setOpenMenu(null);
83
+ }, 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: () => {
84
+ onSetTransportMode(mode);
85
+ setOpenMenu(null);
86
+ }, 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: () => {
87
+ onOpenSettings();
88
+ setOpenMenu(null);
89
+ }, children: "Settings" })] }))] }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
72
90
  }
73
91
  /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
74
92
  * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
@@ -46,6 +46,18 @@
46
46
  * retroactive" posture. A holder who leaves the call is not specially reassigned - see
47
47
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
48
48
  * the floor, until the host picks someone (or themselves) again.
49
+ *
50
+ * ## Diagnostics, connection method and settings
51
+ *
52
+ * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
53
+ * callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
54
+ * window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
55
+ * participant wants to keep open while they poke at the rest of the call, not something that closes the moment
56
+ * they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
57
+ * else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
58
+ * anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
59
+ * own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
60
+ * about *who* is in the call, which is what the drawer is for.
49
61
  */
50
62
  import React from "react";
51
63
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
@@ -68,12 +80,12 @@ export interface CallViewProps {
68
80
  * server couldn't resolve one, in which case nobody sees host controls. */
69
81
  hostUid?: string;
70
82
  /** `PublicVideoMeeting.forceMuteOnJoin` as of this call's own `join()` - the starting value for the host's
71
- * toggle in the participants drawer, which this view then owns and keeps current itself (see
83
+ * toggle in the settings modal, which this view then owns and keeps current itself (see
72
84
  * `handleToggleForceMuteOnJoin()`). Does not update if changed elsewhere while this view is mounted - there is
73
85
  * no signal for that today, matching every other meeting-settings field's lack of live sync. */
74
86
  initialForceMuteOnJoin?: boolean;
75
87
  /** `PublicVideoMeeting.hasPassword` as of this call's own `join()` - the starting value for the host's password
76
- * section in the participants drawer, same "owned and kept current by this view" shape as
88
+ * section in the settings modal, same "owned and kept current by this view" shape as
77
89
  * `initialForceMuteOnJoin`. */
78
90
  initialHasPassword?: boolean;
79
91
  /** `PublicVideoMeeting.waitingRoomEnabled` as of this call's own `join()` - the starting value for the host's
@@ -51,6 +51,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
51
51
  * retroactive" posture. A holder who leaves the call is not specially reassigned - see
52
52
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
53
53
  * the floor, until the host picks someone (or themselves) again.
54
+ *
55
+ * ## Diagnostics, connection method and settings
56
+ *
57
+ * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
58
+ * callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
59
+ * window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
60
+ * participant wants to keep open while they poke at the rest of the call, not something that closes the moment
61
+ * they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
62
+ * else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
63
+ * anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
64
+ * own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
65
+ * about *who* is in the call, which is what the drawer is for.
54
66
  */
55
67
  import { useCallback, useEffect, useRef, useState } from "react";
56
68
  import { playRaisedHandChime } from "../shared/media/chime.js";
@@ -65,8 +77,11 @@ import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
65
77
  import CallControls from "./_CallControls.js";
66
78
  import { BatonIcon } from "./_icons.js";
67
79
  import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
80
+ import DiagnosticsPanel from "./_DiagnosticsPanel.js";
81
+ import DiagnosticsWindow from "./_DiagnosticsWindow.js";
68
82
  import ParticipantTile from "./_ParticipantTile.js";
69
83
  import ParticipantsDrawer from "./_ParticipantsDrawer.js";
84
+ import SettingsModal from "./_SettingsModal.js";
70
85
  /** How long a reaction floats on screen. */
71
86
  const REACTION_MS = 4000;
72
87
  /** The most reactions shown at once - a burst beyond this drops the oldest. */
@@ -127,6 +142,9 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
127
142
  const [waitingParticipants, setWaitingParticipants] = useState([]);
128
143
  const [talkingStickActive, setTalkingStickActive] = useState(false);
129
144
  const [talkingStickHolder, setTalkingStickHolder] = useState(undefined);
145
+ const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
146
+ const [settingsOpen, setSettingsOpen] = useState(false);
147
+ const [transportMode, setTransportModeState] = useState("auto");
130
148
  const managerRef = useRef(null);
131
149
  /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
132
150
  * indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
@@ -415,6 +433,13 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
415
433
  function handleGiveTalkingStick(uid) {
416
434
  managerRef.current?.setTalkingStick(true, uid);
417
435
  }
436
+ /** Forces this tab's own transport policy - see `MeshConnectionManager.setTransportMode()`'s doc comment for
437
+ * what each mode actually does, and `_CallControls.tsx`'s doc comment on its "…" menu for why this is a
438
+ * personal, per-tab choice rather than a meeting setting. */
439
+ function handleSetTransportMode(mode) {
440
+ setTransportModeState(mode);
441
+ managerRef.current?.setTransportMode(mode);
442
+ }
418
443
  /** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
419
444
  * anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
420
445
  * delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
@@ -502,10 +527,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
502
527
  else {
503
528
  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")) }));
504
529
  }
505
- 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, selfName: selfName, 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) => {
530
+ 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, isHost: isHost, transportMode: transportMode, onSetTransportMode: handleSetTransportMode, onOpenDiagnostics: () => setDiagnosticsOpen(true), onOpenSettings: () => setSettingsOpen(true), 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) => {
506
531
  const stream = remoteStreams[p.uid];
507
532
  return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
508
- }), _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, forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) }))] }));
533
+ }), _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) }))] }));
509
534
  }
510
535
  /** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
511
536
  * refuses to start it (an autoplay policy) - the view then asks for a click. */
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A draggable, persistent window around `_DiagnosticsPanel.tsx` - unlike every other popover in this call (which
3
+ * closes the moment focus moves elsewhere), this one stays open and visible over the call for as long as a
4
+ * participant is actively troubleshooting a connection, until they close it themselves (the title bar's own ✕).
5
+ * Opened from the navbar's "…" menu (`_CallView.tsx`).
6
+ *
7
+ * Dragging is tracked on `document`, not the title bar itself, so a fast drag that outruns the pointer never gets
8
+ * "stuck" the way listening only on the title bar's own element would - the standard way to implement a drag
9
+ * without relying on `setPointerCapture()` (unimplemented in the jsdom this is tested under). The window's position
10
+ * is owned by this component (`position` state), not the caller - closing and reopening it starts over at
11
+ * `initialPosition` rather than remembering where it was left, since there is nothing durable to anchor that to
12
+ * across the call ending and starting again.
13
+ */
14
+ import React from "react";
15
+ export interface DiagnosticsWindowProps {
16
+ onClose: () => void;
17
+ /** Where the window first appears - defaults to a spot near the top-left corner, clear of the header's own
18
+ * chips and buttons. */
19
+ initialPosition?: {
20
+ x: number;
21
+ y: number;
22
+ };
23
+ children: React.ReactNode;
24
+ }
25
+ export default function DiagnosticsWindow({ onClose, initialPosition, children }: DiagnosticsWindowProps): React.JSX.Element;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * A draggable, persistent window around `_DiagnosticsPanel.tsx` - unlike every other popover in this call (which
8
+ * closes the moment focus moves elsewhere), this one stays open and visible over the call for as long as a
9
+ * participant is actively troubleshooting a connection, until they close it themselves (the title bar's own ✕).
10
+ * Opened from the navbar's "…" menu (`_CallView.tsx`).
11
+ *
12
+ * Dragging is tracked on `document`, not the title bar itself, so a fast drag that outruns the pointer never gets
13
+ * "stuck" the way listening only on the title bar's own element would - the standard way to implement a drag
14
+ * without relying on `setPointerCapture()` (unimplemented in the jsdom this is tested under). The window's position
15
+ * is owned by this component (`position` state), not the caller - closing and reopening it starts over at
16
+ * `initialPosition` rather than remembering where it was left, since there is nothing durable to anchor that to
17
+ * across the call ending and starting again.
18
+ */
19
+ import { useEffect, useRef, useState } from "react";
20
+ const DEFAULT_POSITION = { x: 12, y: 56 };
21
+ export default function DiagnosticsWindow({ onClose, initialPosition = DEFAULT_POSITION, children }) {
22
+ const [position, setPosition] = useState(initialPosition);
23
+ const [dragging, setDragging] = useState(false);
24
+ const dragOffsetRef = useRef({ x: 0, y: 0 });
25
+ function handleTitleBarPointerDown(event) {
26
+ dragOffsetRef.current = { x: event.clientX - position.x, y: event.clientY - position.y };
27
+ setDragging(true);
28
+ }
29
+ useEffect(() => {
30
+ if (!dragging) {
31
+ return;
32
+ }
33
+ const onMove = (event) => {
34
+ setPosition({ x: event.clientX - dragOffsetRef.current.x, y: event.clientY - dragOffsetRef.current.y });
35
+ };
36
+ const onUp = () => setDragging(false);
37
+ document.addEventListener("pointermove", onMove);
38
+ document.addEventListener("pointerup", onUp);
39
+ return () => {
40
+ document.removeEventListener("pointermove", onMove);
41
+ document.removeEventListener("pointerup", onUp);
42
+ };
43
+ }, [dragging]);
44
+ return (_jsxs("div", { role: "dialog", "aria-label": "Call diagnostics", className: "fixed z-30 w-80 max-w-[calc(100vw-1rem)] max-h-[70vh] flex flex-col rounded-2xl bg-[#2b2d30]/95 text-white shadow-xl overflow-hidden", style: { left: position.x, top: position.y }, children: [_jsxs("div", { className: "shrink-0 flex items-center justify-between gap-2 px-3 py-2 border-b border-white/10 cursor-move select-none", onPointerDown: handleTitleBarPointerDown, children: [_jsx("span", { className: "text-sm font-medium", children: "Call diagnostics" }), _jsx("button", { type: "button", className: "w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close call diagnostics", onClick: onClose, children: "\u2715" })] }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-3", children: children })] }));
45
+ }
@@ -15,14 +15,12 @@
15
15
  * offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
16
16
  * way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
17
17
  *
18
- * The header also gets a host-only "Mute on join" checkbox (`forceMuteOnJoin`/`onToggleForceMuteOnJoin`) - a
19
- * setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
20
- * what each row's own "Mute" button is for) - a host-only password section (`hasPassword`/`onSetPassword`), the
21
- * same "next joiner" scope: setting or changing it never affects anyone already in the call - and a host-only
22
- * waiting-room toggle plus list (`waitingRoomEnabled`/`onToggleWaitingRoomEnabled`,
23
- * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
24
- * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
25
- * the host needing to close and reopen the drawer.
18
+ * The header also gets a host-only "who's waiting" list (`waitingParticipants`/`onAdmit`/`onDeny`), shown whenever
19
+ * `waitingRoomEnabled` is on - `_CallView.tsx` polls the list while this drawer is open and the caller is the host
20
+ * (there is no push signal for a newly filed admission request), so it stays current without the host needing to
21
+ * close and reopen the drawer. The waiting-room toggle itself, along with "mute new participants on join" and the
22
+ * join password, lives in `_SettingsModal.tsx` instead (opened from the navbar's "…" menu) - this drawer only
23
+ * reads `waitingRoomEnabled` to decide whether the list has anywhere to come from, it doesn't set it.
26
24
  *
27
25
  * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
28
26
  * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
@@ -45,19 +43,10 @@ export interface ParticipantsDrawerProps {
45
43
  /** Removes the given participant's `uid` - see `_CallView.tsx`'s `handleKickParticipant()` for what this
46
44
  * actually does (the cooperative signal plus the enforced server-side revoke). */
47
45
  onKick: (uid: string) => void;
48
- /** Whether a newly joining participant currently starts muted - the header checkbox's own state, host-only. */
49
- forceMuteOnJoin: boolean;
50
- onToggleForceMuteOnJoin: () => void;
51
- /** Whether this meeting currently requires a password - never the password itself, which the server never
52
- * sends back (see `PublicVideoMeeting.hasPassword`'s own doc comment). */
53
- hasPassword: boolean;
54
- /** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets
55
- * `PasswordSection` show its own inline error without this drawer needing to know anything about it. */
56
- onSetPassword: (password: string | null) => Promise<void>;
57
- /** Whether the meeting currently requires the host to admit each participant - the header checkbox's own
58
- * state, host-only. */
46
+ /** Whether the meeting currently requires the host to admit each participant - read-only here, set from
47
+ * `_SettingsModal.tsx` instead; this drawer only uses it to decide whether the waiting list has anywhere to
48
+ * come from. */
59
49
  waitingRoomEnabled: boolean;
60
- onToggleWaitingRoomEnabled: () => void;
61
50
  /** Everyone currently waiting to be admitted - `_CallView.tsx`'s own polled, always-current list; empty
62
51
  * whenever this drawer isn't open and host, by construction. */
63
52
  waitingParticipants: WaitingParticipant[];
@@ -76,4 +65,4 @@ export interface ParticipantsDrawerProps {
76
65
  onGiveTalkingStick: (uid: string) => void;
77
66
  onClose: () => void;
78
67
  }
79
- export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }: ParticipantsDrawerProps): React.JSX.Element;
68
+ export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, waitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }: ParticipantsDrawerProps): React.JSX.Element;
@@ -20,51 +20,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
20
20
  * offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
21
21
  * way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
22
22
  *
23
- * The header also gets a host-only "Mute on join" checkbox (`forceMuteOnJoin`/`onToggleForceMuteOnJoin`) - a
24
- * setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
25
- * what each row's own "Mute" button is for) - a host-only password section (`hasPassword`/`onSetPassword`), the
26
- * same "next joiner" scope: setting or changing it never affects anyone already in the call - and a host-only
27
- * waiting-room toggle plus list (`waitingRoomEnabled`/`onToggleWaitingRoomEnabled`,
28
- * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
29
- * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
30
- * the host needing to close and reopen the drawer.
23
+ * The header also gets a host-only "who's waiting" list (`waitingParticipants`/`onAdmit`/`onDeny`), shown whenever
24
+ * `waitingRoomEnabled` is on - `_CallView.tsx` polls the list while this drawer is open and the caller is the host
25
+ * (there is no push signal for a newly filed admission request), so it stays current without the host needing to
26
+ * close and reopen the drawer. The waiting-room toggle itself, along with "mute new participants on join" and the
27
+ * join password, lives in `_SettingsModal.tsx` instead (opened from the navbar's "…" menu) - this drawer only
28
+ * reads `waitingRoomEnabled` to decide whether the list has anywhere to come from, it doesn't set it.
31
29
  *
32
30
  * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
33
31
  * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
34
32
  * the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
35
33
  * on talking-stick mode for what granting it actually does to the recipient's microphone.
36
34
  */
37
- import { useEffect, useState } from "react";
35
+ import { useEffect } from "react";
38
36
  import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
39
37
  import { MicOffIcon } from "./_icons.js";
40
- /** The host-only password form: one text input plus a Set/Change button, and a Remove button once one is set.
41
- * Owns its own input/error/busy state locally - `_CallView.tsx` only needs to know whether a password exists, not
42
- * what a half-typed one currently says. */
43
- function PasswordSection({ hasPassword, onSetPassword }) {
44
- const [input, setInput] = useState("");
45
- const [error, setError] = useState(null);
46
- const [busy, setBusy] = useState(false);
47
- async function submit(password) {
48
- setBusy(true);
49
- setError(null);
50
- try {
51
- await onSetPassword(password);
52
- setInput("");
53
- }
54
- catch {
55
- setError("Could not save - try again.");
56
- }
57
- finally {
58
- setBusy(false);
59
- }
60
- }
61
- return (_jsxs("div", { className: "px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("p", { className: "text-white/70 mb-1.5", children: hasPassword ? "Password protection is on." : "No password required to join." }), _jsxs("form", { className: "flex gap-2", onSubmit: (event) => {
62
- event.preventDefault();
63
- if (input) {
64
- void submit(input);
65
- }
66
- }, children: [_jsx("input", { type: "password", "aria-label": hasPassword ? "New password" : "Set a password", placeholder: hasPassword ? "New password" : "Set a password", className: "flex-1 min-w-0 px-2 py-1 rounded bg-white/10 text-white placeholder:text-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", value: input, onChange: (event) => setInput(event.target.value), disabled: busy }), _jsx("button", { type: "submit", className: "px-2 py-1 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy || !input, children: hasPassword ? "Change" : "Set" })] }), hasPassword && (_jsx("button", { type: "button", className: "mt-1.5 text-xs text-[#f2b8b5] underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy, onClick: () => void submit(null), children: "Remove password" })), error && _jsx("p", { className: "text-[#f2b8b5] text-xs mt-1.5", children: error })] }));
67
- }
68
38
  /** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
69
39
  * anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
70
40
  * indistinguishable from the list just not having loaded yet). */
@@ -79,7 +49,7 @@ function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerat
79
49
  }
80
50
  }, children: "Remove" }))] }));
81
51
  }
82
- export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }) {
52
+ export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, waitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }) {
83
53
  useEffect(() => {
84
54
  const onKeyDown = (event) => {
85
55
  if (event.key === "Escape") {
@@ -89,5 +59,5 @@ export default function ParticipantsDrawer({ selfName, micOn, handRaised, partic
89
59
  document.addEventListener("keydown", onKeyDown);
90
60
  return () => document.removeEventListener("keydown", onKeyDown);
91
61
  }, [onClose]);
92
- return (_jsxs("aside", { role: "dialog", "aria-label": "Participants", className: "relative z-10 shrink-0 w-72 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsxs("h2", { className: "text-sm font-medium", children: ["Participants (", participants.length + 1, ")"] }), _jsx("button", { type: "button", className: "w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close participants", onClick: onClose, children: "\u2715" })] }), isSelfHost && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: forceMuteOnJoin, onChange: onToggleForceMuteOnJoin, className: "w-4 h-4" }), "Mute new participants on join"] })), isSelfHost && _jsx(PasswordSection, { hasPassword: hasPassword, onSetPassword: onSetPassword }), isSelfHost && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: waitingRoomEnabled, onChange: onToggleWaitingRoomEnabled, className: "w-4 h-4" }), "Require the host to admit participants"] })), isSelfHost && waitingRoomEnabled && (_jsx(WaitingSection, { waitingParticipants: waitingParticipants, onAdmit: onAdmit, onDeny: onDeny })), _jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 text-sm", children: [_jsx(Row, { name: selfName, isSelf: true, isHost: isSelfHost, micMuted: !micOn, handRaised: handRaised, hasStick: talkingStickActive && talkingStickHolder === selfPeerId, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(selfPeerId) }), participants.map((p) => (_jsx(Row, { name: p.name, isHost: isParticipantHost(p.uid), micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, canModerate: isSelfHost, onMute: () => onMute(p.uid), onKick: () => onKick(p.uid), hasStick: talkingStickActive && talkingStickHolder === p.uid, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(p.uid) }, p.uid)))] })] }));
62
+ return (_jsxs("aside", { role: "dialog", "aria-label": "Participants", className: "relative z-10 shrink-0 w-72 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsxs("h2", { className: "text-sm font-medium", children: ["Participants (", participants.length + 1, ")"] }), _jsx("button", { type: "button", className: "w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close participants", onClick: onClose, children: "\u2715" })] }), isSelfHost && waitingRoomEnabled && (_jsx(WaitingSection, { waitingParticipants: waitingParticipants, onAdmit: onAdmit, onDeny: onDeny })), _jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 text-sm", children: [_jsx(Row, { name: selfName, isSelf: true, isHost: isSelfHost, micMuted: !micOn, handRaised: handRaised, hasStick: talkingStickActive && talkingStickHolder === selfPeerId, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(selfPeerId) }), participants.map((p) => (_jsx(Row, { name: p.name, isHost: isParticipantHost(p.uid), micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, canModerate: isSelfHost, onMute: () => onMute(p.uid), onKick: () => onKick(p.uid), hasStick: talkingStickActive && talkingStickHolder === p.uid, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(p.uid) }, p.uid)))] })] }));
93
63
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The host-only meeting settings modal, opened from the navbar's "…" menu (`_CallView.tsx`) - host-only because
3
+ * `_CallView.tsx` never renders this for anyone else. Holds the settings that used to live in the participants
4
+ * drawer's header: "Mute new participants on join" (`forceMuteOnJoin`), the join password
5
+ * (`hasPassword`/`onSetPassword`), and "Require the host to admit participants" (`waitingRoomEnabled`) - each a
6
+ * setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
7
+ * the participants drawer's own "Mute" button; the drawer also keeps the live waiting-room list, since admitting
8
+ * someone is closer to "who's here" than a setting).
9
+ *
10
+ * An ordinary modal, not a docked panel like the drawer or a persistent window like diagnostics: closes on Escape,
11
+ * a click on the backdrop, or its own close button, matching `_CallControls.tsx`'s menus and this app's general
12
+ * "settings is a focused, modal task" convention.
13
+ */
14
+ import React from "react";
15
+ export interface SettingsModalProps {
16
+ forceMuteOnJoin: boolean;
17
+ onToggleForceMuteOnJoin: () => void;
18
+ /** Whether this meeting currently requires a password - never the password itself (see
19
+ * `PublicVideoMeeting.hasPassword`'s own doc comment). */
20
+ hasPassword: boolean;
21
+ /** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets `PasswordSection`
22
+ * show its own inline error without this modal needing to know anything about it. */
23
+ onSetPassword: (password: string | null) => Promise<void>;
24
+ waitingRoomEnabled: boolean;
25
+ onToggleWaitingRoomEnabled: () => void;
26
+ onClose: () => void;
27
+ }
28
+ export default function SettingsModal({ forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, onClose, }: SettingsModalProps): React.JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The host-only meeting settings modal, opened from the navbar's "…" menu (`_CallView.tsx`) - host-only because
8
+ * `_CallView.tsx` never renders this for anyone else. Holds the settings that used to live in the participants
9
+ * drawer's header: "Mute new participants on join" (`forceMuteOnJoin`), the join password
10
+ * (`hasPassword`/`onSetPassword`), and "Require the host to admit participants" (`waitingRoomEnabled`) - each a
11
+ * setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
12
+ * the participants drawer's own "Mute" button; the drawer also keeps the live waiting-room list, since admitting
13
+ * someone is closer to "who's here" than a setting).
14
+ *
15
+ * An ordinary modal, not a docked panel like the drawer or a persistent window like diagnostics: closes on Escape,
16
+ * a click on the backdrop, or its own close button, matching `_CallControls.tsx`'s menus and this app's general
17
+ * "settings is a focused, modal task" convention.
18
+ */
19
+ import { useEffect, useState } from "react";
20
+ /** The password form: one text input plus a Set/Change button, and a Remove button once one is set. Owns its own
21
+ * input/error/busy state locally - the modal only needs to know whether a password exists, not what a half-typed
22
+ * one currently says. */
23
+ function PasswordSection({ hasPassword, onSetPassword }) {
24
+ const [input, setInput] = useState("");
25
+ const [error, setError] = useState(null);
26
+ const [busy, setBusy] = useState(false);
27
+ async function submit(password) {
28
+ setBusy(true);
29
+ setError(null);
30
+ try {
31
+ await onSetPassword(password);
32
+ setInput("");
33
+ }
34
+ catch {
35
+ setError("Could not save - try again.");
36
+ }
37
+ finally {
38
+ setBusy(false);
39
+ }
40
+ }
41
+ return (_jsxs("div", { className: "py-3 border-b border-white/10 text-sm", children: [_jsx("p", { className: "text-white/70 mb-1.5", children: hasPassword ? "Password protection is on." : "No password required to join." }), _jsxs("form", { className: "flex gap-2", onSubmit: (event) => {
42
+ event.preventDefault();
43
+ if (input) {
44
+ void submit(input);
45
+ }
46
+ }, children: [_jsx("input", { type: "password", "aria-label": hasPassword ? "New password" : "Set a password", placeholder: hasPassword ? "New password" : "Set a password", className: "flex-1 min-w-0 px-2 py-1 rounded bg-white/10 text-white placeholder:text-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", value: input, onChange: (event) => setInput(event.target.value), disabled: busy }), _jsx("button", { type: "submit", className: "px-2 py-1 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy || !input, children: hasPassword ? "Change" : "Set" })] }), hasPassword && (_jsx("button", { type: "button", className: "mt-1.5 text-xs text-[#f2b8b5] underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy, onClick: () => void submit(null), children: "Remove password" })), error && _jsx("p", { className: "text-[#f2b8b5] text-xs mt-1.5", children: error })] }));
47
+ }
48
+ export default function SettingsModal({ forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, onClose, }) {
49
+ useEffect(() => {
50
+ const onKeyDown = (event) => {
51
+ if (event.key === "Escape") {
52
+ onClose();
53
+ }
54
+ };
55
+ document.addEventListener("keydown", onKeyDown);
56
+ return () => document.removeEventListener("keydown", onKeyDown);
57
+ }, [onClose]);
58
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-30 bg-black/40", onClick: onClose, "aria-hidden": "true" }), _jsxs("div", { role: "dialog", "aria-label": "Meeting settings", className: "fixed z-30 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 max-w-[calc(100vw-1.5rem)] max-h-[80vh] overflow-y-auto flex flex-col rounded-2xl bg-[#2b2d30] text-white shadow-xl", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-4 py-3 border-b border-white/10", children: [_jsx("h2", { className: "text-sm font-medium", children: "Meeting settings" }), _jsx("button", { type: "button", className: "w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close meeting settings", onClick: onClose, children: "\u2715" })] }), _jsxs("div", { className: "px-4", children: [_jsxs("label", { className: "flex items-center gap-2 py-3 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: forceMuteOnJoin, onChange: onToggleForceMuteOnJoin, className: "w-4 h-4" }), "Mute new participants on join"] }), _jsx(PasswordSection, { hasPassword: hasPassword, onSetPassword: onSetPassword }), _jsxs("label", { className: "flex items-center gap-2 py-3 text-sm", children: [_jsx("input", { type: "checkbox", checked: waitingRoomEnabled, onChange: onToggleWaitingRoomEnabled, className: "w-4 h-4" }), "Require the host to admit participants"] })] })] })] }));
59
+ }
@@ -16,8 +16,8 @@ export declare const HandIcon: () => React.JSX.Element;
16
16
  export declare const EffectsIcon: () => React.JSX.Element;
17
17
  export declare const RotateIcon: () => React.JSX.Element;
18
18
  export declare const FlipIcon: () => React.JSX.Element;
19
- export declare const DiagnosticsIcon: () => React.JSX.Element;
20
19
  /** A talking stick: a rounded shaft with a grip knob at one end, drawn (not a Material path - there isn't one for
21
20
  * this) as a diagonal bar with a circle, rather than reusing `HandIcon`'s emoji-glyph fallback, since this one
22
21
  * needs to light up (`currentColor`) the same way every other control icon here does. */
23
22
  export declare const BatonIcon: () => React.JSX.Element;
23
+ export declare const OverflowIcon: () => React.JSX.Element;
@@ -17,8 +17,8 @@ export const HandIcon = () => (_jsx("span", { className: "text-xl leading-none",
17
17
  export const EffectsIcon = () => (_jsx(Icon, { path: "M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z" }));
18
18
  export const RotateIcon = () => (_jsx(Icon, { path: "M12 6V2L7 7l5 5V8c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" }));
19
19
  export const FlipIcon = () => (_jsx(Icon, { path: "M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z" }));
20
- export const DiagnosticsIcon = () => (_jsx(Icon, { path: "M22 12h-4l-3 9L9 3l-3 9H2v2h5.6l1.4-4.2L13.4 21 17 12h5v-2z" }));
21
20
  /** A talking stick: a rounded shaft with a grip knob at one end, drawn (not a Material path - there isn't one for
22
21
  * this) as a diagonal bar with a circle, rather than reusing `HandIcon`'s emoji-glyph fallback, since this one
23
22
  * needs to light up (`currentColor`) the same way every other control icon here does. */
24
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
+ 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" });
@@ -9,7 +9,12 @@ export declare const MAX_NEGOTIATED_MESSAGE_BYTES: number;
9
9
  /** The most senders the server lets one socket `want`. */
10
10
  export declare const MAX_WANTED_PEERS = 32;
11
11
  /** A media send is dropped, not queued, while more than this many bytes are still waiting to leave - the
12
- * connection is slower than the media, and queueing would only add latency to the frames that are still fresh. */
12
+ * connection is slower than the media, and queueing would only add latency to the frames that are still fresh.
13
+ * 64 KiB is a little under 1.4 seconds of `VideoSender.VIDEO_BITRATE` (350 kbps) plus `AudioSender.AUDIO_BITRATE`
14
+ * (24 kbps) combined (≈46.75 KB/s) - tighter than the server's own `RelayHub.RELAY_WS_MAX_BACKPRESSURE_BYTES`
15
+ * (128 KiB) on purpose, so a sender that is itself the slow end of the connection starts dropping its own stale
16
+ * frames before the server's larger budget would. This was previously 256 KiB (≈5.6 seconds), which defeated the
17
+ * "drop, don't queue" intent on exactly the constrained uplink that routes a call to this fallback tier at all. */
13
18
  export declare const MAX_BUFFERED_BYTES: number;
14
19
  /** Reconnect delay: about a second after the first failure, doubling to a ceiling, jittered so a server restart
15
20
  * doesn't bring every client back in the same instant. */
@@ -32,8 +32,13 @@ export const MAX_NEGOTIATED_MESSAGE_BYTES = 1024 * 1024;
32
32
  /** The most senders the server lets one socket `want`. */
33
33
  export const MAX_WANTED_PEERS = 32;
34
34
  /** A media send is dropped, not queued, while more than this many bytes are still waiting to leave - the
35
- * connection is slower than the media, and queueing would only add latency to the frames that are still fresh. */
36
- export const MAX_BUFFERED_BYTES = 256 * 1024;
35
+ * connection is slower than the media, and queueing would only add latency to the frames that are still fresh.
36
+ * 64 KiB is a little under 1.4 seconds of `VideoSender.VIDEO_BITRATE` (350 kbps) plus `AudioSender.AUDIO_BITRATE`
37
+ * (24 kbps) combined (≈46.75 KB/s) - tighter than the server's own `RelayHub.RELAY_WS_MAX_BACKPRESSURE_BYTES`
38
+ * (128 KiB) on purpose, so a sender that is itself the slow end of the connection starts dropping its own stale
39
+ * frames before the server's larger budget would. This was previously 256 KiB (≈5.6 seconds), which defeated the
40
+ * "drop, don't queue" intent on exactly the constrained uplink that routes a call to this fallback tier at all. */
41
+ export const MAX_BUFFERED_BYTES = 64 * 1024;
37
42
  /** Reconnect delay: about a second after the first failure, doubling to a ceiling, jittered so a server restart
38
43
  * doesn't bring every client back in the same instant. */
39
44
  export const RELAY_BACKOFF_BASE_MS = 1000;