@rapidmx/meet-plugin 0.5.0 → 0.8.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/README.md +15 -2
- package/apps/meet/[token].tsx +3 -2
- package/apps/meet/_CallControls.tsx +28 -2
- package/apps/meet/_CallView.tsx +23 -2
- package/apps/meet/_EffectsPanel.tsx +134 -0
- package/apps/meet/_MeetLobby.tsx +22 -0
- package/apps/meet/_ParticipantTile.tsx +62 -9
- package/apps/meet/_icons.tsx +4 -0
- package/apps/meet/_meetApi.ts +6 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +383 -0
- package/apps/shared/media/filters/accessories.ts +262 -0
- package/apps/shared/media/filters/backgroundImage.ts +69 -0
- package/apps/shared/media/filters/filterTypes.ts +89 -0
- package/apps/shared/media/filters/mlModels.ts +174 -0
- package/apps/shared/media/filters/pixelEffects.ts +61 -0
- package/apps/shared/media/mediaPreferences.ts +105 -0
- package/apps/shared/media/useLocalMedia.ts +198 -14
- package/apps/shared/relay/AudioPlayer.ts +111 -0
- package/apps/shared/relay/AudioSender.ts +194 -0
- package/apps/shared/relay/RelayClient.ts +262 -0
- package/apps/shared/relay/RelayReceiver.ts +72 -0
- package/apps/shared/relay/RelaySender.ts +74 -0
- package/apps/shared/relay/RelayTransport.ts +188 -0
- package/apps/shared/relay/VideoPlayer.ts +110 -0
- package/apps/shared/relay/VideoSender.ts +216 -0
- package/apps/shared/relay/audioResume.ts +55 -0
- package/apps/shared/relay/frames.ts +275 -0
- package/apps/shared/relay/relayEnv.ts +230 -0
- package/apps/shared/relay/senderCommon.ts +30 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +158 -6
- package/apps/shared/webrtc/realPeerConnection.ts +22 -0
- package/apps/shared/webrtc/types.ts +32 -2
- package/dist/apps/meet/[token].js +3 -3
- package/dist/apps/meet/_CallControls.d.ts +2 -1
- package/dist/apps/meet/_CallControls.js +6 -3
- package/dist/apps/meet/_CallView.d.ts +4 -1
- package/dist/apps/meet/_CallView.js +17 -3
- package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
- package/dist/apps/meet/_EffectsPanel.js +60 -0
- package/dist/apps/meet/_MeetLobby.d.ts +4 -0
- package/dist/apps/meet/_MeetLobby.js +8 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +7 -1
- package/dist/apps/meet/_ParticipantTile.js +20 -3
- package/dist/apps/meet/_icons.d.ts +1 -0
- package/dist/apps/meet/_icons.js +1 -0
- package/dist/apps/meet/_meetApi.d.ts +6 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +75 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.js +304 -0
- package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
- package/dist/apps/shared/media/filters/accessories.js +211 -0
- package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
- package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
- package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
- package/dist/apps/shared/media/filters/filterTypes.js +63 -0
- package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
- package/dist/apps/shared/media/filters/mlModels.js +106 -0
- package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
- package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
- package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
- package/dist/apps/shared/media/mediaPreferences.js +93 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
- package/dist/apps/shared/media/useLocalMedia.js +160 -14
- package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
- package/dist/apps/shared/relay/AudioPlayer.js +103 -0
- package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
- package/dist/apps/shared/relay/AudioSender.js +172 -0
- package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
- package/dist/apps/shared/relay/RelayClient.js +236 -0
- package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
- package/dist/apps/shared/relay/RelayReceiver.js +59 -0
- package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
- package/dist/apps/shared/relay/RelaySender.js +54 -0
- package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
- package/dist/apps/shared/relay/RelayTransport.js +142 -0
- package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
- package/dist/apps/shared/relay/VideoPlayer.js +102 -0
- package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
- package/dist/apps/shared/relay/VideoSender.js +204 -0
- package/dist/apps/shared/relay/audioResume.d.ts +12 -0
- package/dist/apps/shared/relay/audioResume.js +45 -0
- package/dist/apps/shared/relay/frames.d.ts +101 -0
- package/dist/apps/shared/relay/frames.js +221 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
- package/dist/apps/shared/relay/relayEnv.js +69 -0
- package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
- package/dist/apps/shared/relay/senderCommon.js +10 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +51 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +137 -6
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
- package/dist/apps/shared/webrtc/types.d.ts +29 -2
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/EffectsUtils.js +21 -0
- package/dist/lib/util/EffectsUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +25 -4
- package/dist/lib/util/IceServerUtils.js.map +1 -1
- package/dist/lib/util/RedisRelayBus.js +241 -0
- package/dist/lib/util/RedisRelayBus.js.map +1 -0
- package/dist/lib/util/RelayHub.js +405 -0
- package/dist/lib/util/RelayHub.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
- package/dist/types/util/EffectsUtils.d.ts +10 -0
- package/dist/types/util/IceServerUtils.d.ts +12 -1
- package/dist/types/util/RedisRelayBus.d.ts +131 -0
- package/dist/types/util/RelayHub.d.ts +188 -0
- package/package.json +24 -7
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Every browser API the relay tier touches, gathered behind one injectable object.
|
|
7
|
+
*
|
|
8
|
+
* WebCodecs, Web Audio and canvas capture are exactly the surfaces jsdom does not have, and the sender and
|
|
9
|
+
* receiver need a lot of them at once, so instead of patching a dozen globals in each test (and reading `globalThis`
|
|
10
|
+
* all over the implementation) the code takes a `RelayEnv` and `detectRelayEnv()` builds the real one. The interfaces
|
|
11
|
+
* here are deliberately the narrowest structural subset the relay actually calls - they are satisfied by the real
|
|
12
|
+
* browser objects, and small enough that a test's fake is a few lines.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The half of an encoded chunk (`EncodedAudioChunk`/`EncodedVideoChunk`) an encoder's output callback reads. */
|
|
16
|
+
export interface EncodedChunkLike {
|
|
17
|
+
readonly type: "key" | "delta";
|
|
18
|
+
/** Microseconds, as given to the encoder. */
|
|
19
|
+
readonly timestamp: number;
|
|
20
|
+
readonly byteLength: number;
|
|
21
|
+
copyTo(destination: Uint8Array): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CoderInit<T> {
|
|
25
|
+
output: (output: T, metadata?: unknown) => void;
|
|
26
|
+
error: (error: Error) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `AudioData`. */
|
|
30
|
+
export interface AudioDataLike {
|
|
31
|
+
readonly numberOfFrames: number;
|
|
32
|
+
readonly sampleRate: number;
|
|
33
|
+
copyTo(destination: Float32Array, options: { planeIndex: number; format?: string }): void;
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AudioDataInit {
|
|
38
|
+
format: "f32-planar";
|
|
39
|
+
sampleRate: number;
|
|
40
|
+
numberOfFrames: number;
|
|
41
|
+
numberOfChannels: number;
|
|
42
|
+
timestamp: number;
|
|
43
|
+
data: Float32Array;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `VideoFrame`. */
|
|
47
|
+
export interface VideoFrameLike {
|
|
48
|
+
readonly displayWidth: number;
|
|
49
|
+
readonly displayHeight: number;
|
|
50
|
+
close(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface EncoderLike<TInput, TOptions = undefined> {
|
|
54
|
+
readonly encodeQueueSize: number;
|
|
55
|
+
configure(config: Record<string, unknown>): void;
|
|
56
|
+
encode(input: TInput, options?: TOptions): void;
|
|
57
|
+
close(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DecoderLike {
|
|
61
|
+
readonly decodeQueueSize: number;
|
|
62
|
+
configure(config: Record<string, unknown>): void;
|
|
63
|
+
decode(chunk: unknown): void;
|
|
64
|
+
close(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AudioNodeLike {
|
|
68
|
+
connect(destination: unknown): unknown;
|
|
69
|
+
disconnect(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ScriptProcessorLike extends AudioNodeLike {
|
|
73
|
+
onaudioprocess: ((event: { inputBuffer: { getChannelData(channel: number): Float32Array } }) => void) | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface GainLike extends AudioNodeLike {
|
|
77
|
+
gain: { value: number };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AudioBufferLike {
|
|
81
|
+
copyToChannel(source: Float32Array, channel: number): void;
|
|
82
|
+
readonly duration: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BufferSourceLike extends AudioNodeLike {
|
|
86
|
+
buffer: AudioBufferLike | null;
|
|
87
|
+
onended: (() => void) | null;
|
|
88
|
+
start(when?: number): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AudioContextLike {
|
|
92
|
+
readonly state: string;
|
|
93
|
+
readonly currentTime: number;
|
|
94
|
+
readonly destination: unknown;
|
|
95
|
+
createMediaStreamSource(stream: MediaStream): AudioNodeLike;
|
|
96
|
+
createScriptProcessor(bufferSize: number, inputChannels: number, outputChannels: number): ScriptProcessorLike;
|
|
97
|
+
createGain(): GainLike;
|
|
98
|
+
createMediaStreamDestination(): { readonly stream: MediaStream };
|
|
99
|
+
createBuffer(channels: number, length: number, sampleRate: number): AudioBufferLike;
|
|
100
|
+
createBufferSource(): BufferSourceLike;
|
|
101
|
+
resume(): Promise<void>;
|
|
102
|
+
close(): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface CanvasContextLike {
|
|
106
|
+
drawImage(source: unknown, dx: number, dy: number, dw?: number, dh?: number): void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A hidden `<canvas>`. */
|
|
110
|
+
export interface CanvasLike {
|
|
111
|
+
width: number;
|
|
112
|
+
height: number;
|
|
113
|
+
getContext(type: "2d"): CanvasContextLike | null;
|
|
114
|
+
captureStream(frameRate: number): MediaStream;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A hidden `<video>`. */
|
|
118
|
+
export interface VideoElementLike {
|
|
119
|
+
muted: boolean;
|
|
120
|
+
autoplay: boolean;
|
|
121
|
+
playsInline: boolean;
|
|
122
|
+
srcObject: unknown;
|
|
123
|
+
readonly readyState: number;
|
|
124
|
+
readonly videoWidth: number;
|
|
125
|
+
readonly videoHeight: number;
|
|
126
|
+
play(): Promise<void> | undefined;
|
|
127
|
+
pause(): void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The subset of `WebSocket` the relay client uses (the same seam shape `GuestSignalingClient`'s `PushSocket` has). */
|
|
131
|
+
export interface RelaySocket {
|
|
132
|
+
readyState: number;
|
|
133
|
+
/** Bytes queued by `send()` that the browser has not yet handed to the network - the backpressure signal. */
|
|
134
|
+
readonly bufferedAmount: number;
|
|
135
|
+
binaryType: string;
|
|
136
|
+
send(data: string | Uint8Array): void;
|
|
137
|
+
close(code?: number, reason?: string): void;
|
|
138
|
+
onopen: ((event: unknown) => void) | null;
|
|
139
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
140
|
+
onclose: ((event: unknown) => void) | null;
|
|
141
|
+
onerror: ((event: unknown) => void) | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What `resumeAudioContext()` needs from `document`. */
|
|
145
|
+
export interface EventTargetLike {
|
|
146
|
+
addEventListener(type: string, listener: () => void, options?: boolean): void;
|
|
147
|
+
removeEventListener(type: string, listener: () => void, options?: boolean): void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface RelayEnv {
|
|
151
|
+
createSocket(url: string): RelaySocket;
|
|
152
|
+
/** `new AudioContext({ sampleRate })`. */
|
|
153
|
+
createAudioContext(sampleRate: number): AudioContextLike;
|
|
154
|
+
createMediaStream(tracks: MediaStreamTrack[]): MediaStream;
|
|
155
|
+
createVideoElement(): VideoElementLike;
|
|
156
|
+
createCanvas(): CanvasLike;
|
|
157
|
+
createAudioEncoder(init: CoderInit<EncodedChunkLike>): EncoderLike<AudioDataLike>;
|
|
158
|
+
createAudioDecoder(init: CoderInit<AudioDataLike>): DecoderLike;
|
|
159
|
+
createVideoEncoder(init: CoderInit<EncodedChunkLike>): EncoderLike<VideoFrameLike, { keyFrame: boolean }>;
|
|
160
|
+
createVideoDecoder(init: CoderInit<VideoFrameLike>): DecoderLike;
|
|
161
|
+
createAudioData(init: AudioDataInit): AudioDataLike;
|
|
162
|
+
/** `new VideoFrame(canvas, { timestamp })`. */
|
|
163
|
+
createVideoFrame(canvas: CanvasLike, timestampUs: number): VideoFrameLike;
|
|
164
|
+
createEncodedAudioChunk(init: { type: "key" | "delta"; timestamp: number; data: Uint8Array }): unknown;
|
|
165
|
+
createEncodedVideoChunk(init: { type: "key" | "delta"; timestamp: number; data: Uint8Array }): unknown;
|
|
166
|
+
document: EventTargetLike;
|
|
167
|
+
setInterval(callback: () => void, ms: number): unknown;
|
|
168
|
+
clearInterval(handle: unknown): void;
|
|
169
|
+
/** A monotonic clock in milliseconds (`performance.now()`). */
|
|
170
|
+
now(): number;
|
|
171
|
+
random(): number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The names of the globals `detectRelayEnv()` requires, in one place so the check and the tests agree. */
|
|
175
|
+
export const REQUIRED_GLOBALS = [
|
|
176
|
+
"WebSocket",
|
|
177
|
+
"AudioEncoder",
|
|
178
|
+
"AudioDecoder",
|
|
179
|
+
"VideoEncoder",
|
|
180
|
+
"VideoDecoder",
|
|
181
|
+
"AudioData",
|
|
182
|
+
"VideoFrame",
|
|
183
|
+
"EncodedAudioChunk",
|
|
184
|
+
"EncodedVideoChunk",
|
|
185
|
+
"AudioContext",
|
|
186
|
+
"MediaStream",
|
|
187
|
+
"HTMLCanvasElement",
|
|
188
|
+
"document",
|
|
189
|
+
] as const;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Builds the real environment from the browser's globals, or returns `undefined` when this browser lacks any
|
|
193
|
+
* piece of it (no WebSocket, no WebCodecs, no `AudioContext`, no `canvas.captureStream`, or not a browser at all).
|
|
194
|
+
* The relay codecs are fixed (Opus and VP8), so there is nothing useful to do with a partial environment - the
|
|
195
|
+
* mesh simply never falls back to the relay on such a browser.
|
|
196
|
+
*/
|
|
197
|
+
export function detectRelayEnv(): RelayEnv | undefined {
|
|
198
|
+
const scope = globalThis as unknown as Record<string, unknown>;
|
|
199
|
+
for (const name of REQUIRED_GLOBALS) {
|
|
200
|
+
if (typeof scope[name] === "undefined") {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (typeof HTMLCanvasElement.prototype.captureStream !== "function") {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
// Each factory resolves its constructor when called (not once here) so the environment follows whatever the page
|
|
208
|
+
// has at that moment, and so a test can stub a constructor after detection.
|
|
209
|
+
const construct = (name: string, ...args: unknown[]): never => new (scope[name] as new (...a: unknown[]) => never)(...args);
|
|
210
|
+
return {
|
|
211
|
+
createSocket: (url) => construct("WebSocket", url),
|
|
212
|
+
createAudioContext: (sampleRate) => construct("AudioContext", { sampleRate }),
|
|
213
|
+
createMediaStream: (tracks) => construct("MediaStream", tracks),
|
|
214
|
+
createVideoElement: () => document.createElement("video"),
|
|
215
|
+
createCanvas: () => document.createElement("canvas"),
|
|
216
|
+
createAudioEncoder: (init) => construct("AudioEncoder", init),
|
|
217
|
+
createAudioDecoder: (init) => construct("AudioDecoder", init),
|
|
218
|
+
createVideoEncoder: (init) => construct("VideoEncoder", init),
|
|
219
|
+
createVideoDecoder: (init) => construct("VideoDecoder", init),
|
|
220
|
+
createAudioData: (init) => construct("AudioData", init),
|
|
221
|
+
createVideoFrame: (canvas, timestamp) => construct("VideoFrame", canvas, { timestamp }),
|
|
222
|
+
createEncodedAudioChunk: (init) => construct("EncodedAudioChunk", init),
|
|
223
|
+
createEncodedVideoChunk: (init) => construct("EncodedVideoChunk", init),
|
|
224
|
+
document,
|
|
225
|
+
setInterval: (callback, ms) => globalThis.setInterval(callback, ms),
|
|
226
|
+
clearInterval: (handle) => globalThis.clearInterval(handle as ReturnType<typeof setInterval>),
|
|
227
|
+
now: () => performance.now(),
|
|
228
|
+
random: () => Math.random(),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { FrameKind } from "./frames.js";
|
|
6
|
+
import type { RelayEnv } from "./relayEnv.js";
|
|
7
|
+
|
|
8
|
+
/** Hands one encoded frame to the transport. `timestampMs` is on the `env.now()` clock. Returns whether every
|
|
9
|
+
* fragment of it was accepted by the socket - `false` means it was dropped (not ready, or backed up), which for
|
|
10
|
+
* video means the receiver will need a fresh key frame. */
|
|
11
|
+
export type PublishFrame = (kind: FrameKind, keyFrame: boolean, data: Uint8Array, timestampMs: number) => boolean;
|
|
12
|
+
|
|
13
|
+
/** What `AudioSender` and `VideoSender` are given. */
|
|
14
|
+
export interface SenderDeps {
|
|
15
|
+
env: RelayEnv;
|
|
16
|
+
publish: PublishFrame;
|
|
17
|
+
/** Whether a frame encoded now could actually be sent. Encoding while the socket is down or still handshaking
|
|
18
|
+
* would burn CPU on frames that are then dropped, so the senders idle instead. */
|
|
19
|
+
canSend: () => boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Closes an encoder/decoder, ignoring the `InvalidStateError` a browser throws for one that is already closed
|
|
23
|
+
* (which is what a coder that reported an error is). */
|
|
24
|
+
export function safeClose(coder: { close(): void } | undefined): void {
|
|
25
|
+
try {
|
|
26
|
+
coder?.close();
|
|
27
|
+
} catch {
|
|
28
|
+
// Already closed.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -61,14 +61,37 @@
|
|
|
61
61
|
* `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
|
|
62
62
|
*
|
|
63
63
|
* Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
|
|
64
|
+
*
|
|
65
|
+
* ## Three media paths, tried in order
|
|
66
|
+
*
|
|
67
|
+
* Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
|
|
68
|
+
* got:
|
|
69
|
+
*
|
|
70
|
+
* 1. **Direct peer-to-peer** (`"p2p"`). The `RTCPeerConnection` is given the STUN servers and the TURN server
|
|
71
|
+
* together, and ICE ranks a direct pair above a relayed one, so this is what a pair gets whenever it can.
|
|
72
|
+
* 2. **Relayed through the TURN server** (`"turn"`). The same connection, on the relay candidate ICE falls back to
|
|
73
|
+
* when the direct pairs fail - a participant behind a symmetric NAT or a firewall that blocks direct UDP. Which of
|
|
74
|
+
* the two ICE picked is read from the selected candidate pair once connected (`RTCPeerConnectionLike.connectionType()`).
|
|
75
|
+
* 3. **Proxied by this server over a WebSocket** (`"websocket"`, `apps/shared/relay/`). For a participant whose
|
|
76
|
+
* network lets nothing but ordinary HTTPS out, neither of the above ever connects. A pair whose connection has not
|
|
77
|
+
* come up within `connectTimeoutMs`, has failed, or has stayed `disconnected` for `disconnectedGraceMs` gives up
|
|
78
|
+
* on WebRTC and switches to it, telling the other side with a `relay-fallback` message so both switch even when
|
|
79
|
+
* only one of them noticed. Nothing is renegotiated - the pair simply stops using the `RTCPeerConnection` - and the
|
|
80
|
+
* relay's stream replaces the one WebRTC would have filled (a `remote-stream` event again). When the relay is
|
|
81
|
+
* unavailable (turned off by the operator, or a browser without WebCodecs) the pair is marked `"failed"` and stays
|
|
82
|
+
* in the roster, rather than the participant vanishing with no explanation as it used to.
|
|
83
|
+
*
|
|
84
|
+
* A pair never moves back up: once on the relay it stays there for the rest of the call.
|
|
64
85
|
*/
|
|
65
86
|
import {
|
|
87
|
+
type MediaTransport,
|
|
66
88
|
type MeshEvent,
|
|
67
89
|
type MeshParticipant,
|
|
68
90
|
type ParticipantState,
|
|
69
91
|
type RTCPeerConnectionFactory,
|
|
70
92
|
type RTCPeerConnectionLike,
|
|
71
93
|
type RTCRtpSenderLike,
|
|
94
|
+
type RelayTransportLike,
|
|
72
95
|
type SignalMessage,
|
|
73
96
|
type SignalingChannel,
|
|
74
97
|
REACTION_EMOJIS,
|
|
@@ -84,6 +107,14 @@ export function isOfferer(selfId: string, peerId: string): boolean {
|
|
|
84
107
|
const MAX_ORPHAN_PEERS = 32;
|
|
85
108
|
const MAX_ORPHAN_CANDIDATES = 64;
|
|
86
109
|
|
|
110
|
+
/** How long a connection may stay in `connecting` before the pair gives up on WebRTC. ICE with an unreachable TURN
|
|
111
|
+
* server can take the better part of a minute to report `failed`; a person waiting on a call will not. It is still long
|
|
112
|
+
* enough for a slow TURN allocation and a few round trips of signaling. */
|
|
113
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
|
114
|
+
|
|
115
|
+
/** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
|
|
116
|
+
export const DEFAULT_DISCONNECTED_GRACE_MS = 8_000;
|
|
117
|
+
|
|
87
118
|
type MediaKind = "audio" | "video";
|
|
88
119
|
|
|
89
120
|
interface PeerState extends MeshParticipant {
|
|
@@ -93,6 +124,10 @@ interface PeerState extends MeshParticipant {
|
|
|
93
124
|
remoteStream: MediaStream;
|
|
94
125
|
remoteDescriptionSet: boolean;
|
|
95
126
|
pendingCandidates: RTCIceCandidateInit[];
|
|
127
|
+
/** Gives up on WebRTC when the connection is still `connecting` after `connectTimeoutMs`. */
|
|
128
|
+
connectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
+
/** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
|
|
130
|
+
disconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
96
131
|
}
|
|
97
132
|
|
|
98
133
|
export interface MeshConnectionManagerOptions {
|
|
@@ -114,6 +149,13 @@ export interface MeshConnectionManagerOptions {
|
|
|
114
149
|
/** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
|
|
115
150
|
* `new MediaStream()`; a test supplies a fake. */
|
|
116
151
|
createMediaStream?: () => MediaStream;
|
|
152
|
+
/** The WebSocket media relay, the last-resort path - see this module's doc comment. Leave unset when the
|
|
153
|
+
* operator has turned it off; without one a pair WebRTC cannot connect is marked `"failed"`. */
|
|
154
|
+
relay?: RelayTransportLike;
|
|
155
|
+
/** Defaults to `DEFAULT_CONNECT_TIMEOUT_MS`. */
|
|
156
|
+
connectTimeoutMs?: number;
|
|
157
|
+
/** Defaults to `DEFAULT_DISCONNECTED_GRACE_MS`. */
|
|
158
|
+
disconnectedGraceMs?: number;
|
|
117
159
|
}
|
|
118
160
|
|
|
119
161
|
export class MeshConnectionManager {
|
|
@@ -137,6 +179,8 @@ export class MeshConnectionManager {
|
|
|
137
179
|
handRaised: false,
|
|
138
180
|
...options.localState,
|
|
139
181
|
};
|
|
182
|
+
options.relay?.setLocalTrack("audio", this.localTracks.audio);
|
|
183
|
+
options.relay?.setLocalTrack("video", this.localTracks.video);
|
|
140
184
|
}
|
|
141
185
|
|
|
142
186
|
get participants(): MeshParticipant[] {
|
|
@@ -177,11 +221,13 @@ export class MeshConnectionManager {
|
|
|
177
221
|
this.unsubscribe?.();
|
|
178
222
|
this.unsubscribe = undefined;
|
|
179
223
|
for (const peer of this.peers.values()) {
|
|
224
|
+
this.clearTimers(peer);
|
|
180
225
|
peer.pc.close();
|
|
181
226
|
}
|
|
182
227
|
this.peers.clear();
|
|
183
228
|
this.orphanCandidates.clear();
|
|
184
229
|
this.listeners.clear();
|
|
230
|
+
this.options.relay?.close();
|
|
185
231
|
}
|
|
186
232
|
|
|
187
233
|
/** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
|
|
@@ -191,6 +237,7 @@ export class MeshConnectionManager {
|
|
|
191
237
|
for (const peer of this.peers.values()) {
|
|
192
238
|
void peer.senders[kind]?.replaceTrack(track);
|
|
193
239
|
}
|
|
240
|
+
this.options.relay?.setLocalTrack(kind, track);
|
|
194
241
|
}
|
|
195
242
|
|
|
196
243
|
/** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
|
|
@@ -295,6 +342,9 @@ export class MeshConnectionManager {
|
|
|
295
342
|
case "reaction":
|
|
296
343
|
this.handleReaction(message);
|
|
297
344
|
return;
|
|
345
|
+
case "relay-fallback":
|
|
346
|
+
this.handleRelayFallback(message.from);
|
|
347
|
+
return;
|
|
298
348
|
}
|
|
299
349
|
}
|
|
300
350
|
|
|
@@ -357,8 +407,13 @@ export class MeshConnectionManager {
|
|
|
357
407
|
if (!peer) {
|
|
358
408
|
return;
|
|
359
409
|
}
|
|
410
|
+
this.clearTimers(peer);
|
|
360
411
|
peer.pc.close();
|
|
361
412
|
this.peers.delete(uid);
|
|
413
|
+
if (peer.transport === "websocket") {
|
|
414
|
+
this.options.relay?.stopReceivingFrom(uid);
|
|
415
|
+
this.syncRelaySending();
|
|
416
|
+
}
|
|
362
417
|
this.emit({ type: "participant-left", uid });
|
|
363
418
|
if (this.currentPresenterUid === uid) {
|
|
364
419
|
this.currentPresenterUid = undefined;
|
|
@@ -382,6 +437,9 @@ export class MeshConnectionManager {
|
|
|
382
437
|
remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
|
|
383
438
|
remoteDescriptionSet: false,
|
|
384
439
|
pendingCandidates: this.orphanCandidates.get(uid) ?? [],
|
|
440
|
+
transport: "connecting",
|
|
441
|
+
connectTimer: undefined,
|
|
442
|
+
disconnectTimer: undefined,
|
|
385
443
|
};
|
|
386
444
|
this.orphanCandidates.delete(uid);
|
|
387
445
|
this.peers.set(uid, peer);
|
|
@@ -396,14 +454,108 @@ export class MeshConnectionManager {
|
|
|
396
454
|
}
|
|
397
455
|
this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
|
|
398
456
|
};
|
|
399
|
-
pc.onconnectionstatechange = () =>
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
};
|
|
457
|
+
pc.onconnectionstatechange = () => this.handleConnectionState(peer);
|
|
458
|
+
// Cleared the moment the connection comes up, so this only ever fires for a pair still waiting on WebRTC.
|
|
459
|
+
peer.connectTimer = setTimeout(() => this.fallBack(peer), this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
|
404
460
|
return peer;
|
|
405
461
|
}
|
|
406
462
|
|
|
463
|
+
/** Reacts to the peer connection's state: notes which path a connected pair got, and gives up on WebRTC for a
|
|
464
|
+
* pair that failed or stays disconnected - see `fallBack()`. Ignores a pair that already left WebRTC. */
|
|
465
|
+
private handleConnectionState(peer: PeerState): void {
|
|
466
|
+
if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
switch (peer.pc.connectionState) {
|
|
470
|
+
case "connected":
|
|
471
|
+
this.clearTimers(peer);
|
|
472
|
+
void this.detectTransport(peer);
|
|
473
|
+
return;
|
|
474
|
+
case "disconnected":
|
|
475
|
+
// ICE often recovers from this on its own, so it is only given up on after a grace period.
|
|
476
|
+
peer.disconnectTimer ??= setTimeout(() => this.fallBack(peer), this.options.disconnectedGraceMs ?? DEFAULT_DISCONNECTED_GRACE_MS);
|
|
477
|
+
return;
|
|
478
|
+
case "failed":
|
|
479
|
+
this.fallBack(peer);
|
|
480
|
+
return;
|
|
481
|
+
case "closed":
|
|
482
|
+
this.handleBye(peer.uid);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Records whether a connected pair is direct or relayed through TURN, from the candidate pair ICE selected. */
|
|
488
|
+
private async detectTransport(peer: PeerState): Promise<void> {
|
|
489
|
+
let type: "p2p" | "turn" | "unknown";
|
|
490
|
+
try {
|
|
491
|
+
type = await peer.pc.connectionType();
|
|
492
|
+
} catch {
|
|
493
|
+
type = "unknown";
|
|
494
|
+
}
|
|
495
|
+
// The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
|
|
496
|
+
if (peer.transport !== "websocket" && this.peers.get(peer.uid) === peer && peer.pc.connectionState === "connected") {
|
|
497
|
+
this.setTransport(peer, type === "turn" ? "turn" : "p2p");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private setTransport(peer: PeerState, transport: MediaTransport): void {
|
|
502
|
+
if (peer.transport !== transport) {
|
|
503
|
+
peer.transport = transport;
|
|
504
|
+
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private clearTimers(peer: PeerState): void {
|
|
509
|
+
clearTimeout(peer.connectTimer);
|
|
510
|
+
clearTimeout(peer.disconnectTimer);
|
|
511
|
+
peer.connectTimer = undefined;
|
|
512
|
+
peer.disconnectTimer = undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Gives up on WebRTC for `peer` and moves the pair to the WebSocket relay (the third path - see this module's doc
|
|
517
|
+
* comment), or marks it `"failed"` when there is no relay to move to. `notify` tells the other side, which may not
|
|
518
|
+
* have noticed yet; a pair told by the other side does not tell it back.
|
|
519
|
+
*/
|
|
520
|
+
private fallBack(peer: PeerState, notify = true): void {
|
|
521
|
+
if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
this.clearTimers(peer);
|
|
525
|
+
const relay = this.options.relay;
|
|
526
|
+
if (!relay?.supported) {
|
|
527
|
+
this.setTransport(peer, "failed");
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
// Stop listening to the dead connection before closing it, so closing it cannot be mistaken for the peer leaving.
|
|
531
|
+
peer.pc.onconnectionstatechange = null;
|
|
532
|
+
peer.pc.onicecandidate = null;
|
|
533
|
+
peer.pc.ontrack = null;
|
|
534
|
+
peer.pc.close();
|
|
535
|
+
peer.transport = "websocket";
|
|
536
|
+
relay.receiveFrom(peer.uid, (stream) => {
|
|
537
|
+
peer.remoteStream = stream;
|
|
538
|
+
this.emit({ type: "remote-stream", uid: peer.uid, stream });
|
|
539
|
+
});
|
|
540
|
+
this.syncRelaySending();
|
|
541
|
+
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
542
|
+
if (notify) {
|
|
543
|
+
this.send({ kind: "relay-fallback", to: peer.uid });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private handleRelayFallback(from: string): void {
|
|
548
|
+
const peer = this.peers.get(from);
|
|
549
|
+
if (peer) {
|
|
550
|
+
this.fallBack(peer, false);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The relay only encodes and uploads the local media while at least one pair is actually using it. */
|
|
555
|
+
private syncRelaySending(): void {
|
|
556
|
+
this.options.relay?.setSending([...this.peers.values()].some((peer) => peer.transport === "websocket"));
|
|
557
|
+
}
|
|
558
|
+
|
|
407
559
|
private async initiateOffer(peer: PeerState): Promise<void> {
|
|
408
560
|
const offer = await peer.pc.createOffer();
|
|
409
561
|
await peer.pc.setLocalDescription(offer);
|
|
@@ -515,7 +667,7 @@ export class MeshConnectionManager {
|
|
|
515
667
|
}
|
|
516
668
|
|
|
517
669
|
function toParticipant(peer: PeerState): MeshParticipant {
|
|
518
|
-
return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised };
|
|
670
|
+
return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised, transport: peer.transport };
|
|
519
671
|
}
|
|
520
672
|
|
|
521
673
|
function sameState(a: ParticipantState, b: ParticipantState): boolean {
|
|
@@ -9,6 +9,27 @@
|
|
|
9
9
|
* exist at all - it would only throw if actually *called* outside a browser. */
|
|
10
10
|
import type { RTCPeerConnectionFactory, RTCPeerConnectionLike } from "./types.js";
|
|
11
11
|
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
export async function selectedConnectionType(pc: Pick<RTCPeerConnection, "getStats">): Promise<"p2p" | "turn" | "unknown"> {
|
|
19
|
+
const reports = new Map<string, Record<string, unknown>>();
|
|
20
|
+
(await pc.getStats()).forEach((report: Record<string, unknown>) => reports.set(String(report.id), report));
|
|
21
|
+
const transport = [...reports.values()].find((report) => report.type === "transport" && report.selectedCandidatePairId);
|
|
22
|
+
const pair =
|
|
23
|
+
(transport ? reports.get(String(transport.selectedCandidatePairId)) : undefined) ??
|
|
24
|
+
[...reports.values()].find((report) => report.type === "candidate-pair" && (report.selected || (report.nominated && report.state === "succeeded")));
|
|
25
|
+
if (!pair) {
|
|
26
|
+
return "unknown";
|
|
27
|
+
}
|
|
28
|
+
const local = reports.get(String(pair.localCandidateId));
|
|
29
|
+
const remote = reports.get(String(pair.remoteCandidateId));
|
|
30
|
+
return local?.candidateType === "relay" || remote?.candidateType === "relay" ? "turn" : "p2p";
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) => {
|
|
13
34
|
const pc = new RTCPeerConnection(config);
|
|
14
35
|
const like: RTCPeerConnectionLike = {
|
|
@@ -33,6 +54,7 @@ export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) =>
|
|
|
33
54
|
setRemoteDescription: (description) => pc.setRemoteDescription(description),
|
|
34
55
|
addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
|
|
35
56
|
close: () => pc.close(),
|
|
57
|
+
connectionType: () => selectedConnectionType(pc),
|
|
36
58
|
onicecandidate: null,
|
|
37
59
|
ontrack: null,
|
|
38
60
|
onconnectionstatechange: null,
|
|
@@ -44,7 +44,8 @@ export interface SignalMessage {
|
|
|
44
44
|
| "presenter-claim"
|
|
45
45
|
| "presenter-release"
|
|
46
46
|
| "state"
|
|
47
|
-
| "reaction"
|
|
47
|
+
| "reaction"
|
|
48
|
+
| "relay-fallback";
|
|
48
49
|
/** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
|
|
49
50
|
* the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
|
|
50
51
|
* `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
|
|
@@ -55,7 +56,7 @@ export interface SignalMessage {
|
|
|
55
56
|
* ignored, so it can't name someone else's tab. Absent from a sender that predates it (the participant is then
|
|
56
57
|
* identified by `from`). */
|
|
57
58
|
peer?: string;
|
|
58
|
-
/** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
|
|
59
|
+
/** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`/`relay-fallback`). */
|
|
59
60
|
to?: string;
|
|
60
61
|
/** `hello` only - the display name the sender chose in the lobby. */
|
|
61
62
|
name?: string;
|
|
@@ -112,6 +113,33 @@ export interface RTCPeerConnectionLike {
|
|
|
112
113
|
ontrack: ((event: { track: MediaStreamTrack }) => void) | null;
|
|
113
114
|
onconnectionstatechange: (() => void) | null;
|
|
114
115
|
connectionState: string;
|
|
116
|
+
/** Which path the connected pair is using - read from the selected candidate pair once `connectionState` is
|
|
117
|
+
* `connected`. `"unknown"` when the browser does not report one (treated as direct by the caller). */
|
|
118
|
+
connectionType(): Promise<"p2p" | "turn" | "unknown">;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Which of the three media paths a participant's audio and video currently take, in the order they are tried:
|
|
122
|
+
*
|
|
123
|
+
* - `"p2p"`: a direct peer-to-peer WebRTC connection, the preferred path.
|
|
124
|
+
* - `"turn"`: still WebRTC, but relayed through the TURN server, for a participant a direct path cannot reach.
|
|
125
|
+
* - `"websocket"`: not WebRTC at all - encoded frames proxied by this server over a WebSocket (see
|
|
126
|
+
* `apps/shared/relay/`), the last resort when neither of the above connects.
|
|
127
|
+
* - `"connecting"`: no path established yet. `"failed"`: every path this browser can try has failed. */
|
|
128
|
+
export type MediaTransport = "connecting" | "p2p" | "turn" | "websocket" | "failed";
|
|
129
|
+
|
|
130
|
+
/** What `MeshConnectionManager` needs from the WebSocket media relay (`apps/shared/relay/RelayTransport.ts` implements
|
|
131
|
+
* it) - declared here so the mesh, and its tests, depend on the shape alone. */
|
|
132
|
+
export interface RelayTransportLike {
|
|
133
|
+
/** `false` when this browser cannot do it (no WebSocket, no WebCodecs) - the mesh then never falls back to it. */
|
|
134
|
+
readonly supported: boolean;
|
|
135
|
+
/** Starts receiving `peerId`'s media. `onStream` is given the `MediaStream` that plays it. */
|
|
136
|
+
receiveFrom(peerId: string, onStream: (stream: MediaStream) => void): void;
|
|
137
|
+
stopReceivingFrom(peerId: string): void;
|
|
138
|
+
/** Starts or stops publishing the local tracks to the relay. */
|
|
139
|
+
setSending(active: boolean): void;
|
|
140
|
+
/** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
|
|
141
|
+
setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
|
|
142
|
+
close(): void;
|
|
115
143
|
}
|
|
116
144
|
|
|
117
145
|
export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] }) => RTCPeerConnectionLike;
|
|
@@ -121,6 +149,8 @@ export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] })
|
|
|
121
149
|
export interface MeshParticipant extends ParticipantState {
|
|
122
150
|
uid: string;
|
|
123
151
|
name: string;
|
|
152
|
+
/** How this participant's media currently reaches the local one - see `MediaTransport`. */
|
|
153
|
+
transport: MediaTransport;
|
|
124
154
|
}
|
|
125
155
|
|
|
126
156
|
export type MeshEvent =
|
|
@@ -49,10 +49,10 @@ export default function MeetJoinPage({ params }) {
|
|
|
49
49
|
* window instead (see `_CallView.tsx`). */
|
|
50
50
|
function MeetJoinContent({ token, branding }) {
|
|
51
51
|
const [phase, setPhase] = useState("loading");
|
|
52
|
-
const [joinResult, setJoinResult] = useState(null);
|
|
53
52
|
const [name, setName] = useState("");
|
|
54
53
|
const [loadError, setLoadError] = useState(null);
|
|
55
|
-
const
|
|
54
|
+
const [joinResult, setJoinResult] = useState(null);
|
|
55
|
+
const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl });
|
|
56
56
|
const { release } = media;
|
|
57
57
|
React.useEffect(() => {
|
|
58
58
|
let cancelled = false;
|
|
@@ -93,7 +93,7 @@ function MeetJoinContent({ token, branding }) {
|
|
|
93
93
|
// guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
|
|
94
94
|
// browser's own already-existing `jwt` session cookie instead (see its own doc comment and
|
|
95
95
|
// `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
|
|
96
|
-
token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, media: media, onLeave: handleLeave }));
|
|
96
|
+
token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, media: media, onLeave: handleLeave }));
|
|
97
97
|
}
|
|
98
98
|
let content;
|
|
99
99
|
if (phase === "loading") {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
|
|
3
3
|
* them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
|
|
4
4
|
* hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
|
|
5
|
-
* screen, reactions, raise hand, grid/focus and leave.
|
|
5
|
+
* screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
|
|
6
|
+
* (`_EffectsPanel.tsx`) and lights up while any filter is on.
|
|
6
7
|
*
|
|
7
8
|
* The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
|
|
8
9
|
* picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
|