@rapidmx/meet-plugin 0.9.2 → 0.10.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 (60) hide show
  1. package/apps/meet/[token].tsx +204 -13
  2. package/apps/meet/_CallControls.tsx +64 -3
  3. package/apps/meet/_CallView.tsx +244 -7
  4. package/apps/meet/_DiagnosticsPanel.tsx +113 -0
  5. package/apps/meet/_ParticipantTile.tsx +10 -2
  6. package/apps/meet/_ParticipantsDrawer.tsx +341 -0
  7. package/apps/meet/_icons.tsx +12 -0
  8. package/apps/meet/_meetApi.ts +141 -1
  9. package/apps/shared/media/deviceMedia.ts +22 -4
  10. package/apps/shared/media/filters/ScreenTransform.ts +148 -0
  11. package/apps/shared/media/useLocalMedia.ts +38 -10
  12. package/apps/shared/relay/AudioPlayer.ts +30 -4
  13. package/apps/shared/relay/relayEnv.ts +8 -1
  14. package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
  15. package/apps/shared/webrtc/realPeerConnection.ts +47 -7
  16. package/apps/shared/webrtc/types.ts +55 -7
  17. package/dist/apps/meet/[token].d.ts +4 -1
  18. package/dist/apps/meet/[token].js +121 -9
  19. package/dist/apps/meet/_CallControls.d.ts +9 -1
  20. package/dist/apps/meet/_CallControls.js +6 -5
  21. package/dist/apps/meet/_CallView.d.ts +28 -2
  22. package/dist/apps/meet/_CallView.js +155 -6
  23. package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
  24. package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
  25. package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
  26. package/dist/apps/meet/_ParticipantTile.js +9 -2
  27. package/dist/apps/meet/_ParticipantsDrawer.d.ts +61 -0
  28. package/dist/apps/meet/_ParticipantsDrawer.js +86 -0
  29. package/dist/apps/meet/_icons.d.ts +3 -0
  30. package/dist/apps/meet/_icons.js +3 -0
  31. package/dist/apps/meet/_meetApi.d.ts +93 -1
  32. package/dist/apps/meet/_meetApi.js +82 -0
  33. package/dist/apps/shared/media/deviceMedia.d.ts +11 -2
  34. package/dist/apps/shared/media/deviceMedia.js +20 -4
  35. package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
  36. package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
  37. package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
  38. package/dist/apps/shared/media/useLocalMedia.js +32 -10
  39. package/dist/apps/shared/relay/AudioPlayer.d.ts +14 -2
  40. package/dist/apps/shared/relay/AudioPlayer.js +31 -4
  41. package/dist/apps/shared/relay/relayEnv.d.ts +5 -0
  42. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
  43. package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -3
  44. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
  45. package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
  46. package/dist/apps/shared/webrtc/types.d.ts +54 -6
  47. package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
  48. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
  49. package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
  50. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
  51. package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
  52. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  53. package/dist/lib/util/PasswordUtils.js +52 -0
  54. package/dist/lib/util/PasswordUtils.js.map +1 -0
  55. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
  56. package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
  57. package/dist/types/models/types.d.ts +23 -0
  58. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
  59. package/dist/types/util/PasswordUtils.d.ts +17 -0
  60. package/package.json +1 -1
@@ -0,0 +1,148 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Rotates and/or flips a screen-share track before it's sent, so everyone - the presenter included, since their own
7
+ * preview is this same processor's output - sees it the right way round, rather than however the browser's own
8
+ * window/screen capture happened to hand it back. Window capture in particular has a known, driver/compositor-
9
+ * dependent history of occasionally returning upside-down or sideways frames for a specific captured window; this
10
+ * is the presenter's own manual correction for that, not an automatic fix (there's no reliable way to detect it
11
+ * from pixels alone).
12
+ *
13
+ * Reuses `VideoFilterProcessor.ts`'s own shape (a hidden `<video>` playing the source track, a canvas redrawn on a
14
+ * timer, `captureStream()` exposing the result) but far simpler: no models to load, no per-frame throttling to
15
+ * worry about, just a transform applied on each draw. Entirely independent of the camera pipeline - `useLocalMedia`'s
16
+ * filters never see the screen-share track, and this never touches the camera's.
17
+ */
18
+
19
+ /** A rotation in degrees clockwise. */
20
+ export type ScreenRotation = 0 | 90 | 180 | 270;
21
+
22
+ export interface ScreenTransformState {
23
+ rotation: ScreenRotation;
24
+ /** Mirrored left-right, applied before the rotation (so "flip, then turn" - the usual photo-editing order). */
25
+ flipped: boolean;
26
+ }
27
+
28
+ export const NO_SCREEN_TRANSFORM: ScreenTransformState = { rotation: 0, flipped: false };
29
+
30
+ /** The next rotation clockwise, wrapping back to 0 after 270. */
31
+ export function rotateClockwise(state: ScreenTransformState): ScreenTransformState {
32
+ const next: Record<ScreenRotation, ScreenRotation> = { 0: 90, 90: 180, 180: 270, 270: 0 };
33
+ return { ...state, rotation: next[state.rotation] };
34
+ }
35
+
36
+ export interface ScreenTransformDeps {
37
+ createCanvas(): HTMLCanvasElement;
38
+ createVideo(): HTMLVideoElement;
39
+ now(): number;
40
+ }
41
+
42
+ /** Screen share doesn't need the camera's 30fps - matches `VIDEO_FPS`, what the relay's own `VideoSender` already
43
+ * encodes a shared screen at. */
44
+ const DEFAULT_FRAME_RATE = 15;
45
+
46
+ const DEFAULT_DEPS: ScreenTransformDeps = {
47
+ createCanvas: () => document.createElement("canvas"),
48
+ createVideo: () => document.createElement("video"),
49
+ now: () => performance.now(),
50
+ };
51
+
52
+ export interface ScreenTransformOptions {
53
+ /** The screen-share track. Only ever read - stopping it stays with whoever owns it (`_CallView.tsx`). */
54
+ source: MediaStreamTrack;
55
+ state?: ScreenTransformState;
56
+ frameRate?: number;
57
+ deps?: Partial<ScreenTransformDeps>;
58
+ }
59
+
60
+ export class ScreenTransformProcessor {
61
+ /** The transformed video - what to send in place of the screen track's own. */
62
+ readonly track: MediaStreamTrack;
63
+
64
+ private readonly deps: ScreenTransformDeps;
65
+ private readonly interval: number;
66
+ private readonly video: HTMLVideoElement;
67
+ private readonly canvas: HTMLCanvasElement;
68
+ private readonly ctx: CanvasRenderingContext2D;
69
+
70
+ private state: ScreenTransformState;
71
+ private stopped = false;
72
+ private timer: ReturnType<typeof setTimeout> | undefined;
73
+
74
+ constructor(options: ScreenTransformOptions) {
75
+ this.deps = { ...DEFAULT_DEPS, ...options.deps };
76
+ this.state = options.state ?? NO_SCREEN_TRANSFORM;
77
+ const frameRate = options.frameRate ?? DEFAULT_FRAME_RATE;
78
+ this.interval = 1000 / frameRate;
79
+
80
+ this.canvas = this.deps.createCanvas();
81
+ const ctx = this.canvas.getContext("2d");
82
+ if (!ctx) {
83
+ throw new Error("This browser can't rotate the shared screen.");
84
+ }
85
+ this.ctx = ctx;
86
+ this.track = this.canvas.captureStream(frameRate).getVideoTracks()[0];
87
+
88
+ this.video = this.deps.createVideo();
89
+ this.video.muted = true;
90
+ this.video.playsInline = true;
91
+ this.video.srcObject = new MediaStream([options.source]);
92
+ void this.video.play().catch(() => undefined);
93
+
94
+ this.tick();
95
+ }
96
+
97
+ /** Changes the rotation/flip without interrupting the video. */
98
+ setState(state: ScreenTransformState): void {
99
+ this.state = state;
100
+ }
101
+
102
+ stop(): void {
103
+ this.stopped = true;
104
+ clearTimeout(this.timer);
105
+ this.video.pause();
106
+ this.video.srcObject = null;
107
+ this.track.stop();
108
+ }
109
+
110
+ private tick = (): void => {
111
+ if (this.stopped) {
112
+ return;
113
+ }
114
+ const started = this.deps.now();
115
+ if (this.video.readyState >= 2 && this.video.videoWidth > 0) {
116
+ this.render();
117
+ }
118
+ this.timer = setTimeout(this.tick, Math.max(0, this.interval - (this.deps.now() - started)));
119
+ };
120
+
121
+ private render(): void {
122
+ const { video, canvas, ctx, state } = this;
123
+ const sourceWidth = video.videoWidth;
124
+ const sourceHeight = video.videoHeight;
125
+ // A quarter turn swaps which way is "wide" - the canvas (and so the track everyone actually receives) must
126
+ // match, or the picture is squashed into the untransformed aspect ratio.
127
+ const swapped = state.rotation === 90 || state.rotation === 270;
128
+ const width = swapped ? sourceHeight : sourceWidth;
129
+ const height = swapped ? sourceWidth : sourceHeight;
130
+ if (canvas.width !== width || canvas.height !== height) {
131
+ canvas.width = width;
132
+ canvas.height = height;
133
+ }
134
+
135
+ if (state.rotation === 0 && !state.flipped) {
136
+ ctx.drawImage(video, 0, 0, sourceWidth, sourceHeight);
137
+ return;
138
+ }
139
+ ctx.save();
140
+ ctx.translate(width / 2, height / 2);
141
+ ctx.rotate((state.rotation * Math.PI) / 180);
142
+ if (state.flipped) {
143
+ ctx.scale(-1, 1);
144
+ }
145
+ ctx.drawImage(video, -sourceWidth / 2, -sourceHeight / 2, sourceWidth, sourceHeight);
146
+ ctx.restore();
147
+ }
148
+ }
@@ -38,6 +38,7 @@ import {
38
38
  defaultMediaDevices,
39
39
  isMediaDevicesSupported,
40
40
  listDevices,
41
+ notFoundError,
41
42
  requestUserMedia,
42
43
  } from "./deviceMedia.js";
43
44
  import { startLevelMeter } from "./levelMeter.js";
@@ -115,9 +116,32 @@ function statusFor(error: MediaAccessError): TrackStatus {
115
116
  }
116
117
  }
117
118
 
119
+ /** After asking for the camera and the microphone separately (`acquireBoth()`'s fallback, one at a time rather
120
+ * than together), the one error to show for both attempts together - naming both devices when both are actually
121
+ * missing, rather than just whichever of the two happened to be asked about last (which would otherwise say "no
122
+ * camera" even when the microphone is *also* missing, or say nothing at all when only the microphone is missing,
123
+ * since the camera's own success would otherwise clear the error the microphone's failure had just set). `null`
124
+ * when both succeeded. */
125
+ function combineErrors(audio: MediaAccessError | null, video: MediaAccessError | null): MediaAccessError | null {
126
+ if (!audio || !video) {
127
+ return audio ?? video;
128
+ }
129
+ if (audio.kind === "not-found" && video.kind === "not-found") {
130
+ return notFoundError(true, true);
131
+ }
132
+ // Different reasons - unusual (e.g. permission denied for one, missing hardware for the other). Permission is
133
+ // the more actionable of the two, so it's what's shown; otherwise there's no single message that honestly
134
+ // describes both, so this just picks one.
135
+ return audio.kind === "permission-denied" ? audio : video;
136
+ }
137
+
118
138
  export interface LocalMediaOptions {
119
139
  /** Where the filters' models are hosted, when not on the default CDN - see `filters/mlModels.ts`. */
120
140
  effectsAssetsUrl?: string;
141
+ /** The host's `VideoMeeting.forceMuteOnJoin` setting - when `true`, this call's microphone starts muted
142
+ * regardless of what was remembered from a previous call, overriding (but never overwriting) that preference -
143
+ * see `ensurePrefs()`. `undefined`/`false` changes nothing. */
144
+ forceMuteOnJoin?: boolean;
121
145
  }
122
146
 
123
147
  /** `base`, asking for `preferredId` (the device used last time) when there is one - as a preference, not a demand. */
@@ -125,7 +149,7 @@ function preferring(base: MediaTrackConstraints, preferredId?: string): MediaTra
125
149
  return preferredId ? { ...base, deviceId: { ideal: preferredId } } : base;
126
150
  }
127
151
 
128
- export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): LocalMedia {
152
+ export function useLocalMedia({ effectsAssetsUrl, forceMuteOnJoin }: LocalMediaOptions = {}): LocalMedia {
129
153
  const [supported, setSupported] = useState(true);
130
154
  const [requesting, setRequesting] = useState(false);
131
155
  const [error, setError] = useState<MediaAccessError | null>(null);
@@ -166,7 +190,7 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
166
190
  }
167
191
  const prefs = loadPreferences();
168
192
  prefsRef.current = prefs;
169
- if (prefs.micEnabled === false) {
193
+ if (prefs.micEnabled === false || forceMuteOnJoin) {
170
194
  micEnabledRef.current = false;
171
195
  setMicEnabled(false);
172
196
  }
@@ -184,7 +208,7 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
184
208
  );
185
209
  }
186
210
  return prefs;
187
- }, [applyBackground, applyFilters]);
211
+ }, [applyBackground, applyFilters, forceMuteOnJoin]);
188
212
 
189
213
  /** Saves a change to the remembered settings. */
190
214
  const remember = useCallback(
@@ -245,7 +269,7 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
245
269
 
246
270
  /** Asks for one kind on its own - a specific device when `deviceId` is given, else the one used last time. */
247
271
  const acquire = useCallback(
248
- async (kind: MediaKind, deviceId?: string): Promise<boolean> => {
272
+ async (kind: MediaKind, deviceId?: string): Promise<MediaAccessError | null> => {
249
273
  const base = kind === "audio" ? AUDIO_CONSTRAINTS : VIDEO_CONSTRAINTS;
250
274
  const prefs = ensurePrefs();
251
275
  const constraint: MediaTrackConstraints = deviceId
@@ -257,13 +281,13 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
257
281
  setError(result.error);
258
282
  setStatus((prev) => ({ ...prev, [kind]: statusFor(result.error) }));
259
283
  }
260
- return false;
284
+ return result.error;
261
285
  }
262
286
  adoptStream(result.value);
263
287
  if (mountedRef.current) {
264
288
  setError(null);
265
289
  }
266
- return true;
290
+ return null;
267
291
  },
268
292
  [adoptStream, ensurePrefs],
269
293
  );
@@ -292,9 +316,13 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
292
316
  }
293
317
  } else {
294
318
  // No camera, no microphone, or the two together couldn't be satisfied: ask for each on its own, so one
295
- // missing device doesn't cost the participant the other.
296
- await acquire("audio");
297
- await acquire("video");
319
+ // missing device doesn't cost the participant the other - then combine what each found into one
320
+ // message (see `combineErrors()`) rather than showing whichever of the two ran last.
321
+ const audioError = await acquire("audio");
322
+ const videoError = await acquire("video");
323
+ if (mountedRef.current) {
324
+ setError(combineErrors(audioError, videoError));
325
+ }
298
326
  }
299
327
  }, [acquire, adoptStream, ensurePrefs]);
300
328
 
@@ -359,7 +387,7 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
359
387
  setTracks(tracksRef.current);
360
388
  }
361
389
  }
362
- if (await acquire(kind, deviceId)) {
390
+ if ((await acquire(kind, deviceId)) === null) {
363
391
  remember(kind === "audio" ? { microphoneId: deviceId } : { cameraId: deviceId });
364
392
  }
365
393
  await refreshDevices();
@@ -16,6 +16,10 @@ export const JITTER_BUFFER_SECONDS = 0.08;
16
16
  export const MAX_AHEAD_SECONDS = 0.4;
17
17
  /** Skip a packet instead of queueing it when the decoder is this far behind. */
18
18
  export const AUDIO_MAX_DECODE_QUEUE = 16;
19
+ /** The length of the fade-in `play()` gives a block that doesn't pick up exactly where the last one left off (the
20
+ * first block, or the one after a gap) - long enough to mask the click such a discontinuity would otherwise
21
+ * produce, short enough that it is never heard as an actual fade. */
22
+ export const DISCONTINUITY_FADE_SECONDS = 0.004;
19
23
 
20
24
  /**
21
25
  * Decodes one sender's Opus packets and schedules them for playback into a `MediaStreamAudioDestinationNode`.
@@ -23,8 +27,16 @@ export const AUDIO_MAX_DECODE_QUEUE = 16;
23
27
  * Each decoded block is copied into an `AudioBuffer` and started on its own `AudioBufferSourceNode` at
24
28
  * `max(currentTime + jitter, nextTime)`, where `nextTime` is when the previous block ends - so consecutive blocks
25
29
  * join seamlessly, and after a gap in the stream playback restarts with the jitter buffer's worth of headroom.
26
- * Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step;
27
- * the decoder is created lazily and discarded on any error, and replaced by the next packet.
30
+ * Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step
31
+ * *in terms of content* - but the shared decoder (reused across packets so it keeps its normal state between them,
32
+ * not recreated per packet) is never told a packet was skipped, so its internal prediction state no longer matches
33
+ * what it decodes next. Audibly this can be a click right at that seam, not just silence where the lost packet
34
+ * would have been - the same is true of the very first block (nothing at all came before it) and of the block
35
+ * after a stall long enough to need the jitter buffer's full headroom again. `play()` gives exactly those blocks a
36
+ * few milliseconds' fade-in (`DISCONTINUITY_FADE_SECONDS`) through a `GainNode` to mask it; an ordinary block that
37
+ * picks up exactly where the last one ended gets none - fading *every* block would itself be audible, a faint
38
+ * tremolo at the packet rate. The decoder is created lazily and discarded on any error, and replaced by the next
39
+ * packet.
28
40
  */
29
41
  export class AudioPlayer {
30
42
  private decoder: DecoderLike | undefined;
@@ -97,9 +109,23 @@ export class AudioPlayer {
97
109
  buffer.copyToChannel(pcm, 0);
98
110
  const source = this.ctx.createBufferSource();
99
111
  source.buffer = buffer;
100
- source.connect(this.destination);
101
- source.onended = () => source.disconnect();
102
112
  const start = Math.max(now + JITTER_BUFFER_SECONDS, this.nextTime);
113
+ if (start > this.nextTime) {
114
+ // A gap, not a continuation - the decoder's state no longer matches what it's about to decode (see
115
+ // this class's doc comment), so fade this block in rather than let it start abruptly.
116
+ const gain = this.ctx.createGain();
117
+ gain.gain.setValueAtTime(0, start);
118
+ gain.gain.linearRampToValueAtTime(1, start + DISCONTINUITY_FADE_SECONDS);
119
+ source.connect(gain);
120
+ gain.connect(this.destination);
121
+ source.onended = () => {
122
+ source.disconnect();
123
+ gain.disconnect();
124
+ };
125
+ } else {
126
+ source.connect(this.destination);
127
+ source.onended = () => source.disconnect();
128
+ }
103
129
  source.start(start);
104
130
  this.nextTime = start + buffer.duration;
105
131
  } catch {
@@ -74,7 +74,14 @@ export interface ScriptProcessorLike extends AudioNodeLike {
74
74
  }
75
75
 
76
76
  export interface GainLike extends AudioNodeLike {
77
- gain: { value: number };
77
+ gain: {
78
+ value: number;
79
+ /** `AudioParam.setValueAtTime()` - used to schedule a fade (`AudioPlayer`'s discontinuity fade-in), not
80
+ * needed for `AudioSender`'s own static, unscheduled mute (a plain `.value = 0` assignment). */
81
+ setValueAtTime(value: number, startTime: number): unknown;
82
+ /** `AudioParam.linearRampToValueAtTime()`. */
83
+ linearRampToValueAtTime(value: number, endTime: number): unknown;
84
+ };
78
85
  }
79
86
 
80
87
  export interface AudioBufferLike {
@@ -62,6 +62,15 @@
62
62
  *
63
63
  * Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
64
64
  *
65
+ * ## Host moderation (`mute-request`, `kicked`)
66
+ *
67
+ * `sendMuteRequest()`/`sendKick()` send a point-to-point signal to one peer; the manager itself does not decide who
68
+ * may send one - a "host" identity exists only in `_CallView.tsx`, which shows the controls that call these
69
+ * methods to nobody but the host (see `types.ts`'s `SignalMessage` doc comment for why this is cooperative, not
70
+ * enforced, at the signaling layer). Receiving one emits `mute-requested`/`kicked` for the caller to act on; this
71
+ * manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
72
+ * participant's actual departure is `stop()`, which the caller decides to call.
73
+ *
65
74
  * ## Three media paths, tried in order
66
75
  *
67
76
  * Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
@@ -115,6 +124,11 @@ export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
115
124
  /** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
116
125
  export const DEFAULT_DISCONNECTED_GRACE_MS = 8_000;
117
126
 
127
+ /** How often a connected pair's quality stats (the diagnostics panel) are refreshed - low frequency on purpose:
128
+ * this is for a participant occasionally checking why a call feels off, not a live chart, and `getStats()` is not
129
+ * free to call every frame. */
130
+ export const DEFAULT_DIAGNOSTICS_POLL_MS = 3_000;
131
+
118
132
  type MediaKind = "audio" | "video";
119
133
 
120
134
  interface PeerState extends MeshParticipant {
@@ -128,6 +142,9 @@ interface PeerState extends MeshParticipant {
128
142
  connectTimer: ReturnType<typeof setTimeout> | undefined;
129
143
  /** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
130
144
  disconnectTimer: ReturnType<typeof setTimeout> | undefined;
145
+ /** Refreshes `diagnostics` (`MeshParticipant`) while this pair is on a real `RTCPeerConnection` - started once
146
+ * it first connects, stopped the moment it leaves WebRTC for the relay or the pair ends. */
147
+ diagnosticsTimer: ReturnType<typeof setInterval> | undefined;
131
148
  }
132
149
 
133
150
  export interface MeshConnectionManagerOptions {
@@ -156,6 +173,8 @@ export interface MeshConnectionManagerOptions {
156
173
  connectTimeoutMs?: number;
157
174
  /** Defaults to `DEFAULT_DISCONNECTED_GRACE_MS`. */
158
175
  disconnectedGraceMs?: number;
176
+ /** Defaults to `DEFAULT_DIAGNOSTICS_POLL_MS`. */
177
+ diagnosticsPollMs?: number;
159
178
  }
160
179
 
161
180
  export class MeshConnectionManager {
@@ -222,6 +241,7 @@ export class MeshConnectionManager {
222
241
  this.unsubscribe = undefined;
223
242
  for (const peer of this.peers.values()) {
224
243
  this.clearTimers(peer);
244
+ this.stopDiagnosticsPolling(peer);
225
245
  peer.pc.close();
226
246
  }
227
247
  this.peers.clear();
@@ -285,6 +305,20 @@ export class MeshConnectionManager {
285
305
  this.emit({ type: "presenter-changed", uid: undefined });
286
306
  }
287
307
 
308
+ /** Asks `peerUid`'s participant to mute - a cooperative signal (see this module's doc comment on host
309
+ * moderation), not enforcement. Nothing here checks that the local participant is actually the host; the caller
310
+ * is responsible for only offering this to one. */
311
+ sendMuteRequest(peerUid: string): void {
312
+ this.send({ kind: "mute-request", to: peerUid });
313
+ }
314
+
315
+ /** Tells `peerUid`'s participant they have been removed from the call. Sends only the cooperative signal - it
316
+ * does not itself revoke `peerUid`'s server-side channel grant (see `BaseVideoMeetingRoute.revokeChannelGrant()`,
317
+ * which the caller is expected to call alongside this for an enforced removal). */
318
+ sendKick(peerUid: string): void {
319
+ this.send({ kind: "kicked", to: peerUid });
320
+ }
321
+
288
322
  private sendHello(): void {
289
323
  this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
290
324
  }
@@ -345,6 +379,12 @@ export class MeshConnectionManager {
345
379
  case "relay-fallback":
346
380
  this.handleRelayFallback(message.from);
347
381
  return;
382
+ case "mute-request":
383
+ this.emit({ type: "mute-requested" });
384
+ return;
385
+ case "kicked":
386
+ this.emit({ type: "kicked" });
387
+ return;
348
388
  }
349
389
  }
350
390
 
@@ -408,6 +448,7 @@ export class MeshConnectionManager {
408
448
  return;
409
449
  }
410
450
  this.clearTimers(peer);
451
+ this.stopDiagnosticsPolling(peer);
411
452
  peer.pc.close();
412
453
  this.peers.delete(uid);
413
454
  if (peer.transport === "websocket") {
@@ -440,6 +481,7 @@ export class MeshConnectionManager {
440
481
  transport: "connecting",
441
482
  connectTimer: undefined,
442
483
  disconnectTimer: undefined,
484
+ diagnosticsTimer: undefined,
443
485
  };
444
486
  this.orphanCandidates.delete(uid);
445
487
  this.peers.set(uid, peer);
@@ -484,9 +526,10 @@ export class MeshConnectionManager {
484
526
  }
485
527
  }
486
528
 
487
- /** Records whether a connected pair is direct or relayed through TURN, from the candidate pair ICE selected. */
529
+ /** Records whether a connected pair is direct, relayed through TURN, or relayed through TURN over TCP (see
530
+ * `MediaTransport`'s doc comment), from the candidate pair ICE selected. */
488
531
  private async detectTransport(peer: PeerState): Promise<void> {
489
- let type: "p2p" | "turn" | "unknown";
532
+ let type: "p2p" | "turn" | "turn-tcp" | "unknown";
490
533
  try {
491
534
  type = await peer.pc.connectionType();
492
535
  } catch {
@@ -494,7 +537,39 @@ export class MeshConnectionManager {
494
537
  }
495
538
  // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
496
539
  if (peer.transport !== "websocket" && this.peers.get(peer.uid) === peer && peer.pc.connectionState === "connected") {
497
- this.setTransport(peer, type === "turn" ? "turn" : "p2p");
540
+ this.setTransport(peer, type === "turn" || type === "turn-tcp" ? type : "p2p");
541
+ this.startDiagnosticsPolling(peer);
542
+ }
543
+ }
544
+
545
+ /** Idempotent - a pair that reconnects without ever leaving WebRTC (`connected` -> `disconnected` -> `connected`
546
+ * again) does not get a second timer. */
547
+ private startDiagnosticsPolling(peer: PeerState): void {
548
+ if (peer.diagnosticsTimer !== undefined) {
549
+ return;
550
+ }
551
+ const pollMs = this.options.diagnosticsPollMs ?? DEFAULT_DIAGNOSTICS_POLL_MS;
552
+ peer.diagnosticsTimer = setInterval(() => void this.pollDiagnostics(peer), pollMs);
553
+ void this.pollDiagnostics(peer); // the first sample doesn't wait a full interval
554
+ }
555
+
556
+ private stopDiagnosticsPolling(peer: PeerState): void {
557
+ clearInterval(peer.diagnosticsTimer);
558
+ peer.diagnosticsTimer = undefined;
559
+ }
560
+
561
+ private async pollDiagnostics(peer: PeerState): Promise<void> {
562
+ let diagnostics: MeshParticipant["diagnostics"];
563
+ try {
564
+ diagnostics = await peer.pc.collectDiagnostics();
565
+ } catch {
566
+ // Leaves whatever was last polled in place rather than blanking a momentary failure.
567
+ return;
568
+ }
569
+ // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
570
+ if (this.peers.get(peer.uid) === peer && peer.diagnosticsTimer !== undefined) {
571
+ peer.diagnostics = diagnostics;
572
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
498
573
  }
499
574
  }
500
575
 
@@ -522,6 +597,8 @@ export class MeshConnectionManager {
522
597
  return;
523
598
  }
524
599
  this.clearTimers(peer);
600
+ this.stopDiagnosticsPolling(peer);
601
+ peer.diagnostics = undefined;
525
602
  const relay = this.options.relay;
526
603
  if (!relay?.supported) {
527
604
  this.setTransport(peer, "failed");
@@ -667,7 +744,15 @@ export class MeshConnectionManager {
667
744
  }
668
745
 
669
746
  function toParticipant(peer: PeerState): MeshParticipant {
670
- return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised, transport: peer.transport };
747
+ return {
748
+ uid: peer.uid,
749
+ name: peer.name,
750
+ audioOn: peer.audioOn,
751
+ videoOn: peer.videoOn,
752
+ handRaised: peer.handRaised,
753
+ transport: peer.transport,
754
+ diagnostics: peer.diagnostics,
755
+ };
671
756
  }
672
757
 
673
758
  function sameState(a: ParticipantState, b: ParticipantState): boolean {
@@ -7,15 +7,23 @@
7
7
  * this factory's only real caller. `createBrowserPeerConnection` is a plain function value, never invoked at
8
8
  * module scope, so importing this file is safe during this plugin's page SSR, where `RTCPeerConnection` does not
9
9
  * exist at all - it would only throw if actually *called* outside a browser. */
10
- import type { RTCPeerConnectionFactory, RTCPeerConnectionLike } from "./types.js";
10
+ import type { ConnectionDiagnostics, RTCPeerConnectionFactory, RTCPeerConnectionLike, RtpStreamDiagnostics } from "./types.js";
11
11
 
12
12
  /**
13
- * Whether the pair ICE settled on goes through a TURN relay. The selected pair is found the standard way (the
14
- * `transport` report's `selectedCandidatePairId`) and, for a browser that does not fill that in, as the succeeded and
15
- * nominated `candidate-pair`. A relay on *either* end means the media passes through a TURN server, so it counts as
16
- * `"turn"`; `"unknown"` when no pair is reported at all.
13
+ * Whether the pair ICE settled on goes through a TURN relay, and - when it's our own end that's relayed - whether
14
+ * that relay is TCP (`turn-tcp`, meaning `turns:` too, TLS being TCP-based as well) rather than UDP. The selected
15
+ * pair is found the standard way (the `transport` report's `selectedCandidatePairId`) and, for a browser that does
16
+ * not fill that in, as the succeeded and nominated `candidate-pair`. A relay on *either* end means the media
17
+ * passes through a TURN server, so it counts as at least `"turn"`; `"unknown"` when no pair is reported at all.
18
+ *
19
+ * The TCP/UDP distinction matters because they fail very differently under loss: UDP just drops the lost packet
20
+ * (a click), while TCP retransmits it and blocks everything queued behind it until that arrives (a stall) - exactly
21
+ * the "gaps that feel like latency, not packet loss" a participant on a TCP-relayed connection reports. `relayProtocol`
22
+ * (`"udp"`/`"tcp"`/`"tls"`) is only ever reported on a *local* relay candidate - there's no equivalent visibility into
23
+ * how the *remote* peer reaches its own TURN allocation, so a relay on the remote end alone is reported as plain
24
+ * `"turn"` rather than guessed at.
17
25
  */
18
- export async function selectedConnectionType(pc: Pick<RTCPeerConnection, "getStats">): Promise<"p2p" | "turn" | "unknown"> {
26
+ export async function selectedConnectionType(pc: Pick<RTCPeerConnection, "getStats">): Promise<"p2p" | "turn" | "turn-tcp" | "unknown"> {
19
27
  const reports = new Map<string, Record<string, unknown>>();
20
28
  (await pc.getStats()).forEach((report: Record<string, unknown>) => reports.set(String(report.id), report));
21
29
  const transport = [...reports.values()].find((report) => report.type === "transport" && report.selectedCandidatePairId);
@@ -27,7 +35,38 @@ export async function selectedConnectionType(pc: Pick<RTCPeerConnection, "getSta
27
35
  }
28
36
  const local = reports.get(String(pair.localCandidateId));
29
37
  const remote = reports.get(String(pair.remoteCandidateId));
30
- return local?.candidateType === "relay" || remote?.candidateType === "relay" ? "turn" : "p2p";
38
+ if (local?.candidateType !== "relay" && remote?.candidateType !== "relay") {
39
+ return "p2p";
40
+ }
41
+ const relayProtocol = local?.candidateType === "relay" ? String(local.relayProtocol ?? "") : "";
42
+ return relayProtocol === "tcp" || relayProtocol === "tls" ? "turn-tcp" : "turn";
43
+ }
44
+
45
+ function streamDiagnostics(reports: Map<string, Record<string, unknown>>, kind: "audio" | "video"): RtpStreamDiagnostics {
46
+ const inbound = [...reports.values()].find((report) => report.type === "inbound-rtp" && report.kind === kind);
47
+ const outbound = [...reports.values()].find((report) => report.type === "outbound-rtp" && report.kind === kind);
48
+ const num = (value: unknown): number | undefined => (typeof value === "number" ? value : undefined);
49
+ return {
50
+ packetsLost: num(inbound?.packetsLost),
51
+ jitter: num(inbound?.jitter),
52
+ bytesSent: num(outbound?.bytesSent),
53
+ bytesReceived: num(inbound?.bytesReceived),
54
+ };
55
+ }
56
+
57
+ /** One point-in-time sample of this connection's own quality stats, for the diagnostics panel - a single
58
+ * `getStats()` walk, independent of (and in addition to) `selectedConnectionType()`'s own call, since this is
59
+ * polled periodically at a much lower priority than the one-time transport check and there's no value in coupling
60
+ * the two together. */
61
+ export async function collectDiagnostics(pc: Pick<RTCPeerConnection, "getStats">): Promise<ConnectionDiagnostics> {
62
+ const reports = new Map<string, Record<string, unknown>>();
63
+ (await pc.getStats()).forEach((report: Record<string, unknown>) => reports.set(String(report.id), report));
64
+ const transport = [...reports.values()].find((report) => report.type === "transport" && report.selectedCandidatePairId);
65
+ const pair =
66
+ (transport ? reports.get(String(transport.selectedCandidatePairId)) : undefined) ??
67
+ [...reports.values()].find((report) => report.type === "candidate-pair" && (report.selected || (report.nominated && report.state === "succeeded")));
68
+ const roundTripTimeSeconds = typeof pair?.currentRoundTripTime === "number" ? pair.currentRoundTripTime : undefined;
69
+ return { roundTripTimeSeconds, audio: streamDiagnostics(reports, "audio"), video: streamDiagnostics(reports, "video") };
31
70
  }
32
71
 
33
72
  export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) => {
@@ -55,6 +94,7 @@ export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) =>
55
94
  addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
56
95
  close: () => pc.close(),
57
96
  connectionType: () => selectedConnectionType(pc),
97
+ collectDiagnostics: () => collectDiagnostics(pc),
58
98
  onicecandidate: null,
59
99
  ontrack: null,
60
100
  onconnectionstatechange: null,