@rapidmx/meet-plugin 0.9.3 → 0.11.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/[token].tsx +204 -13
- package/apps/meet/_CallControls.tsx +75 -5
- package/apps/meet/_CallView.tsx +326 -7
- package/apps/meet/_DiagnosticsPanel.tsx +113 -0
- package/apps/meet/_ParticipantTile.tsx +10 -2
- package/apps/meet/_ParticipantsDrawer.tsx +397 -0
- package/apps/meet/_icons.tsx +12 -0
- package/apps/meet/_meetApi.ts +141 -1
- package/apps/shared/media/filters/ScreenTransform.ts +148 -0
- package/apps/shared/media/useLocalMedia.ts +7 -3
- package/apps/shared/webrtc/MeshConnectionManager.ts +115 -4
- package/apps/shared/webrtc/realPeerConnection.ts +47 -7
- package/apps/shared/webrtc/types.ts +67 -8
- package/dist/apps/meet/[token].d.ts +4 -1
- package/dist/apps/meet/[token].js +121 -9
- package/dist/apps/meet/_CallControls.d.ts +16 -2
- package/dist/apps/meet/_CallControls.js +9 -6
- package/dist/apps/meet/_CallView.d.ts +45 -2
- package/dist/apps/meet/_CallView.js +215 -6
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
- package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
- package/dist/apps/meet/_ParticipantTile.js +9 -2
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +77 -0
- package/dist/apps/meet/_ParticipantsDrawer.js +91 -0
- package/dist/apps/meet/_icons.d.ts +3 -0
- package/dist/apps/meet/_icons.js +3 -0
- package/dist/apps/meet/_meetApi.d.ts +93 -1
- package/dist/apps/meet/_meetApi.js +82 -0
- package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
- package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
- package/dist/apps/shared/media/useLocalMedia.js +3 -3
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +49 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +103 -3
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
- package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
- package/dist/apps/shared/webrtc/types.d.ts +69 -7
- package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
- package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/PasswordUtils.js +52 -0
- package/dist/lib/util/PasswordUtils.js.map +1 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
- package/dist/types/models/types.d.ts +23 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
- package/dist/types/util/PasswordUtils.d.ts +17 -0
- 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
|
+
}
|
|
@@ -138,6 +138,10 @@ function combineErrors(audio: MediaAccessError | null, video: MediaAccessError |
|
|
|
138
138
|
export interface LocalMediaOptions {
|
|
139
139
|
/** Where the filters' models are hosted, when not on the default CDN - see `filters/mlModels.ts`. */
|
|
140
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;
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
/** `base`, asking for `preferredId` (the device used last time) when there is one - as a preference, not a demand. */
|
|
@@ -145,7 +149,7 @@ function preferring(base: MediaTrackConstraints, preferredId?: string): MediaTra
|
|
|
145
149
|
return preferredId ? { ...base, deviceId: { ideal: preferredId } } : base;
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): LocalMedia {
|
|
152
|
+
export function useLocalMedia({ effectsAssetsUrl, forceMuteOnJoin }: LocalMediaOptions = {}): LocalMedia {
|
|
149
153
|
const [supported, setSupported] = useState(true);
|
|
150
154
|
const [requesting, setRequesting] = useState(false);
|
|
151
155
|
const [error, setError] = useState<MediaAccessError | null>(null);
|
|
@@ -186,7 +190,7 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
|
|
|
186
190
|
}
|
|
187
191
|
const prefs = loadPreferences();
|
|
188
192
|
prefsRef.current = prefs;
|
|
189
|
-
if (prefs.micEnabled === false) {
|
|
193
|
+
if (prefs.micEnabled === false || forceMuteOnJoin) {
|
|
190
194
|
micEnabledRef.current = false;
|
|
191
195
|
setMicEnabled(false);
|
|
192
196
|
}
|
|
@@ -204,7 +208,7 @@ export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): Loc
|
|
|
204
208
|
);
|
|
205
209
|
}
|
|
206
210
|
return prefs;
|
|
207
|
-
}, [applyBackground, applyFilters]);
|
|
211
|
+
}, [applyBackground, applyFilters, forceMuteOnJoin]);
|
|
208
212
|
|
|
209
213
|
/** Saves a change to the remembered settings. */
|
|
210
214
|
const remember = useCallback(
|
|
@@ -62,6 +62,28 @@
|
|
|
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
|
+
*
|
|
74
|
+
* ## Talking stick (`talking-stick`)
|
|
75
|
+
*
|
|
76
|
+
* One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
|
|
77
|
+
* there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
|
|
78
|
+
* `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
|
|
79
|
+
* simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
|
|
80
|
+
* local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
|
|
81
|
+
* leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
|
|
82
|
+
* already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
|
|
83
|
+
* right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
|
|
84
|
+
* backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
|
|
85
|
+
* actually in the call together.
|
|
86
|
+
*
|
|
65
87
|
* ## Three media paths, tried in order
|
|
66
88
|
*
|
|
67
89
|
* Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
|
|
@@ -115,6 +137,11 @@ export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
|
|
115
137
|
/** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
|
|
116
138
|
export const DEFAULT_DISCONNECTED_GRACE_MS = 8_000;
|
|
117
139
|
|
|
140
|
+
/** How often a connected pair's quality stats (the diagnostics panel) are refreshed - low frequency on purpose:
|
|
141
|
+
* this is for a participant occasionally checking why a call feels off, not a live chart, and `getStats()` is not
|
|
142
|
+
* free to call every frame. */
|
|
143
|
+
export const DEFAULT_DIAGNOSTICS_POLL_MS = 3_000;
|
|
144
|
+
|
|
118
145
|
type MediaKind = "audio" | "video";
|
|
119
146
|
|
|
120
147
|
interface PeerState extends MeshParticipant {
|
|
@@ -128,6 +155,9 @@ interface PeerState extends MeshParticipant {
|
|
|
128
155
|
connectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
129
156
|
/** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
|
|
130
157
|
disconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
/** Refreshes `diagnostics` (`MeshParticipant`) while this pair is on a real `RTCPeerConnection` - started once
|
|
159
|
+
* it first connects, stopped the moment it leaves WebRTC for the relay or the pair ends. */
|
|
160
|
+
diagnosticsTimer: ReturnType<typeof setInterval> | undefined;
|
|
131
161
|
}
|
|
132
162
|
|
|
133
163
|
export interface MeshConnectionManagerOptions {
|
|
@@ -156,6 +186,8 @@ export interface MeshConnectionManagerOptions {
|
|
|
156
186
|
connectTimeoutMs?: number;
|
|
157
187
|
/** Defaults to `DEFAULT_DISCONNECTED_GRACE_MS`. */
|
|
158
188
|
disconnectedGraceMs?: number;
|
|
189
|
+
/** Defaults to `DEFAULT_DIAGNOSTICS_POLL_MS`. */
|
|
190
|
+
diagnosticsPollMs?: number;
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
export class MeshConnectionManager {
|
|
@@ -222,6 +254,7 @@ export class MeshConnectionManager {
|
|
|
222
254
|
this.unsubscribe = undefined;
|
|
223
255
|
for (const peer of this.peers.values()) {
|
|
224
256
|
this.clearTimers(peer);
|
|
257
|
+
this.stopDiagnosticsPolling(peer);
|
|
225
258
|
peer.pc.close();
|
|
226
259
|
}
|
|
227
260
|
this.peers.clear();
|
|
@@ -285,6 +318,30 @@ export class MeshConnectionManager {
|
|
|
285
318
|
this.emit({ type: "presenter-changed", uid: undefined });
|
|
286
319
|
}
|
|
287
320
|
|
|
321
|
+
/** Asks `peerUid`'s participant to mute - a cooperative signal (see this module's doc comment on host
|
|
322
|
+
* moderation), not enforcement. Nothing here checks that the local participant is actually the host; the caller
|
|
323
|
+
* is responsible for only offering this to one. */
|
|
324
|
+
sendMuteRequest(peerUid: string): void {
|
|
325
|
+
this.send({ kind: "mute-request", to: peerUid });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Tells `peerUid`'s participant they have been removed from the call. Sends only the cooperative signal - it
|
|
329
|
+
* does not itself revoke `peerUid`'s server-side channel grant (see `BaseVideoMeetingRoute.revokeChannelGrant()`,
|
|
330
|
+
* which the caller is expected to call alongside this for an enforced removal). */
|
|
331
|
+
sendKick(peerUid: string): void {
|
|
332
|
+
this.send({ kind: "kicked", to: peerUid });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
|
|
336
|
+
* omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
|
|
337
|
+
* there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
|
|
338
|
+
* there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
|
|
339
|
+
* host. */
|
|
340
|
+
setTalkingStick(active: boolean, holder?: string): void {
|
|
341
|
+
this.send({ kind: "talking-stick", active, holder });
|
|
342
|
+
this.emit({ type: "talking-stick-changed", active, holder });
|
|
343
|
+
}
|
|
344
|
+
|
|
288
345
|
private sendHello(): void {
|
|
289
346
|
this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
|
|
290
347
|
}
|
|
@@ -345,6 +402,15 @@ export class MeshConnectionManager {
|
|
|
345
402
|
case "relay-fallback":
|
|
346
403
|
this.handleRelayFallback(message.from);
|
|
347
404
|
return;
|
|
405
|
+
case "mute-request":
|
|
406
|
+
this.emit({ type: "mute-requested" });
|
|
407
|
+
return;
|
|
408
|
+
case "kicked":
|
|
409
|
+
this.emit({ type: "kicked" });
|
|
410
|
+
return;
|
|
411
|
+
case "talking-stick":
|
|
412
|
+
this.emit({ type: "talking-stick-changed", active: message.active === true, holder: typeof message.holder === "string" ? message.holder : undefined });
|
|
413
|
+
return;
|
|
348
414
|
}
|
|
349
415
|
}
|
|
350
416
|
|
|
@@ -408,6 +474,7 @@ export class MeshConnectionManager {
|
|
|
408
474
|
return;
|
|
409
475
|
}
|
|
410
476
|
this.clearTimers(peer);
|
|
477
|
+
this.stopDiagnosticsPolling(peer);
|
|
411
478
|
peer.pc.close();
|
|
412
479
|
this.peers.delete(uid);
|
|
413
480
|
if (peer.transport === "websocket") {
|
|
@@ -440,6 +507,7 @@ export class MeshConnectionManager {
|
|
|
440
507
|
transport: "connecting",
|
|
441
508
|
connectTimer: undefined,
|
|
442
509
|
disconnectTimer: undefined,
|
|
510
|
+
diagnosticsTimer: undefined,
|
|
443
511
|
};
|
|
444
512
|
this.orphanCandidates.delete(uid);
|
|
445
513
|
this.peers.set(uid, peer);
|
|
@@ -484,9 +552,10 @@ export class MeshConnectionManager {
|
|
|
484
552
|
}
|
|
485
553
|
}
|
|
486
554
|
|
|
487
|
-
/** Records whether a connected pair is direct
|
|
555
|
+
/** Records whether a connected pair is direct, relayed through TURN, or relayed through TURN over TCP (see
|
|
556
|
+
* `MediaTransport`'s doc comment), from the candidate pair ICE selected. */
|
|
488
557
|
private async detectTransport(peer: PeerState): Promise<void> {
|
|
489
|
-
let type: "p2p" | "turn" | "unknown";
|
|
558
|
+
let type: "p2p" | "turn" | "turn-tcp" | "unknown";
|
|
490
559
|
try {
|
|
491
560
|
type = await peer.pc.connectionType();
|
|
492
561
|
} catch {
|
|
@@ -494,7 +563,39 @@ export class MeshConnectionManager {
|
|
|
494
563
|
}
|
|
495
564
|
// The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
|
|
496
565
|
if (peer.transport !== "websocket" && this.peers.get(peer.uid) === peer && peer.pc.connectionState === "connected") {
|
|
497
|
-
this.setTransport(peer, type === "turn"
|
|
566
|
+
this.setTransport(peer, type === "turn" || type === "turn-tcp" ? type : "p2p");
|
|
567
|
+
this.startDiagnosticsPolling(peer);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Idempotent - a pair that reconnects without ever leaving WebRTC (`connected` -> `disconnected` -> `connected`
|
|
572
|
+
* again) does not get a second timer. */
|
|
573
|
+
private startDiagnosticsPolling(peer: PeerState): void {
|
|
574
|
+
if (peer.diagnosticsTimer !== undefined) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const pollMs = this.options.diagnosticsPollMs ?? DEFAULT_DIAGNOSTICS_POLL_MS;
|
|
578
|
+
peer.diagnosticsTimer = setInterval(() => void this.pollDiagnostics(peer), pollMs);
|
|
579
|
+
void this.pollDiagnostics(peer); // the first sample doesn't wait a full interval
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private stopDiagnosticsPolling(peer: PeerState): void {
|
|
583
|
+
clearInterval(peer.diagnosticsTimer);
|
|
584
|
+
peer.diagnosticsTimer = undefined;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async pollDiagnostics(peer: PeerState): Promise<void> {
|
|
588
|
+
let diagnostics: MeshParticipant["diagnostics"];
|
|
589
|
+
try {
|
|
590
|
+
diagnostics = await peer.pc.collectDiagnostics();
|
|
591
|
+
} catch {
|
|
592
|
+
// Leaves whatever was last polled in place rather than blanking a momentary failure.
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
// The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
|
|
596
|
+
if (this.peers.get(peer.uid) === peer && peer.diagnosticsTimer !== undefined) {
|
|
597
|
+
peer.diagnostics = diagnostics;
|
|
598
|
+
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
498
599
|
}
|
|
499
600
|
}
|
|
500
601
|
|
|
@@ -522,6 +623,8 @@ export class MeshConnectionManager {
|
|
|
522
623
|
return;
|
|
523
624
|
}
|
|
524
625
|
this.clearTimers(peer);
|
|
626
|
+
this.stopDiagnosticsPolling(peer);
|
|
627
|
+
peer.diagnostics = undefined;
|
|
525
628
|
const relay = this.options.relay;
|
|
526
629
|
if (!relay?.supported) {
|
|
527
630
|
this.setTransport(peer, "failed");
|
|
@@ -667,7 +770,15 @@ export class MeshConnectionManager {
|
|
|
667
770
|
}
|
|
668
771
|
|
|
669
772
|
function toParticipant(peer: PeerState): MeshParticipant {
|
|
670
|
-
return {
|
|
773
|
+
return {
|
|
774
|
+
uid: peer.uid,
|
|
775
|
+
name: peer.name,
|
|
776
|
+
audioOn: peer.audioOn,
|
|
777
|
+
videoOn: peer.videoOn,
|
|
778
|
+
handRaised: peer.handRaised,
|
|
779
|
+
transport: peer.transport,
|
|
780
|
+
diagnostics: peer.diagnostics,
|
|
781
|
+
};
|
|
671
782
|
}
|
|
672
783
|
|
|
673
784
|
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
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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,
|
|
@@ -28,10 +28,18 @@ export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮"
|
|
|
28
28
|
* literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
|
|
29
29
|
* grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
|
|
30
30
|
*
|
|
31
|
-
* `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left
|
|
32
|
-
* participant is relevant); `offer`/`answer`/`ice-candidate` are
|
|
33
|
-
* a recipient that isn't `to` (when set) ignores the message,
|
|
34
|
-
* every subscriber, sender included.
|
|
31
|
+
* `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction`/`talking-stick` are broadcast (`to` left
|
|
32
|
+
* unset - every participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
|
|
33
|
+
* point-to-point (`to` is the intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
|
|
34
|
+
* since a channel's pub/sub fans every message out to every subscriber, sender included.
|
|
35
|
+
*
|
|
36
|
+
* `mute-request`/`kicked`/`talking-stick` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
|
|
37
|
+
* purely cooperative, like every other message here: the recipient's own client decides whether to honor one, and
|
|
38
|
+
* nothing on the wire distinguishes a genuine host's message from any other participant's, since this client
|
|
39
|
+
* protocol has no host identity of its own to check against server-side. `kicked` is paired with the sender also
|
|
40
|
+
* revoking the recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
|
|
41
|
+
* enforced and does not depend on the recipient's client cooperating; `talking-stick` has no server-side
|
|
42
|
+
* counterpart at all (see this module's doc comment on why it is purely an in-call runtime signal).
|
|
35
43
|
*/
|
|
36
44
|
export interface SignalMessage {
|
|
37
45
|
type: "video-meeting-signal";
|
|
@@ -45,7 +53,10 @@ export interface SignalMessage {
|
|
|
45
53
|
| "presenter-release"
|
|
46
54
|
| "state"
|
|
47
55
|
| "reaction"
|
|
48
|
-
| "relay-fallback"
|
|
56
|
+
| "relay-fallback"
|
|
57
|
+
| "mute-request"
|
|
58
|
+
| "kicked"
|
|
59
|
+
| "talking-stick";
|
|
49
60
|
/** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
|
|
50
61
|
* the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
|
|
51
62
|
* `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
|
|
@@ -64,6 +75,11 @@ export interface SignalMessage {
|
|
|
64
75
|
state?: ParticipantState;
|
|
65
76
|
/** `reaction` only - one of `REACTION_EMOJIS`. */
|
|
66
77
|
emoji?: string;
|
|
78
|
+
/** `talking-stick` only - whether talking-stick mode is currently on. */
|
|
79
|
+
active?: boolean;
|
|
80
|
+
/** `talking-stick` only - the peer id (`MeshParticipant.uid`/this tab's own id) currently holding the stick.
|
|
81
|
+
* Absent when `active` is `false`, or when nobody holds it yet. */
|
|
82
|
+
holder?: string;
|
|
67
83
|
/** `offer`/`answer` only. */
|
|
68
84
|
sdp?: RTCSessionDescriptionInit;
|
|
69
85
|
/** `ice-candidate` only. */
|
|
@@ -81,6 +97,28 @@ export interface SignalingChannel {
|
|
|
81
97
|
onMessage(handler: (message: SignalMessage) => void): () => void;
|
|
82
98
|
}
|
|
83
99
|
|
|
100
|
+
/** One kind's RTP stream stats, as of one sample - cumulative counters (`bytesSent`/`bytesReceived`), not a rate,
|
|
101
|
+
* since a single `getStats()` snapshot has no "since when" to divide by; a caller wanting a bitrate keeps the
|
|
102
|
+
* previous sample itself and divides the difference by the elapsed time (see `MeshConnectionManager`'s poller).
|
|
103
|
+
* `undefined` for anything this browser (or this stream's direction - an audio-only connection reports nothing for
|
|
104
|
+
* video, and vice versa) doesn't report. */
|
|
105
|
+
export interface RtpStreamDiagnostics {
|
|
106
|
+
packetsLost?: number;
|
|
107
|
+
/** Seconds. */
|
|
108
|
+
jitter?: number;
|
|
109
|
+
bytesSent?: number;
|
|
110
|
+
bytesReceived?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One point-in-time sample of a connection's own quality stats, for the diagnostics panel. */
|
|
114
|
+
export interface ConnectionDiagnostics {
|
|
115
|
+
/** Seconds - the selected candidate pair's own current round-trip time. `undefined` when this browser doesn't
|
|
116
|
+
* report one (a connection still connecting, or simply not supported). */
|
|
117
|
+
roundTripTimeSeconds?: number;
|
|
118
|
+
audio: RtpStreamDiagnostics;
|
|
119
|
+
video: RtpStreamDiagnostics;
|
|
120
|
+
}
|
|
121
|
+
|
|
84
122
|
/** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
|
|
85
123
|
* one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
|
|
86
124
|
* switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
|
|
@@ -115,17 +153,24 @@ export interface RTCPeerConnectionLike {
|
|
|
115
153
|
connectionState: string;
|
|
116
154
|
/** Which path the connected pair is using - read from the selected candidate pair once `connectionState` is
|
|
117
155
|
* `connected`. `"unknown"` when the browser does not report one (treated as direct by the caller). */
|
|
118
|
-
connectionType(): Promise<"p2p" | "turn" | "unknown">;
|
|
156
|
+
connectionType(): Promise<"p2p" | "turn" | "turn-tcp" | "unknown">;
|
|
157
|
+
/** One point-in-time sample of this connection's own quality stats, for the diagnostics panel - polled
|
|
158
|
+
* periodically and independently of `connectionType()` (see `MeshConnectionManager`'s doc comment). */
|
|
159
|
+
collectDiagnostics(): Promise<ConnectionDiagnostics>;
|
|
119
160
|
}
|
|
120
161
|
|
|
121
162
|
/** Which of the three media paths a participant's audio and video currently take, in the order they are tried:
|
|
122
163
|
*
|
|
123
164
|
* - `"p2p"`: a direct peer-to-peer WebRTC connection, the preferred path.
|
|
124
165
|
* - `"turn"`: still WebRTC, but relayed through the TURN server, for a participant a direct path cannot reach.
|
|
166
|
+
* - `"turn-tcp"`: the same, but the relay hop itself is TCP (or `turns:`, TLS being TCP-based too) rather than UDP -
|
|
167
|
+
* a network that blocks UDP outright. TCP's reliable, ordered delivery means one lost or delayed packet stalls
|
|
168
|
+
* everything queued behind it instead of just being dropped, so this can sound like pauses rather than the clicks
|
|
169
|
+
* plain packet loss would - worth telling apart from ordinary `"turn"` for exactly that reason.
|
|
125
170
|
* - `"websocket"`: not WebRTC at all - encoded frames proxied by this server over a WebSocket (see
|
|
126
171
|
* `apps/shared/relay/`), the last resort when neither of the above connects.
|
|
127
172
|
* - `"connecting"`: no path established yet. `"failed"`: every path this browser can try has failed. */
|
|
128
|
-
export type MediaTransport = "connecting" | "p2p" | "turn" | "websocket" | "failed";
|
|
173
|
+
export type MediaTransport = "connecting" | "p2p" | "turn" | "turn-tcp" | "websocket" | "failed";
|
|
129
174
|
|
|
130
175
|
/** What `MeshConnectionManager` needs from the WebSocket media relay (`apps/shared/relay/RelayTransport.ts` implements
|
|
131
176
|
* it) - declared here so the mesh, and its tests, depend on the shape alone. */
|
|
@@ -151,6 +196,10 @@ export interface MeshParticipant extends ParticipantState {
|
|
|
151
196
|
name: string;
|
|
152
197
|
/** How this participant's media currently reaches the local one - see `MediaTransport`. */
|
|
153
198
|
transport: MediaTransport;
|
|
199
|
+
/** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
|
|
200
|
+
* first poll completes (shortly after `transport` first leaves `"connecting"`), and never present at all for
|
|
201
|
+
* a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll). */
|
|
202
|
+
diagnostics?: ConnectionDiagnostics;
|
|
154
203
|
}
|
|
155
204
|
|
|
156
205
|
export type MeshEvent =
|
|
@@ -160,4 +209,14 @@ export type MeshEvent =
|
|
|
160
209
|
| { type: "remote-stream"; uid: string; stream: MediaStream }
|
|
161
210
|
| { type: "hand-raised"; uid: string; name: string }
|
|
162
211
|
| { type: "reaction"; uid: string; name: string; emoji: string }
|
|
163
|
-
| { type: "presenter-changed"; uid: string | undefined }
|
|
212
|
+
| { type: "presenter-changed"; uid: string | undefined }
|
|
213
|
+
/** Someone (implicitly the host, on the client's own say-so - see `SignalMessage`'s doc comment) asked the local
|
|
214
|
+
* participant to mute. The caller (`_CallView.tsx`) mutes only if currently unmuted; this never unmutes anyone. */
|
|
215
|
+
| { type: "mute-requested" }
|
|
216
|
+
/** The local participant was removed from the call. The caller tears down the mesh and shows a distinct
|
|
217
|
+
* "removed by the host" state rather than the ordinary "you left" one. */
|
|
218
|
+
| { type: "kicked" }
|
|
219
|
+
/** Talking-stick mode turned on or off, or the stick changed hands - see this module's doc comment and
|
|
220
|
+
* `MeshConnectionManager.setTalkingStick()`. `holder` is the peer id now holding it; absent while `active` is
|
|
221
|
+
* `false`, or (transiently) if whoever held it has since left the call. */
|
|
222
|
+
| { type: "talking-stick-changed"; active: boolean; holder?: string };
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
|
|
3
3
|
* slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
|
|
4
|
-
* `loading` -> `
|
|
4
|
+
* `loading` -> (`password`, only when a password is required) -> (`admission-request` then `waiting`, only when
|
|
5
|
+
* the meeting has a waiting room - see that phase's own render branch) -> `lobby` -> `in-call` -> `ended`. A
|
|
6
|
+
* waiting room takes over the entire gate (asking for a password too, on the same form) rather than stacking
|
|
7
|
+
* after a separate password screen - see `BaseVideoMeetingRoute.join()`'s own doc comment for why.
|
|
5
8
|
*
|
|
6
9
|
* ## Session-based name prefill - NOT implemented, and why
|
|
7
10
|
*
|