@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
@@ -14,12 +14,24 @@
14
14
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
15
15
  * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
16
16
  * `_CallView.tsx`'s doc comment.
17
+ *
18
+ * ## The "…" menu
19
+ *
20
+ * Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
21
+ * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
22
+ * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
23
+ * `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
24
+ * forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
25
+ * "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
26
+ * grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
27
+ * where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
28
+ * Escape/outside click like every other one here).
17
29
  */
18
30
  import React, { useEffect, useRef, useState } from "react";
19
31
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
20
32
  import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
21
33
  import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
22
- import { REACTION_EMOJIS, type MeshParticipant } from "../shared/webrtc/types.js";
34
+ import { REACTION_EMOJIS, type MeshParticipant, type TransportMode } from "../shared/webrtc/types.js";
23
35
  import EffectsPanel from "./_EffectsPanel.js";
24
36
  import {
25
37
  ChevronUpIcon,
@@ -32,12 +44,21 @@ import {
32
44
  LeaveIcon,
33
45
  MicIcon,
34
46
  MicOffIcon,
47
+ OverflowIcon,
35
48
  RotateIcon,
36
49
  ScreenShareIcon,
37
50
  VideoIcon,
38
51
  VideoOffIcon,
39
52
  } from "./_icons.js";
40
53
 
54
+ /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
55
+ const TRANSPORT_MODE_OPTIONS: { mode: TransportMode; label: string }[] = [
56
+ { mode: "auto", label: "Auto" },
57
+ { mode: "p2p", label: "P2P" },
58
+ { mode: "relay", label: "Relay" },
59
+ { mode: "websocket", label: "WebSocket Relay" },
60
+ ];
61
+
41
62
  export type CallViewMode = "grid" | "focus";
42
63
 
43
64
  export interface CallControlsProps {
@@ -62,10 +83,20 @@ export interface CallControlsProps {
62
83
  * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
63
84
  * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
64
85
  micLocked?: boolean;
86
+ /** Whether the "…" menu's "Settings" item is offered at all - see this module's doc comment on the menu. */
87
+ isHost: boolean;
88
+ transportMode: TransportMode;
89
+ onSetTransportMode: (mode: TransportMode) => void;
90
+ onOpenDiagnostics: () => void;
91
+ onOpenSettings: () => void;
92
+ /** Whether the local tile is currently hidden - the "…" menu's "Hide self"/"Show self" item reflects and
93
+ * toggles this; see `_CallView.tsx`'s doc comment on where the local tile appears. */
94
+ selfHidden: boolean;
95
+ onToggleSelfHidden: () => void;
65
96
  onLeave: () => void;
66
97
  }
67
98
 
68
- type OpenMenu = MediaKind | "emoji" | "effects" | null;
99
+ type OpenMenu = MediaKind | "emoji" | "effects" | "overflow" | null;
69
100
 
70
101
  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";
71
102
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
@@ -102,6 +133,13 @@ export default function CallControls({
102
133
  viewMode,
103
134
  onToggleViewMode,
104
135
  micLocked,
136
+ isHost,
137
+ transportMode,
138
+ onSetTransportMode,
139
+ onOpenDiagnostics,
140
+ onOpenSettings,
141
+ selfHidden,
142
+ onToggleSelfHidden,
105
143
  onLeave,
106
144
  }: CallControlsProps) {
107
145
  const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
@@ -307,6 +345,85 @@ export default function CallControls({
307
345
  {viewMode === "grid" ? <FocusIcon /> : <GridIcon />}
308
346
  </button>
309
347
 
348
+ <div className="sm:relative">
349
+ <button
350
+ type="button"
351
+ className={`${BUTTON} w-12 ${openMenu === "overflow" ? ACTIVE : NEUTRAL}`}
352
+ aria-label="More options"
353
+ aria-haspopup="menu"
354
+ aria-expanded={openMenu === "overflow"}
355
+ onClick={() => toggleMenu("overflow")}
356
+ >
357
+ <OverflowIcon />
358
+ </button>
359
+ {openMenu === "overflow" && (
360
+ <div
361
+ role="menu"
362
+ aria-label="More options"
363
+ 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"
364
+ >
365
+ <button
366
+ type="button"
367
+ role="menuitem"
368
+ 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"
369
+ onClick={() => {
370
+ onOpenDiagnostics();
371
+ setOpenMenu(null);
372
+ }}
373
+ >
374
+ Diagnostics
375
+ </button>
376
+ <button
377
+ type="button"
378
+ role="menuitem"
379
+ 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"
380
+ onClick={() => {
381
+ onToggleSelfHidden();
382
+ setOpenMenu(null);
383
+ }}
384
+ >
385
+ {selfHidden ? "Show self" : "Hide self"}
386
+ </button>
387
+ <div className="my-1 border-t border-white/10" />
388
+ <p className="px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60">Connection method</p>
389
+ {TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (
390
+ <button
391
+ key={mode}
392
+ type="button"
393
+ role="menuitemradio"
394
+ aria-checked={transportMode === mode}
395
+ 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"
396
+ onClick={() => {
397
+ onSetTransportMode(mode);
398
+ setOpenMenu(null);
399
+ }}
400
+ >
401
+ <span className="w-4 text-[#8ab4f8]" aria-hidden="true">
402
+ {transportMode === mode ? "✓" : ""}
403
+ </span>
404
+ {label}
405
+ </button>
406
+ ))}
407
+ {isHost && (
408
+ <>
409
+ <div className="my-1 border-t border-white/10" />
410
+ <button
411
+ type="button"
412
+ role="menuitem"
413
+ 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"
414
+ onClick={() => {
415
+ onOpenSettings();
416
+ setOpenMenu(null);
417
+ }}
418
+ >
419
+ Settings
420
+ </button>
421
+ </>
422
+ )}
423
+ </div>
424
+ )}
425
+ </div>
426
+
310
427
  <button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={() => onLeave()}>
311
428
  <LeaveIcon />
312
429
  </button>
@@ -15,10 +15,24 @@
15
15
  *
16
16
  * The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
17
17
  * is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
18
- * the branded page shell, whose header, footer and padding pushed it off the screen. The local participant's own
19
- * tile is a small tile in the bottom-right corner while anyone else is in the call (above the control bar on a narrower
20
- * window, and at the top on a phone, where the bar wraps onto a second row), and fills the tile area while they are
21
- * alone.
18
+ * the branded page shell, whose header, footer and padding pushed it off the screen.
19
+ *
20
+ * ## Where the local tile appears
21
+ *
22
+ * In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
23
+ * everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
24
+ * genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
25
+ * tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
26
+ * is reserved for focus/presentation layouts, where there's already a single large tile the local one would
27
+ * otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
28
+ * (see the `alone` branch of the `stage` computation).
29
+ *
30
+ * `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
31
+ * it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
32
+ * directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
33
+ * "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
34
+ * nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
35
+ * just blank it, so the local tile ignores `selfHidden` there and always shows.
22
36
  *
23
37
  * ## Who is heard
24
38
  *
@@ -51,18 +65,17 @@
51
65
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
52
66
  * the floor, until the host picks someone (or themselves) again.
53
67
  *
54
- * ## The navbar's "…" menu
68
+ * ## Diagnostics, connection method and settings
55
69
  *
56
- * A catch-all for controls that don't need a dedicated button of their own: "Diagnostics" opens
57
- * `_DiagnosticsWindow.tsx` (a persistent, draggable window - not a popover like everything else here, since
58
- * troubleshooting a connection is something a participant wants to keep open while they poke at the rest of the
59
- * call, not something that closes the moment they click elsewhere); "Connection method" is a four-way choice
60
- * (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment) for forcing how *this tab's*
61
- * media reaches everyone else, independent of what anyone else has chosen; "Settings" (host-only) opens
62
- * `_SettingsModal.tsx`, which used to be the participants drawer's own header (mute-on-join, the join password, the
63
- * waiting-room toggle) - moved out because none of those are about *who* is in the call, which is what the drawer
64
- * is for. The menu itself closes on Escape, a click outside it, or picking an item; the windows it opens do not
65
- * close along with it.
70
+ * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
71
+ * callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
72
+ * window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
73
+ * participant wants to keep open while they poke at the rest of the call, not something that closes the moment
74
+ * they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
75
+ * else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
76
+ * anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
77
+ * own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
78
+ * about *who* is in the call, which is what the drawer is for.
66
79
  */
67
80
  import React, { useCallback, useEffect, useRef, useState } from "react";
68
81
  import { playRaisedHandChime } from "../shared/media/chime.js";
@@ -77,7 +90,7 @@ import type { MeshParticipant, RelayTransportLike, TransportMode } from "../shar
77
90
  import { createRelayTransport } from "../shared/relay/RelayTransport.js";
78
91
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
79
92
  import CallControls, { type CallViewMode } from "./_CallControls.js";
80
- import { BatonIcon, OverflowIcon } from "./_icons.js";
93
+ import { BatonIcon } from "./_icons.js";
81
94
  import {
82
95
  admitParticipant,
83
96
  denyParticipant,
@@ -139,14 +152,6 @@ const MAX_REACTIONS = 12;
139
152
  /** How often the host's drawer refreshes its own waiting-room list while open. */
140
153
  const DEFAULT_WAITING_POLL_MS = 3_000;
141
154
 
142
- /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
143
- const TRANSPORT_MODE_OPTIONS: { mode: TransportMode; label: string }[] = [
144
- { mode: "auto", label: "Auto" },
145
- { mode: "p2p", label: "P2P" },
146
- { mode: "relay", label: "Relay" },
147
- { mode: "websocket", label: "WebSocket Relay" },
148
- ];
149
-
150
155
  interface Reaction {
151
156
  id: number;
152
157
  emoji: string;
@@ -229,13 +234,12 @@ export default function CallView({
229
234
  const [waitingParticipants, setWaitingParticipants] = useState<WaitingParticipant[]>([]);
230
235
  const [talkingStickActive, setTalkingStickActive] = useState(false);
231
236
  const [talkingStickHolder, setTalkingStickHolder] = useState<string | undefined>(undefined);
232
- const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
233
237
  const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
234
238
  const [settingsOpen, setSettingsOpen] = useState(false);
235
239
  const [transportMode, setTransportModeState] = useState<TransportMode>("auto");
240
+ const [selfHidden, setSelfHidden] = useState(false);
236
241
 
237
242
  const managerRef = useRef<MeshConnectionManager | null>(null);
238
- const overflowMenuRef = useRef<HTMLDivElement>(null);
239
243
  /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
240
244
  * indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
241
245
  * actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
@@ -409,31 +413,6 @@ export default function CallView({
409
413
  }
410
414
  }, [talkingStickActive, selfHasTalkingStick]);
411
415
 
412
- // The "…" menu closes on Escape or a press anywhere outside it - matching `_CallControls.tsx`'s own menus -
413
- // but never the windows it opens (diagnostics, settings), which stay open independently (see this module's
414
- // doc comment on the "…" menu).
415
- useEffect(() => {
416
- if (!overflowMenuOpen) {
417
- return;
418
- }
419
- const onPointerDown = (event: PointerEvent) => {
420
- if (!overflowMenuRef.current?.contains(event.target as Node)) {
421
- setOverflowMenuOpen(false);
422
- }
423
- };
424
- const onKeyDown = (event: KeyboardEvent) => {
425
- if (event.key === "Escape") {
426
- setOverflowMenuOpen(false);
427
- }
428
- };
429
- document.addEventListener("pointerdown", onPointerDown);
430
- document.addEventListener("keydown", onKeyDown);
431
- return () => {
432
- document.removeEventListener("pointerdown", onPointerDown);
433
- document.removeEventListener("keydown", onKeyDown);
434
- };
435
- }, [overflowMenuOpen]);
436
-
437
416
  useEffect(() => {
438
417
  if (!presenterUid) {
439
418
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
@@ -505,6 +484,17 @@ export default function CallView({
505
484
  setHandRaised((prev) => !prev);
506
485
  }
507
486
 
487
+ /** Hides the local tile - the tile's own "…" menu calls this directly (one-directional: there is no "show
488
+ * self" from in-grid, since a hidden tile isn't there to click on). Re-showing it is the navbar "…" menu's
489
+ * `handleToggleSelfHidden()` instead. */
490
+ function handleHideSelf() {
491
+ setSelfHidden(true);
492
+ }
493
+
494
+ function handleToggleSelfHidden() {
495
+ setSelfHidden((prev) => !prev);
496
+ }
497
+
508
498
  function handleReaction(emoji: string) {
509
499
  if (managerRef.current?.sendReaction(emoji)) {
510
500
  showReaction(emoji, "You");
@@ -573,12 +563,11 @@ export default function CallView({
573
563
  }
574
564
 
575
565
  /** Forces this tab's own transport policy - see `MeshConnectionManager.setTransportMode()`'s doc comment for
576
- * what each mode actually does, and this module's doc comment on the "…" menu for why this is a personal,
577
- * per-tab choice rather than a meeting setting. */
566
+ * what each mode actually does, and `_CallControls.tsx`'s doc comment on its "…" menu for why this is a
567
+ * personal, per-tab choice rather than a meeting setting. */
578
568
  function handleSetTransportMode(mode: TransportMode) {
579
569
  setTransportModeState(mode);
580
570
  managerRef.current?.setTransportMode(mode);
581
- setOverflowMenuOpen(false);
582
571
  }
583
572
 
584
573
  /** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
@@ -673,7 +662,7 @@ export default function CallView({
673
662
  />
674
663
  );
675
664
 
676
- const selfTile = (className?: string) => (
665
+ const selfTile = (className?: string, canHide?: boolean) => (
677
666
  <ParticipantTile
678
667
  name={selfName}
679
668
  stream={media.videoStream}
@@ -685,6 +674,7 @@ export default function CallView({
685
674
  // A custom background is a fixed picture, not a live reflection - mirroring it would show it backwards
686
675
  // to no one but the participant themselves (see `_ParticipantTile.tsx`'s doc comment).
687
676
  mirrored={media.filters.background !== "image"}
677
+ onHideSelf={canHide ? handleHideSelf : undefined}
688
678
  className={className}
689
679
  />
690
680
  );
@@ -694,7 +684,8 @@ export default function CallView({
694
684
 
695
685
  let stage: React.ReactNode;
696
686
  if (alone) {
697
- // Nobody else yet: the local participant fills the tile area, presenting or not.
687
+ // Nobody else yet: the local participant fills the tile area, presenting or not - and is never hideable,
688
+ // since there'd be nothing left to look at (see this module's doc comment on hiding the local tile).
698
689
  stage = isPresenting ? (
699
690
  <ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
700
691
  ) : (
@@ -722,8 +713,11 @@ export default function CallView({
722
713
  </div>
723
714
  );
724
715
  } else {
716
+ // Plain grid, nobody presenting: the local tile joins the grid as an ordinary tile (unless hidden) instead
717
+ // of floating in a corner - see this module's doc comment.
725
718
  stage = (
726
719
  <div className="h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]">
720
+ {!selfHidden && selfTile("h-full", true)}
727
721
  {participants.map((p) => remoteTile(p, "h-full"))}
728
722
  </div>
729
723
  );
@@ -768,70 +762,6 @@ export default function CallView({
768
762
  >
769
763
  {participants.length + 1}
770
764
  </button>
771
- <div className="relative" ref={overflowMenuRef}>
772
- <button
773
- type="button"
774
- className={`w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${overflowMenuOpen ? "bg-[#4b4f53]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`}
775
- aria-label="More options"
776
- aria-haspopup="menu"
777
- aria-expanded={overflowMenuOpen}
778
- onClick={() => setOverflowMenuOpen((prev) => !prev)}
779
- >
780
- <OverflowIcon />
781
- </button>
782
- {overflowMenuOpen && (
783
- <div
784
- role="menu"
785
- aria-label="More options"
786
- className="absolute top-full right-0 mt-2 w-56 rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm"
787
- >
788
- <button
789
- type="button"
790
- role="menuitem"
791
- 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"
792
- onClick={() => {
793
- setDiagnosticsOpen(true);
794
- setOverflowMenuOpen(false);
795
- }}
796
- >
797
- Diagnostics
798
- </button>
799
- <div className="my-1 border-t border-white/10" />
800
- <p className="px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60">Connection method</p>
801
- {TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (
802
- <button
803
- key={mode}
804
- type="button"
805
- role="menuitemradio"
806
- aria-checked={transportMode === mode}
807
- 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"
808
- onClick={() => handleSetTransportMode(mode)}
809
- >
810
- <span className="w-4 text-[#8ab4f8]" aria-hidden="true">
811
- {transportMode === mode ? "✓" : ""}
812
- </span>
813
- {label}
814
- </button>
815
- ))}
816
- {isHost && (
817
- <>
818
- <div className="my-1 border-t border-white/10" />
819
- <button
820
- type="button"
821
- role="menuitem"
822
- 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"
823
- onClick={() => {
824
- setSettingsOpen(true);
825
- setOverflowMenuOpen(false);
826
- }}
827
- >
828
- Settings
829
- </button>
830
- </>
831
- )}
832
- </div>
833
- )}
834
- </div>
835
765
  </div>
836
766
  </header>
837
767
  {connectError && (
@@ -870,16 +800,23 @@ export default function CallView({
870
800
  viewMode={viewMode}
871
801
  onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
872
802
  micLocked={micLocked}
803
+ selfHidden={selfHidden}
804
+ onToggleSelfHidden={handleToggleSelfHidden}
805
+ isHost={isHost}
806
+ transportMode={transportMode}
807
+ onSetTransportMode={handleSetTransportMode}
808
+ onOpenDiagnostics={() => setDiagnosticsOpen(true)}
809
+ onOpenSettings={() => setSettingsOpen(true)}
873
810
  onLeave={onLeave}
874
811
  />
875
812
  </footer>
876
813
 
877
- {!alone && (
814
+ {!alone && !selfHidden && showFocusLayout && (
878
815
  <div
879
816
  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"
880
817
  data-testid="self-view"
881
818
  >
882
- {selfTile("h-full")}
819
+ {selfTile("h-full", true)}
883
820
  </div>
884
821
  )}
885
822
 
@@ -10,12 +10,14 @@
10
10
  * (`MeshParticipant.diagnostics`, refreshed every few seconds by `MeshConnectionManager`'s own poller - see its doc
11
11
  * comment). Purely informational and entirely local: nothing here is sent anywhere.
12
12
  *
13
- * `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up) and always
14
- * absent for a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll there) - both cases show
15
- * "Not available" rather than a blank space, so it never looks like the panel forgot to load.
13
+ * `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up, or the pair moves
14
+ * to the server relay), shown as "Not available yet" rather than a blank space, so it never looks like the panel
15
+ * forgot to load. For a `"websocket"`-relayed participant the round trip is this browser's own leg to the relay
16
+ * server (the relay has no direct path to the peer), and the relay's own counters follow (`RelayRows`): how much of this browser's microphone audio the capture path
17
+ * delivered and how much of it was digital silence, and how this peer's audio is playing back here.
16
18
  */
17
19
  import React from "react";
18
- import type { MeshParticipant } from "../shared/webrtc/types.js";
20
+ import type { MeshParticipant, RelayDiagnostics } from "../shared/webrtc/types.js";
19
21
  import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
20
22
 
21
23
  export interface DiagnosticsPanelProps {
@@ -64,6 +66,59 @@ function StreamRow({ kind, packetsLost, jitter, bytesSent, bytesReceived }: { ki
64
66
  );
65
67
  }
66
68
 
69
+ /** `part` as a whole-number percentage of `whole`, or "—" when there is no whole yet. */
70
+ function percent(part: number, whole: number): string {
71
+ return whole > 0 ? `${Math.round((part / whole) * 100)}%` : "—";
72
+ }
73
+
74
+ function plural(count: number, noun: string): string {
75
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
76
+ }
77
+
78
+ /** The server relay's own counters (see `RelayDiagnostics`). The capture row is this browser's own microphone, the
79
+ * playback row this peer's audio as heard here - so a mostly silent capture points at the sender, silence arriving in
80
+ * playback with no capture problem points at the other end's capture, and gaps point at the network. */
81
+ function RelayRows({ relay }: { relay: RelayDiagnostics }) {
82
+ const { send, receive } = relay;
83
+ const notSent = send.audio.framesDropped + send.video.framesDropped;
84
+ return (
85
+ <>
86
+ {send.audioCaptureWallMs > 0 && (
87
+ <div className={ROW}>
88
+ <span className={LABEL}>Your mic capture</span>
89
+ <span>
90
+ {percent(send.audioCapturedMs, send.audioCaptureWallMs)} of real time · {percent(send.audioSilentMs, send.audioCapturedMs)} silent
91
+ {send.audioEncoderSkippedMs > 0 && ` · ${formatMs(send.audioEncoderSkippedMs / 1000)} skipped`}
92
+ </span>
93
+ </div>
94
+ )}
95
+ {notSent > 0 && (
96
+ <div className={ROW}>
97
+ <span className={LABEL}>Not sent</span>
98
+ <span>
99
+ {send.audio.framesDropped} audio · {send.video.framesDropped} video frames
100
+ </span>
101
+ </div>
102
+ )}
103
+ {receive && (
104
+ <div className={ROW}>
105
+ <span className={LABEL}>Playback</span>
106
+ <span>
107
+ {`${percent(receive.audio.silentMs, receive.audio.playedMs)} silent · ${plural(receive.audio.gaps, "gap")} ` +
108
+ `(${formatMs(receive.audio.gapMs / 1000)}) · ${formatMs(receive.audio.bufferedMs / 1000)} buffered`}
109
+ </span>
110
+ </div>
111
+ )}
112
+ {receive && receive.audio.droppedLate > 0 && (
113
+ <div className={ROW}>
114
+ <span className={LABEL}>Dropped late</span>
115
+ <span>{plural(receive.audio.droppedLate, "packet")}</span>
116
+ </div>
117
+ )}
118
+ </>
119
+ );
120
+ }
121
+
67
122
  export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }: DiagnosticsPanelProps) {
68
123
  return (
69
124
  <div className="text-sm">
@@ -93,16 +148,19 @@ export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participan
93
148
  <span className={LABEL}>Connection</span>
94
149
  <span title={badge?.title}>{badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct")}</span>
95
150
  </div>
96
- {p.transport === "websocket" || !p.diagnostics ? (
97
- <p className={`${LABEL} py-0.5`}>Not available{p.transport !== "websocket" && " yet"}.</p>
151
+ {!p.diagnostics ? (
152
+ <p className={`${LABEL} py-0.5`}>Not available yet.</p>
98
153
  ) : (
99
154
  <>
100
155
  <div className={ROW}>
101
- <span className={LABEL}>Round-trip time</span>
156
+ <span className={LABEL} title={p.diagnostics.relay ? "From this browser to the relay server and back" : undefined}>
157
+ {p.diagnostics.relay ? "Round-trip to server" : "Round-trip time"}
158
+ </span>
102
159
  <span>{formatMs(p.diagnostics.roundTripTimeSeconds)}</span>
103
160
  </div>
104
161
  <StreamRow kind="Audio" {...p.diagnostics.audio} />
105
162
  <StreamRow kind="Video" {...p.diagnostics.video} />
163
+ {p.diagnostics.relay && <RelayRows relay={p.diagnostics.relay} />}
106
164
  </>
107
165
  )}
108
166
  </div>