@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
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* older one, which accepts `MAX_MESSAGE_BYTES`). Nothing else may be sent before `ready`.
|
|
14
14
|
* - `{"op":"want","peers":[...]}` REPLACES the set of senders this socket receives from (the server accepts at most
|
|
15
15
|
* `MAX_WANTED_PEERS`). It has to be re-sent after every reconnect, because the server's set dies with the socket.
|
|
16
|
+
* - `{"op":"ping","t":<this client's clock>}`, every `RELAY_PING_INTERVAL_MS` once ready, is answered with
|
|
17
|
+
* `{"op":"pong","t":<the same>}`, which times the round trip to the server (`roundTripMs`) - over the same socket as
|
|
18
|
+
* the media, so it includes whatever the media has queued ahead of it. An older server ignores the ping and the
|
|
19
|
+
* round trip stays unknown.
|
|
16
20
|
* - A binary message from the client is an opaque payload of at most `maxMessageBytes`. The server forwards it to
|
|
17
21
|
* every socket that wants this sender as `[1 byte N][N bytes UTF-8 sender peer id][payload]`, stamping the sender
|
|
18
22
|
* itself so a peer cannot speak as another.
|
|
@@ -48,6 +52,9 @@ export const RELAY_BACKOFF_MAX_MS = 15_000;
|
|
|
48
52
|
/** How long a socket may take to become `ready` before it is abandoned and retried (a server that accepts the
|
|
49
53
|
* upgrade but never answers the hello would otherwise hang the tier forever). */
|
|
50
54
|
export const RELAY_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
55
|
+
/** How often a ready socket pings the server to time the round trip - often enough that the diagnostics panel (which
|
|
56
|
+
* polls every few seconds) always has a recent sample, rarely enough to cost nothing next to the media. */
|
|
57
|
+
export const RELAY_PING_INTERVAL_MS = 2_000;
|
|
51
58
|
|
|
52
59
|
const SOCKET_OPEN = 1;
|
|
53
60
|
|
|
@@ -56,6 +63,8 @@ export interface RelayClientOptions {
|
|
|
56
63
|
peerId: string;
|
|
57
64
|
createSocket: (url: string) => RelaySocket;
|
|
58
65
|
random: () => number;
|
|
66
|
+
/** A monotonic millisecond clock for timing pings. Defaults to `Date.now`. */
|
|
67
|
+
now?: () => number;
|
|
59
68
|
/** Called with the sender's peer id (as stamped by the server) and the original payload. */
|
|
60
69
|
onMedia: (peerId: string, payload: Uint8Array) => void;
|
|
61
70
|
}
|
|
@@ -82,6 +91,8 @@ export class RelayClient {
|
|
|
82
91
|
private wanted: string[] = [];
|
|
83
92
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
84
93
|
private handshakeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
94
|
+
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
95
|
+
private lastRoundTripMs: number | undefined;
|
|
85
96
|
private readonly decoder = new TextDecoder();
|
|
86
97
|
|
|
87
98
|
constructor(private readonly options: RelayClientOptions) {}
|
|
@@ -91,6 +102,12 @@ export class RelayClient {
|
|
|
91
102
|
return this.isReady;
|
|
92
103
|
}
|
|
93
104
|
|
|
105
|
+
/** The most recent ping's round trip to the server, in milliseconds - `undefined` until the first pong on the
|
|
106
|
+
* current socket (and for good on a server too old to answer pings). */
|
|
107
|
+
get roundTripMs(): number | undefined {
|
|
108
|
+
return this.lastRoundTripMs;
|
|
109
|
+
}
|
|
110
|
+
|
|
94
111
|
/** The largest message `sendMedia()` will send: what the server announced in `ready` (validated and clamped), or
|
|
95
112
|
* `MAX_MESSAGE_BYTES` before that, without one, and again after every disconnect until the next `ready`. The
|
|
96
113
|
* frame fragmenter sizes its fragments from this. */
|
|
@@ -148,6 +165,7 @@ export class RelayClient {
|
|
|
148
165
|
clearTimeout(this.reconnectTimer);
|
|
149
166
|
clearTimeout(this.handshakeTimer);
|
|
150
167
|
this.reconnectTimer = this.handshakeTimer = undefined;
|
|
168
|
+
this.stopPinging();
|
|
151
169
|
this.detach();
|
|
152
170
|
}
|
|
153
171
|
|
|
@@ -200,7 +218,15 @@ export class RelayClient {
|
|
|
200
218
|
} catch {
|
|
201
219
|
return;
|
|
202
220
|
}
|
|
203
|
-
if (!message || typeof message !== "object"
|
|
221
|
+
if (!message || typeof message !== "object") {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const op = (message as { op?: unknown }).op;
|
|
225
|
+
if (op === "pong") {
|
|
226
|
+
this.handlePong((message as { t?: unknown }).t);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (op !== "ready" || this.isReady) {
|
|
204
230
|
return;
|
|
205
231
|
}
|
|
206
232
|
this.isReady = true;
|
|
@@ -213,6 +239,30 @@ export class RelayClient {
|
|
|
213
239
|
this.handshakeTimer = undefined;
|
|
214
240
|
// The server's wanted set is per socket, so it starts empty on every (re)connect.
|
|
215
241
|
this.sendText({ op: "want", peers: this.wanted });
|
|
242
|
+
this.pingTimer = setInterval(() => this.sendText({ op: "ping", t: this.now() }), RELAY_PING_INTERVAL_MS);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Times the round trip of the ping that `t` came back from. Ignored before ready, for a `t` that isn't a number,
|
|
246
|
+
* or one from the future (nothing this client sent). */
|
|
247
|
+
private handlePong(t: unknown): void {
|
|
248
|
+
if (!this.isReady || typeof t !== "number") {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const roundTrip = this.now() - t;
|
|
252
|
+
if (roundTrip >= 0) {
|
|
253
|
+
this.lastRoundTripMs = roundTrip;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private now(): number {
|
|
258
|
+
return (this.options.now ?? Date.now)();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Stops pinging and forgets the last round trip - it described a socket that is gone. */
|
|
262
|
+
private stopPinging(): void {
|
|
263
|
+
clearInterval(this.pingTimer);
|
|
264
|
+
this.pingTimer = undefined;
|
|
265
|
+
this.lastRoundTripMs = undefined;
|
|
216
266
|
}
|
|
217
267
|
|
|
218
268
|
private handleClose(socket: RelaySocket): void {
|
|
@@ -220,6 +270,7 @@ export class RelayClient {
|
|
|
220
270
|
return;
|
|
221
271
|
}
|
|
222
272
|
this.isReady = false;
|
|
273
|
+
this.stopPinging();
|
|
223
274
|
// The next server might be a different one (a rolling upgrade), so the limit is learned afresh.
|
|
224
275
|
this.messageLimit = MAX_MESSAGE_BYTES;
|
|
225
276
|
clearTimeout(this.handshakeTimer);
|
|
@@ -2,11 +2,38 @@
|
|
|
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, RelayReceiveStats } from "../webrtc/types.js";
|
|
5
6
|
import { AudioPlayer } from "./AudioPlayer.js";
|
|
6
|
-
import { decodeFragment, FrameReassembler, KIND_AUDIO } from "./frames.js";
|
|
7
|
+
import { decodeFragment, type Frame, FrameReassembler, KIND_AUDIO, seqIsNewer } from "./frames.js";
|
|
7
8
|
import type { AudioContextLike, RelayEnv } from "./relayEnv.js";
|
|
8
9
|
import { VideoPlayer } from "./VideoPlayer.js";
|
|
9
10
|
|
|
11
|
+
const SEQ_MODULUS = 0x10000;
|
|
12
|
+
/** A jump in sequence numbers bigger than this is taken for the sender restarting its counter, not for that many
|
|
13
|
+
* lost frames. Ten seconds of 20 ms audio packets - far more than any real outage the stream would survive. */
|
|
14
|
+
const MAX_COUNTED_SEQ_GAP = 500;
|
|
15
|
+
|
|
16
|
+
/** Counts one kind's frames as they complete, and the ones the sequence numbers say never did. */
|
|
17
|
+
class ReceiveCounter {
|
|
18
|
+
readonly stats: RelayReceiveStats = { framesReceived: 0, framesLost: 0, bytesReceived: 0 };
|
|
19
|
+
private lastSeq: number | undefined;
|
|
20
|
+
|
|
21
|
+
fragment(bytes: number): void {
|
|
22
|
+
this.stats.bytesReceived += bytes;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
frame(frame: Frame): void {
|
|
26
|
+
this.stats.framesReceived += 1;
|
|
27
|
+
if (this.lastSeq !== undefined && seqIsNewer(frame.seq, this.lastSeq)) {
|
|
28
|
+
const missing = ((frame.seq - this.lastSeq + SEQ_MODULUS) % SEQ_MODULUS) - 1;
|
|
29
|
+
if (missing <= MAX_COUNTED_SEQ_GAP) {
|
|
30
|
+
this.stats.framesLost += missing;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
this.lastSeq = frame.seq;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
10
37
|
/** The canvas is sized to the first decoded picture; until then it is this size. */
|
|
11
38
|
const INITIAL_CANVAS_WIDTH = 320;
|
|
12
39
|
const INITIAL_CANVAS_HEIGHT = 240;
|
|
@@ -25,6 +52,8 @@ export class RelayReceiver {
|
|
|
25
52
|
readonly stream: MediaStream;
|
|
26
53
|
private readonly audioFrames = new FrameReassembler();
|
|
27
54
|
private readonly videoFrames = new FrameReassembler();
|
|
55
|
+
private readonly audioCounter = new ReceiveCounter();
|
|
56
|
+
private readonly videoCounter = new ReceiveCounter();
|
|
28
57
|
private readonly audio: AudioPlayer;
|
|
29
58
|
private readonly video: VideoPlayer | undefined;
|
|
30
59
|
|
|
@@ -50,10 +79,14 @@ export class RelayReceiver {
|
|
|
50
79
|
if (!fragment) {
|
|
51
80
|
return;
|
|
52
81
|
}
|
|
53
|
-
const
|
|
82
|
+
const isAudio = fragment.kind === KIND_AUDIO;
|
|
83
|
+
const counter = isAudio ? this.audioCounter : this.videoCounter;
|
|
84
|
+
counter.fragment(payload.length);
|
|
85
|
+
const frame = (isAudio ? this.audioFrames : this.videoFrames).push(fragment);
|
|
54
86
|
if (!frame) {
|
|
55
87
|
return;
|
|
56
88
|
}
|
|
89
|
+
counter.frame(frame);
|
|
57
90
|
if (frame.kind === KIND_AUDIO) {
|
|
58
91
|
this.audio.push(frame);
|
|
59
92
|
} else {
|
|
@@ -61,6 +94,14 @@ export class RelayReceiver {
|
|
|
61
94
|
}
|
|
62
95
|
}
|
|
63
96
|
|
|
97
|
+
/** A snapshot of what has been received from this peer - see `RelayReceiveDiagnostics`. */
|
|
98
|
+
diagnostics(): RelayReceiveDiagnostics {
|
|
99
|
+
return {
|
|
100
|
+
audio: { ...this.audioCounter.stats, ...this.audio.stats() },
|
|
101
|
+
video: { ...this.videoCounter.stats },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
/** Releases the decoders and ends the stream's tracks. */
|
|
65
106
|
close(): void {
|
|
66
107
|
this.audio.close();
|
|
@@ -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, RelaySendStats } from "../webrtc/types.js";
|
|
5
6
|
import { AudioSender } from "./AudioSender.js";
|
|
6
7
|
import { DEFAULT_MESSAGE_BYTES, fragmentFrame, type FrameKind, KIND_AUDIO, KIND_VIDEO } from "./frames.js";
|
|
7
8
|
import type { RelayEnv } from "./relayEnv.js";
|
|
@@ -15,6 +16,11 @@ const SEQ_MODULUS = 0x10000;
|
|
|
15
16
|
* otherwise treat the restarted stream as older than what it has already seen. */
|
|
16
17
|
export class FramePublisher {
|
|
17
18
|
private readonly seqs: Record<FrameKind, number> = { [KIND_AUDIO]: 0, [KIND_VIDEO]: 0 };
|
|
19
|
+
/** What happened to each kind's frames - for the diagnostics panel. */
|
|
20
|
+
readonly stats: Record<FrameKind, RelaySendStats> = {
|
|
21
|
+
[KIND_AUDIO]: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
|
|
22
|
+
[KIND_VIDEO]: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
|
|
23
|
+
};
|
|
18
24
|
|
|
19
25
|
/** `send` returns whether one message was accepted by the socket. */
|
|
20
26
|
/** `maxMessageBytes` is the largest message the socket may send right now (it changes when the server's `ready`
|
|
@@ -29,15 +35,20 @@ export class FramePublisher {
|
|
|
29
35
|
publish: PublishFrame = (kind, keyFrame, data, timestampMs) => {
|
|
30
36
|
const seq = this.seqs[kind];
|
|
31
37
|
this.seqs[kind] = (seq + 1) % SEQ_MODULUS;
|
|
38
|
+
const stats = this.stats[kind];
|
|
32
39
|
const fragments = fragmentFrame(kind, keyFrame, seq, timestampMs, data, this.maxMessageBytes());
|
|
33
40
|
if (fragments.length === 0) {
|
|
41
|
+
stats.framesDropped += 1;
|
|
34
42
|
return false;
|
|
35
43
|
}
|
|
36
44
|
for (const fragment of fragments) {
|
|
37
45
|
if (!this.send(fragment)) {
|
|
46
|
+
stats.framesDropped += 1;
|
|
38
47
|
return false;
|
|
39
48
|
}
|
|
49
|
+
stats.bytesSent += fragment.length;
|
|
40
50
|
}
|
|
51
|
+
stats.framesSent += 1;
|
|
41
52
|
return true;
|
|
42
53
|
};
|
|
43
54
|
}
|
|
@@ -52,15 +63,26 @@ export interface RelaySenderOptions {
|
|
|
52
63
|
|
|
53
64
|
/** The publishing half of the relay: the two capture paths plus the frame numbering they share. */
|
|
54
65
|
export class RelaySender {
|
|
66
|
+
private readonly publisher: FramePublisher;
|
|
55
67
|
private readonly audio: AudioSender;
|
|
56
68
|
private readonly video: VideoSender;
|
|
57
69
|
|
|
58
70
|
constructor(options: RelaySenderOptions) {
|
|
59
|
-
|
|
71
|
+
this.publisher = new FramePublisher(options.send, options.maxMessageBytes);
|
|
72
|
+
const deps = { env: options.env, publish: this.publisher.publish, canSend: options.canSend };
|
|
60
73
|
this.audio = new AudioSender(deps);
|
|
61
74
|
this.video = new VideoSender(deps);
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
/** A snapshot of the sending counters - see `RelaySendDiagnostics`. */
|
|
78
|
+
diagnostics(): RelaySendDiagnostics {
|
|
79
|
+
return {
|
|
80
|
+
...this.audio.captureStats(),
|
|
81
|
+
audio: { ...this.publisher.stats[KIND_AUDIO] },
|
|
82
|
+
video: { ...this.publisher.stats[KIND_VIDEO] },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/** Starts or stops encoding whatever tracks are set. Stopping releases the encoders and audio nodes. */
|
|
65
87
|
setActive(active: boolean): void {
|
|
66
88
|
this.audio.setActive(active);
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* negotiation, because both ends are this same code.
|
|
18
18
|
*/
|
|
19
19
|
import { apiOrigin } from "@rapidmx/web-client/lib/util/api.js";
|
|
20
|
+
import type { RelayDiagnostics } from "../webrtc/types.js";
|
|
20
21
|
import { PLAYBACK_SAMPLE_RATE } from "./AudioPlayer.js";
|
|
21
22
|
import { resumeAudioContext } from "./audioResume.js";
|
|
22
23
|
import { RelayClient } from "./RelayClient.js";
|
|
@@ -39,6 +40,9 @@ export interface RelayTransport {
|
|
|
39
40
|
/** Same semantics as `MeshConnectionManager.setLocalTrack`: null = send nothing for that kind. Safe to call
|
|
40
41
|
* before or after `setSending`. */
|
|
41
42
|
setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
|
|
43
|
+
/** The sending counters (shared by every peer) plus what has been received from `peerId`, for the diagnostics
|
|
44
|
+
* panel - cumulative, so a caller wanting a rate keeps the previous sample. */
|
|
45
|
+
diagnostics(peerId: string): RelayDiagnostics;
|
|
42
46
|
/** Closes the socket, all coders, streams and timers. Idempotent. */
|
|
43
47
|
close(): void;
|
|
44
48
|
}
|
|
@@ -68,6 +72,16 @@ const UNSUPPORTED_TRANSPORT: RelayTransport = {
|
|
|
68
72
|
stopReceivingFrom: () => undefined,
|
|
69
73
|
setSending: () => undefined,
|
|
70
74
|
setLocalTrack: () => undefined,
|
|
75
|
+
diagnostics: () => ({
|
|
76
|
+
send: {
|
|
77
|
+
audioCapturedMs: 0,
|
|
78
|
+
audioCaptureWallMs: 0,
|
|
79
|
+
audioSilentMs: 0,
|
|
80
|
+
audioEncoderSkippedMs: 0,
|
|
81
|
+
audio: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
|
|
82
|
+
video: { framesSent: 0, framesDropped: 0, bytesSent: 0 },
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
71
85
|
close: () => undefined,
|
|
72
86
|
};
|
|
73
87
|
|
|
@@ -90,6 +104,7 @@ class WebSocketRelayTransport implements RelayTransport {
|
|
|
90
104
|
peerId,
|
|
91
105
|
createSocket: (target) => env.createSocket(target),
|
|
92
106
|
random: () => env.random(),
|
|
107
|
+
now: () => env.now(),
|
|
93
108
|
onMedia: (sender, payload) => this.receivers.get(sender)?.handleMedia(payload),
|
|
94
109
|
});
|
|
95
110
|
this.sender = new RelaySender({
|
|
@@ -140,6 +155,10 @@ class WebSocketRelayTransport implements RelayTransport {
|
|
|
140
155
|
}
|
|
141
156
|
}
|
|
142
157
|
|
|
158
|
+
diagnostics(peerId: string): RelayDiagnostics {
|
|
159
|
+
return { roundTripMs: this.client.roundTripMs, send: this.sender.diagnostics(), receive: this.receivers.get(peerId)?.diagnostics() };
|
|
160
|
+
}
|
|
161
|
+
|
|
143
162
|
close(): void {
|
|
144
163
|
if (this.closed) {
|
|
145
164
|
return;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** A sample this close to zero counts as silent: about -80 dBFS, well under the noise floor of any real microphone
|
|
7
|
+
* (even through noise suppression), so only digital silence - a zero-filled block - falls below it. */
|
|
8
|
+
export const SILENCE_THRESHOLD = 1e-4;
|
|
9
|
+
/** A run of silent samples counts only once it lasts this long, so a waveform merely crossing zero never does. */
|
|
10
|
+
export const MIN_SILENT_RUN_SECONDS = 0.005;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Measures how much of a PCM stream is digital silence, for the relay's diagnostics. A live microphone always carries
|
|
14
|
+
* some noise, so stretches of exact (or near-exact) zero mean the audio was lost or zero-filled somewhere on its way,
|
|
15
|
+
* not that the room went quiet. Runs are tracked across `feed()` calls, so a gap that straddles two blocks counts in
|
|
16
|
+
* full.
|
|
17
|
+
*/
|
|
18
|
+
export class SilenceMeter {
|
|
19
|
+
private totalSeconds = 0;
|
|
20
|
+
private silentSeconds = 0;
|
|
21
|
+
private run = 0;
|
|
22
|
+
private runRate = 0;
|
|
23
|
+
|
|
24
|
+
/** Adds one block of `sampleRate` Hz samples. */
|
|
25
|
+
feed(samples: Float32Array, sampleRate: number): void {
|
|
26
|
+
if (sampleRate !== this.runRate) {
|
|
27
|
+
this.endRun();
|
|
28
|
+
this.runRate = sampleRate;
|
|
29
|
+
}
|
|
30
|
+
for (const sample of samples) {
|
|
31
|
+
if (Math.abs(sample) < SILENCE_THRESHOLD) {
|
|
32
|
+
this.run += 1;
|
|
33
|
+
} else if (this.run > 0) {
|
|
34
|
+
this.endRun();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.totalSeconds += samples.length / sampleRate;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Milliseconds of audio fed so far. */
|
|
41
|
+
get totalMs(): number {
|
|
42
|
+
return this.totalSeconds * 1000;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Milliseconds of it that were silent - including a silent run still in progress once it is long enough. */
|
|
46
|
+
get silentMs(): number {
|
|
47
|
+
return (this.silentSeconds + this.runSeconds(this.run)) * 1000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private runSeconds(run: number): number {
|
|
51
|
+
const seconds = this.runRate > 0 ? run / this.runRate : 0;
|
|
52
|
+
return seconds >= MIN_SILENT_RUN_SECONDS ? seconds : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private endRun(): void {
|
|
56
|
+
this.silentSeconds += this.runSeconds(this.run);
|
|
57
|
+
this.run = 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -134,6 +134,7 @@
|
|
|
134
134
|
* not something a meeting agrees on together.
|
|
135
135
|
*/
|
|
136
136
|
import {
|
|
137
|
+
type ConnectionDiagnostics,
|
|
137
138
|
type MediaTransport,
|
|
138
139
|
type MeshEvent,
|
|
139
140
|
type MeshParticipant,
|
|
@@ -141,6 +142,7 @@ import {
|
|
|
141
142
|
type RTCPeerConnectionFactory,
|
|
142
143
|
type RTCPeerConnectionLike,
|
|
143
144
|
type RTCRtpSenderLike,
|
|
145
|
+
type RelayDiagnostics,
|
|
144
146
|
type RelayTransportLike,
|
|
145
147
|
type SignalMessage,
|
|
146
148
|
type SignalingChannel,
|
|
@@ -184,8 +186,8 @@ interface PeerState extends MeshParticipant {
|
|
|
184
186
|
connectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
185
187
|
/** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
|
|
186
188
|
disconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
187
|
-
/** Refreshes `diagnostics` (`MeshParticipant`)
|
|
188
|
-
*
|
|
189
|
+
/** Refreshes `diagnostics` (`MeshParticipant`) - from the `RTCPeerConnection` once it first connects, then (after
|
|
190
|
+
* a restart in `fallBack()`) from the relay's counters once the pair moves there; stopped when the pair ends. */
|
|
189
191
|
diagnosticsTimer: ReturnType<typeof setInterval> | undefined;
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -663,7 +665,11 @@ export class MeshConnectionManager {
|
|
|
663
665
|
private async pollDiagnostics(peer: PeerState): Promise<void> {
|
|
664
666
|
let diagnostics: MeshParticipant["diagnostics"];
|
|
665
667
|
try {
|
|
666
|
-
|
|
668
|
+
const relay = this.options.relay;
|
|
669
|
+
diagnostics =
|
|
670
|
+
peer.transport === "websocket" && relay?.diagnostics
|
|
671
|
+
? fromRelayDiagnostics(relay.diagnostics(peer.uid))
|
|
672
|
+
: await peer.pc.collectDiagnostics();
|
|
667
673
|
} catch {
|
|
668
674
|
// Leaves whatever was last polled in place rather than blanking a momentary failure.
|
|
669
675
|
return;
|
|
@@ -744,6 +750,9 @@ export class MeshConnectionManager {
|
|
|
744
750
|
});
|
|
745
751
|
this.syncRelaySending();
|
|
746
752
|
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
753
|
+
if (relay.diagnostics) {
|
|
754
|
+
this.startDiagnosticsPolling(peer);
|
|
755
|
+
}
|
|
747
756
|
if (notify) {
|
|
748
757
|
this.send({ kind: "relay-fallback", to: peer.uid });
|
|
749
758
|
}
|
|
@@ -925,6 +934,19 @@ function toParticipant(peer: PeerState): MeshParticipant {
|
|
|
925
934
|
};
|
|
926
935
|
}
|
|
927
936
|
|
|
937
|
+
/** A relayed pair's diagnostics: the relay's own counters, with the part that maps onto RTP stats (round trip, frames
|
|
938
|
+
* lost, bytes each way) also filled into the ordinary fields, so the panel's ordinary rows show it too. The round trip
|
|
939
|
+
* is to the relay server, not to the peer - see `RelayDiagnostics.roundTripMs`. */
|
|
940
|
+
function fromRelayDiagnostics(relay: RelayDiagnostics): ConnectionDiagnostics {
|
|
941
|
+
const { send, receive } = relay;
|
|
942
|
+
return {
|
|
943
|
+
roundTripTimeSeconds: relay.roundTripMs === undefined ? undefined : relay.roundTripMs / 1000,
|
|
944
|
+
audio: { packetsLost: receive?.audio.framesLost, bytesSent: send.audio.bytesSent, bytesReceived: receive?.audio.bytesReceived },
|
|
945
|
+
video: { packetsLost: receive?.video.framesLost, bytesSent: send.video.bytesSent, bytesReceived: receive?.video.bytesReceived },
|
|
946
|
+
relay,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
928
950
|
function sameState(a: ParticipantState, b: ParticipantState): boolean {
|
|
929
951
|
return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
|
|
930
952
|
}
|
|
@@ -139,6 +139,71 @@ export interface ConnectionDiagnostics {
|
|
|
139
139
|
roundTripTimeSeconds?: number;
|
|
140
140
|
audio: RtpStreamDiagnostics;
|
|
141
141
|
video: RtpStreamDiagnostics;
|
|
142
|
+
/** The WebSocket relay's own counters, present only for a `"websocket"` pair - `audio`/`video` then carry the
|
|
143
|
+
* subset of them that maps onto RTP stats (frames lost, bytes each way). */
|
|
144
|
+
relay?: RelayDiagnostics;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What the relay's sending half did with one kind of frame, cumulative since the relay transport was created. */
|
|
148
|
+
export interface RelaySendStats {
|
|
149
|
+
framesSent: number;
|
|
150
|
+
/** Frames encoded but not sent: the socket was not ready, backed up, or refused a fragment. */
|
|
151
|
+
framesDropped: number;
|
|
152
|
+
bytesSent: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What the relay's sending half has done, cumulative since the relay transport was created. It is per browser, not
|
|
156
|
+
* per peer: every relayed peer receives the same stream. */
|
|
157
|
+
export interface RelaySendDiagnostics {
|
|
158
|
+
/** Milliseconds of microphone audio the capture path delivered, against `audioCaptureWallMs` - the wall-clock time
|
|
159
|
+
* it was capturing for. Well short of it means capture is losing whole blocks. */
|
|
160
|
+
audioCapturedMs: number;
|
|
161
|
+
audioCaptureWallMs: number;
|
|
162
|
+
/** How much of `audioCapturedMs` was digital silence (see `SilenceMeter`) - a live microphone always has some
|
|
163
|
+
* noise, so a large share means the capture path is handing the encoder zero-filled blocks. */
|
|
164
|
+
audioSilentMs: number;
|
|
165
|
+
/** Milliseconds of captured audio not encoded because the encoder was too far behind. */
|
|
166
|
+
audioEncoderSkippedMs: number;
|
|
167
|
+
audio: RelaySendStats;
|
|
168
|
+
video: RelaySendStats;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What the relay's receiving half got of one kind of frame from one peer, cumulative. */
|
|
172
|
+
export interface RelayReceiveStats {
|
|
173
|
+
framesReceived: number;
|
|
174
|
+
/** Frames the sequence numbers say never arrived complete - dropped by the sender, the server or in transit. */
|
|
175
|
+
framesLost: number;
|
|
176
|
+
bytesReceived: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What the relay's receiving half has done with one peer's media, cumulative since it started receiving from them. */
|
|
180
|
+
export interface RelayReceiveDiagnostics {
|
|
181
|
+
audio: RelayReceiveStats & {
|
|
182
|
+
/** Milliseconds of decoded audio scheduled for playback. */
|
|
183
|
+
playedMs: number;
|
|
184
|
+
/** How much of `playedMs` was digital silence - silence that arrived inside the packets, i.e. the sender sent
|
|
185
|
+
* it, as opposed to `gapMs`. */
|
|
186
|
+
silentMs: number;
|
|
187
|
+
/** Times playback ran dry and restarted, and the total silence that inserted. */
|
|
188
|
+
gaps: number;
|
|
189
|
+
gapMs: number;
|
|
190
|
+
/** Packets dropped on arrival because the decoder was backed up or playback was already too far ahead. */
|
|
191
|
+
droppedLate: number;
|
|
192
|
+
/** How far ahead of the clock audio is scheduled right now - the receive buffer's current depth. */
|
|
193
|
+
bufferedMs: number;
|
|
194
|
+
};
|
|
195
|
+
video: RelayReceiveStats;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One sample of the relay's counters for one peer. */
|
|
199
|
+
export interface RelayDiagnostics {
|
|
200
|
+
/** The latest round trip from this browser to the relay server, in milliseconds - not to the peer: the relay has
|
|
201
|
+
* no direct path between the two, and each end only knows its own leg. `undefined` before the first pong, or for
|
|
202
|
+
* a server too old to answer pings. */
|
|
203
|
+
roundTripMs?: number;
|
|
204
|
+
send: RelaySendDiagnostics;
|
|
205
|
+
/** `undefined` when nothing is being received from this peer. */
|
|
206
|
+
receive?: RelayReceiveDiagnostics;
|
|
142
207
|
}
|
|
143
208
|
|
|
144
209
|
/** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
|
|
@@ -206,6 +271,9 @@ export interface RelayTransportLike {
|
|
|
206
271
|
setSending(active: boolean): void;
|
|
207
272
|
/** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
|
|
208
273
|
setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
|
|
274
|
+
/** The relay's counters as they stand for `peerId`, for the diagnostics panel. Optional so a test's fake relay
|
|
275
|
+
* needn't provide it; the mesh then shows nothing for a relayed pair. */
|
|
276
|
+
diagnostics?(peerId: string): RelayDiagnostics;
|
|
209
277
|
close(): void;
|
|
210
278
|
}
|
|
211
279
|
|
|
@@ -222,8 +290,9 @@ export interface MeshParticipant extends ParticipantState {
|
|
|
222
290
|
/** How this participant's media currently reaches the local one - see `MediaTransport`. */
|
|
223
291
|
transport: MediaTransport;
|
|
224
292
|
/** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
|
|
225
|
-
* first poll completes (shortly after `transport` first leaves `"connecting"
|
|
226
|
-
* a `"websocket"`-relayed participant
|
|
293
|
+
* first poll completes (shortly after `transport` first leaves `"connecting"`, or the pair moves to the relay).
|
|
294
|
+
* For a `"websocket"`-relayed participant they come from the relay's own counters (`relay`) rather than an
|
|
295
|
+
* `RTCPeerConnection`. */
|
|
227
296
|
diagnostics?: ConnectionDiagnostics;
|
|
228
297
|
}
|
|
229
298
|
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
* their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
|
|
18
18
|
* persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
|
|
19
19
|
* `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
|
|
20
|
-
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen
|
|
21
|
-
*
|
|
20
|
+
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
|
|
21
|
+
* "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
|
|
22
|
+
* grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
|
|
23
|
+
* where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
|
|
24
|
+
* Escape/outside click like every other one here).
|
|
22
25
|
*/
|
|
23
26
|
import React from "react";
|
|
24
27
|
import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
@@ -53,10 +56,14 @@ export interface CallControlsProps {
|
|
|
53
56
|
onSetTransportMode: (mode: TransportMode) => void;
|
|
54
57
|
onOpenDiagnostics: () => void;
|
|
55
58
|
onOpenSettings: () => void;
|
|
59
|
+
/** Whether the local tile is currently hidden - the "…" menu's "Hide self"/"Show self" item reflects and
|
|
60
|
+
* toggles this; see `_CallView.tsx`'s doc comment on where the local tile appears. */
|
|
61
|
+
selfHidden: boolean;
|
|
62
|
+
onToggleSelfHidden: () => void;
|
|
56
63
|
onLeave: () => void;
|
|
57
64
|
}
|
|
58
65
|
/** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
|
|
59
66
|
export declare function LevelBars({ level }: {
|
|
60
67
|
level: number;
|
|
61
68
|
}): React.JSX.Element;
|
|
62
|
-
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
69
|
+
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
@@ -22,8 +22,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
22
22
|
* their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
|
|
23
23
|
* persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
|
|
24
24
|
* `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
|
|
25
|
-
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen
|
|
26
|
-
*
|
|
25
|
+
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
|
|
26
|
+
* "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
|
|
27
|
+
* grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
|
|
28
|
+
* where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
|
|
29
|
+
* Escape/outside click like every other one here).
|
|
27
30
|
*/
|
|
28
31
|
import { useEffect, useRef, useState } from "react";
|
|
29
32
|
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
@@ -45,7 +48,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
|
|
|
45
48
|
export function LevelBars({ level }) {
|
|
46
49
|
return (_jsx("span", { className: "flex items-center gap-[2px] h-5", "data-testid": "mic-level", "data-level": level, "aria-hidden": "true", children: [0.6, 1, 0.6].map((weight, i) => (_jsx("span", { className: `w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`, style: { height: `${4 + weight * level * 3}px` } }, i))) }));
|
|
47
50
|
}
|
|
48
|
-
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, onLeave, }) {
|
|
51
|
+
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, onLeave, }) {
|
|
49
52
|
const [openMenu, setOpenMenu] = useState(null);
|
|
50
53
|
const barRef = useRef(null);
|
|
51
54
|
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
@@ -80,7 +83,10 @@ export default function CallControls({ media, participants, isPresenting, presen
|
|
|
80
83
|
}, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", disabled: participants.length < 2, title: participants.length < 2 ? "Grid and focused view look the same with only one other participant." : undefined, onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "overflow" ? ACTIVE : NEUTRAL}`, "aria-label": "More options", "aria-haspopup": "menu", "aria-expanded": openMenu === "overflow", onClick: () => toggleMenu("overflow"), children: _jsx(OverflowIcon, {}) }), openMenu === "overflow" && (_jsxs("div", { role: "menu", "aria-label": "More options", className: "absolute bottom-full mb-3 right-0 w-56 max-w-[calc(100vw-1rem)] rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: [_jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
81
84
|
onOpenDiagnostics();
|
|
82
85
|
setOpenMenu(null);
|
|
83
|
-
}, children: "Diagnostics" }), _jsx("
|
|
86
|
+
}, children: "Diagnostics" }), _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
87
|
+
onToggleSelfHidden();
|
|
88
|
+
setOpenMenu(null);
|
|
89
|
+
}, children: selfHidden ? "Show self" : "Hide self" }), _jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("p", { className: "px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60", children: "Connection method" }), TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": transportMode === mode, className: "flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
84
90
|
onSetTransportMode(mode);
|
|
85
91
|
setOpenMenu(null);
|
|
86
92
|
}, children: [_jsx("span", { className: "w-4 text-[#8ab4f8]", "aria-hidden": "true", children: transportMode === mode ? "✓" : "" }), label] }, mode))), isHost && (_jsxs(_Fragment, { children: [_jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
@@ -11,10 +11,24 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
|
|
13
13
|
* is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
|
|
14
|
-
* the branded page shell, whose header, footer and padding pushed it off the screen.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* the branded page shell, whose header, footer and padding pushed it off the screen.
|
|
15
|
+
*
|
|
16
|
+
* ## Where the local tile appears
|
|
17
|
+
*
|
|
18
|
+
* In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
|
|
19
|
+
* everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
|
|
20
|
+
* genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
|
|
21
|
+
* tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
|
|
22
|
+
* is reserved for focus/presentation layouts, where there's already a single large tile the local one would
|
|
23
|
+
* otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
|
|
24
|
+
* (see the `alone` branch of the `stage` computation).
|
|
25
|
+
*
|
|
26
|
+
* `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
|
|
27
|
+
* it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
|
|
28
|
+
* directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
|
|
29
|
+
* "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
|
|
30
|
+
* nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
|
|
31
|
+
* just blank it, so the local tile ignores `selfHidden` there and always shows.
|
|
18
32
|
*
|
|
19
33
|
* ## Who is heard
|
|
20
34
|
*
|