@appsocom/client 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/adaptive.d.ts +246 -0
- package/dist/adaptive.js +389 -0
- package/dist/adaptive.js.map +1 -0
- package/dist/captions.d.ts +18 -0
- package/dist/captions.js +190 -0
- package/dist/captions.js.map +1 -0
- package/dist/e2ee.d.ts +71 -0
- package/dist/e2ee.js +268 -0
- package/dist/e2ee.js.map +1 -0
- package/dist/effects.d.ts +84 -0
- package/dist/effects.js +417 -0
- package/dist/effects.js.map +1 -0
- package/dist/emitter.d.ts +9 -0
- package/dist/emitter.js +35 -0
- package/dist/emitter.js.map +1 -0
- package/dist/index.d.ts +632 -0
- package/dist/index.js +2289 -0
- package/dist/index.js.map +1 -0
- package/dist/preflight.d.ts +67 -0
- package/dist/preflight.js +365 -0
- package/dist/preflight.js.map +1 -0
- package/dist/speaker.d.ts +62 -0
- package/dist/speaker.js +89 -0
- package/dist/speaker.js.map +1 -0
- package/package.json +64 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,2289 @@
|
|
|
1
|
+
import "webrtc-adapter";
|
|
2
|
+
import { PartyTracks, getCamera, getMic, getScreenshare, } from "partytracks/client";
|
|
3
|
+
import { BehaviorSubject, Subscription, firstValueFrom, combineLatest, debounceTime, distinctUntilChanged, filter, map, } from "rxjs";
|
|
4
|
+
import { CloseCodes, QUALITY_TO_LAYER, pickLayer, resolveDataSaver, canHost, canPublishAny, claimsPermissions, claimsRole, } from "@appsocom/protocol";
|
|
5
|
+
import { Emitter } from "./emitter.js";
|
|
6
|
+
import { SPEAKER, SpeakerTracker, VoiceActivity, rmsLevel } from "./speaker.js";
|
|
7
|
+
export { SPEAKER, SpeakerTracker, VoiceActivity } from "./speaker.js";
|
|
8
|
+
import { CAMERA_CONSTRAINTS, CAMERA_LAYERS, bytesFromStats, cameraEncodings, mergeEncodings, initialAdaptiveState, initialFallbackState, initialUsageMeter, nextFallbackState, nextUsage, videoAllowList, minQuality, nextAdaptiveState, qualityForHeight, connectionQualityFromSample, sampleFromStats, micBitrateCap, } from "./adaptive.js";
|
|
9
|
+
import { CaptionCapture } from "./captions.js";
|
|
10
|
+
export { qualityForHeight, nextAdaptiveState, initialAdaptiveState, ADAPTIVE, connectionQualityFromSample, FALLBACK, nextFallbackState, initialFallbackState, cameraEncodings, videoAllowList, nextUsage, initialUsageMeter, bytesFromStats, } from "./adaptive.js";
|
|
11
|
+
export { DATA_SAVER_PRESETS, DATA_SAVER_MODES, resolveDataSaver, estimateDataUsage, pickLayer, TYPICAL_BITRATES } from "@appsocom/protocol";
|
|
12
|
+
import { E2EEContext, isEncryptedMessage } from "./e2ee.js";
|
|
13
|
+
export { isE2EESupported, deriveE2EEKeys, createE2EEWorker, e2eeWorkerSource, } from "./e2ee.js";
|
|
14
|
+
import { backgroundEffect, noiseSuppression, } from "./effects.js";
|
|
15
|
+
export * from "./effects.js";
|
|
16
|
+
export { CloseCodes, attendanceToCsv } from "@appsocom/protocol";
|
|
17
|
+
export { runPreflight } from "./preflight.js";
|
|
18
|
+
export const TOKEN_HEADER = "X-SfuKit-Token";
|
|
19
|
+
const TRACK_KINDS = ["audio", "video", "screenVideo", "screenAudio"];
|
|
20
|
+
const DEVICE_RETRIES = 4;
|
|
21
|
+
/** Room keep-alive. No server message for 2.5× this = dead socket. */
|
|
22
|
+
const PING_EVERY_MS = 10_000;
|
|
23
|
+
const DEVICE_RETRY_MS = 1000;
|
|
24
|
+
/** A device that has stayed on this long resets its retry budget. */
|
|
25
|
+
const DEVICE_HEALTHY_MS = 10_000;
|
|
26
|
+
const VIDEO_KINDS = ["video", "screenVideo"];
|
|
27
|
+
/** Join a room. Resolves once you're in, rejects if the token is bad, the room is full, etc. */
|
|
28
|
+
export function joinRoom(options) {
|
|
29
|
+
const room = new Room(options);
|
|
30
|
+
return room._start().then(() => room);
|
|
31
|
+
}
|
|
32
|
+
const GROUP_KINDS = {
|
|
33
|
+
mic: ["audio"],
|
|
34
|
+
camera: ["video"],
|
|
35
|
+
screen: ["screenVideo", "screenAudio"],
|
|
36
|
+
};
|
|
37
|
+
export class Room extends Emitter {
|
|
38
|
+
roomId;
|
|
39
|
+
userId;
|
|
40
|
+
/** Your role: "teacher", "student", "observer", or your app's own label. */
|
|
41
|
+
/** Updated when your app's backend changes it during the call. */
|
|
42
|
+
role;
|
|
43
|
+
/** What you may do right now. Updated when a teacher/host changes it. */
|
|
44
|
+
permissions;
|
|
45
|
+
/** Room-wide settings (locked, …). */
|
|
46
|
+
roomState = { locked: false, chat: true, spotlight: [], waitingRoom: false, breakouts: [] };
|
|
47
|
+
/** Teachers/hosts: who is in the waiting room now, first come first. */
|
|
48
|
+
waiting = [];
|
|
49
|
+
/** Room metadata set by your app's backend (same as roomState.metadata). */
|
|
50
|
+
get metadata() {
|
|
51
|
+
return this.roomState.metadata;
|
|
52
|
+
}
|
|
53
|
+
/** Chat messages you've seen (public history + live, private ones to/from you), oldest first. Last 200. */
|
|
54
|
+
chat = [];
|
|
55
|
+
/** True if you may publish anything at all right now. */
|
|
56
|
+
get canPublish() {
|
|
57
|
+
return canPublishAny(this.permissions);
|
|
58
|
+
}
|
|
59
|
+
/** Your own participant record (id changes after a reconnect). */
|
|
60
|
+
self = null;
|
|
61
|
+
/** The recording in progress, or null. See the "recording" event. */
|
|
62
|
+
recording = null;
|
|
63
|
+
connectionState = "connecting";
|
|
64
|
+
/** Raw partytracks objects for advanced use (device lists, transforms…). */
|
|
65
|
+
media;
|
|
66
|
+
#opts;
|
|
67
|
+
#token;
|
|
68
|
+
#headers;
|
|
69
|
+
#ws = null;
|
|
70
|
+
#remotes = new Map();
|
|
71
|
+
#subs = new Subscription();
|
|
72
|
+
#localTracks = {};
|
|
73
|
+
#localState = { micOn: false, cameraOn: false, screenOn: false };
|
|
74
|
+
#customState;
|
|
75
|
+
#left = false;
|
|
76
|
+
/** Pulls wait until partyTracks exists (it needs ICE servers, fetched after the welcome). */
|
|
77
|
+
#mediaReady = false;
|
|
78
|
+
/** What the user asked for, so device retries never override "off". */
|
|
79
|
+
#wanted = { microphone: true, camera: true };
|
|
80
|
+
#adaptive = initialAdaptiveState();
|
|
81
|
+
#statsCursor = null;
|
|
82
|
+
#statsTimer = null;
|
|
83
|
+
#lastSample = null;
|
|
84
|
+
#audioOnly = false;
|
|
85
|
+
#cameraBeforeAudioOnly = true;
|
|
86
|
+
#cameraEncodings$ = null;
|
|
87
|
+
/** Layers our camera announces (fewer with a data saver). */
|
|
88
|
+
#cameraLayers = [...CAMERA_LAYERS];
|
|
89
|
+
#cameraSending = true;
|
|
90
|
+
#micEncodings$ = new BehaviorSubject([{}]);
|
|
91
|
+
/** Latest media peer connection (to adjust our senders' encodings). */
|
|
92
|
+
#pc = null;
|
|
93
|
+
#saver = resolveDataSaver("off");
|
|
94
|
+
#fallback = initialFallbackState();
|
|
95
|
+
#fallbackOn = true;
|
|
96
|
+
#keepScreenInFallback = false;
|
|
97
|
+
/** Remote participant ids, most recent speaker first (data saver: video for speakers only). */
|
|
98
|
+
#recentSpeakers = [];
|
|
99
|
+
#videoAllow = null;
|
|
100
|
+
#usage = initialUsageMeter(Date.now());
|
|
101
|
+
#myQuality = null;
|
|
102
|
+
#speakers = new SpeakerTracker();
|
|
103
|
+
#vad = new VoiceActivity();
|
|
104
|
+
#speakerTimer = null;
|
|
105
|
+
#levelTimer = null;
|
|
106
|
+
#audioCtx = null;
|
|
107
|
+
#levelSource = null;
|
|
108
|
+
#lastLevelSentAt = 0;
|
|
109
|
+
#qualities = new Map();
|
|
110
|
+
#attempt = 0;
|
|
111
|
+
#reconnectTimer = null;
|
|
112
|
+
/** Per device group: the subscriptions that push and watch it. */
|
|
113
|
+
#deviceSubs = {};
|
|
114
|
+
#deviceAttempts = {};
|
|
115
|
+
#broadcasting = { mic: false, camera: false, screen: false };
|
|
116
|
+
#stateTimer = null;
|
|
117
|
+
#onWaiting = null;
|
|
118
|
+
#attendanceWaiters = [];
|
|
119
|
+
#pingTimer = null;
|
|
120
|
+
/** Room WebSocket: up, or dropped (and since when). */
|
|
121
|
+
#signalUp = false;
|
|
122
|
+
#signalLostAt = null;
|
|
123
|
+
#lastServerMsgAt = 0;
|
|
124
|
+
#mediaState = "new";
|
|
125
|
+
#mediaEverConnected = false;
|
|
126
|
+
#tokenTimer = null;
|
|
127
|
+
#onOnline = () => this.#networkChanged("online");
|
|
128
|
+
#onOffline = () => this.#networkChanged("offline");
|
|
129
|
+
#onConnChange = () => this.#networkChanged("change");
|
|
130
|
+
#e2ee = null;
|
|
131
|
+
/** Encrypted sends finish in order. */
|
|
132
|
+
#sendChain = Promise.resolve();
|
|
133
|
+
#files = new Map();
|
|
134
|
+
#polls = new Map();
|
|
135
|
+
#lessons = new Map();
|
|
136
|
+
#myVotes = new Map();
|
|
137
|
+
#questions = new Map();
|
|
138
|
+
#myUpvotes = new Set();
|
|
139
|
+
#captionsState = { on: false };
|
|
140
|
+
#captionLines = [];
|
|
141
|
+
#captionLanguage = null;
|
|
142
|
+
#summary;
|
|
143
|
+
#capture = null;
|
|
144
|
+
#micTrack = null;
|
|
145
|
+
#captionUploads = 0;
|
|
146
|
+
#localPreview = {};
|
|
147
|
+
#sinkId = null;
|
|
148
|
+
#bgOptions = null;
|
|
149
|
+
#videoTransform = null;
|
|
150
|
+
#nsOn = false;
|
|
151
|
+
#audioTransform = null;
|
|
152
|
+
/** @internal use joinRoom() */
|
|
153
|
+
constructor(options) {
|
|
154
|
+
super();
|
|
155
|
+
this.#opts = { autoPlayAudio: true, reconnect: true, joinTimeoutMs: 15000, ...options };
|
|
156
|
+
if (options.mode === "audio")
|
|
157
|
+
this.#opts.video = false;
|
|
158
|
+
this.#wanted = { microphone: options.audio ?? true, camera: this.#opts.video ?? true };
|
|
159
|
+
this.#audioOnly = options.mode === "audio";
|
|
160
|
+
this.#opts.url = options.url.replace(/\/+$/, "");
|
|
161
|
+
this.#token = options.token;
|
|
162
|
+
const claims = decodeClaims(options.token);
|
|
163
|
+
this.#saver = resolveDataSaver(options.dataSaver ?? claims.ds ?? "off");
|
|
164
|
+
this.#cameraBeforeAudioOnly = this.#wanted.camera;
|
|
165
|
+
if (this.#saver.settings.audioOnly)
|
|
166
|
+
this.#wanted.camera = false;
|
|
167
|
+
const fb = options.audioFallback ?? options.adaptive !== false;
|
|
168
|
+
this.#fallbackOn = fb !== false;
|
|
169
|
+
this.#keepScreenInFallback = typeof fb === "object" && !!fb.keepScreenShare;
|
|
170
|
+
this.#micEncodings$.next(this.#micEncodings());
|
|
171
|
+
this.#localState = { ...this.#localState, ...this.#lowBandwidthState() };
|
|
172
|
+
this.roomId = claims.room;
|
|
173
|
+
this.userId = claims.sub;
|
|
174
|
+
this.role = claimsRole(claims);
|
|
175
|
+
this.permissions = claimsPermissions(claims);
|
|
176
|
+
if (options.e2ee) {
|
|
177
|
+
this.#e2ee = new E2EEContext(this.roomId, options.e2ee.worker, (type, detail) => this.emit("encryption-error", type, detail));
|
|
178
|
+
this.#localState.e2ee = true;
|
|
179
|
+
}
|
|
180
|
+
else if (claims.e2ee) {
|
|
181
|
+
throw new Error("this join token requires end-to-end encryption: pass an e2ee key to joinRoom()");
|
|
182
|
+
}
|
|
183
|
+
if (options.backgroundEffect)
|
|
184
|
+
this.#setBackground(options.backgroundEffect === true ? {} : options.backgroundEffect);
|
|
185
|
+
if (options.noiseSuppression)
|
|
186
|
+
this.#setNs(options.noiseSuppression === true ? {} : options.noiseSuppression);
|
|
187
|
+
this.#headers = new Headers({ [TOKEN_HEADER]: this.#token });
|
|
188
|
+
// partyTracks is created in _start(), once ICE servers are known.
|
|
189
|
+
this.media = { partyTracks: null, mic: null, camera: null, screen: null };
|
|
190
|
+
}
|
|
191
|
+
// ---------------- public API ----------------
|
|
192
|
+
/** All other participants currently in the room. */
|
|
193
|
+
get participants() {
|
|
194
|
+
return [...this.#remotes.values()].map((r) => r.participant);
|
|
195
|
+
}
|
|
196
|
+
/** Current remote track for a participant, if any. */
|
|
197
|
+
getTrack(participantId, kind) {
|
|
198
|
+
return this.#remotes.get(participantId)?.tracks[kind];
|
|
199
|
+
}
|
|
200
|
+
get localState() {
|
|
201
|
+
return { ...this.#localState };
|
|
202
|
+
}
|
|
203
|
+
toggleMic() {
|
|
204
|
+
this.setMic(!this.#localState.micOn);
|
|
205
|
+
}
|
|
206
|
+
setMic(on) {
|
|
207
|
+
this.#wanted.microphone = on;
|
|
208
|
+
if (on)
|
|
209
|
+
this.media.mic?.startBroadcasting();
|
|
210
|
+
else
|
|
211
|
+
this.media.mic?.stopBroadcasting();
|
|
212
|
+
}
|
|
213
|
+
toggleCamera() {
|
|
214
|
+
this.setCamera(!this.#localState.cameraOn);
|
|
215
|
+
}
|
|
216
|
+
setCamera(on) {
|
|
217
|
+
if (on && this.#opts.mode === "audio")
|
|
218
|
+
return; // voice call: no camera
|
|
219
|
+
this.#wanted.camera = on;
|
|
220
|
+
if (on)
|
|
221
|
+
this.media.camera?.startBroadcasting();
|
|
222
|
+
else
|
|
223
|
+
this.media.camera?.stopBroadcasting();
|
|
224
|
+
}
|
|
225
|
+
/** Must be called from a click/tap handler (browser rule for screen capture). */
|
|
226
|
+
toggleScreenShare() {
|
|
227
|
+
this.media.screen?.toggleBroadcasting();
|
|
228
|
+
}
|
|
229
|
+
/** Pick a specific mic/camera, e.g. from navigator.mediaDevices.enumerateDevices(). */
|
|
230
|
+
setMicDevice(device) {
|
|
231
|
+
this.media.mic?.setPreferredDevice(device);
|
|
232
|
+
}
|
|
233
|
+
setCameraDevice(device) {
|
|
234
|
+
this.media.camera?.setPreferredDevice(device);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Start the camera or mic again from scratch and turn it on. Use it for a
|
|
238
|
+
* "Try again" button after an "error" saying the device could not start
|
|
239
|
+
* (blocked, busy in another app, or stuck). Call it from a click/tap so the
|
|
240
|
+
* browser can ask for permission again.
|
|
241
|
+
*/
|
|
242
|
+
restartDevice(kind) {
|
|
243
|
+
if (this.#left || !this.permissions[kind])
|
|
244
|
+
return;
|
|
245
|
+
if (kind === "camera" && this.#opts.mode === "audio")
|
|
246
|
+
return;
|
|
247
|
+
this.#wanted[kind === "mic" ? "microphone" : "camera"] = true;
|
|
248
|
+
this.#deviceAttempts[kind] = 0;
|
|
249
|
+
this.#disableDevice(kind);
|
|
250
|
+
this.#enableDevice(kind);
|
|
251
|
+
}
|
|
252
|
+
/** Your own camera / screen preview track right now (also sent as "local-track" events). */
|
|
253
|
+
getLocalTrack(kind) {
|
|
254
|
+
return this.#localPreview[kind] ?? null;
|
|
255
|
+
}
|
|
256
|
+
/** Play remote audio through this output device (speakers/headset). Not supported in every browser. */
|
|
257
|
+
setAudioOutputDevice(deviceId) {
|
|
258
|
+
this.#sinkId = deviceId;
|
|
259
|
+
for (const r of this.#remotes.values())
|
|
260
|
+
for (const el of Object.values(r.audioEls))
|
|
261
|
+
this.#applySink(el);
|
|
262
|
+
}
|
|
263
|
+
get audioOutputDevice() {
|
|
264
|
+
return this.#sinkId;
|
|
265
|
+
}
|
|
266
|
+
#emitLocal(kind, track) {
|
|
267
|
+
this.#localPreview[kind] = track;
|
|
268
|
+
this.emit("local-track", kind, track);
|
|
269
|
+
}
|
|
270
|
+
#applySink(el) {
|
|
271
|
+
const e = el;
|
|
272
|
+
if (e && this.#sinkId !== null && e.setSinkId) {
|
|
273
|
+
e.setSinkId(this.#sinkId).catch((err) => this.emit("error", new Error(`Could not switch speakers (${String(err)})`)));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Current background effect (null = off). */
|
|
277
|
+
get backgroundEffect() {
|
|
278
|
+
return this.#bgOptions;
|
|
279
|
+
}
|
|
280
|
+
/** True while RNNoise noise suppression is on. */
|
|
281
|
+
get noiseSuppression() {
|
|
282
|
+
return this.#nsOn;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Blur your background (true / { blurRadius }), replace it
|
|
286
|
+
* ({ backgroundImage: url }), or switch the effect off (false).
|
|
287
|
+
* Runs on-device with MediaPipe; loaded on first use.
|
|
288
|
+
*/
|
|
289
|
+
setBackgroundEffect(effect) {
|
|
290
|
+
const prev = this.#videoTransform;
|
|
291
|
+
this.#setBackground(effect === true ? {} : effect || null);
|
|
292
|
+
if (prev)
|
|
293
|
+
this.media.camera?.removeTransform(prev);
|
|
294
|
+
if (this.#videoTransform)
|
|
295
|
+
this.media.camera?.addTransform(this.#videoTransform);
|
|
296
|
+
this.#emitEffects();
|
|
297
|
+
}
|
|
298
|
+
/** Shorthand: setBackgroundEffect(on ? { blurRadius } : false). */
|
|
299
|
+
setBackgroundBlur(on, blurRadius) {
|
|
300
|
+
this.setBackgroundEffect(on ? { blurRadius } : false);
|
|
301
|
+
}
|
|
302
|
+
/** Switch RNNoise noise suppression on the mic on or off. */
|
|
303
|
+
setNoiseSuppression(on) {
|
|
304
|
+
const prev = this.#audioTransform;
|
|
305
|
+
this.#setNs(on === true ? {} : on || null);
|
|
306
|
+
if (prev)
|
|
307
|
+
this.media.mic?.removeTransform(prev);
|
|
308
|
+
if (this.#audioTransform)
|
|
309
|
+
this.media.mic?.addTransform(this.#audioTransform);
|
|
310
|
+
this.#emitEffects();
|
|
311
|
+
}
|
|
312
|
+
#setBackground(opts) {
|
|
313
|
+
this.#bgOptions = opts ? { ...opts } : null;
|
|
314
|
+
this.#videoTransform = opts
|
|
315
|
+
? backgroundEffect({ ...opts, onError: (e) => this.#effectError("Background effect", e, opts.onError) })
|
|
316
|
+
: null;
|
|
317
|
+
}
|
|
318
|
+
#setNs(opts) {
|
|
319
|
+
this.#nsOn = !!opts;
|
|
320
|
+
this.#audioTransform = opts
|
|
321
|
+
? noiseSuppression({ ...opts, onError: (e) => this.#effectError("Noise suppression", e, opts.onError) })
|
|
322
|
+
: null;
|
|
323
|
+
}
|
|
324
|
+
#effectError(what, e, user) {
|
|
325
|
+
user?.(e);
|
|
326
|
+
this.emit("error", new Error(`${what} unavailable, sending the original (${e.message})`));
|
|
327
|
+
}
|
|
328
|
+
#emitEffects() {
|
|
329
|
+
this.emit("effects", { background: this.#bgOptions, noiseSuppression: this.#nsOn });
|
|
330
|
+
}
|
|
331
|
+
/** Small app-defined state shared with everyone (hand raised, etc.). Max ~4KB. */
|
|
332
|
+
setCustomState(custom) {
|
|
333
|
+
this.#customState = custom;
|
|
334
|
+
this.#send({ type: "state", state: { custom } });
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Teachers/hosts (permissions.moderate): allow or stop a user's mic, camera
|
|
338
|
+
* or screen share, e.g. { screen: true } to let a student present.
|
|
339
|
+
* Applies to all their connections and lasts until the room empties.
|
|
340
|
+
*/
|
|
341
|
+
setPermissions(userId, permissions) {
|
|
342
|
+
if (!this.permissions.moderate)
|
|
343
|
+
throw new Error("not allowed to moderate");
|
|
344
|
+
this.#send({ type: "set-permissions", userId, permissions });
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Teachers/hosts: turn off a user's devices (default: mic). They can turn
|
|
348
|
+
* them back on, unless `lock` is true (that also takes the permission away;
|
|
349
|
+
* give it back with setPermissions).
|
|
350
|
+
*/
|
|
351
|
+
mute(userId, devices = ["mic"], options = {}) {
|
|
352
|
+
this.#moderate({ type: "mute", userId, devices, lock: options.lock });
|
|
353
|
+
}
|
|
354
|
+
/** Teachers/hosts: mute everyone who isn't a teacher/host (default: mics). */
|
|
355
|
+
muteAll(devices = ["mic"], options = {}) {
|
|
356
|
+
this.#moderate({ type: "mute", userId: "*", devices, lock: options.lock });
|
|
357
|
+
}
|
|
358
|
+
/** Teachers/hosts: stop someone's screen share. */
|
|
359
|
+
stopScreenShare(userId) {
|
|
360
|
+
this.#moderate({ type: "mute", userId, devices: ["screen"] });
|
|
361
|
+
}
|
|
362
|
+
/** Teachers/hosts: remove a user. `ban` keeps them out until the room empties. */
|
|
363
|
+
remove(userId, options = {}) {
|
|
364
|
+
this.#moderate({ type: "remove", userId, ban: options.ban });
|
|
365
|
+
}
|
|
366
|
+
/** Raise your hand. Raising it again keeps your place in the queue. */
|
|
367
|
+
raiseHand() {
|
|
368
|
+
this.#send({ type: "hand", raised: true });
|
|
369
|
+
}
|
|
370
|
+
/** Lower your own hand, or (teachers/hosts) someone else's. */
|
|
371
|
+
lowerHand(userId) {
|
|
372
|
+
if (!userId || userId === this.userId)
|
|
373
|
+
this.#send({ type: "hand", raised: false });
|
|
374
|
+
else
|
|
375
|
+
this.#moderate({ type: "lower-hand", userId });
|
|
376
|
+
}
|
|
377
|
+
/** Teachers/hosts: lower every raised hand. */
|
|
378
|
+
lowerAllHands() {
|
|
379
|
+
this.#moderate({ type: "lower-hand", userId: "*" });
|
|
380
|
+
}
|
|
381
|
+
/** Everyone with a hand up (you included), first raised first. */
|
|
382
|
+
get raisedHands() {
|
|
383
|
+
const all = this.self ? [this.self, ...this.participants] : this.participants;
|
|
384
|
+
return all.filter((p) => p.handRaisedAt != null).sort((a, b) => a.handRaisedAt - b.handRaisedAt);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Send a chat message to everyone, or privately with { to: userId }.
|
|
388
|
+
* Students can only message teachers/hosts privately, and can't chat while a
|
|
389
|
+
* teacher has turned chat off (you get an `error` event).
|
|
390
|
+
*/
|
|
391
|
+
sendChat(text, options = {}) {
|
|
392
|
+
this.#send({
|
|
393
|
+
type: "chat",
|
|
394
|
+
text,
|
|
395
|
+
...(options.to ? { to: options.to } : {}),
|
|
396
|
+
...(options.everywhere ? { everywhere: true } : {}),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/** Teachers/hosts: delete a chat message for everyone. */
|
|
400
|
+
deleteChat(id) {
|
|
401
|
+
this.#moderate({ type: "chat-delete", id });
|
|
402
|
+
}
|
|
403
|
+
/** Teachers/hosts: turn chat on/off for everyone who isn't a teacher/host. */
|
|
404
|
+
setChatEnabled(enabled) {
|
|
405
|
+
this.#moderate({ type: "room", chat: enabled });
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Teachers/hosts: put users in the spotlight for everyone (up to 6, in
|
|
409
|
+
* order; [] clears). Read it from room.roomState.spotlight / "room-state".
|
|
410
|
+
*/
|
|
411
|
+
setSpotlight(userIds) {
|
|
412
|
+
this.#moderate({ type: "room", spotlight: userIds });
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Teachers/hosts: start breakout rooms. Each group lists userIds; anyone not
|
|
416
|
+
* listed stays in the main room. Groups get ids "g1", "g2", … (room.roomState.breakouts).
|
|
417
|
+
*/
|
|
418
|
+
startBreakouts(groups) {
|
|
419
|
+
this.#moderate({ type: "breakouts", groups });
|
|
420
|
+
}
|
|
421
|
+
/** Teachers/hosts: move someone to a group id (null = main room). */
|
|
422
|
+
moveToGroup(userId, group) {
|
|
423
|
+
this.#moderate({ type: "move", userId, group });
|
|
424
|
+
}
|
|
425
|
+
/** Teachers/hosts: join a breakout group yourself (null = back to the main room). */
|
|
426
|
+
visitGroup(group) {
|
|
427
|
+
this.moveToGroup(this.userId, group);
|
|
428
|
+
}
|
|
429
|
+
/** Teachers/hosts: end breakouts; everyone returns to the main room. */
|
|
430
|
+
endBreakouts() {
|
|
431
|
+
this.#moderate({ type: "end-breakouts" });
|
|
432
|
+
}
|
|
433
|
+
/** Your breakout group id, or null in the main room. */
|
|
434
|
+
get group() {
|
|
435
|
+
return this.self?.group ?? null;
|
|
436
|
+
}
|
|
437
|
+
/** Teachers/hosts: turn the waiting room on/off (off admits everyone waiting). */
|
|
438
|
+
setWaitingRoom(on) {
|
|
439
|
+
this.#moderate({ type: "room", waitingRoom: on });
|
|
440
|
+
}
|
|
441
|
+
/** Teachers/hosts: let someone in from the waiting room ("*" = everyone). */
|
|
442
|
+
admit(userId) {
|
|
443
|
+
this.#moderate({ type: "admit", userId });
|
|
444
|
+
}
|
|
445
|
+
/** Teachers/hosts: turn someone in the waiting room away. */
|
|
446
|
+
deny(userId) {
|
|
447
|
+
this.#moderate({ type: "deny", userId });
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Teachers/hosts: who attended and for how long (this room, last 30 days).
|
|
451
|
+
* Turn it into a spreadsheet with attendanceToCsv(report).
|
|
452
|
+
*/
|
|
453
|
+
fetchAttendance(timeoutMs = 10000) {
|
|
454
|
+
return new Promise((resolve, reject) => {
|
|
455
|
+
try {
|
|
456
|
+
this.#moderate({ type: "attendance" });
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
reject(err);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.#attendanceWaiters.push(resolve);
|
|
463
|
+
setTimeout(() => {
|
|
464
|
+
const i = this.#attendanceWaiters.indexOf(resolve);
|
|
465
|
+
if (i >= 0) {
|
|
466
|
+
this.#attendanceWaiters.splice(i, 1);
|
|
467
|
+
reject(new Error("attendance request timed out"));
|
|
468
|
+
}
|
|
469
|
+
}, timeoutMs);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
/** Teachers/hosts: while locked, only teachers/hosts can join. */
|
|
473
|
+
lockRoom(locked = true) {
|
|
474
|
+
this.#moderate({ type: "room", locked });
|
|
475
|
+
}
|
|
476
|
+
#moderate(msg) {
|
|
477
|
+
if (!this.permissions.moderate)
|
|
478
|
+
throw new Error("not allowed to moderate");
|
|
479
|
+
this.#send(msg);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Teachers/hosts (permissions.moderate): start recording the room to R2.
|
|
483
|
+
* Audio only by default; { video: true } records everything with the
|
|
484
|
+
* recorder bot (the kit must be deployed with it).
|
|
485
|
+
*/
|
|
486
|
+
startRecording(options = {}) {
|
|
487
|
+
if (!this.permissions.moderate)
|
|
488
|
+
throw new Error("not allowed to moderate");
|
|
489
|
+
this.#send({ type: "start-recording", video: options.video === true });
|
|
490
|
+
}
|
|
491
|
+
/** Teachers/hosts (permissions.moderate): stop the recording. */
|
|
492
|
+
stopRecording() {
|
|
493
|
+
if (!this.permissions.moderate)
|
|
494
|
+
throw new Error("not allowed to moderate");
|
|
495
|
+
this.#send({ type: "stop-recording" });
|
|
496
|
+
}
|
|
497
|
+
// ---------------- subscription controls ----------------
|
|
498
|
+
/**
|
|
499
|
+
* Start or stop receiving some of a participant's tracks, e.g. stop their
|
|
500
|
+
* video while their tile is off screen. Stopping really stops the media
|
|
501
|
+
* (no bandwidth used); starting again pulls it fresh.
|
|
502
|
+
*/
|
|
503
|
+
setSubscribed(participantId, kinds, on) {
|
|
504
|
+
const entry = this.#remotes.get(participantId);
|
|
505
|
+
if (!entry)
|
|
506
|
+
return;
|
|
507
|
+
for (const kind of Array.isArray(kinds) ? kinds : [kinds])
|
|
508
|
+
entry.subscribed[kind] = on;
|
|
509
|
+
this.#syncPulls(entry);
|
|
510
|
+
}
|
|
511
|
+
/** Whether you're set to receive this participant's track kind (if they publish it and you're allowed). */
|
|
512
|
+
isSubscribed(participantId, kind) {
|
|
513
|
+
const entry = this.#remotes.get(participantId);
|
|
514
|
+
return !!entry && this.#wants(entry, kind);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Receive a participant's video only while `el` (their tile) is on screen
|
|
518
|
+
* and the tab is visible. Video stops 1s after the tile leaves the screen
|
|
519
|
+
* (`hideDelayMs`) and comes back when it returns. Returns a function that
|
|
520
|
+
* stops watching (and leaves video on).
|
|
521
|
+
*/
|
|
522
|
+
observeVisibility(el, participantId, options = {}) {
|
|
523
|
+
const kinds = options.kinds ?? VIDEO_KINDS;
|
|
524
|
+
const hideDelay = options.hideDelayMs ?? 1000;
|
|
525
|
+
let inView = true;
|
|
526
|
+
let timer = null;
|
|
527
|
+
const apply = () => {
|
|
528
|
+
const visible = inView && (typeof document === "undefined" || document.visibilityState !== "hidden");
|
|
529
|
+
if (timer)
|
|
530
|
+
clearTimeout(timer);
|
|
531
|
+
timer = null;
|
|
532
|
+
if (visible)
|
|
533
|
+
this.setSubscribed(participantId, kinds, true);
|
|
534
|
+
else
|
|
535
|
+
timer = setTimeout(() => this.setSubscribed(participantId, kinds, false), hideDelay);
|
|
536
|
+
};
|
|
537
|
+
const io = typeof IntersectionObserver !== "undefined"
|
|
538
|
+
? new IntersectionObserver((entries) => {
|
|
539
|
+
inView = entries.some((e) => e.isIntersecting);
|
|
540
|
+
apply();
|
|
541
|
+
})
|
|
542
|
+
: null;
|
|
543
|
+
io?.observe(el);
|
|
544
|
+
const onVis = () => apply();
|
|
545
|
+
if (typeof document !== "undefined")
|
|
546
|
+
document.addEventListener("visibilitychange", onVis);
|
|
547
|
+
return () => {
|
|
548
|
+
io?.disconnect();
|
|
549
|
+
if (timer)
|
|
550
|
+
clearTimeout(timer);
|
|
551
|
+
if (typeof document !== "undefined")
|
|
552
|
+
document.removeEventListener("visibilitychange", onVis);
|
|
553
|
+
this.setSubscribed(participantId, kinds, true);
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Choose who may receive YOUR tracks. Enforced by the server: others only
|
|
558
|
+
* see (and can only pull) what you allow; anyone who loses access stops
|
|
559
|
+
* receiving within seconds. Kept until the room empties.
|
|
560
|
+
*
|
|
561
|
+
* room.setTrackPermissions({ allowAll: true }); // default
|
|
562
|
+
* room.setTrackPermissions({ allowAll: false, allow: [{ userId: "t1" }] });
|
|
563
|
+
* room.setTrackPermissions({ allowAll: false, allow: [{ userId: "t1", kinds: ["screenVideo"] }] });
|
|
564
|
+
*/
|
|
565
|
+
setTrackPermissions(permissions) {
|
|
566
|
+
this.#send({ type: "track-permissions", permissions });
|
|
567
|
+
}
|
|
568
|
+
#wants(entry, kind) {
|
|
569
|
+
const chosen = entry.subscribed[kind];
|
|
570
|
+
if (chosen !== undefined)
|
|
571
|
+
return chosen;
|
|
572
|
+
const auto = this.#opts.autoSubscribe ?? true;
|
|
573
|
+
if (typeof auto === "boolean")
|
|
574
|
+
return auto;
|
|
575
|
+
return VIDEO_KINDS.includes(kind) ? auto.video ?? true : auto.audio ?? true;
|
|
576
|
+
}
|
|
577
|
+
/** AI agents currently in the room. */
|
|
578
|
+
get agents() {
|
|
579
|
+
return this.participants.filter((p) => p.kind === "agent");
|
|
580
|
+
}
|
|
581
|
+
/** Type to an AI agent instead of speaking; it answers out loud. */
|
|
582
|
+
sendToAgent(agentId, text) {
|
|
583
|
+
this.#send({ type: "agent-text", agentId, text });
|
|
584
|
+
}
|
|
585
|
+
/** AI lessons running in this room (usually one). */
|
|
586
|
+
get lessons() {
|
|
587
|
+
return [...this.#lessons.values()];
|
|
588
|
+
}
|
|
589
|
+
/** The AI lesson running in this room, if any. */
|
|
590
|
+
get lesson() {
|
|
591
|
+
return this.#lessons.values().next().value ?? null;
|
|
592
|
+
}
|
|
593
|
+
/** AI participants in the room (agents with a role: contributor, scribe, co-teacher…). */
|
|
594
|
+
get aiParticipants() {
|
|
595
|
+
return this.participants.filter((p) => p.kind === "agent" && p.agent?.ai);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* "Ask AI": type a question to an AI participant (the alternative to asking
|
|
599
|
+
* the host or teacher). It answers in the chat, or out loud when its voice is
|
|
600
|
+
* on. `private`: only you see the answer, in chat. Needs the AI's
|
|
601
|
+
* directQuestions on unless you're a host. Saying its name in the call works too.
|
|
602
|
+
*/
|
|
603
|
+
askAI(text, options = {}) {
|
|
604
|
+
const agentId = options.agentId ?? this.aiParticipants[0]?.id;
|
|
605
|
+
if (!agentId)
|
|
606
|
+
throw new Error("no AI participant in this room");
|
|
607
|
+
this.#send({ type: "ai-ask", agentId, text, ...(options.private ? { private: true } : {}) });
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Hosts: steer an AI participant: { action: "pause" | "resume" | "recap" | "minutes" },
|
|
611
|
+
* { action: "settings", voice, directQuestions, flags, watchPolicy, knowledge },
|
|
612
|
+
* co-teachers { action: "teach", topic | planId } and { action: "stop-teaching" }.
|
|
613
|
+
*/
|
|
614
|
+
controlAI(control, agentId = this.aiParticipants[0]?.id) {
|
|
615
|
+
if (!agentId)
|
|
616
|
+
throw new Error("no AI participant in this room");
|
|
617
|
+
this.#send({ type: "ai-control", agentId, control });
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Keep yourself out of what AI participants hear (true) or let them hear you
|
|
621
|
+
* again (false). Your speech and chat are then never handed to the AI.
|
|
622
|
+
* Shown to everyone as `participant.aiOptOut`.
|
|
623
|
+
*/
|
|
624
|
+
setAiOptOut(optOut) {
|
|
625
|
+
this.#send({ type: "ai-opt-out", optOut });
|
|
626
|
+
}
|
|
627
|
+
/** Type a question to the AI teacher. Raise your hand (raiseHand) to ask out loud instead. */
|
|
628
|
+
askLesson(text, lessonId = this.lesson?.lessonId) {
|
|
629
|
+
if (!lessonId)
|
|
630
|
+
throw new Error("no lesson in this room");
|
|
631
|
+
this.#send({ type: "lesson-ask", lessonId, text });
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Moderators: steer the AI lesson, e.g. { action: "pause" }, { action: "takeover" }
|
|
635
|
+
* (you speak, the AI listens and keeps notes), { action: "handback" },
|
|
636
|
+
* { action: "call-on", userId }, { action: "goto", section: 2 },
|
|
637
|
+
* { action: "scope", scope: "strict" }, { action: "note", text: "slow down" }.
|
|
638
|
+
*/
|
|
639
|
+
controlLesson(control, lessonId = this.lesson?.lessonId) {
|
|
640
|
+
if (!lessonId)
|
|
641
|
+
throw new Error("no lesson in this room");
|
|
642
|
+
this.#send({ type: "lesson-control", lessonId, control });
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Send an app-level message to everyone else in the room (chat, reactions…),
|
|
646
|
+
* or with `to` only to those userIds. `topic` travels with it so you can route
|
|
647
|
+
* messages (e.g. "whiteboard").
|
|
648
|
+
* With end-to-end encryption on, it's encrypted first (other participants
|
|
649
|
+
* receive the original `data`; the server sees ciphertext).
|
|
650
|
+
*/
|
|
651
|
+
send(data, options = {}) {
|
|
652
|
+
const extra = {
|
|
653
|
+
...(options.to ? { to: options.to } : {}),
|
|
654
|
+
...(options.topic ? { topic: options.topic } : {}),
|
|
655
|
+
};
|
|
656
|
+
const e2ee = this.#e2ee;
|
|
657
|
+
if (!e2ee)
|
|
658
|
+
return this.#send({ type: "broadcast", data, ...extra });
|
|
659
|
+
const encrypted = e2ee.encryptMessage(data);
|
|
660
|
+
this.#sendChain = this.#sendChain
|
|
661
|
+
.then(() => encrypted)
|
|
662
|
+
.then((enc) => this.#send({ type: "broadcast", data: enc, ...extra }))
|
|
663
|
+
.catch((err) => this.emit("error", err instanceof Error ? err : new Error(String(err))));
|
|
664
|
+
}
|
|
665
|
+
/** End-to-end encryption status. `keyIndex` is the key you send with (-1 = off). */
|
|
666
|
+
get encryption() {
|
|
667
|
+
return { enabled: Boolean(this.#e2ee), keyIndex: this.#e2ee?.keyIndex ?? -1 };
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Add or rotate the end-to-end encryption key. Give every participant the new
|
|
671
|
+
* key with a new index (0-255). With `use: false` it's only added for
|
|
672
|
+
* receiving, so you can hand it out first and switch everyone over after.
|
|
673
|
+
* The last 16 keys are kept for decrypting.
|
|
674
|
+
*/
|
|
675
|
+
async setEncryptionKey(key, index, options = {}) {
|
|
676
|
+
if (!this.#e2ee)
|
|
677
|
+
throw new Error("end-to-end encryption is off for this room (join with the e2ee option)");
|
|
678
|
+
await this.#e2ee.setKey(key, index, options.use ?? true);
|
|
679
|
+
}
|
|
680
|
+
// ---------------- reactions & files ----------------
|
|
681
|
+
/** Send an emoji reaction to everyone (e.g. "👍"). Max ~10 per 5 seconds. */
|
|
682
|
+
react(emoji) {
|
|
683
|
+
this.#send({ type: "reaction", emoji });
|
|
684
|
+
}
|
|
685
|
+
/** Files shared in this room, oldest first. They're deleted when the room empties. */
|
|
686
|
+
get files() {
|
|
687
|
+
return [...this.#files.values()].sort((a, b) => a.at - b.at);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Share a file with everyone in the room (needs the Worker's STORAGE bucket).
|
|
691
|
+
* Observers can't share. Max size is the Worker's MAX_FILE_MB (default 25).
|
|
692
|
+
*/
|
|
693
|
+
async shareFile(file, name = file.name || "file") {
|
|
694
|
+
const res = await fetch(`${this.#opts.url}/rooms/${encodeURIComponent(this.roomId)}/files`, {
|
|
695
|
+
method: "POST",
|
|
696
|
+
headers: {
|
|
697
|
+
[TOKEN_HEADER]: this.#token,
|
|
698
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
699
|
+
"X-File-Name": encodeURIComponent(name),
|
|
700
|
+
},
|
|
701
|
+
body: file,
|
|
702
|
+
});
|
|
703
|
+
const body = (await res.json().catch(() => ({})));
|
|
704
|
+
if (!res.ok)
|
|
705
|
+
throw new Error(body.error ?? `file upload failed (HTTP ${res.status})`);
|
|
706
|
+
this.#addFile(body);
|
|
707
|
+
return body;
|
|
708
|
+
}
|
|
709
|
+
/** Download a shared file's bytes. */
|
|
710
|
+
async downloadFile(fileId) {
|
|
711
|
+
const res = await fetch(this.#fileEndpoint(fileId), { headers: { [TOKEN_HEADER]: this.#token } });
|
|
712
|
+
if (!res.ok)
|
|
713
|
+
throw new Error(`download failed (HTTP ${res.status})`);
|
|
714
|
+
return res.blob();
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* A link that downloads the file (carries your join token, so don't share it
|
|
718
|
+
* outside the room). `inline` shows PNG/JPEG/GIF/WebP images instead.
|
|
719
|
+
*/
|
|
720
|
+
fileUrl(fileId, inline = false) {
|
|
721
|
+
return `${this.#fileEndpoint(fileId)}?token=${encodeURIComponent(this.#token)}${inline ? "&inline=1" : ""}`;
|
|
722
|
+
}
|
|
723
|
+
/** Remove a file you shared (moderators can remove anyone's). */
|
|
724
|
+
async deleteFile(fileId) {
|
|
725
|
+
const res = await fetch(this.#fileEndpoint(fileId), { method: "DELETE", headers: { [TOKEN_HEADER]: this.#token } });
|
|
726
|
+
if (!res.ok && res.status !== 404) {
|
|
727
|
+
const body = (await res.json().catch(() => ({})));
|
|
728
|
+
throw new Error(body.error ?? `delete failed (HTTP ${res.status})`);
|
|
729
|
+
}
|
|
730
|
+
this.#removeFile(fileId);
|
|
731
|
+
}
|
|
732
|
+
// ---------------- polls & Q&A ----------------
|
|
733
|
+
/** True if you may run polls and moderate Q&A (moderators, or everyone in rooms without roles). */
|
|
734
|
+
get isHost() {
|
|
735
|
+
return this.self ? canHost(this.self) : canHost({ role: this.role, permissions: this.permissions });
|
|
736
|
+
}
|
|
737
|
+
/** Polls in this room, oldest first. `counts` is empty while results are hidden from you. */
|
|
738
|
+
get polls() {
|
|
739
|
+
return [...this.#polls.values()].sort((a, b) => a.at - b.at);
|
|
740
|
+
}
|
|
741
|
+
/** Your vote in a poll (option indexes), if you voted. */
|
|
742
|
+
myVote(pollId) {
|
|
743
|
+
return this.#myVotes.get(pollId);
|
|
744
|
+
}
|
|
745
|
+
/** Hosts: start a poll. */
|
|
746
|
+
createPoll(poll) {
|
|
747
|
+
this.#send({ type: "poll-create", poll });
|
|
748
|
+
}
|
|
749
|
+
/** Vote, or change your vote. One option index unless the poll allows multiple. */
|
|
750
|
+
vote(pollId, choices) {
|
|
751
|
+
this.#send({ type: "poll-vote", pollId, choices: Array.isArray(choices) ? choices : [choices] });
|
|
752
|
+
}
|
|
753
|
+
closePoll(pollId) {
|
|
754
|
+
this.#send({ type: "poll-close", pollId });
|
|
755
|
+
}
|
|
756
|
+
deletePoll(pollId) {
|
|
757
|
+
this.#send({ type: "poll-delete", pollId });
|
|
758
|
+
}
|
|
759
|
+
/** Q&A questions, oldest first. Sort by `upvotes` for a "top questions" view. */
|
|
760
|
+
get questions() {
|
|
761
|
+
return [...this.#questions.values()].sort((a, b) => a.at - b.at);
|
|
762
|
+
}
|
|
763
|
+
/** Did you upvote this question? */
|
|
764
|
+
hasUpvoted(questionId) {
|
|
765
|
+
return this.#myUpvotes.has(questionId);
|
|
766
|
+
}
|
|
767
|
+
/** `anonymous` hides your name from everyone except hosts. Max 500 characters, 5 a minute. */
|
|
768
|
+
askQuestion(text, anonymous = false) {
|
|
769
|
+
this.#send({ type: "question-ask", text, anonymous });
|
|
770
|
+
}
|
|
771
|
+
upvoteQuestion(questionId, up = true) {
|
|
772
|
+
this.#send({ type: "question-upvote", id: questionId, up });
|
|
773
|
+
}
|
|
774
|
+
/** Hosts. */
|
|
775
|
+
markAnswered(questionId, answered = true) {
|
|
776
|
+
this.#send({ type: "question-answer", id: questionId, answered });
|
|
777
|
+
}
|
|
778
|
+
/** Hosts, or your own question. */
|
|
779
|
+
deleteQuestion(questionId) {
|
|
780
|
+
this.#send({ type: "question-delete", id: questionId });
|
|
781
|
+
}
|
|
782
|
+
// ---------------- captions, transcript, summary ----------------
|
|
783
|
+
/** Are captions on in this room, and with what settings. */
|
|
784
|
+
get captions() {
|
|
785
|
+
return { ...this.#captionsState };
|
|
786
|
+
}
|
|
787
|
+
/** Recent caption lines (the last 200 you've seen). Hosts can fetch the full transcript. */
|
|
788
|
+
get recentCaptions() {
|
|
789
|
+
return [...this.#captionLines];
|
|
790
|
+
}
|
|
791
|
+
/** The latest AI summary of this call, if a host made one. */
|
|
792
|
+
get summary() {
|
|
793
|
+
return this.#summary;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Hosts: turn live captions on/off (needs Workers AI on the Worker).
|
|
797
|
+
* `language` ("en", "fr", …) improves accuracy; `translate` gives English captions for any language.
|
|
798
|
+
* `server`: transcribe every mic on the server (participants on any SDK get captioned, nobody uploads clips).
|
|
799
|
+
*/
|
|
800
|
+
setCaptions(on, options = {}) {
|
|
801
|
+
this.#send({ type: "captions", on, ...options });
|
|
802
|
+
}
|
|
803
|
+
/** The language you read captions in ("fr", "ha", …): lines arrive with a translation. null = as spoken. */
|
|
804
|
+
setCaptionLanguage(language) {
|
|
805
|
+
this.#captionLanguage = language && /^[a-z]{2,3}$/.test(language) ? language : null;
|
|
806
|
+
this.#send({ type: "caption-language", language: this.#captionLanguage });
|
|
807
|
+
}
|
|
808
|
+
/** The language you asked to read captions in (null = as spoken). */
|
|
809
|
+
get captionLanguage() {
|
|
810
|
+
return this.#captionLanguage;
|
|
811
|
+
}
|
|
812
|
+
/** Hosts: the whole transcript so far, as JSON lines, plain text or WebVTT. */
|
|
813
|
+
async getTranscript(format = "json") {
|
|
814
|
+
const res = await fetch(`${this.#roomEndpoint()}/transcript?format=${format}`, { headers: { [TOKEN_HEADER]: this.#token } });
|
|
815
|
+
if (!res.ok)
|
|
816
|
+
throw new Error(`transcript: HTTP ${res.status}`);
|
|
817
|
+
return format === "json" ? (await res.json()).lines : res.text();
|
|
818
|
+
}
|
|
819
|
+
/** Hosts: summarize the transcript so far with AI. Everyone gets the `summary` event. */
|
|
820
|
+
async summarize(style = "meeting") {
|
|
821
|
+
const res = await fetch(`${this.#roomEndpoint()}/summary`, {
|
|
822
|
+
method: "POST",
|
|
823
|
+
headers: { [TOKEN_HEADER]: this.#token, "Content-Type": "application/json" },
|
|
824
|
+
body: JSON.stringify({ style }),
|
|
825
|
+
});
|
|
826
|
+
const body = (await res.json().catch(() => ({})));
|
|
827
|
+
if (!res.ok)
|
|
828
|
+
throw new Error(body.error ?? `summary: HTTP ${res.status}`);
|
|
829
|
+
return body;
|
|
830
|
+
}
|
|
831
|
+
#roomEndpoint() {
|
|
832
|
+
return `${this.#opts.url}/rooms/${encodeURIComponent(this.roomId)}`;
|
|
833
|
+
}
|
|
834
|
+
/** Start/stop transcribing our own mic to match captions + mic state. */
|
|
835
|
+
#syncCaptions() {
|
|
836
|
+
const want = !this.#left &&
|
|
837
|
+
this.#opts.sendCaptions !== false &&
|
|
838
|
+
this.#captionsState.on &&
|
|
839
|
+
!this.#captionsState.server &&
|
|
840
|
+
this.#broadcasting.mic &&
|
|
841
|
+
this.permissions.mic &&
|
|
842
|
+
this.#micTrack !== null &&
|
|
843
|
+
typeof AudioContext !== "undefined";
|
|
844
|
+
if (want) {
|
|
845
|
+
this.#capture ??= new CaptionCapture((wav) => this.#sendCaption(wav));
|
|
846
|
+
void this.#capture.start(this.#micTrack);
|
|
847
|
+
}
|
|
848
|
+
else if (this.#capture?.running) {
|
|
849
|
+
void this.#capture.stop();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
#sendCaption(wav) {
|
|
853
|
+
if (this.#captionUploads >= 3)
|
|
854
|
+
return; // network can't keep up: skip rather than lag
|
|
855
|
+
this.#captionUploads++;
|
|
856
|
+
fetch(`${this.#roomEndpoint()}/captions`, {
|
|
857
|
+
method: "POST",
|
|
858
|
+
headers: { [TOKEN_HEADER]: this.#token, "Content-Type": "audio/wav" },
|
|
859
|
+
body: wav,
|
|
860
|
+
})
|
|
861
|
+
.then(async (res) => {
|
|
862
|
+
if (!res.ok && res.status !== 409) {
|
|
863
|
+
const body = (await res.json().catch(() => ({})));
|
|
864
|
+
this.emit("error", new Error(`captions: ${body.error ?? "HTTP " + res.status}`));
|
|
865
|
+
}
|
|
866
|
+
})
|
|
867
|
+
.catch(() => { })
|
|
868
|
+
.finally(() => this.#captionUploads--);
|
|
869
|
+
}
|
|
870
|
+
#addCaptionLine(line, translation) {
|
|
871
|
+
if (this.#captionLines.some((l) => l.seq === line.seq))
|
|
872
|
+
return;
|
|
873
|
+
this.#captionLines.push(line);
|
|
874
|
+
if (this.#captionLines.length > 200)
|
|
875
|
+
this.#captionLines.shift();
|
|
876
|
+
this.emit("caption", line, translation);
|
|
877
|
+
}
|
|
878
|
+
#fileEndpoint(fileId) {
|
|
879
|
+
return `${this.#opts.url}/rooms/${encodeURIComponent(this.roomId)}/files/${encodeURIComponent(fileId)}`;
|
|
880
|
+
}
|
|
881
|
+
#addFile(file) {
|
|
882
|
+
if (this.#files.has(file.id))
|
|
883
|
+
return;
|
|
884
|
+
this.#files.set(file.id, file);
|
|
885
|
+
this.emit("file-shared", file);
|
|
886
|
+
}
|
|
887
|
+
#removeFile(id) {
|
|
888
|
+
if (this.#files.delete(id))
|
|
889
|
+
this.emit("file-removed", id);
|
|
890
|
+
}
|
|
891
|
+
/** Swap in a fresh token before the current one expires. */
|
|
892
|
+
updateToken(token) {
|
|
893
|
+
const claims = decodeClaims(token);
|
|
894
|
+
if (claims.room !== this.roomId || claims.sub !== this.userId) {
|
|
895
|
+
throw new Error("new token is for a different room or user");
|
|
896
|
+
}
|
|
897
|
+
this.#token = token;
|
|
898
|
+
this.#headers.set(TOKEN_HEADER, token);
|
|
899
|
+
this.#send({ type: "token", token }); // the room keeps this connection past the old expiry
|
|
900
|
+
this.#scheduleTokenWarning(claims.exp);
|
|
901
|
+
}
|
|
902
|
+
#scheduleTokenWarning(exp) {
|
|
903
|
+
if (this.#tokenTimer)
|
|
904
|
+
clearTimeout(this.#tokenTimer);
|
|
905
|
+
const ms = exp * 1000 - Date.now() - 60_000;
|
|
906
|
+
// setTimeout can't take > ~24.8 days; tokens are ≤ 24h anyway.
|
|
907
|
+
this.#tokenTimer = setTimeout(() => !this.#left && this.emit("token-expiring", exp), Math.max(0, Math.min(ms, 2 ** 31 - 1)));
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Choose how much video you want from one participant: "high" | "medium" |
|
|
911
|
+
* "low" | "off" (audio only for them). Adaptive limits can still lower it.
|
|
912
|
+
*/
|
|
913
|
+
setVideoQuality(participantId, quality) {
|
|
914
|
+
const entry = this.#remotes.get(participantId);
|
|
915
|
+
if (!entry)
|
|
916
|
+
return;
|
|
917
|
+
entry.requested = quality;
|
|
918
|
+
this.#applyQuality(entry);
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Keep a participant's received video matched to the size of the element
|
|
922
|
+
* showing it (ResizeObserver). Returns a function that stops watching.
|
|
923
|
+
*/
|
|
924
|
+
fitVideoQuality(participantId, el) {
|
|
925
|
+
if (typeof ResizeObserver === "undefined")
|
|
926
|
+
return () => { };
|
|
927
|
+
const update = () => {
|
|
928
|
+
const h = el.getBoundingClientRect().height * (globalThis.devicePixelRatio || 1);
|
|
929
|
+
this.setVideoQuality(participantId, qualityForHeight(h));
|
|
930
|
+
};
|
|
931
|
+
const ro = new ResizeObserver(update);
|
|
932
|
+
ro.observe(el);
|
|
933
|
+
update();
|
|
934
|
+
return () => ro.disconnect();
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Audio-only mode: stop receiving everyone's camera video and turn your own
|
|
938
|
+
* camera off. Screen shares still come through. Turning it off restores both.
|
|
939
|
+
*/
|
|
940
|
+
setAudioOnly(on) {
|
|
941
|
+
if (on === this.#audioOnly || (!on && this.#opts.mode === "audio"))
|
|
942
|
+
return;
|
|
943
|
+
const before = this.#chosenAudioOnly();
|
|
944
|
+
this.#audioOnly = on;
|
|
945
|
+
this.#afterAudioOnlyChange(before);
|
|
946
|
+
}
|
|
947
|
+
/** Audio only by choice: setAudioOnly(true), mode "audio" or the "audio" data saver. See also audioFallback. */
|
|
948
|
+
get audioOnly() {
|
|
949
|
+
return this.#chosenAudioOnly();
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Data saver for costly or weak mobile data. A preset ("off", "low",
|
|
953
|
+
* "minimal", "audio") or custom settings, e.g.
|
|
954
|
+
* `{ base: "low", activeSpeakerVideo: 2 }`. Applies straight away, both to
|
|
955
|
+
* what you send and what you receive. Your choice is shown to others as
|
|
956
|
+
* participant.state.dataSaver.
|
|
957
|
+
*/
|
|
958
|
+
setDataSaver(input) {
|
|
959
|
+
const next = resolveDataSaver(input);
|
|
960
|
+
if (this.#left)
|
|
961
|
+
return;
|
|
962
|
+
const before = this.#chosenAudioOnly();
|
|
963
|
+
this.#saver = next;
|
|
964
|
+
this.#updateMicEncodings();
|
|
965
|
+
this.#afterAudioOnlyChange(before);
|
|
966
|
+
this.emit("data-saver", next.mode, { ...next.settings });
|
|
967
|
+
}
|
|
968
|
+
/** The current data-saver mode and what it does. */
|
|
969
|
+
get dataSaver() {
|
|
970
|
+
return { mode: this.#saver.mode, settings: { ...this.#saver.settings } };
|
|
971
|
+
}
|
|
972
|
+
/** True while your connection has fallen back to audio only (see "audio-fallback"). */
|
|
973
|
+
get audioFallback() {
|
|
974
|
+
return this.#fallback.active;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Turn the audio-first fallback on or off during the call. Turning it off
|
|
978
|
+
* while it's active brings video back now.
|
|
979
|
+
*/
|
|
980
|
+
setAudioFallback(enabled) {
|
|
981
|
+
this.#fallbackOn = enabled;
|
|
982
|
+
if (!enabled && this.#fallback.active) {
|
|
983
|
+
this.#fallback = { ...this.#fallback, active: false, good: 0, lastRecoveredAt: Date.now() };
|
|
984
|
+
this.#afterFallbackChange();
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Media data used in this call so far (uploaded + downloaded, as measured by
|
|
989
|
+
* WebRTC), the current rates and a per-hour projection. Updated every
|
|
990
|
+
* statsIntervalMs; also see the "data-usage" event and estimateDataUsage().
|
|
991
|
+
*/
|
|
992
|
+
get dataUsage() {
|
|
993
|
+
return { ...this.#usage.usage };
|
|
994
|
+
}
|
|
995
|
+
#chosenAudioOnly() {
|
|
996
|
+
return this.#audioOnly || this.#saver.settings.audioOnly;
|
|
997
|
+
}
|
|
998
|
+
/** Camera on/off around audio-only by choice, then re-apply everything. */
|
|
999
|
+
#afterAudioOnlyChange(before) {
|
|
1000
|
+
const now = this.#chosenAudioOnly();
|
|
1001
|
+
if (now && !before) {
|
|
1002
|
+
this.#cameraBeforeAudioOnly = this.#wanted.camera;
|
|
1003
|
+
this.setCamera(false);
|
|
1004
|
+
}
|
|
1005
|
+
else if (!now && before && this.#cameraBeforeAudioOnly && this.#opts.mode !== "audio") {
|
|
1006
|
+
this.setCamera(true);
|
|
1007
|
+
}
|
|
1008
|
+
this.#refreshVideoAllow(false);
|
|
1009
|
+
this.#applyAllQualities();
|
|
1010
|
+
this.#updateCameraEncodings();
|
|
1011
|
+
this.#sendLowBandwidthState();
|
|
1012
|
+
}
|
|
1013
|
+
#afterFallbackChange() {
|
|
1014
|
+
if (!this.#fallback.active) {
|
|
1015
|
+
// Come back gently: start low and let the adaptive logic climb.
|
|
1016
|
+
this.#adaptive = { ...initialAdaptiveState(), receiveCap: "low" };
|
|
1017
|
+
}
|
|
1018
|
+
this.#applyAllQualities();
|
|
1019
|
+
this.#updateCameraEncodings();
|
|
1020
|
+
this.#updateMicEncodings();
|
|
1021
|
+
this.#sendLowBandwidthState();
|
|
1022
|
+
this.emit("audio-fallback", this.#fallback.active);
|
|
1023
|
+
}
|
|
1024
|
+
#applyAllQualities() {
|
|
1025
|
+
for (const entry of this.#remotes.values()) {
|
|
1026
|
+
this.#applyQuality(entry);
|
|
1027
|
+
this.#syncPulls(entry);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
#lowBandwidthState() {
|
|
1031
|
+
const mode = this.#saver.mode === "custom" ? "low" : this.#saver.mode;
|
|
1032
|
+
return {
|
|
1033
|
+
audioOnly: this.#chosenAudioOnly() || this.#fallback.active,
|
|
1034
|
+
audioFallback: this.#fallback.active,
|
|
1035
|
+
dataSaver: mode,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
#sendLowBandwidthState() {
|
|
1039
|
+
const next = this.#lowBandwidthState();
|
|
1040
|
+
const cur = this.#localState;
|
|
1041
|
+
if (cur.audioOnly === next.audioOnly && cur.audioFallback === next.audioFallback && cur.dataSaver === next.dataSaver)
|
|
1042
|
+
return;
|
|
1043
|
+
this.#localState = { ...cur, ...next };
|
|
1044
|
+
this.#send({ type: "state", state: next });
|
|
1045
|
+
this.emit("local-state", this.localState);
|
|
1046
|
+
}
|
|
1047
|
+
#micEncodings() {
|
|
1048
|
+
const cap = micBitrateCap(this.#saver.settings.maxAudioBitrate, this.#fallback.active);
|
|
1049
|
+
return [cap > 0 ? { maxBitrate: cap } : {}];
|
|
1050
|
+
}
|
|
1051
|
+
#updateMicEncodings() {
|
|
1052
|
+
const next = this.#micEncodings();
|
|
1053
|
+
if (JSON.stringify(next) !== JSON.stringify(this.#micEncodings$.value))
|
|
1054
|
+
this.#micEncodings$.next(next);
|
|
1055
|
+
this.#syncSenders();
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Make our senders match the data saver / fallback / adaptive state. Sets
|
|
1059
|
+
* the parameters directly, keeping the encodings the browser negotiated
|
|
1060
|
+
* (it may drop simulcast layers for small cameras, or have none). Also run
|
|
1061
|
+
* on every stats sample, so a rebuilt connection gets the same settings.
|
|
1062
|
+
*/
|
|
1063
|
+
#syncSenders() {
|
|
1064
|
+
const pc = this.#pc;
|
|
1065
|
+
if (!pc || this.#left)
|
|
1066
|
+
return;
|
|
1067
|
+
const s = this.#saver.settings;
|
|
1068
|
+
for (const sender of pc.getSenders()) {
|
|
1069
|
+
const t = sender.track;
|
|
1070
|
+
if (!t)
|
|
1071
|
+
continue;
|
|
1072
|
+
let params;
|
|
1073
|
+
try {
|
|
1074
|
+
params = sender.getParameters();
|
|
1075
|
+
}
|
|
1076
|
+
catch {
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
const current = params.encodings ?? [];
|
|
1080
|
+
if (!current.length)
|
|
1081
|
+
continue;
|
|
1082
|
+
let planned;
|
|
1083
|
+
if (t.kind === "audio" && this.#micTrack && (t === this.#micTrack || (!!t.label && t.label === this.#micTrack.label))) {
|
|
1084
|
+
planned = this.#micEncodings();
|
|
1085
|
+
}
|
|
1086
|
+
else if (t.kind === "video" && this.#isCameraTrack(t)) {
|
|
1087
|
+
const rids = current.map((e) => e.rid).filter((r) => r === "f" || r === "h" || r === "q");
|
|
1088
|
+
const simulcast = rids.length > 1;
|
|
1089
|
+
const height = t.getSettings?.().height ?? 720;
|
|
1090
|
+
const plan = cameraEncodings(simulcast, s, this.#cameraSending, height, simulcast ? rids : undefined);
|
|
1091
|
+
planned = plan.encodings;
|
|
1092
|
+
if (simulcast)
|
|
1093
|
+
this.#announceLayers(plan.layers);
|
|
1094
|
+
}
|
|
1095
|
+
else
|
|
1096
|
+
continue;
|
|
1097
|
+
const merged = mergeEncodings(current, planned);
|
|
1098
|
+
if (merged)
|
|
1099
|
+
sender.setParameters({ ...params, encodings: merged }).catch(() => { });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
/** partytracks may hand the sender its own copy of the camera track: match by device label too. */
|
|
1103
|
+
#isCameraTrack(t) {
|
|
1104
|
+
const cam = this.#localPreview.video;
|
|
1105
|
+
if (!cam)
|
|
1106
|
+
return false;
|
|
1107
|
+
if (t === cam)
|
|
1108
|
+
return true;
|
|
1109
|
+
const screen = this.#localPreview.screenVideo;
|
|
1110
|
+
if (screen && (t === screen || t.label === screen.label))
|
|
1111
|
+
return false;
|
|
1112
|
+
return !!t.label && t.label === cam.label;
|
|
1113
|
+
}
|
|
1114
|
+
#announceLayers(layers) {
|
|
1115
|
+
if (layers.join() === this.#cameraLayers.join())
|
|
1116
|
+
return;
|
|
1117
|
+
this.#cameraLayers = layers;
|
|
1118
|
+
const v = this.#localTracks.video;
|
|
1119
|
+
if (v) {
|
|
1120
|
+
v.layers = [...layers];
|
|
1121
|
+
this.#send({ type: "tracks", tracks: { video: v } });
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/** Camera encodings and announced layers from adaptive + fallback + data saver. */
|
|
1125
|
+
#updateCameraEncodings() {
|
|
1126
|
+
const s = this.#saver.settings;
|
|
1127
|
+
const sending = this.#adaptive.sendVideo && !this.#fallback.active && !s.audioOnly && s.maxSendHeight !== 0;
|
|
1128
|
+
const enc$ = this.#cameraEncodings$;
|
|
1129
|
+
const changed = sending !== this.#cameraSending;
|
|
1130
|
+
this.#cameraSending = sending;
|
|
1131
|
+
this.#syncSenders();
|
|
1132
|
+
if (changed) {
|
|
1133
|
+
if (enc$) {
|
|
1134
|
+
this.#localState = { ...this.#localState, videoPaused: !sending };
|
|
1135
|
+
this.#send({ type: "state", state: { videoPaused: !sending } });
|
|
1136
|
+
this.emit("video-paused", null, !sending);
|
|
1137
|
+
this.emit("local-state", this.localState);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Data saver with activeSpeakerVideo: work out whose camera video we take.
|
|
1143
|
+
* Re-applies qualities when the set changes (unless `apply` is false).
|
|
1144
|
+
*/
|
|
1145
|
+
#refreshVideoAllow(apply = true) {
|
|
1146
|
+
const n = this.#saver.settings.activeSpeakerVideo;
|
|
1147
|
+
const present = [...this.#remotes.keys()];
|
|
1148
|
+
const spot = new Set(this.roomState.spotlight ?? []);
|
|
1149
|
+
const always = [...this.#remotes.values()].filter((e) => spot.has(e.participant.userId)).map((e) => e.participant.id);
|
|
1150
|
+
this.#recentSpeakers = this.#recentSpeakers.filter((id) => this.#remotes.has(id));
|
|
1151
|
+
const next = videoAllowList(n, this.#recentSpeakers, present, always);
|
|
1152
|
+
const same = next === this.#videoAllow || (!!next && !!this.#videoAllow && next.size === this.#videoAllow.size && [...next].every((id) => this.#videoAllow.has(id)));
|
|
1153
|
+
this.#videoAllow = next;
|
|
1154
|
+
if (!same && apply)
|
|
1155
|
+
this.#applyAllQualities();
|
|
1156
|
+
return !same;
|
|
1157
|
+
}
|
|
1158
|
+
/** Highest video quality the adaptive logic currently allows you to receive. */
|
|
1159
|
+
get receiveCap() {
|
|
1160
|
+
return this.#adaptive.receiveCap;
|
|
1161
|
+
}
|
|
1162
|
+
/** Participant ids currently speaking, loudest first (includes room.self.id). */
|
|
1163
|
+
get activeSpeakers() {
|
|
1164
|
+
return this.#speakers.active(Date.now());
|
|
1165
|
+
}
|
|
1166
|
+
/** Connection rating for a participant (use room.self.id for yourself). */
|
|
1167
|
+
getConnectionQuality(participantId) {
|
|
1168
|
+
if (participantId === this.self?.id)
|
|
1169
|
+
return this.#myQuality ?? undefined;
|
|
1170
|
+
return this.#qualities.get(participantId);
|
|
1171
|
+
}
|
|
1172
|
+
/** Latest connection measurement (updated every statsIntervalMs). */
|
|
1173
|
+
get linkStats() {
|
|
1174
|
+
return this.#lastSample;
|
|
1175
|
+
}
|
|
1176
|
+
/** Leave the room, stop camera/mic and clean everything up. */
|
|
1177
|
+
leave(reason = "left") {
|
|
1178
|
+
if (this.#left)
|
|
1179
|
+
return;
|
|
1180
|
+
this.#left = true;
|
|
1181
|
+
if (this.#reconnectTimer)
|
|
1182
|
+
clearTimeout(this.#reconnectTimer);
|
|
1183
|
+
if (this.#pingTimer)
|
|
1184
|
+
clearInterval(this.#pingTimer);
|
|
1185
|
+
if (this.#tokenTimer)
|
|
1186
|
+
clearTimeout(this.#tokenTimer);
|
|
1187
|
+
this.#watchNetwork(false);
|
|
1188
|
+
if (this.#statsTimer)
|
|
1189
|
+
clearInterval(this.#statsTimer);
|
|
1190
|
+
if (this.#speakerTimer)
|
|
1191
|
+
clearInterval(this.#speakerTimer);
|
|
1192
|
+
if (this.#levelTimer)
|
|
1193
|
+
clearInterval(this.#levelTimer);
|
|
1194
|
+
void this.#audioCtx?.close().catch(() => { });
|
|
1195
|
+
try {
|
|
1196
|
+
this.#ws?.close(1000, "leaving");
|
|
1197
|
+
}
|
|
1198
|
+
catch {
|
|
1199
|
+
/* ignore */
|
|
1200
|
+
}
|
|
1201
|
+
this.#ws = null;
|
|
1202
|
+
for (const id of [...this.#remotes.keys()])
|
|
1203
|
+
this.#removeRemote(id);
|
|
1204
|
+
this.media.mic?.stopBroadcasting();
|
|
1205
|
+
this.media.camera?.stopBroadcasting();
|
|
1206
|
+
this.media.screen?.stopBroadcasting();
|
|
1207
|
+
this.media.mic?.disableSource();
|
|
1208
|
+
this.media.camera?.disableSource();
|
|
1209
|
+
this.media.screen?.disableSource();
|
|
1210
|
+
this.#e2ee?.close();
|
|
1211
|
+
if (this.#stateTimer)
|
|
1212
|
+
clearTimeout(this.#stateTimer);
|
|
1213
|
+
for (const sub of Object.values(this.#deviceSubs))
|
|
1214
|
+
sub?.unsubscribe();
|
|
1215
|
+
this.#subs.unsubscribe();
|
|
1216
|
+
void this.#capture?.stop();
|
|
1217
|
+
this.#setConnection("disconnected");
|
|
1218
|
+
this.emit("left", reason);
|
|
1219
|
+
this.removeAllListeners();
|
|
1220
|
+
}
|
|
1221
|
+
// ---------------- startup ----------------
|
|
1222
|
+
/** @internal */
|
|
1223
|
+
async _start() {
|
|
1224
|
+
this.#watchNetwork(true);
|
|
1225
|
+
this.#scheduleTokenWarning(decodeClaims(this.#token).exp);
|
|
1226
|
+
if (this.#e2ee)
|
|
1227
|
+
await this.#e2ee.setKey(this.#opts.e2ee.key, this.#opts.e2ee.keyIndex ?? 0, true);
|
|
1228
|
+
await new Promise((resolve, reject) => {
|
|
1229
|
+
const startTimer = () => setTimeout(() => {
|
|
1230
|
+
reject(new Error("timed out joining room"));
|
|
1231
|
+
this.leave("join-timeout");
|
|
1232
|
+
}, this.#opts.joinTimeoutMs);
|
|
1233
|
+
let timer = startTimer();
|
|
1234
|
+
this.#onWaiting = () => {
|
|
1235
|
+
if (timer)
|
|
1236
|
+
clearTimeout(timer);
|
|
1237
|
+
timer = null;
|
|
1238
|
+
this.#opts.onWaiting?.();
|
|
1239
|
+
this.emit("waiting");
|
|
1240
|
+
};
|
|
1241
|
+
this.#connect({
|
|
1242
|
+
onWelcome: () => {
|
|
1243
|
+
if (timer)
|
|
1244
|
+
clearTimeout(timer);
|
|
1245
|
+
resolve();
|
|
1246
|
+
},
|
|
1247
|
+
onFatal: (reason) => {
|
|
1248
|
+
if (timer)
|
|
1249
|
+
clearTimeout(timer);
|
|
1250
|
+
reject(new Error(reason));
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
// partytracks fetches ICE servers without custom headers, so we fetch them
|
|
1255
|
+
// ourselves (authenticated) and pass them in. TURN credentials last 24h.
|
|
1256
|
+
const iceServers = this.#opts.iceServers ?? (await this.#fetchIceServers());
|
|
1257
|
+
if (this.#left)
|
|
1258
|
+
return;
|
|
1259
|
+
this.media.partyTracks = new PartyTracks({
|
|
1260
|
+
prefix: `${this.#opts.url}/partytracks`,
|
|
1261
|
+
headers: this.#headers,
|
|
1262
|
+
iceServers,
|
|
1263
|
+
});
|
|
1264
|
+
// Encrypt/decrypt transforms go on each transceiver as partytracks creates it,
|
|
1265
|
+
// before negotiation, so no frame ever leaves unencrypted.
|
|
1266
|
+
const e2ee = this.#e2ee;
|
|
1267
|
+
if (e2ee) {
|
|
1268
|
+
this.#subs.add(this.media.partyTracks.transceiver$.subscribe((t) => {
|
|
1269
|
+
try {
|
|
1270
|
+
e2ee.attach(t);
|
|
1271
|
+
}
|
|
1272
|
+
catch (err) {
|
|
1273
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
1274
|
+
}
|
|
1275
|
+
}));
|
|
1276
|
+
}
|
|
1277
|
+
this.#mediaReady = true;
|
|
1278
|
+
this.#startMedia();
|
|
1279
|
+
// Pull tracks of people who were already publishing when we joined.
|
|
1280
|
+
for (const entry of this.#remotes.values())
|
|
1281
|
+
this.#syncPulls(entry);
|
|
1282
|
+
}
|
|
1283
|
+
async #fetchIceServers() {
|
|
1284
|
+
try {
|
|
1285
|
+
const res = await fetch(`${this.#opts.url}/partytracks/generate-ice-servers`, { headers: this.#headers });
|
|
1286
|
+
if (!res.ok)
|
|
1287
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1288
|
+
const body = (await res.json());
|
|
1289
|
+
if (Array.isArray(body.iceServers) && body.iceServers.length)
|
|
1290
|
+
return body.iceServers;
|
|
1291
|
+
throw new Error("no ICE servers in response");
|
|
1292
|
+
}
|
|
1293
|
+
catch (err) {
|
|
1294
|
+
this.emit("error", new Error(`Could not load ICE servers, using public STUN only (${String(err)})`));
|
|
1295
|
+
return [{ urls: ["stun:stun.cloudflare.com:3478", "stun:stun.cloudflare.com:53"] }];
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
#startMedia() {
|
|
1299
|
+
const pt = this.media.partyTracks;
|
|
1300
|
+
this.#subs.add(pt.peerConnectionState$.subscribe((s) => {
|
|
1301
|
+
this.#mediaState = s;
|
|
1302
|
+
if (s === "connected")
|
|
1303
|
+
this.#mediaEverConnected = true;
|
|
1304
|
+
this.#updateConnection();
|
|
1305
|
+
this.emit("media-connection", s);
|
|
1306
|
+
if (s === "disconnected" || s === "failed")
|
|
1307
|
+
this.#setMyQuality("lost");
|
|
1308
|
+
}));
|
|
1309
|
+
this.#subs.add(pt.sessionError$.subscribe((e) => this.emit("error", new Error(e))));
|
|
1310
|
+
this.#subs.add(pt.peerConnection$.subscribe((pc) => (this.#pc = pc)));
|
|
1311
|
+
this.#startStats();
|
|
1312
|
+
this.#speakerTimer = setInterval(() => this.#emitSpeakersIfChanged(), 300);
|
|
1313
|
+
for (const group of ["mic", "camera", "screen"]) {
|
|
1314
|
+
if (this.permissions[group])
|
|
1315
|
+
this.#enableDevice(group);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
/** Create a device and start pushing it. Only for groups we're allowed to publish. */
|
|
1319
|
+
#enableDevice(group) {
|
|
1320
|
+
if (this.#deviceSubs[group] || !this.#mediaReady || this.#left)
|
|
1321
|
+
return;
|
|
1322
|
+
// Voice calls never open the camera (and never ask for permission).
|
|
1323
|
+
if (group === "camera" && this.#opts.mode === "audio")
|
|
1324
|
+
return;
|
|
1325
|
+
const pt = this.media.partyTracks;
|
|
1326
|
+
const simulcast = this.#opts.simulcast ?? true;
|
|
1327
|
+
const subs = new Subscription();
|
|
1328
|
+
this.#deviceSubs[group] = subs;
|
|
1329
|
+
// Publish a track; announce its metadata to the room.
|
|
1330
|
+
const announce = (kind, meta$, active$) => {
|
|
1331
|
+
const source$ = active$
|
|
1332
|
+
? combineLatest([meta$, active$]).pipe(map(([m, on]) => (on ? m : null)))
|
|
1333
|
+
: meta$;
|
|
1334
|
+
subs.add(source$.subscribe((meta) => {
|
|
1335
|
+
const clean = meta?.trackName && meta.sessionId
|
|
1336
|
+
? { location: "remote", trackName: meta.trackName, sessionId: meta.sessionId }
|
|
1337
|
+
: null;
|
|
1338
|
+
if (clean && kind === "video" && simulcast)
|
|
1339
|
+
clean.layers = [...this.#cameraLayers];
|
|
1340
|
+
this.#localTracks[kind] = clean;
|
|
1341
|
+
this.#send({ type: "tracks", tracks: { [kind]: clean } });
|
|
1342
|
+
}));
|
|
1343
|
+
};
|
|
1344
|
+
const watch = (on$) => subs.add(on$.subscribe((on) => {
|
|
1345
|
+
this.#broadcasting[group] = on;
|
|
1346
|
+
this.#scheduleState();
|
|
1347
|
+
if (group === "mic")
|
|
1348
|
+
this.#syncCaptions();
|
|
1349
|
+
}));
|
|
1350
|
+
if (group === "screen") {
|
|
1351
|
+
const screen = getScreenshare({ audio: true, video: true });
|
|
1352
|
+
this.media.screen = screen;
|
|
1353
|
+
subs.add(screen.video.error$.subscribe((e) => this.emit("error", e)));
|
|
1354
|
+
subs.add(combineLatest([screen.video.broadcastTrack$, screen.video.isBroadcasting$]).subscribe(([t, on]) => this.#emitLocal("screenVideo", on ? t : null)));
|
|
1355
|
+
announce("screenVideo", pt.push(screen.video.broadcastTrack$), screen.video.isBroadcasting$);
|
|
1356
|
+
announce("screenAudio", pt.push(screen.audio.broadcastTrack$), screen.audio.isBroadcasting$);
|
|
1357
|
+
watch(screen.isBroadcasting$);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
const label = group === "mic" ? "microphone" : "camera";
|
|
1361
|
+
const d = group === "mic"
|
|
1362
|
+
? getMic({
|
|
1363
|
+
broadcasting: this.#wanted.microphone,
|
|
1364
|
+
transformations: this.#audioTransform ? [this.#audioTransform] : [],
|
|
1365
|
+
})
|
|
1366
|
+
: getCamera({
|
|
1367
|
+
broadcasting: this.#wanted.camera,
|
|
1368
|
+
transformations: this.#videoTransform ? [this.#videoTransform] : [],
|
|
1369
|
+
...(simulcast ? { constraints: CAMERA_CONSTRAINTS } : {}),
|
|
1370
|
+
});
|
|
1371
|
+
if (group === "mic")
|
|
1372
|
+
this.media.mic = d;
|
|
1373
|
+
else
|
|
1374
|
+
this.media.camera = d;
|
|
1375
|
+
// A camera/mic can fail to start for a moment (busy, still waking up, OS
|
|
1376
|
+
// permission dialog). partytracks then gives up and turns it off, so retry
|
|
1377
|
+
// a few times before telling the app.
|
|
1378
|
+
//
|
|
1379
|
+
// The retry rebuilds the device from scratch: partytracks' device stream
|
|
1380
|
+
// can stay in a failed state (seen on Windows: the track is stopped ~15s
|
|
1381
|
+
// after starting and the device list never recovers), so restarting the
|
|
1382
|
+
// same device object isn't enough.
|
|
1383
|
+
const attempts = this.#deviceAttempts[group] ?? 0;
|
|
1384
|
+
let healthyTimer = null;
|
|
1385
|
+
subs.add(() => healthyTimer && clearTimeout(healthyTimer));
|
|
1386
|
+
subs.add(d.isBroadcasting$.subscribe((on) => {
|
|
1387
|
+
if (healthyTimer)
|
|
1388
|
+
clearTimeout(healthyTimer);
|
|
1389
|
+
if (on)
|
|
1390
|
+
healthyTimer = setTimeout(() => (this.#deviceAttempts[group] = 0), DEVICE_HEALTHY_MS);
|
|
1391
|
+
}));
|
|
1392
|
+
subs.add(d.error$.subscribe((e) => {
|
|
1393
|
+
if (this.#left || this.#deviceSubs[group] !== subs)
|
|
1394
|
+
return;
|
|
1395
|
+
if (!this.#wanted[label])
|
|
1396
|
+
return;
|
|
1397
|
+
if (attempts < DEVICE_RETRIES) {
|
|
1398
|
+
this.#deviceAttempts[group] = attempts + 1;
|
|
1399
|
+
setTimeout(() => {
|
|
1400
|
+
if (this.#left || this.#deviceSubs[group] !== subs || !this.#wanted[label])
|
|
1401
|
+
return;
|
|
1402
|
+
if (!this.permissions[group])
|
|
1403
|
+
return;
|
|
1404
|
+
this.#disableDevice(group);
|
|
1405
|
+
this.#enableDevice(group);
|
|
1406
|
+
}, DEVICE_RETRY_MS * 2 ** attempts);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
this.#deviceAttempts[group] = 0;
|
|
1410
|
+
const err = new Error(`Could not start the ${label} (${e.name}${e.message ? ": " + e.message : ""})`);
|
|
1411
|
+
err.name = e.name;
|
|
1412
|
+
this.emit("error", err);
|
|
1413
|
+
}));
|
|
1414
|
+
if (group === "camera")
|
|
1415
|
+
subs.add(d.broadcastTrack$.subscribe((t) => this.#emitLocal("video", t)));
|
|
1416
|
+
if (group === "mic") {
|
|
1417
|
+
announce("audio", pt.push(d.broadcastTrack$, { sendEncodings$: this.#micEncodings$ }));
|
|
1418
|
+
this.#startLevelMeter(d.broadcastTrack$, subs);
|
|
1419
|
+
subs.add(d.broadcastTrack$.subscribe((t) => {
|
|
1420
|
+
this.#micTrack = t;
|
|
1421
|
+
this.#syncCaptions();
|
|
1422
|
+
}));
|
|
1423
|
+
subs.add(() => {
|
|
1424
|
+
this.#micTrack = null;
|
|
1425
|
+
this.#syncCaptions();
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
// Camera: simulcast layers; the encodings stream also pauses/resumes sending.
|
|
1430
|
+
const s = this.#saver.settings;
|
|
1431
|
+
const on = this.#adaptive.sendVideo && !this.#fallback.active && !s.audioOnly && s.maxSendHeight !== 0;
|
|
1432
|
+
const { encodings, layers } = cameraEncodings(simulcast, s, on);
|
|
1433
|
+
this.#cameraSending = on;
|
|
1434
|
+
this.#cameraLayers = layers;
|
|
1435
|
+
// Without limits, a non-simulcast camera starts with the browser's defaults.
|
|
1436
|
+
const initial = !simulcast && on && encodings.length === 1 && Object.keys(encodings[0]).length === 1 ? [] : encodings;
|
|
1437
|
+
this.#cameraEncodings$ = new BehaviorSubject(initial);
|
|
1438
|
+
announce("video", pt.push(d.broadcastTrack$, { sendEncodings$: this.#cameraEncodings$ }));
|
|
1439
|
+
subs.add(() => (this.#cameraEncodings$ = null));
|
|
1440
|
+
}
|
|
1441
|
+
watch(d.isBroadcasting$);
|
|
1442
|
+
}
|
|
1443
|
+
/** Stop a device we're no longer allowed to publish, and unpublish its tracks. */
|
|
1444
|
+
#disableDevice(group) {
|
|
1445
|
+
const subs = this.#deviceSubs[group];
|
|
1446
|
+
if (!subs)
|
|
1447
|
+
return;
|
|
1448
|
+
delete this.#deviceSubs[group];
|
|
1449
|
+
const d = group === "mic" ? this.media.mic : group === "camera" ? this.media.camera : this.media.screen;
|
|
1450
|
+
d?.stopBroadcasting();
|
|
1451
|
+
subs.unsubscribe(); // closes the pushed tracks
|
|
1452
|
+
d?.disableSource();
|
|
1453
|
+
if (group === "mic")
|
|
1454
|
+
this.media.mic = null;
|
|
1455
|
+
else if (group === "camera")
|
|
1456
|
+
this.media.camera = null;
|
|
1457
|
+
else
|
|
1458
|
+
this.media.screen = null;
|
|
1459
|
+
const cleared = {};
|
|
1460
|
+
for (const kind of GROUP_KINDS[group]) {
|
|
1461
|
+
delete this.#localTracks[kind];
|
|
1462
|
+
cleared[kind] = null;
|
|
1463
|
+
}
|
|
1464
|
+
this.#send({ type: "tracks", tracks: cleared });
|
|
1465
|
+
if (group === "camera")
|
|
1466
|
+
this.#emitLocal("video", null);
|
|
1467
|
+
if (group === "screen")
|
|
1468
|
+
this.#emitLocal("screenVideo", null);
|
|
1469
|
+
this.#broadcasting[group] = false;
|
|
1470
|
+
this.#scheduleState();
|
|
1471
|
+
}
|
|
1472
|
+
/** Mic/camera/screen on/off, sent once things settle (devices flip together). */
|
|
1473
|
+
#scheduleState() {
|
|
1474
|
+
if (this.#stateTimer)
|
|
1475
|
+
clearTimeout(this.#stateTimer);
|
|
1476
|
+
this.#stateTimer = setTimeout(() => {
|
|
1477
|
+
this.#stateTimer = null;
|
|
1478
|
+
if (this.#left)
|
|
1479
|
+
return;
|
|
1480
|
+
const { mic: micOn, camera: cameraOn, screen: screenOn } = this.#broadcasting;
|
|
1481
|
+
const e2ee = this.#e2ee ? true : undefined;
|
|
1482
|
+
this.#localState = { ...this.#localState, micOn, cameraOn, screenOn, custom: this.#customState, ...(e2ee ? { e2ee } : {}) };
|
|
1483
|
+
this.#send({ type: "state", state: { micOn, cameraOn, screenOn, ...(e2ee ? { e2ee } : {}) } });
|
|
1484
|
+
this.emit("local-state", this.localState);
|
|
1485
|
+
}, 50);
|
|
1486
|
+
}
|
|
1487
|
+
#applyRole(next) {
|
|
1488
|
+
if (!next || next === this.role)
|
|
1489
|
+
return;
|
|
1490
|
+
const previous = this.role;
|
|
1491
|
+
this.role = next;
|
|
1492
|
+
this.emit("role", next, previous);
|
|
1493
|
+
}
|
|
1494
|
+
#applyPermissions(next) {
|
|
1495
|
+
const previous = this.permissions;
|
|
1496
|
+
this.permissions = { ...next };
|
|
1497
|
+
if (!this.#mediaReady)
|
|
1498
|
+
return; // _start() enables devices from this.permissions
|
|
1499
|
+
for (const group of ["mic", "camera", "screen"]) {
|
|
1500
|
+
if (next[group] && !this.#deviceSubs[group])
|
|
1501
|
+
this.#enableDevice(group);
|
|
1502
|
+
if (!next[group] && this.#deviceSubs[group])
|
|
1503
|
+
this.#disableDevice(group);
|
|
1504
|
+
}
|
|
1505
|
+
if (JSON.stringify(previous) !== JSON.stringify(next))
|
|
1506
|
+
this.emit("permissions", this.permissions, previous);
|
|
1507
|
+
}
|
|
1508
|
+
// ---------------- adaptive quality ----------------
|
|
1509
|
+
#startStats() {
|
|
1510
|
+
const every = this.#opts.statsIntervalMs ?? 2000;
|
|
1511
|
+
this.#statsTimer = setInterval(() => void this.#sampleStats(), every);
|
|
1512
|
+
}
|
|
1513
|
+
async #sampleStats() {
|
|
1514
|
+
if (this.#left)
|
|
1515
|
+
return;
|
|
1516
|
+
let pc;
|
|
1517
|
+
try {
|
|
1518
|
+
pc = await firstValueFrom(this.media.partyTracks.peerConnection$);
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (pc.connectionState !== "connected")
|
|
1524
|
+
return;
|
|
1525
|
+
const report = await pc.getStats();
|
|
1526
|
+
if (this.#left)
|
|
1527
|
+
return;
|
|
1528
|
+
this.#syncSenders();
|
|
1529
|
+
const bytes = bytesFromStats(report);
|
|
1530
|
+
if (bytes) {
|
|
1531
|
+
this.#usage = nextUsage(this.#usage, bytes, Date.now());
|
|
1532
|
+
this.emit("data-usage", this.dataUsage);
|
|
1533
|
+
}
|
|
1534
|
+
const { sample, cursor } = sampleFromStats(report, this.#statsCursor);
|
|
1535
|
+
const first = this.#statsCursor === null;
|
|
1536
|
+
this.#statsCursor = cursor;
|
|
1537
|
+
if (first)
|
|
1538
|
+
return; // need two readings for a loss rate
|
|
1539
|
+
this.#lastSample = sample;
|
|
1540
|
+
this.#setMyQuality(connectionQualityFromSample(sample));
|
|
1541
|
+
if (this.#opts.adaptive !== false) {
|
|
1542
|
+
const prev = this.#adaptive;
|
|
1543
|
+
this.#adaptive = nextAdaptiveState(prev, sample);
|
|
1544
|
+
if (this.#adaptive.receiveCap !== prev.receiveCap) {
|
|
1545
|
+
for (const entry of this.#remotes.values())
|
|
1546
|
+
this.#applyQuality(entry);
|
|
1547
|
+
}
|
|
1548
|
+
if (this.#adaptive.sendVideo !== prev.sendVideo)
|
|
1549
|
+
this.#updateCameraEncodings();
|
|
1550
|
+
}
|
|
1551
|
+
if (this.#fallbackOn) {
|
|
1552
|
+
const was = this.#fallback.active;
|
|
1553
|
+
this.#fallback = nextFallbackState(this.#fallback, sample, this.#adaptive, Date.now());
|
|
1554
|
+
if (this.#fallback.active !== was)
|
|
1555
|
+
this.#afterFallbackChange();
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
// ---------------- active speaker ----------------
|
|
1559
|
+
/** Measure our own mic level (Web Audio) and share it while speaking. */
|
|
1560
|
+
#startLevelMeter(track$, subs) {
|
|
1561
|
+
if (typeof AudioContext === "undefined")
|
|
1562
|
+
return;
|
|
1563
|
+
if (this.#levelSource) {
|
|
1564
|
+
// Mic re-enabled (permission given back): reuse the meter, follow the new track.
|
|
1565
|
+
subs.add(track$.subscribe((t) => this.#levelSource(t)));
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
const ctx = new AudioContext();
|
|
1569
|
+
this.#audioCtx = ctx;
|
|
1570
|
+
if (ctx.state === "suspended" && typeof document !== "undefined") {
|
|
1571
|
+
// Some browsers only start audio after a user gesture.
|
|
1572
|
+
const resume = () => void ctx.resume().catch(() => { });
|
|
1573
|
+
document.addEventListener("pointerdown", resume, { once: true });
|
|
1574
|
+
document.addEventListener("keydown", resume, { once: true });
|
|
1575
|
+
void ctx.resume().catch(() => { });
|
|
1576
|
+
}
|
|
1577
|
+
const analyser = ctx.createAnalyser();
|
|
1578
|
+
analyser.fftSize = 1024;
|
|
1579
|
+
const buf = new Float32Array(analyser.fftSize);
|
|
1580
|
+
let source = null;
|
|
1581
|
+
this.#levelSource = (t) => {
|
|
1582
|
+
if (ctx.state === "closed")
|
|
1583
|
+
return;
|
|
1584
|
+
source?.disconnect();
|
|
1585
|
+
source = ctx.createMediaStreamSource(new MediaStream([t]));
|
|
1586
|
+
source.connect(analyser);
|
|
1587
|
+
};
|
|
1588
|
+
subs.add(track$.subscribe((t) => this.#levelSource(t)));
|
|
1589
|
+
this.#levelTimer = setInterval(() => {
|
|
1590
|
+
if (this.#left || !this.self)
|
|
1591
|
+
return;
|
|
1592
|
+
analyser.getFloatTimeDomainData(buf);
|
|
1593
|
+
const level = this.#localState.micOn ? rmsLevel(buf) : 0;
|
|
1594
|
+
const now = Date.now();
|
|
1595
|
+
const flipped = this.#vad.update(level, now);
|
|
1596
|
+
if (this.#vad.speaking && (flipped || now - this.#lastLevelSentAt >= SPEAKER.sendEveryMs)) {
|
|
1597
|
+
const shared = Math.max(level, 0.01);
|
|
1598
|
+
this.#send({ type: "speaking", level: shared });
|
|
1599
|
+
this.#lastLevelSentAt = now;
|
|
1600
|
+
this.#speakers.set(this.self.id, shared, now);
|
|
1601
|
+
}
|
|
1602
|
+
else if (flipped && !this.#vad.speaking) {
|
|
1603
|
+
this.#send({ type: "speaking", level: 0 });
|
|
1604
|
+
this.#speakers.set(this.self.id, 0, now);
|
|
1605
|
+
}
|
|
1606
|
+
this.#emitSpeakersIfChanged();
|
|
1607
|
+
}, 100);
|
|
1608
|
+
}
|
|
1609
|
+
#emitSpeakersIfChanged() {
|
|
1610
|
+
const changed = this.#speakers.changed(Date.now());
|
|
1611
|
+
if (!changed)
|
|
1612
|
+
return;
|
|
1613
|
+
this.emit("active-speakers", changed);
|
|
1614
|
+
if (this.#saver.settings.activeSpeakerVideo > 0) {
|
|
1615
|
+
const talking = changed.filter((id) => this.#remotes.has(id));
|
|
1616
|
+
if (talking.length) {
|
|
1617
|
+
this.#recentSpeakers = [...talking, ...this.#recentSpeakers.filter((id) => !talking.includes(id))].slice(0, 16);
|
|
1618
|
+
this.#refreshVideoAllow();
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
#setMyQuality(q) {
|
|
1623
|
+
if (q === this.#myQuality)
|
|
1624
|
+
return;
|
|
1625
|
+
this.#myQuality = q;
|
|
1626
|
+
this.#localState = { ...this.#localState, connectionQuality: q };
|
|
1627
|
+
this.#send({ type: "state", state: { connectionQuality: q } });
|
|
1628
|
+
if (this.self)
|
|
1629
|
+
this.emit("connection-quality", this.self.id, q);
|
|
1630
|
+
}
|
|
1631
|
+
#effectiveQuality(entry) {
|
|
1632
|
+
if (this.#chosenAudioOnly() || this.#fallback.active)
|
|
1633
|
+
return "off";
|
|
1634
|
+
const cap = this.#opts.adaptive === false ? "high" : this.#adaptive.receiveCap;
|
|
1635
|
+
const q = minQuality(minQuality(entry.requested, cap), this.#saver.settings.maxReceiveQuality);
|
|
1636
|
+
if (this.#videoAllow && !this.#videoAllow.has(entry.participant.id))
|
|
1637
|
+
return "off";
|
|
1638
|
+
return q;
|
|
1639
|
+
}
|
|
1640
|
+
#layerFor(entry, q) {
|
|
1641
|
+
return pickLayer(QUALITY_TO_LAYER[q], entry.meta.video.value?.layers);
|
|
1642
|
+
}
|
|
1643
|
+
#applyQuality(entry) {
|
|
1644
|
+
const next = this.#effectiveQuality(entry);
|
|
1645
|
+
const prev = entry.effective;
|
|
1646
|
+
if (next === prev)
|
|
1647
|
+
return;
|
|
1648
|
+
entry.effective = next;
|
|
1649
|
+
if (next !== "off")
|
|
1650
|
+
entry.layer$.next(this.#layerFor(entry, next));
|
|
1651
|
+
this.#syncPulls(entry);
|
|
1652
|
+
if ((prev === "off") !== (next === "off") && entry.meta.video.value) {
|
|
1653
|
+
this.emit("video-paused", entry.participant, next === "off");
|
|
1654
|
+
}
|
|
1655
|
+
this.emit("video-quality", entry.participant, next);
|
|
1656
|
+
}
|
|
1657
|
+
// ---------------- room WebSocket ----------------
|
|
1658
|
+
#connect(hooks) {
|
|
1659
|
+
const wsUrl = this.#opts.url.replace(/^http/, "ws") +
|
|
1660
|
+
`/rooms/${encodeURIComponent(this.roomId)}/ws?token=${encodeURIComponent(this.#token)}`;
|
|
1661
|
+
const ws = new WebSocket(wsUrl);
|
|
1662
|
+
this.#ws = ws;
|
|
1663
|
+
let welcomed = false;
|
|
1664
|
+
ws.onmessage = (ev) => {
|
|
1665
|
+
this.#lastServerMsgAt = Date.now();
|
|
1666
|
+
let msg;
|
|
1667
|
+
try {
|
|
1668
|
+
msg = JSON.parse(String(ev.data));
|
|
1669
|
+
}
|
|
1670
|
+
catch {
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
try {
|
|
1674
|
+
this.#onMessage(ws, msg, hooks, () => (welcomed = true));
|
|
1675
|
+
}
|
|
1676
|
+
catch (err) {
|
|
1677
|
+
// Never let a handler bug silently stall the join.
|
|
1678
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
ws.onclose = (ev) => this.#handleDrop(ws, ev.code, welcomed, hooks);
|
|
1682
|
+
}
|
|
1683
|
+
/** The room WebSocket closed (or we declared it dead). Decide: give up, or retry. */
|
|
1684
|
+
#handleDrop(ws, code, welcomed, hooks) {
|
|
1685
|
+
if (this.#pingTimer)
|
|
1686
|
+
clearInterval(this.#pingTimer);
|
|
1687
|
+
if (this.#left || ws !== this.#ws)
|
|
1688
|
+
return;
|
|
1689
|
+
this.#ws = null;
|
|
1690
|
+
const fatal = closeReason(code);
|
|
1691
|
+
if (fatal) {
|
|
1692
|
+
hooks?.onFatal(fatal);
|
|
1693
|
+
this.leave(fatal);
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
if (!welcomed && hooks && this.#attempt === 0 && this.#signalLostAt === null) {
|
|
1697
|
+
hooks.onFatal("could not connect to room");
|
|
1698
|
+
this.leave("connect-failed");
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
this.#signalUp = false;
|
|
1702
|
+
this.#signalLostAt ??= Date.now();
|
|
1703
|
+
this.#updateConnection();
|
|
1704
|
+
const timeout = this.#opts.reconnectTimeoutMs ?? 120_000;
|
|
1705
|
+
if (!this.#opts.reconnect || Date.now() - this.#signalLostAt > timeout) {
|
|
1706
|
+
this.leave("connection-lost");
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
this.#scheduleReconnect();
|
|
1710
|
+
}
|
|
1711
|
+
#scheduleReconnect(delay) {
|
|
1712
|
+
if (this.#reconnectTimer)
|
|
1713
|
+
clearTimeout(this.#reconnectTimer);
|
|
1714
|
+
// While the browser says it's offline, wait for the "online" event instead.
|
|
1715
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false && delay === undefined) {
|
|
1716
|
+
this.#reconnectTimer = setTimeout(() => this.#scheduleReconnect(), 5000);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const wait = delay ?? Math.min(500 * 2 ** this.#attempt, 8000) * (0.8 + Math.random() * 0.4);
|
|
1720
|
+
this.#attempt++;
|
|
1721
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
1722
|
+
this.#reconnectTimer = null;
|
|
1723
|
+
if (this.#left)
|
|
1724
|
+
return;
|
|
1725
|
+
const timeout = this.#opts.reconnectTimeoutMs ?? 120_000;
|
|
1726
|
+
if (this.#signalLostAt !== null && Date.now() - this.#signalLostAt > timeout) {
|
|
1727
|
+
this.leave("connection-lost");
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
this.#connect();
|
|
1731
|
+
}, wait);
|
|
1732
|
+
}
|
|
1733
|
+
/** Network came back / changed: don't wait for timers, check and reconnect now. */
|
|
1734
|
+
#networkChanged(kind) {
|
|
1735
|
+
if (this.#left)
|
|
1736
|
+
return;
|
|
1737
|
+
if (kind === "offline") {
|
|
1738
|
+
this.#updateConnection(true);
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
if (!this.#signalUp) {
|
|
1742
|
+
this.#attempt = 0;
|
|
1743
|
+
this.#scheduleReconnect(0);
|
|
1744
|
+
}
|
|
1745
|
+
else {
|
|
1746
|
+
// The socket may be silently dead after switching networks: probe it.
|
|
1747
|
+
this.#send({ type: "ping" });
|
|
1748
|
+
this.#lastServerMsgAt = Math.min(this.#lastServerMsgAt, Date.now() - PING_EVERY_MS);
|
|
1749
|
+
}
|
|
1750
|
+
this.#updateConnection();
|
|
1751
|
+
}
|
|
1752
|
+
#watchNetwork(on) {
|
|
1753
|
+
if (typeof window === "undefined")
|
|
1754
|
+
return;
|
|
1755
|
+
const conn = navigator.connection;
|
|
1756
|
+
const fn = on ? "addEventListener" : "removeEventListener";
|
|
1757
|
+
window[fn]("online", this.#onOnline);
|
|
1758
|
+
window[fn]("offline", this.#onOffline);
|
|
1759
|
+
conn?.[fn]("change", this.#onConnChange);
|
|
1760
|
+
}
|
|
1761
|
+
/** Combine room + media state into one ConnectionState. */
|
|
1762
|
+
#updateConnection(offline = false) {
|
|
1763
|
+
if (this.#left)
|
|
1764
|
+
return;
|
|
1765
|
+
const mediaDown = this.#mediaEverConnected && (this.#mediaState === "disconnected" || this.#mediaState === "failed");
|
|
1766
|
+
if (this.#signalUp && !mediaDown && !offline) {
|
|
1767
|
+
this.#setConnection("connected");
|
|
1768
|
+
}
|
|
1769
|
+
else if (this.#signalLostAt !== null || mediaDown || offline) {
|
|
1770
|
+
this.#setConnection("reconnecting");
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
#onMessage(ws, msg, hooks, markWelcomed) {
|
|
1774
|
+
if (msg.type === "welcome") {
|
|
1775
|
+
markWelcomed();
|
|
1776
|
+
this.#attempt = 0;
|
|
1777
|
+
this.#signalUp = true;
|
|
1778
|
+
this.#signalLostAt = null;
|
|
1779
|
+
this.self = msg.self;
|
|
1780
|
+
this.#applyRole(msg.self.role);
|
|
1781
|
+
if (msg.self.permissions)
|
|
1782
|
+
this.#applyPermissions(msg.self.permissions);
|
|
1783
|
+
if (msg.room)
|
|
1784
|
+
this.#setRoomState(msg.room);
|
|
1785
|
+
this.#setRecording(msg.recording ?? null);
|
|
1786
|
+
try {
|
|
1787
|
+
this.#reconcile(msg.participants);
|
|
1788
|
+
}
|
|
1789
|
+
catch (err) {
|
|
1790
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
1791
|
+
}
|
|
1792
|
+
this.#checkHands();
|
|
1793
|
+
// Re-announce our state after a reconnect. State first: it says whether we
|
|
1794
|
+
// encrypt, which rooms that require encryption check before taking tracks.
|
|
1795
|
+
this.#send({ type: "state", state: { ...this.#localState, custom: this.#customState } });
|
|
1796
|
+
if (Object.keys(this.#localTracks).length)
|
|
1797
|
+
this.#send({ type: "tracks", tracks: this.#localTracks });
|
|
1798
|
+
if (this.#captionLanguage)
|
|
1799
|
+
this.#send({ type: "caption-language", language: this.#captionLanguage });
|
|
1800
|
+
this.#updateConnection();
|
|
1801
|
+
if (this.connectionState === "connecting")
|
|
1802
|
+
this.#setConnection("connected");
|
|
1803
|
+
this.#startPing(ws);
|
|
1804
|
+
hooks?.onWelcome();
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (msg.type === "bye") {
|
|
1808
|
+
const fatal = closeReason(msg.code) ?? "closed-by-server";
|
|
1809
|
+
this.#ws = null; // ignore the close event that follows
|
|
1810
|
+
try {
|
|
1811
|
+
ws.close();
|
|
1812
|
+
}
|
|
1813
|
+
catch {
|
|
1814
|
+
/* ignore */
|
|
1815
|
+
}
|
|
1816
|
+
hooks?.onFatal(fatal);
|
|
1817
|
+
this.leave(fatal);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
this.#handle(msg);
|
|
1821
|
+
}
|
|
1822
|
+
/** Keep-alive + dead-socket detection (a socket can stay "open" after a network switch). */
|
|
1823
|
+
#startPing(ws) {
|
|
1824
|
+
if (this.#pingTimer)
|
|
1825
|
+
clearInterval(this.#pingTimer);
|
|
1826
|
+
this.#lastServerMsgAt = Date.now();
|
|
1827
|
+
this.#pingTimer = setInterval(() => {
|
|
1828
|
+
if (ws !== this.#ws)
|
|
1829
|
+
return;
|
|
1830
|
+
if (Date.now() - this.#lastServerMsgAt > PING_EVERY_MS * 2.5) {
|
|
1831
|
+
try {
|
|
1832
|
+
ws.close();
|
|
1833
|
+
}
|
|
1834
|
+
catch {
|
|
1835
|
+
/* ignore */
|
|
1836
|
+
}
|
|
1837
|
+
this.#handleDrop(ws, 1006, true);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
this.#send({ type: "ping" });
|
|
1841
|
+
}, PING_EVERY_MS);
|
|
1842
|
+
}
|
|
1843
|
+
#handle(msg) {
|
|
1844
|
+
switch (msg.type) {
|
|
1845
|
+
case "participant-joined":
|
|
1846
|
+
this.#upsertRemote(msg.participant, true);
|
|
1847
|
+
this.#checkHands();
|
|
1848
|
+
break;
|
|
1849
|
+
case "participant-updated":
|
|
1850
|
+
this.#upsertRemote(msg.participant, false);
|
|
1851
|
+
this.#checkHands();
|
|
1852
|
+
break;
|
|
1853
|
+
case "participant-left":
|
|
1854
|
+
this.#removeRemote(msg.id);
|
|
1855
|
+
this.#checkHands();
|
|
1856
|
+
break;
|
|
1857
|
+
case "chat":
|
|
1858
|
+
this.#addChat(msg.message);
|
|
1859
|
+
break;
|
|
1860
|
+
case "chat-history":
|
|
1861
|
+
for (const m of msg.messages)
|
|
1862
|
+
this.#addChat(m);
|
|
1863
|
+
break;
|
|
1864
|
+
case "chat-deleted":
|
|
1865
|
+
if (this.chat.some((m) => m.id === msg.id)) {
|
|
1866
|
+
this.chat = this.chat.filter((m) => m.id !== msg.id);
|
|
1867
|
+
this.emit("chat-deleted", msg.id);
|
|
1868
|
+
}
|
|
1869
|
+
break;
|
|
1870
|
+
case "waiting":
|
|
1871
|
+
this.#onWaiting?.();
|
|
1872
|
+
break;
|
|
1873
|
+
case "group-changed": {
|
|
1874
|
+
if (this.self)
|
|
1875
|
+
this.self = { ...this.self, group: msg.group };
|
|
1876
|
+
this.#reconcile(msg.participants);
|
|
1877
|
+
this.#checkHands();
|
|
1878
|
+
this.emit("group-changed", msg.group);
|
|
1879
|
+
break;
|
|
1880
|
+
}
|
|
1881
|
+
case "attendance": {
|
|
1882
|
+
const waiters = this.#attendanceWaiters.splice(0);
|
|
1883
|
+
for (const w of waiters)
|
|
1884
|
+
w(msg.report);
|
|
1885
|
+
break;
|
|
1886
|
+
}
|
|
1887
|
+
case "waiting-list":
|
|
1888
|
+
this.waiting = msg.waiting;
|
|
1889
|
+
this.emit("waiting-list", msg.waiting);
|
|
1890
|
+
break;
|
|
1891
|
+
case "room-updated":
|
|
1892
|
+
this.#setRoomState(msg.room);
|
|
1893
|
+
break;
|
|
1894
|
+
case "muted": {
|
|
1895
|
+
for (const d of msg.devices) {
|
|
1896
|
+
if (d === "mic")
|
|
1897
|
+
this.setMic(false);
|
|
1898
|
+
else if (d === "camera")
|
|
1899
|
+
this.setCamera(false);
|
|
1900
|
+
else if (d === "screen")
|
|
1901
|
+
this.media.screen?.stopBroadcasting();
|
|
1902
|
+
}
|
|
1903
|
+
this.emit("muted", msg.devices, msg.by ? this.#remotes.get(msg.by)?.participant : undefined);
|
|
1904
|
+
break;
|
|
1905
|
+
}
|
|
1906
|
+
case "self-updated": {
|
|
1907
|
+
this.self = msg.self;
|
|
1908
|
+
this.emit("self", msg.self);
|
|
1909
|
+
this.#checkHands();
|
|
1910
|
+
this.#applyRole(msg.self.role);
|
|
1911
|
+
const prev = this.permissions;
|
|
1912
|
+
this.#applyPermissions(msg.self.permissions);
|
|
1913
|
+
// _start() not done yet: still tell the app.
|
|
1914
|
+
if (!this.#mediaReady && JSON.stringify(prev) !== JSON.stringify(msg.self.permissions)) {
|
|
1915
|
+
this.emit("permissions", this.permissions, prev);
|
|
1916
|
+
}
|
|
1917
|
+
break;
|
|
1918
|
+
}
|
|
1919
|
+
case "broadcast":
|
|
1920
|
+
this.#onBroadcast(msg.from, msg.data, { topic: msg.topic ?? null, private: msg.private === true });
|
|
1921
|
+
break;
|
|
1922
|
+
case "speaking":
|
|
1923
|
+
this.#speakers.set(msg.from, msg.level, Date.now());
|
|
1924
|
+
this.#emitSpeakersIfChanged();
|
|
1925
|
+
break;
|
|
1926
|
+
case "recording":
|
|
1927
|
+
this.#setRecording(msg.recording);
|
|
1928
|
+
break;
|
|
1929
|
+
case "transcript":
|
|
1930
|
+
this.emit("transcript", msg.segment);
|
|
1931
|
+
break;
|
|
1932
|
+
case "lesson":
|
|
1933
|
+
this.#lessons.set(msg.lesson.lessonId, msg.lesson);
|
|
1934
|
+
this.emit("lesson", msg.lesson);
|
|
1935
|
+
break;
|
|
1936
|
+
case "lesson-ended":
|
|
1937
|
+
if (this.#lessons.delete(msg.lessonId))
|
|
1938
|
+
this.emit("lesson-ended", msg.lessonId);
|
|
1939
|
+
break;
|
|
1940
|
+
case "lesson-alert":
|
|
1941
|
+
this.emit("lesson-alert", msg.lessonId, msg.alert);
|
|
1942
|
+
break;
|
|
1943
|
+
case "ai-flag":
|
|
1944
|
+
this.emit("ai-flag", msg.agentId, msg.flag);
|
|
1945
|
+
break;
|
|
1946
|
+
case "ai-minutes":
|
|
1947
|
+
this.emit("ai-minutes", msg.agentId, msg.minutes);
|
|
1948
|
+
break;
|
|
1949
|
+
case "reaction":
|
|
1950
|
+
this.emit("reaction", msg.emoji, msg.from === this.self?.id ? null : (this.#remotes.get(msg.from)?.participant ?? null), msg.at);
|
|
1951
|
+
break;
|
|
1952
|
+
case "file-shared":
|
|
1953
|
+
this.#addFile(msg.file);
|
|
1954
|
+
break;
|
|
1955
|
+
case "file-removed":
|
|
1956
|
+
this.#removeFile(msg.id);
|
|
1957
|
+
break;
|
|
1958
|
+
case "poll":
|
|
1959
|
+
this.#polls.set(msg.poll.id, msg.poll);
|
|
1960
|
+
this.emit("poll", msg.poll);
|
|
1961
|
+
break;
|
|
1962
|
+
case "poll-removed":
|
|
1963
|
+
this.#myVotes.delete(msg.id);
|
|
1964
|
+
if (this.#polls.delete(msg.id))
|
|
1965
|
+
this.emit("poll-removed", msg.id);
|
|
1966
|
+
break;
|
|
1967
|
+
case "poll-voted": {
|
|
1968
|
+
this.#myVotes.set(msg.pollId, msg.choices);
|
|
1969
|
+
const poll = this.#polls.get(msg.pollId);
|
|
1970
|
+
if (poll)
|
|
1971
|
+
this.emit("poll", poll);
|
|
1972
|
+
break;
|
|
1973
|
+
}
|
|
1974
|
+
case "question":
|
|
1975
|
+
this.#questions.set(msg.question.id, msg.question);
|
|
1976
|
+
this.emit("question", msg.question);
|
|
1977
|
+
break;
|
|
1978
|
+
case "question-removed":
|
|
1979
|
+
this.#myUpvotes.delete(msg.id);
|
|
1980
|
+
if (this.#questions.delete(msg.id))
|
|
1981
|
+
this.emit("question-removed", msg.id);
|
|
1982
|
+
break;
|
|
1983
|
+
case "question-upvoted": {
|
|
1984
|
+
if (msg.up)
|
|
1985
|
+
this.#myUpvotes.add(msg.id);
|
|
1986
|
+
else
|
|
1987
|
+
this.#myUpvotes.delete(msg.id);
|
|
1988
|
+
const q = this.#questions.get(msg.id);
|
|
1989
|
+
if (q)
|
|
1990
|
+
this.emit("question", q);
|
|
1991
|
+
break;
|
|
1992
|
+
}
|
|
1993
|
+
case "captions-state":
|
|
1994
|
+
this.#captionsState = msg.captions;
|
|
1995
|
+
this.#syncCaptions();
|
|
1996
|
+
this.emit("captions", this.captions);
|
|
1997
|
+
break;
|
|
1998
|
+
case "caption":
|
|
1999
|
+
this.#addCaptionLine(msg.line, msg.translation);
|
|
2000
|
+
break;
|
|
2001
|
+
case "summary":
|
|
2002
|
+
this.#summary = msg.summary;
|
|
2003
|
+
this.emit("summary", msg.summary);
|
|
2004
|
+
break;
|
|
2005
|
+
case "engage-state": {
|
|
2006
|
+
const st = msg.state;
|
|
2007
|
+
if (st.captions) {
|
|
2008
|
+
this.#captionsState = st.captions;
|
|
2009
|
+
this.#syncCaptions();
|
|
2010
|
+
this.emit("captions", this.captions);
|
|
2011
|
+
}
|
|
2012
|
+
for (const l of st.recentCaptions ?? [])
|
|
2013
|
+
this.#addCaptionLine(l);
|
|
2014
|
+
if (st.summary) {
|
|
2015
|
+
this.#summary = st.summary;
|
|
2016
|
+
this.emit("summary", st.summary);
|
|
2017
|
+
}
|
|
2018
|
+
const ids = new Set(st.files.map((f) => f.id));
|
|
2019
|
+
for (const id of [...this.#files.keys()])
|
|
2020
|
+
if (!ids.has(id))
|
|
2021
|
+
this.#removeFile(id);
|
|
2022
|
+
for (const f of st.files)
|
|
2023
|
+
this.#addFile(f);
|
|
2024
|
+
this.#myVotes = new Map(Object.entries(st.myVotes ?? {}));
|
|
2025
|
+
this.#myUpvotes = new Set(st.myUpvotes ?? []);
|
|
2026
|
+
const pollIds = new Set((st.polls ?? []).map((p) => p.id));
|
|
2027
|
+
for (const id of [...this.#polls.keys()])
|
|
2028
|
+
if (!pollIds.has(id) && this.#polls.delete(id))
|
|
2029
|
+
this.emit("poll-removed", id);
|
|
2030
|
+
for (const p of st.polls ?? []) {
|
|
2031
|
+
this.#polls.set(p.id, p);
|
|
2032
|
+
this.emit("poll", p);
|
|
2033
|
+
}
|
|
2034
|
+
const qIds = new Set((st.questions ?? []).map((q) => q.id));
|
|
2035
|
+
for (const id of [...this.#questions.keys()])
|
|
2036
|
+
if (!qIds.has(id) && this.#questions.delete(id))
|
|
2037
|
+
this.emit("question-removed", id);
|
|
2038
|
+
for (const q of st.questions ?? []) {
|
|
2039
|
+
this.#questions.set(q.id, q);
|
|
2040
|
+
this.emit("question", q);
|
|
2041
|
+
}
|
|
2042
|
+
break;
|
|
2043
|
+
}
|
|
2044
|
+
case "error":
|
|
2045
|
+
this.emit("error", new Error(msg.message));
|
|
2046
|
+
break;
|
|
2047
|
+
case "pong":
|
|
2048
|
+
case "bye":
|
|
2049
|
+
break;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
#addChat(m) {
|
|
2053
|
+
if (this.chat.some((x) => x.id === m.id))
|
|
2054
|
+
return; // history after a reconnect
|
|
2055
|
+
this.chat.push(m);
|
|
2056
|
+
this.chat.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
2057
|
+
if (this.chat.length > 200)
|
|
2058
|
+
this.chat.splice(0, this.chat.length - 200);
|
|
2059
|
+
this.emit("chat", m);
|
|
2060
|
+
}
|
|
2061
|
+
#handsKey = "";
|
|
2062
|
+
/** Emit `hands` when the queue changed. */
|
|
2063
|
+
#checkHands() {
|
|
2064
|
+
const list = this.raisedHands;
|
|
2065
|
+
const key = list.map((p) => `${p.id}@${p.handRaisedAt}`).join(",");
|
|
2066
|
+
if (key === this.#handsKey)
|
|
2067
|
+
return;
|
|
2068
|
+
this.#handsKey = key;
|
|
2069
|
+
this.emit("hands", list);
|
|
2070
|
+
}
|
|
2071
|
+
#onBroadcast(from, data, info) {
|
|
2072
|
+
const e2ee = this.#e2ee;
|
|
2073
|
+
if (!e2ee) {
|
|
2074
|
+
if (isEncryptedMessage(data))
|
|
2075
|
+
this.emit("encryption-error", "missing-key", "received an encrypted message; dropped");
|
|
2076
|
+
else
|
|
2077
|
+
this.emit("message", data, this.#remotes.get(from)?.participant, info);
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
if (!isEncryptedMessage(data)) {
|
|
2081
|
+
this.emit("encryption-error", "not-encrypted", "received a message that isn't end-to-end encrypted; dropped");
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
e2ee.decryptMessage(data).then((plain) => !this.#left && this.emit("message", plain, this.#remotes.get(from)?.participant, info), (err) => !this.#left && this.emit("encryption-error", err.e2ee ?? "decrypt-failed", err.message));
|
|
2085
|
+
}
|
|
2086
|
+
#setRoomState(state) {
|
|
2087
|
+
const changed = JSON.stringify(state) !== JSON.stringify(this.roomState);
|
|
2088
|
+
const spotChanged = JSON.stringify(state.spotlight ?? []) !== JSON.stringify(this.roomState.spotlight ?? []);
|
|
2089
|
+
this.roomState = { ...state };
|
|
2090
|
+
if (spotChanged)
|
|
2091
|
+
this.#refreshVideoAllow();
|
|
2092
|
+
if (changed)
|
|
2093
|
+
this.emit("room-state", this.roomState);
|
|
2094
|
+
}
|
|
2095
|
+
#setRecording(next) {
|
|
2096
|
+
if (this.recording?.id === next?.id)
|
|
2097
|
+
return;
|
|
2098
|
+
this.recording = next;
|
|
2099
|
+
this.emit("recording", next);
|
|
2100
|
+
}
|
|
2101
|
+
#send(msg) {
|
|
2102
|
+
if (this.#ws?.readyState === WebSocket.OPEN)
|
|
2103
|
+
this.#ws.send(JSON.stringify(msg));
|
|
2104
|
+
}
|
|
2105
|
+
#setConnection(state) {
|
|
2106
|
+
if (this.connectionState === state)
|
|
2107
|
+
return;
|
|
2108
|
+
this.connectionState = state;
|
|
2109
|
+
this.emit("connection", state);
|
|
2110
|
+
}
|
|
2111
|
+
// ---------------- remote participants ----------------
|
|
2112
|
+
#reconcile(list) {
|
|
2113
|
+
const ids = new Set(list.map((p) => p.id));
|
|
2114
|
+
for (const id of [...this.#remotes.keys()])
|
|
2115
|
+
if (!ids.has(id))
|
|
2116
|
+
this.#removeRemote(id);
|
|
2117
|
+
for (const p of list)
|
|
2118
|
+
this.#upsertRemote(p, !this.#remotes.has(p.id));
|
|
2119
|
+
}
|
|
2120
|
+
#upsertRemote(p, isNew) {
|
|
2121
|
+
let entry = this.#remotes.get(p.id);
|
|
2122
|
+
if (!entry) {
|
|
2123
|
+
entry = {
|
|
2124
|
+
participant: p,
|
|
2125
|
+
meta: Object.fromEntries(TRACK_KINDS.map((k) => [k, new BehaviorSubject(null)])),
|
|
2126
|
+
pulls: {},
|
|
2127
|
+
tracks: {},
|
|
2128
|
+
audioEls: {},
|
|
2129
|
+
requested: "high",
|
|
2130
|
+
effective: "high",
|
|
2131
|
+
layer$: new BehaviorSubject("f"),
|
|
2132
|
+
subscribed: {},
|
|
2133
|
+
};
|
|
2134
|
+
this.#remotes.set(p.id, entry);
|
|
2135
|
+
const allowChanged = this.#refreshVideoAllow(false);
|
|
2136
|
+
entry.effective = this.#effectiveQuality(entry);
|
|
2137
|
+
if (allowChanged)
|
|
2138
|
+
this.#applyAllQualities();
|
|
2139
|
+
}
|
|
2140
|
+
const prevAgentState = isNew ? undefined : entry.participant.agent?.state;
|
|
2141
|
+
entry.participant = p;
|
|
2142
|
+
if (isNew)
|
|
2143
|
+
this.emit("participant-joined", p);
|
|
2144
|
+
else
|
|
2145
|
+
this.emit("participant-updated", p);
|
|
2146
|
+
const q = p.state.connectionQuality;
|
|
2147
|
+
if (q && q !== this.#qualities.get(p.id)) {
|
|
2148
|
+
this.#qualities.set(p.id, q);
|
|
2149
|
+
this.emit("connection-quality", p.id, q);
|
|
2150
|
+
}
|
|
2151
|
+
if (p.agent && p.agent.state !== prevAgentState)
|
|
2152
|
+
this.emit("agent-state", p, p.agent.state);
|
|
2153
|
+
for (const kind of TRACK_KINDS)
|
|
2154
|
+
entry.meta[kind].next(p.tracks[kind] ?? null);
|
|
2155
|
+
// Publishers on a data saver announce fewer layers: ask for one they send.
|
|
2156
|
+
if (entry.effective !== "off") {
|
|
2157
|
+
const layer = this.#layerFor(entry, entry.effective);
|
|
2158
|
+
if (layer !== entry.layer$.value)
|
|
2159
|
+
entry.layer$.next(layer);
|
|
2160
|
+
}
|
|
2161
|
+
this.#syncPulls(entry);
|
|
2162
|
+
}
|
|
2163
|
+
#syncPulls(entry) {
|
|
2164
|
+
if (!this.#mediaReady)
|
|
2165
|
+
return;
|
|
2166
|
+
for (const kind of TRACK_KINDS) {
|
|
2167
|
+
let meta = entry.meta[kind].value;
|
|
2168
|
+
if (kind === "video" && entry.effective === "off")
|
|
2169
|
+
meta = null; // audio-only for this participant
|
|
2170
|
+
if (kind === "screenVideo" && this.#fallback.active && !this.#keepScreenInFallback)
|
|
2171
|
+
meta = null;
|
|
2172
|
+
const want = !!meta && this.#wants(entry, kind);
|
|
2173
|
+
if (want && !entry.pulls[kind])
|
|
2174
|
+
this.#pull(entry, kind);
|
|
2175
|
+
if (!want && entry.pulls[kind])
|
|
2176
|
+
this.#stopPull(entry, kind);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
#pull(entry, kind) {
|
|
2180
|
+
const meta$ = entry.meta[kind].pipe(filter((m) => m !== null), distinctUntilChanged((a, b) => a.sessionId === b.sessionId && a.trackName === b.trackName),
|
|
2181
|
+
// Only what the SFU API understands (our own fields like `layers` stay out).
|
|
2182
|
+
map((m) => ({ location: "remote", trackName: m.trackName, sessionId: m.sessionId })));
|
|
2183
|
+
// Simulcast publishers: ask for the layer we need; changes go to tracks/update.
|
|
2184
|
+
const simulcast = kind === "video" && entry.meta.video.value?.layers?.length ? { preferredRid$: entry.layer$ } : undefined;
|
|
2185
|
+
entry.pulls[kind] = this.media.partyTracks.pull(meta$, simulcast ? { simulcast } : {}).subscribe({
|
|
2186
|
+
next: (track) => {
|
|
2187
|
+
entry.tracks[kind] = track;
|
|
2188
|
+
if (this.#opts.autoPlayAudio && (kind === "audio" || kind === "screenAudio")) {
|
|
2189
|
+
this.#playAudio(entry, kind, track);
|
|
2190
|
+
}
|
|
2191
|
+
this.emit("track", entry.participant, kind, track);
|
|
2192
|
+
},
|
|
2193
|
+
error: (err) => this.emit("error", err instanceof Error ? err : new Error(String(err))),
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
#stopPull(entry, kind) {
|
|
2197
|
+
entry.pulls[kind]?.unsubscribe();
|
|
2198
|
+
delete entry.pulls[kind];
|
|
2199
|
+
delete entry.tracks[kind];
|
|
2200
|
+
const el = entry.audioEls[kind];
|
|
2201
|
+
if (el) {
|
|
2202
|
+
el.srcObject = null;
|
|
2203
|
+
el.remove();
|
|
2204
|
+
delete entry.audioEls[kind];
|
|
2205
|
+
}
|
|
2206
|
+
this.emit("track-removed", entry.participant, kind);
|
|
2207
|
+
}
|
|
2208
|
+
#removeRemote(id) {
|
|
2209
|
+
const entry = this.#remotes.get(id);
|
|
2210
|
+
if (!entry)
|
|
2211
|
+
return;
|
|
2212
|
+
for (const kind of TRACK_KINDS)
|
|
2213
|
+
if (entry.pulls[kind])
|
|
2214
|
+
this.#stopPull(entry, kind);
|
|
2215
|
+
for (const s of Object.values(entry.meta))
|
|
2216
|
+
s.complete();
|
|
2217
|
+
entry.layer$.complete();
|
|
2218
|
+
this.#remotes.delete(id);
|
|
2219
|
+
this.#qualities.delete(id);
|
|
2220
|
+
this.#speakers.remove(id);
|
|
2221
|
+
this.#refreshVideoAllow();
|
|
2222
|
+
this.emit("participant-left", entry.participant);
|
|
2223
|
+
}
|
|
2224
|
+
#playAudio(entry, kind, track) {
|
|
2225
|
+
if (typeof document === "undefined")
|
|
2226
|
+
return;
|
|
2227
|
+
let el = entry.audioEls[kind];
|
|
2228
|
+
if (!el) {
|
|
2229
|
+
el = document.createElement("audio");
|
|
2230
|
+
el.autoplay = true;
|
|
2231
|
+
el.style.display = "none";
|
|
2232
|
+
el.dataset.sfukit = `${entry.participant.id}:${kind}`;
|
|
2233
|
+
document.body.appendChild(el);
|
|
2234
|
+
entry.audioEls[kind] = el;
|
|
2235
|
+
this.#applySink(el);
|
|
2236
|
+
}
|
|
2237
|
+
el.srcObject = new MediaStream([track]);
|
|
2238
|
+
el.play().catch(() => {
|
|
2239
|
+
this.emit("error", new Error("Browser blocked audio autoplay. Call room.resumeAudio() from a click."));
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
/** Retry playing remote audio after a user gesture (if autoplay was blocked). */
|
|
2243
|
+
resumeAudio() {
|
|
2244
|
+
for (const r of this.#remotes.values())
|
|
2245
|
+
for (const el of Object.values(r.audioEls))
|
|
2246
|
+
void el?.play().catch(() => { });
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
// ---------------- helpers ----------------
|
|
2250
|
+
/** Attach a track to a <video> or <audio> element. */
|
|
2251
|
+
export function attachTrack(el, track) {
|
|
2252
|
+
el.srcObject = track ? new MediaStream([track]) : null;
|
|
2253
|
+
if (track && el instanceof HTMLVideoElement) {
|
|
2254
|
+
el.playsInline = true;
|
|
2255
|
+
el.autoplay = true;
|
|
2256
|
+
}
|
|
2257
|
+
if (track)
|
|
2258
|
+
void el.play().catch(() => { });
|
|
2259
|
+
}
|
|
2260
|
+
/** Read (not verify) the claims in a join token. */
|
|
2261
|
+
export function decodeClaims(token) {
|
|
2262
|
+
const part = token.split(".")[1];
|
|
2263
|
+
if (!part)
|
|
2264
|
+
throw new Error("invalid join token");
|
|
2265
|
+
const json = atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "="));
|
|
2266
|
+
const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(json, (c) => c.charCodeAt(0))));
|
|
2267
|
+
if (!claims.room || !claims.sub)
|
|
2268
|
+
throw new Error("invalid join token");
|
|
2269
|
+
return claims;
|
|
2270
|
+
}
|
|
2271
|
+
function closeReason(code) {
|
|
2272
|
+
switch (code) {
|
|
2273
|
+
case CloseCodes.Kicked:
|
|
2274
|
+
return "kicked";
|
|
2275
|
+
case CloseCodes.RoomFull:
|
|
2276
|
+
return "room-full";
|
|
2277
|
+
case CloseCodes.Unauthorized:
|
|
2278
|
+
return "unauthorized";
|
|
2279
|
+
case CloseCodes.RoomClosed:
|
|
2280
|
+
return "room-closed";
|
|
2281
|
+
case CloseCodes.RoomLocked:
|
|
2282
|
+
return "room-locked";
|
|
2283
|
+
case CloseCodes.Denied:
|
|
2284
|
+
return "denied";
|
|
2285
|
+
default:
|
|
2286
|
+
return null;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
//# sourceMappingURL=index.js.map
|