@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
@@ -0,0 +1,59 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** A sample this close to zero counts as silent: about -80 dBFS, well under the noise floor of any real microphone
7
+ * (even through noise suppression), so only digital silence - a zero-filled block - falls below it. */
8
+ export const SILENCE_THRESHOLD = 1e-4;
9
+ /** A run of silent samples counts only once it lasts this long, so a waveform merely crossing zero never does. */
10
+ export const MIN_SILENT_RUN_SECONDS = 0.005;
11
+
12
+ /**
13
+ * Measures how much of a PCM stream is digital silence, for the relay's diagnostics. A live microphone always carries
14
+ * some noise, so stretches of exact (or near-exact) zero mean the audio was lost or zero-filled somewhere on its way,
15
+ * not that the room went quiet. Runs are tracked across `feed()` calls, so a gap that straddles two blocks counts in
16
+ * full.
17
+ */
18
+ export class SilenceMeter {
19
+ private totalSeconds = 0;
20
+ private silentSeconds = 0;
21
+ private run = 0;
22
+ private runRate = 0;
23
+
24
+ /** Adds one block of `sampleRate` Hz samples. */
25
+ feed(samples: Float32Array, sampleRate: number): void {
26
+ if (sampleRate !== this.runRate) {
27
+ this.endRun();
28
+ this.runRate = sampleRate;
29
+ }
30
+ for (const sample of samples) {
31
+ if (Math.abs(sample) < SILENCE_THRESHOLD) {
32
+ this.run += 1;
33
+ } else if (this.run > 0) {
34
+ this.endRun();
35
+ }
36
+ }
37
+ this.totalSeconds += samples.length / sampleRate;
38
+ }
39
+
40
+ /** Milliseconds of audio fed so far. */
41
+ get totalMs(): number {
42
+ return this.totalSeconds * 1000;
43
+ }
44
+
45
+ /** Milliseconds of it that were silent - including a silent run still in progress once it is long enough. */
46
+ get silentMs(): number {
47
+ return (this.silentSeconds + this.runSeconds(this.run)) * 1000;
48
+ }
49
+
50
+ private runSeconds(run: number): number {
51
+ const seconds = this.runRate > 0 ? run / this.runRate : 0;
52
+ return seconds >= MIN_SILENT_RUN_SECONDS ? seconds : 0;
53
+ }
54
+
55
+ private endRun(): void {
56
+ this.silentSeconds += this.runSeconds(this.run);
57
+ this.run = 0;
58
+ }
59
+ }
@@ -134,6 +134,7 @@
134
134
  * not something a meeting agrees on together.
135
135
  */
136
136
  import {
137
+ type ConnectionDiagnostics,
137
138
  type MediaTransport,
138
139
  type MeshEvent,
139
140
  type MeshParticipant,
@@ -141,6 +142,7 @@ import {
141
142
  type RTCPeerConnectionFactory,
142
143
  type RTCPeerConnectionLike,
143
144
  type RTCRtpSenderLike,
145
+ type RelayDiagnostics,
144
146
  type RelayTransportLike,
145
147
  type SignalMessage,
146
148
  type SignalingChannel,
@@ -184,8 +186,8 @@ interface PeerState extends MeshParticipant {
184
186
  connectTimer: ReturnType<typeof setTimeout> | undefined;
185
187
  /** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
186
188
  disconnectTimer: ReturnType<typeof setTimeout> | undefined;
187
- /** Refreshes `diagnostics` (`MeshParticipant`) while this pair is on a real `RTCPeerConnection` - started once
188
- * it first connects, stopped the moment it leaves WebRTC for the relay or the pair ends. */
189
+ /** Refreshes `diagnostics` (`MeshParticipant`) - from the `RTCPeerConnection` once it first connects, then (after
190
+ * a restart in `fallBack()`) from the relay's counters once the pair moves there; stopped when the pair ends. */
189
191
  diagnosticsTimer: ReturnType<typeof setInterval> | undefined;
190
192
  }
191
193
 
@@ -663,7 +665,11 @@ export class MeshConnectionManager {
663
665
  private async pollDiagnostics(peer: PeerState): Promise<void> {
664
666
  let diagnostics: MeshParticipant["diagnostics"];
665
667
  try {
666
- diagnostics = await peer.pc.collectDiagnostics();
668
+ const relay = this.options.relay;
669
+ diagnostics =
670
+ peer.transport === "websocket" && relay?.diagnostics
671
+ ? fromRelayDiagnostics(relay.diagnostics(peer.uid))
672
+ : await peer.pc.collectDiagnostics();
667
673
  } catch {
668
674
  // Leaves whatever was last polled in place rather than blanking a momentary failure.
669
675
  return;
@@ -744,6 +750,9 @@ export class MeshConnectionManager {
744
750
  });
745
751
  this.syncRelaySending();
746
752
  this.emit({ type: "participant-updated", participant: toParticipant(peer) });
753
+ if (relay.diagnostics) {
754
+ this.startDiagnosticsPolling(peer);
755
+ }
747
756
  if (notify) {
748
757
  this.send({ kind: "relay-fallback", to: peer.uid });
749
758
  }
@@ -925,6 +934,19 @@ function toParticipant(peer: PeerState): MeshParticipant {
925
934
  };
926
935
  }
927
936
 
937
+ /** A relayed pair's diagnostics: the relay's own counters, with the part that maps onto RTP stats (round trip, frames
938
+ * lost, bytes each way) also filled into the ordinary fields, so the panel's ordinary rows show it too. The round trip
939
+ * is to the relay server, not to the peer - see `RelayDiagnostics.roundTripMs`. */
940
+ function fromRelayDiagnostics(relay: RelayDiagnostics): ConnectionDiagnostics {
941
+ const { send, receive } = relay;
942
+ return {
943
+ roundTripTimeSeconds: relay.roundTripMs === undefined ? undefined : relay.roundTripMs / 1000,
944
+ audio: { packetsLost: receive?.audio.framesLost, bytesSent: send.audio.bytesSent, bytesReceived: receive?.audio.bytesReceived },
945
+ video: { packetsLost: receive?.video.framesLost, bytesSent: send.video.bytesSent, bytesReceived: receive?.video.bytesReceived },
946
+ relay,
947
+ };
948
+ }
949
+
928
950
  function sameState(a: ParticipantState, b: ParticipantState): boolean {
929
951
  return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
930
952
  }
@@ -139,6 +139,71 @@ export interface ConnectionDiagnostics {
139
139
  roundTripTimeSeconds?: number;
140
140
  audio: RtpStreamDiagnostics;
141
141
  video: RtpStreamDiagnostics;
142
+ /** The WebSocket relay's own counters, present only for a `"websocket"` pair - `audio`/`video` then carry the
143
+ * subset of them that maps onto RTP stats (frames lost, bytes each way). */
144
+ relay?: RelayDiagnostics;
145
+ }
146
+
147
+ /** What the relay's sending half did with one kind of frame, cumulative since the relay transport was created. */
148
+ export interface RelaySendStats {
149
+ framesSent: number;
150
+ /** Frames encoded but not sent: the socket was not ready, backed up, or refused a fragment. */
151
+ framesDropped: number;
152
+ bytesSent: number;
153
+ }
154
+
155
+ /** What the relay's sending half has done, cumulative since the relay transport was created. It is per browser, not
156
+ * per peer: every relayed peer receives the same stream. */
157
+ export interface RelaySendDiagnostics {
158
+ /** Milliseconds of microphone audio the capture path delivered, against `audioCaptureWallMs` - the wall-clock time
159
+ * it was capturing for. Well short of it means capture is losing whole blocks. */
160
+ audioCapturedMs: number;
161
+ audioCaptureWallMs: number;
162
+ /** How much of `audioCapturedMs` was digital silence (see `SilenceMeter`) - a live microphone always has some
163
+ * noise, so a large share means the capture path is handing the encoder zero-filled blocks. */
164
+ audioSilentMs: number;
165
+ /** Milliseconds of captured audio not encoded because the encoder was too far behind. */
166
+ audioEncoderSkippedMs: number;
167
+ audio: RelaySendStats;
168
+ video: RelaySendStats;
169
+ }
170
+
171
+ /** What the relay's receiving half got of one kind of frame from one peer, cumulative. */
172
+ export interface RelayReceiveStats {
173
+ framesReceived: number;
174
+ /** Frames the sequence numbers say never arrived complete - dropped by the sender, the server or in transit. */
175
+ framesLost: number;
176
+ bytesReceived: number;
177
+ }
178
+
179
+ /** What the relay's receiving half has done with one peer's media, cumulative since it started receiving from them. */
180
+ export interface RelayReceiveDiagnostics {
181
+ audio: RelayReceiveStats & {
182
+ /** Milliseconds of decoded audio scheduled for playback. */
183
+ playedMs: number;
184
+ /** How much of `playedMs` was digital silence - silence that arrived inside the packets, i.e. the sender sent
185
+ * it, as opposed to `gapMs`. */
186
+ silentMs: number;
187
+ /** Times playback ran dry and restarted, and the total silence that inserted. */
188
+ gaps: number;
189
+ gapMs: number;
190
+ /** Packets dropped on arrival because the decoder was backed up or playback was already too far ahead. */
191
+ droppedLate: number;
192
+ /** How far ahead of the clock audio is scheduled right now - the receive buffer's current depth. */
193
+ bufferedMs: number;
194
+ };
195
+ video: RelayReceiveStats;
196
+ }
197
+
198
+ /** One sample of the relay's counters for one peer. */
199
+ export interface RelayDiagnostics {
200
+ /** The latest round trip from this browser to the relay server, in milliseconds - not to the peer: the relay has
201
+ * no direct path between the two, and each end only knows its own leg. `undefined` before the first pong, or for
202
+ * a server too old to answer pings. */
203
+ roundTripMs?: number;
204
+ send: RelaySendDiagnostics;
205
+ /** `undefined` when nothing is being received from this peer. */
206
+ receive?: RelayReceiveDiagnostics;
142
207
  }
143
208
 
144
209
  /** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
@@ -206,6 +271,9 @@ export interface RelayTransportLike {
206
271
  setSending(active: boolean): void;
207
272
  /** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
208
273
  setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
274
+ /** The relay's counters as they stand for `peerId`, for the diagnostics panel. Optional so a test's fake relay
275
+ * needn't provide it; the mesh then shows nothing for a relayed pair. */
276
+ diagnostics?(peerId: string): RelayDiagnostics;
209
277
  close(): void;
210
278
  }
211
279
 
@@ -222,8 +290,9 @@ export interface MeshParticipant extends ParticipantState {
222
290
  /** How this participant's media currently reaches the local one - see `MediaTransport`. */
223
291
  transport: MediaTransport;
224
292
  /** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
225
- * first poll completes (shortly after `transport` first leaves `"connecting"`), and never present at all for
226
- * a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll). */
293
+ * first poll completes (shortly after `transport` first leaves `"connecting"`, or the pair moves to the relay).
294
+ * For a `"websocket"`-relayed participant they come from the relay's own counters (`relay`) rather than an
295
+ * `RTCPeerConnection`. */
227
296
  diagnostics?: ConnectionDiagnostics;
228
297
  }
229
298
 
@@ -10,11 +10,23 @@
10
10
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
11
11
  * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
12
12
  * `_CallView.tsx`'s doc comment.
13
+ *
14
+ * ## The "…" menu
15
+ *
16
+ * Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
17
+ * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
18
+ * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
19
+ * `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
20
+ * forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
21
+ * "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
22
+ * grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
23
+ * where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
24
+ * Escape/outside click like every other one here).
13
25
  */
14
26
  import React from "react";
15
27
  import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
16
28
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
17
- import { type MeshParticipant } from "../shared/webrtc/types.js";
29
+ import { type MeshParticipant, type TransportMode } from "../shared/webrtc/types.js";
18
30
  export type CallViewMode = "grid" | "focus";
19
31
  export interface CallControlsProps {
20
32
  media: LocalMedia;
@@ -38,10 +50,20 @@ export interface CallControlsProps {
38
50
  * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
39
51
  * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
40
52
  micLocked?: boolean;
53
+ /** Whether the "…" menu's "Settings" item is offered at all - see this module's doc comment on the menu. */
54
+ isHost: boolean;
55
+ transportMode: TransportMode;
56
+ onSetTransportMode: (mode: TransportMode) => void;
57
+ onOpenDiagnostics: () => void;
58
+ onOpenSettings: () => void;
59
+ /** Whether the local tile is currently hidden - the "…" menu's "Hide self"/"Show self" item reflects and
60
+ * toggles this; see `_CallView.tsx`'s doc comment on where the local tile appears. */
61
+ selfHidden: boolean;
62
+ onToggleSelfHidden: () => void;
41
63
  onLeave: () => void;
42
64
  }
43
65
  /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
44
66
  export declare function LevelBars({ level }: {
45
67
  level: number;
46
68
  }): React.JSX.Element;
47
- export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }: CallControlsProps): React.JSX.Element;
69
+ export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, onLeave, }: CallControlsProps): React.JSX.Element;
@@ -15,12 +15,31 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
15
15
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
16
16
  * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
17
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; "Hide self"/
26
+ * "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
27
+ * grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
28
+ * where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
29
+ * Escape/outside click like every other one here).
18
30
  */
19
31
  import { useEffect, useRef, useState } from "react";
20
32
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
21
33
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
22
34
  import EffectsPanel from "./_EffectsPanel.js";
23
- import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
35
+ import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, OverflowIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
36
+ /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
37
+ const TRANSPORT_MODE_OPTIONS = [
38
+ { mode: "auto", label: "Auto" },
39
+ { mode: "p2p", label: "P2P" },
40
+ { mode: "relay", label: "Relay" },
41
+ { mode: "websocket", label: "WebSocket Relay" },
42
+ ];
24
43
  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";
25
44
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
26
45
  const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
@@ -29,7 +48,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
29
48
  export function LevelBars({ level }) {
30
49
  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))) }));
31
50
  }
32
- export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }) {
51
+ export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, onLeave, }) {
33
52
  const [openMenu, setOpenMenu] = useState(null);
34
53
  const barRef = useRef(null);
35
54
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
@@ -61,7 +80,19 @@ export default function CallControls({ media, participants, isPresenting, presen
61
80
  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: () => {
62
81
  onReaction(emoji);
63
82
  setOpenMenu(null);
64
- }, 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, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
83
+ }, 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: () => {
84
+ onOpenDiagnostics();
85
+ setOpenMenu(null);
86
+ }, children: "Diagnostics" }), _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
+ onToggleSelfHidden();
88
+ setOpenMenu(null);
89
+ }, children: selfHidden ? "Show self" : "Hide self" }), _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: () => {
90
+ onSetTransportMode(mode);
91
+ setOpenMenu(null);
92
+ }, 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: () => {
93
+ onOpenSettings();
94
+ setOpenMenu(null);
95
+ }, 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, {}) })] }));
65
96
  }
66
97
  /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
67
98
  * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
@@ -11,10 +11,24 @@
11
11
  *
12
12
  * The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
13
13
  * is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
14
- * the branded page shell, whose header, footer and padding pushed it off the screen. The local participant's own
15
- * tile is a small tile in the bottom-right corner while anyone else is in the call (above the control bar on a narrower
16
- * window, and at the top on a phone, where the bar wraps onto a second row), and fills the tile area while they are
17
- * alone.
14
+ * the branded page shell, whose header, footer and padding pushed it off the screen.
15
+ *
16
+ * ## Where the local tile appears
17
+ *
18
+ * In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
19
+ * everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
20
+ * genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
21
+ * tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
22
+ * is reserved for focus/presentation layouts, where there's already a single large tile the local one would
23
+ * otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
24
+ * (see the `alone` branch of the `stage` computation).
25
+ *
26
+ * `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
27
+ * it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
28
+ * directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
29
+ * "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
30
+ * nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
31
+ * just blank it, so the local tile ignores `selfHidden` there and always shows.
18
32
  *
19
33
  * ## Who is heard
20
34
  *
@@ -47,18 +61,17 @@
47
61
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
48
62
  * the floor, until the host picks someone (or themselves) again.
49
63
  *
50
- * ## The navbar's "…" menu
64
+ * ## Diagnostics, connection method and settings
51
65
  *
52
- * A catch-all for controls that don't need a dedicated button of their own: "Diagnostics" opens
53
- * `_DiagnosticsWindow.tsx` (a persistent, draggable window - not a popover like everything else here, since
54
- * troubleshooting a connection is something a participant wants to keep open while they poke at the rest of the
55
- * call, not something that closes the moment they click elsewhere); "Connection method" is a four-way choice
56
- * (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment) for forcing how *this tab's*
57
- * media reaches everyone else, independent of what anyone else has chosen; "Settings" (host-only) opens
58
- * `_SettingsModal.tsx`, which used to be the participants drawer's own header (mute-on-join, the join password, the
59
- * waiting-room toggle) - moved out because none of those are about *who* is in the call, which is what the drawer
60
- * is for. The menu itself closes on Escape, a click outside it, or picking an item; the windows it opens do not
61
- * close along with it.
66
+ * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
67
+ * callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
68
+ * window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
69
+ * participant wants to keep open while they poke at the rest of the call, not something that closes the moment
70
+ * they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
71
+ * else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
72
+ * anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
73
+ * own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
74
+ * about *who* is in the call, which is what the drawer is for.
62
75
  */
63
76
  import React from "react";
64
77
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";