@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.
- package/apps/meet/[token].tsx +204 -13
- package/apps/meet/_CallControls.tsx +64 -3
- package/apps/meet/_CallView.tsx +244 -7
- package/apps/meet/_DiagnosticsPanel.tsx +113 -0
- package/apps/meet/_ParticipantTile.tsx +10 -2
- package/apps/meet/_ParticipantsDrawer.tsx +341 -0
- package/apps/meet/_icons.tsx +12 -0
- package/apps/meet/_meetApi.ts +141 -1
- package/apps/shared/media/deviceMedia.ts +22 -4
- package/apps/shared/media/filters/ScreenTransform.ts +148 -0
- package/apps/shared/media/useLocalMedia.ts +38 -10
- package/apps/shared/relay/AudioPlayer.ts +30 -4
- package/apps/shared/relay/relayEnv.ts +8 -1
- package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
- package/apps/shared/webrtc/realPeerConnection.ts +47 -7
- package/apps/shared/webrtc/types.ts +55 -7
- package/dist/apps/meet/[token].d.ts +4 -1
- package/dist/apps/meet/[token].js +121 -9
- package/dist/apps/meet/_CallControls.d.ts +9 -1
- package/dist/apps/meet/_CallControls.js +6 -5
- package/dist/apps/meet/_CallView.d.ts +28 -2
- package/dist/apps/meet/_CallView.js +155 -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 +61 -0
- package/dist/apps/meet/_ParticipantsDrawer.js +86 -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/deviceMedia.d.ts +11 -2
- package/dist/apps/shared/media/deviceMedia.js +20 -4
- 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 +32 -10
- package/dist/apps/shared/relay/AudioPlayer.d.ts +14 -2
- package/dist/apps/shared/relay/AudioPlayer.js +31 -4
- package/dist/apps/shared/relay/relayEnv.d.ts +5 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -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 +54 -6
- 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
|
+
}
|
|
@@ -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<
|
|
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
|
|
284
|
+
return result.error;
|
|
261
285
|
}
|
|
262
286
|
adoptStream(result.value);
|
|
263
287
|
if (mountedRef.current) {
|
|
264
288
|
setError(null);
|
|
265
289
|
}
|
|
266
|
-
return
|
|
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
|
-
|
|
297
|
-
await acquire("
|
|
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
|
-
*
|
|
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: {
|
|
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
|
|
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"
|
|
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 {
|
|
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
|
|
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,
|