@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.
- package/apps/meet/_CallControls.tsx +22 -2
- package/apps/meet/_CallView.tsx +41 -8
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_ParticipantTile.tsx +82 -9
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_icons.tsx +10 -0
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +52 -1
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +19 -0
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +25 -3
- package/apps/shared/webrtc/types.ts +71 -2
- package/dist/apps/meet/_CallControls.d.ts +10 -3
- package/dist/apps/meet/_CallControls.js +10 -4
- package/dist/apps/meet/_CallView.d.ts +18 -4
- package/dist/apps/meet/_CallView.js +36 -9
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +9 -2
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_icons.d.ts +4 -0
- package/dist/apps/meet/_icons.js +4 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +44 -1
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +4 -0
- package/dist/apps/shared/relay/RelayTransport.js +14 -0
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +20 -1
- package/dist/apps/shared/webrtc/types.d.ts +66 -2
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/types/util/RelayHub.d.ts +5 -2
- 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
|
|
25
|
-
*
|
|
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 }) => (
|
package/apps/meet/_CallView.tsx
CHANGED
|
@@ -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.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
{
|
|
97
|
-
<p className={`${LABEL} py-0.5`}>Not available
|
|
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}
|
|
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
|
-
|
|
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
|
-
<
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
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="
|
|
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
|
-
|
|
192
|
+
<BatonIcon />
|
|
192
193
|
</button>
|
|
193
194
|
)}
|
|
194
195
|
{canModerate && !micMuted && (
|
|
195
196
|
<button
|
|
196
197
|
type="button"
|
|
197
|
-
className="
|
|
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
|
-
|
|
203
|
+
<MicOffIcon />
|
|
202
204
|
</button>
|
|
203
205
|
)}
|
|
204
206
|
{canModerate && (
|
|
205
207
|
<button
|
|
206
208
|
type="button"
|
|
207
|
-
className="
|
|
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
|
-
|
|
218
|
+
<RemoveIcon />
|
|
216
219
|
</button>
|
|
217
220
|
)}
|
|
218
221
|
</li>
|
package/apps/meet/_icons.tsx
CHANGED
|
@@ -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;
|