@rapidmx/meet-plugin 0.14.1 → 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 +22 -2
  2. package/apps/meet/_CallView.tsx +41 -8
  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 +10 -3
  17. package/dist/apps/meet/_CallControls.js +10 -4
  18. package/dist/apps/meet/_CallView.d.ts +18 -4
  19. package/dist/apps/meet/_CallView.js +36 -9
  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
@@ -21,8 +21,11 @@
21
21
  * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
22
22
  * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
23
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. Otherwise an
25
- * ordinary menu of this bar's own shape (`openMenu`, closes on Escape/outside click like every other one here).
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).
26
29
  */
27
30
  import React, { useEffect, useRef, useState } from "react";
28
31
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
@@ -86,6 +89,10 @@ export interface CallControlsProps {
86
89
  onSetTransportMode: (mode: TransportMode) => void;
87
90
  onOpenDiagnostics: () => void;
88
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;
89
96
  onLeave: () => void;
90
97
  }
91
98
 
@@ -131,6 +138,8 @@ export default function CallControls({
131
138
  onSetTransportMode,
132
139
  onOpenDiagnostics,
133
140
  onOpenSettings,
141
+ selfHidden,
142
+ onToggleSelfHidden,
134
143
  onLeave,
135
144
  }: CallControlsProps) {
136
145
  const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
@@ -364,6 +373,17 @@ export default function CallControls({
364
373
  >
365
374
  Diagnostics
366
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>
367
387
  <div className="my-1 border-t border-white/10" />
368
388
  <p className="px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60">Connection method</p>
369
389
  {TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (
@@ -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
  *
@@ -223,6 +237,7 @@ export default function CallView({
223
237
  const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
224
238
  const [settingsOpen, setSettingsOpen] = useState(false);
225
239
  const [transportMode, setTransportModeState] = useState<TransportMode>("auto");
240
+ const [selfHidden, setSelfHidden] = useState(false);
226
241
 
227
242
  const managerRef = useRef<MeshConnectionManager | null>(null);
228
243
  /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
@@ -469,6 +484,17 @@ export default function CallView({
469
484
  setHandRaised((prev) => !prev);
470
485
  }
471
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
+
472
498
  function handleReaction(emoji: string) {
473
499
  if (managerRef.current?.sendReaction(emoji)) {
474
500
  showReaction(emoji, "You");
@@ -636,7 +662,7 @@ export default function CallView({
636
662
  />
637
663
  );
638
664
 
639
- const selfTile = (className?: string) => (
665
+ const selfTile = (className?: string, canHide?: boolean) => (
640
666
  <ParticipantTile
641
667
  name={selfName}
642
668
  stream={media.videoStream}
@@ -648,6 +674,7 @@ export default function CallView({
648
674
  // A custom background is a fixed picture, not a live reflection - mirroring it would show it backwards
649
675
  // to no one but the participant themselves (see `_ParticipantTile.tsx`'s doc comment).
650
676
  mirrored={media.filters.background !== "image"}
677
+ onHideSelf={canHide ? handleHideSelf : undefined}
651
678
  className={className}
652
679
  />
653
680
  );
@@ -657,7 +684,8 @@ export default function CallView({
657
684
 
658
685
  let stage: React.ReactNode;
659
686
  if (alone) {
660
- // 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).
661
689
  stage = isPresenting ? (
662
690
  <ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
663
691
  ) : (
@@ -685,8 +713,11 @@ export default function CallView({
685
713
  </div>
686
714
  );
687
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.
688
718
  stage = (
689
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)}
690
721
  {participants.map((p) => remoteTile(p, "h-full"))}
691
722
  </div>
692
723
  );
@@ -769,6 +800,8 @@ export default function CallView({
769
800
  viewMode={viewMode}
770
801
  onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
771
802
  micLocked={micLocked}
803
+ selfHidden={selfHidden}
804
+ onToggleSelfHidden={handleToggleSelfHidden}
772
805
  isHost={isHost}
773
806
  transportMode={transportMode}
774
807
  onSetTransportMode={handleSetTransportMode}
@@ -778,12 +811,12 @@ export default function CallView({
778
811
  />
779
812
  </footer>
780
813
 
781
- {!alone && (
814
+ {!alone && !selfHidden && showFocusLayout && (
782
815
  <div
783
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"
784
817
  data-testid="self-view"
785
818
  >
786
- {selfTile("h-full")}
819
+ {selfTile("h-full", true)}
787
820
  </div>
788
821
  )}
789
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>
@@ -14,10 +14,13 @@
14
14
  * natural - this is a local display transform only, never applied to the video actually sent to anyone, so
15
15
  * everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
16
16
  * background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
17
- * reflection, and mirroring it would show it backwards to no one but themselves. */
18
- import React, { useEffect, useRef } from "react";
17
+ * reflection, and mirroring it would show it backwards to no one but themselves.
18
+ *
19
+ * `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
20
+ * hiding the local tile from grid view. */
21
+ import React, { useEffect, useRef, useState } from "react";
19
22
  import type { MediaTransport } from "../shared/webrtc/types.js";
20
- import { MicOffIcon } from "./_icons.js";
23
+ import { MicOffIcon, OverflowIcon } from "./_icons.js";
21
24
 
22
25
  /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
23
26
  * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
@@ -74,6 +77,10 @@ export interface ParticipantTileProps {
74
77
  /** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
75
78
  * mirrored. Default `true`; see this module's doc comment. */
76
79
  mirrored?: boolean;
80
+ /** Offers a small "…" menu in the tile's top-right corner with one item, "Hide self" - see `_CallView.tsx`'s
81
+ * doc comment on hiding the local tile. Omitted everywhere that shouldn't offer it: every remote tile, and the
82
+ * local tile while alone in the call, where hiding it would just blank the only thing on screen. */
83
+ onHideSelf?: () => void;
77
84
  onClick?: () => void;
78
85
  className?: string;
79
86
  }
@@ -94,12 +101,15 @@ export default function ParticipantTile({
94
101
  isFocused,
95
102
  contain,
96
103
  mirrored = true,
104
+ onHideSelf,
97
105
  onClick,
98
106
  className,
99
107
  }: ParticipantTileProps) {
100
108
  const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
101
109
  const videoRef = useRef<HTMLVideoElement>(null);
102
110
  const showVideo = !!stream && !cameraOff;
111
+ const [hideMenuOpen, setHideMenuOpen] = useState(false);
112
+ const hideMenuRef = useRef<HTMLDivElement>(null);
103
113
 
104
114
  // The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
105
115
  useEffect(() => {
@@ -108,6 +118,30 @@ export default function ParticipantTile({
108
118
  }
109
119
  }, [stream, showVideo]);
110
120
 
121
+ // The tile's own "…" menu closes on Escape or a press anywhere outside it, same shape as every other menu in
122
+ // this app.
123
+ useEffect(() => {
124
+ if (!hideMenuOpen) {
125
+ return;
126
+ }
127
+ const onPointerDown = (event: PointerEvent) => {
128
+ if (!hideMenuRef.current?.contains(event.target as Node)) {
129
+ setHideMenuOpen(false);
130
+ }
131
+ };
132
+ const onKeyDown = (event: KeyboardEvent) => {
133
+ if (event.key === "Escape") {
134
+ setHideMenuOpen(false);
135
+ }
136
+ };
137
+ document.addEventListener("pointerdown", onPointerDown);
138
+ document.addEventListener("keydown", onKeyDown);
139
+ return () => {
140
+ document.removeEventListener("pointerdown", onPointerDown);
141
+ document.removeEventListener("keydown", onKeyDown);
142
+ };
143
+ }, [hideMenuOpen]);
144
+
111
145
  return (
112
146
  <div
113
147
  className={`relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${
@@ -142,12 +176,51 @@ export default function ParticipantTile({
142
176
  ✋
143
177
  </span>
144
178
  )}
145
- {micMuted && (
146
- <span className="absolute top-2 right-2 flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
147
- <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
148
- <MicOffIcon />
149
- </span>
150
- </span>
179
+ {(micMuted || onHideSelf) && (
180
+ <div className="absolute top-2 right-2 flex items-center gap-1">
181
+ {micMuted && (
182
+ <span className="flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
183
+ <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
184
+ <MicOffIcon />
185
+ </span>
186
+ </span>
187
+ )}
188
+ {onHideSelf && (
189
+ <div className="relative" ref={hideMenuRef}>
190
+ <button
191
+ type="button"
192
+ className="flex items-center justify-center w-7 h-7 rounded-full bg-black/60 hover:bg-black/80 text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
193
+ aria-label="Local video options"
194
+ aria-haspopup="menu"
195
+ aria-expanded={hideMenuOpen}
196
+ onClick={() => setHideMenuOpen((prev) => !prev)}
197
+ >
198
+ <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
199
+ <OverflowIcon />
200
+ </span>
201
+ </button>
202
+ {hideMenuOpen && (
203
+ <div
204
+ role="menu"
205
+ aria-label="Local video options"
206
+ className="absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm"
207
+ >
208
+ <button
209
+ type="button"
210
+ role="menuitem"
211
+ className="w-full px-3 py-1.5 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
212
+ onClick={() => {
213
+ setHideMenuOpen(false);
214
+ onHideSelf();
215
+ }}
216
+ >
217
+ Hide self
218
+ </button>
219
+ </div>
220
+ )}
221
+ </div>
222
+ )}
223
+ </div>
151
224
  )}
152
225
  {badge && (
153
226
  <span
@@ -35,7 +35,7 @@ import React, { useEffect } from "react";
35
35
  import type { MeshParticipant } from "../shared/webrtc/types.js";
36
36
  import type { WaitingParticipant } from "./_meetApi.js";
37
37
  import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
38
- import { MicOffIcon } from "./_icons.js";
38
+ import { BatonIcon, MicOffIcon, RemoveIcon } from "./_icons.js";
39
39
 
40
40
  export interface ParticipantsDrawerProps {
41
41
  selfName: string;
@@ -184,35 +184,38 @@ function Row({
184
184
  {canGiveStick && !hasStick && (
185
185
  <button
186
186
  type="button"
187
- className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
187
+ className="w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4"
188
188
  aria-label={`Give the talking stick to ${name}`}
189
+ title={`Give the talking stick to ${name}`}
189
190
  onClick={onGiveStick}
190
191
  >
191
- Give stick
192
+ <BatonIcon />
192
193
  </button>
193
194
  )}
194
195
  {canModerate && !micMuted && (
195
196
  <button
196
197
  type="button"
197
- className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
198
+ className="w-7 h-7 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4"
198
199
  aria-label={`Mute ${name}`}
200
+ title={`Mute ${name}`}
199
201
  onClick={onMute}
200
202
  >
201
- Mute
203
+ <MicOffIcon />
202
204
  </button>
203
205
  )}
204
206
  {canModerate && (
205
207
  <button
206
208
  type="button"
207
- className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
209
+ className="w-7 h-7 flex items-center justify-center rounded-full bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:w-4 [&>svg]:h-4"
208
210
  aria-label={`Remove ${name}`}
211
+ title={`Remove ${name}`}
209
212
  onClick={() => {
210
213
  if (window.confirm(`Remove ${name} from the call?`)) {
211
214
  onKick?.();
212
215
  }
213
216
  }}
214
217
  >
215
- Remove
218
+ <RemoveIcon />
216
219
  </button>
217
220
  )}
218
221
  </li>
@@ -78,3 +78,13 @@ export const BatonIcon = () => (
78
78
  );
79
79
 
80
80
  export const OverflowIcon = () => <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" />;
81
+
82
+ /** A plain "X" - removing a participant from the call (the participants drawer's "Remove" row button). Drawn with
83
+ * strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
84
+ * `BatonIcon`. */
85
+ export const RemoveIcon = () => (
86
+ <svg viewBox="0 0 24 24" className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" focusable="false">
87
+ <line x1="6" y1="6" x2="18" y2="18" />
88
+ <line x1="18" y1="6" x2="6" y2="18" />
89
+ </svg>
90
+ );
@@ -2,9 +2,14 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
+ import type { RelayReceiveDiagnostics } from "../webrtc/types.js";
5
6
  import { TimestampUnwrapper, type Frame } from "./frames.js";
6
7
  import type { AudioContextLike, AudioDataLike, DecoderLike, RelayEnv } from "./relayEnv.js";
7
8
  import { safeClose } from "./senderCommon.js";
9
+ import { SilenceMeter } from "./SilenceMeter.js";
10
+
11
+ /** The playback half of `RelayReceiveDiagnostics.audio`. */
12
+ export type AudioPlaybackStats = Pick<RelayReceiveDiagnostics["audio"], "playedMs" | "silentMs" | "gaps" | "gapMs" | "droppedLate" | "bufferedMs">;
8
13
 
9
14
  export const PLAYBACK_SAMPLE_RATE = 48_000;
10
15
  /** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
@@ -42,6 +47,10 @@ export class AudioPlayer {
42
47
  private decoder: DecoderLike | undefined;
43
48
  private nextTime = 0;
44
49
  private readonly timestamps = new TimestampUnwrapper();
50
+ private readonly played = new SilenceMeter();
51
+ private gaps = 0;
52
+ private gapSeconds = 0;
53
+ private droppedLate = 0;
45
54
 
46
55
  /** `destination` is the node whose stream the peer's `MediaStream` carries. */
47
56
  constructor(
@@ -50,10 +59,23 @@ export class AudioPlayer {
50
59
  private readonly destination: unknown,
51
60
  ) {}
52
61
 
62
+ /** What playback has done so far - see `RelayReceiveDiagnostics`. */
63
+ stats(): AudioPlaybackStats {
64
+ return {
65
+ playedMs: this.played.totalMs,
66
+ silentMs: this.played.silentMs,
67
+ gaps: this.gaps,
68
+ gapMs: this.gapSeconds * 1000,
69
+ droppedLate: this.droppedLate,
70
+ bufferedMs: Math.max(0, this.nextTime - this.ctx.currentTime) * 1000,
71
+ };
72
+ }
73
+
53
74
  /** Feeds one reassembled audio frame (one Opus packet). */
54
75
  push(frame: Frame): void {
55
76
  const decoder = this.decoder ?? this.createDecoder();
56
77
  if (!decoder || decoder.decodeQueueSize > AUDIO_MAX_DECODE_QUEUE) {
78
+ this.droppedLate += 1;
57
79
  return;
58
80
  }
59
81
  try {
@@ -101,6 +123,7 @@ export class AudioPlayer {
101
123
  try {
102
124
  const now = this.ctx.currentTime;
103
125
  if (this.nextTime - now > MAX_AHEAD_SECONDS) {
126
+ this.droppedLate += 1;
104
127
  return;
105
128
  }
106
129
  const buffer = this.ctx.createBuffer(1, data.numberOfFrames, data.sampleRate);
@@ -127,6 +150,12 @@ export class AudioPlayer {
127
150
  source.onended = () => source.disconnect();
128
151
  }
129
152
  source.start(start);
153
+ this.played.feed(pcm, data.sampleRate);
154
+ if (start > this.nextTime && this.nextTime > 0) {
155
+ // Playback ran dry: everything between the end of the last block and this one is silence.
156
+ this.gaps += 1;
157
+ this.gapSeconds += start - this.nextTime;
158
+ }
130
159
  this.nextTime = start + buffer.duration;
131
160
  } catch {
132
161
  // A block the browser will not take (an odd sample rate, a closed context) is dropped like a lost packet.
@@ -2,6 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
+ import type { RelaySendDiagnostics } from "../webrtc/types.js";
5
6
  import { resumeAudioContext } from "./audioResume.js";
6
7
  import { KIND_AUDIO } from "./frames.js";
7
8
  import type {
@@ -14,6 +15,7 @@ import type {
14
15
  ScriptProcessorLike,
15
16
  } from "./relayEnv.js";
16
17
  import { safeClose, type SenderDeps } from "./senderCommon.js";
18
+ import { SilenceMeter } from "./SilenceMeter.js";
17
19
 
18
20
  export const AUDIO_SAMPLE_RATE = 48_000;
19
21
  /** Frames per `ScriptProcessorNode` callback: about 43 ms, a compromise between latency and how often the main
@@ -53,8 +55,24 @@ export class AudioSender {
53
55
  private baseMs = 0;
54
56
  private samples = 0;
55
57
 
58
+ /** Diagnostics, cumulative across capture sessions: what the capture path delivered (and how much of it was
59
+ * silent), the wall-clock time it was attached for, and what the encoder had to skip. */
60
+ private readonly captured = new SilenceMeter();
61
+ private wallMs = 0;
62
+ private encoderSkippedSamples = 0;
63
+
56
64
  constructor(private readonly deps: SenderDeps) {}
57
65
 
66
+ /** The capture half of `RelaySendDiagnostics`. */
67
+ captureStats(): Pick<RelaySendDiagnostics, "audioCapturedMs" | "audioCaptureWallMs" | "audioSilentMs" | "audioEncoderSkippedMs"> {
68
+ return {
69
+ audioCapturedMs: this.captured.totalMs,
70
+ audioCaptureWallMs: this.wallMs + (this.attachedTrack ? this.deps.env.now() - this.baseMs : 0),
71
+ audioSilentMs: this.captured.silentMs,
72
+ audioEncoderSkippedMs: (this.encoderSkippedSamples * 1000) / AUDIO_SAMPLE_RATE,
73
+ };
74
+ }
75
+
58
76
  setActive(active: boolean): void {
59
77
  this.active = active;
60
78
  this.sync();
@@ -105,6 +123,9 @@ export class AudioSender {
105
123
  }
106
124
 
107
125
  private detach(): void {
126
+ if (this.attachedTrack) {
127
+ this.wallMs += this.deps.env.now() - this.baseMs;
128
+ }
108
129
  this.attachedTrack = null;
109
130
  this.stopResume?.();
110
131
  this.stopResume = undefined;
@@ -132,11 +153,13 @@ export class AudioSender {
132
153
  const samples = input.length;
133
154
  const timestampUs = Math.round((this.samples * 1_000_000) / AUDIO_SAMPLE_RATE);
134
155
  this.samples += samples;
156
+ this.captured.feed(input, AUDIO_SAMPLE_RATE);
135
157
  if (!this.deps.canSend()) {
136
158
  return;
137
159
  }
138
160
  const encoder = this.encoder ?? this.createEncoder();
139
161
  if (!encoder || encoder.encodeQueueSize > AUDIO_MAX_ENCODE_QUEUE) {
162
+ this.encoderSkippedSamples += samples;
140
163
  return;
141
164
  }
142
165
  let audio: AudioDataLike | undefined;