@appsocom/react-native 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.
@@ -0,0 +1,1236 @@
1
+ /**
2
+ * A Room for React Native (and anything else with standard WebRTC objects but
3
+ * no DOM). Same room protocol, events and method names as the web Room in
4
+ * @appsocom/client, so the @appsocom/react hooks work with both.
5
+ *
6
+ * Not available here (web only): screen sharing, background blur, noise
7
+ * suppression (use the OS voice processing), end-to-end encryption.
8
+ */
9
+ import { Emitter } from "@appsocom/client/emitter";
10
+ import { SPEAKER, SpeakerTracker, VoiceActivity } from "@appsocom/client/speaker";
11
+ import { CAMERA_SIMULCAST, bytesFromStats, cameraEncodings, connectionQualityFromSample, initialAdaptiveState, initialFallbackState, initialUsageMeter, minQuality, nextFallbackState, nextUsage, sampleFromStats, micBitrateCap, videoAllowList, } from "@appsocom/client/adaptive";
12
+ import { CloseCodes, canPublishAny, claimsPermissions, claimsRole, pickLayer, resolveDataSaver, } from "@appsocom/protocol";
13
+ import { SfuSession } from "./session.js";
14
+ export const TOKEN_HEADER = "X-SfuKit-Token";
15
+ const QUALITY_TO_LAYER = { high: "f", medium: "h", low: "q" };
16
+ const PULL_KINDS = ["audio", "video", "screenVideo", "screenAudio"];
17
+ const PING_MS = 25_000;
18
+ const STATS_MS = 2000;
19
+ const LEVEL_MS = 150;
20
+ /** Join a room from React Native. Resolves once you're in. */
21
+ export async function joinNativeRoom(options) {
22
+ const room = new NativeRoom(options);
23
+ await room._start();
24
+ return room;
25
+ }
26
+ export class NativeRoom extends Emitter {
27
+ roomId;
28
+ userId;
29
+ role;
30
+ permissions;
31
+ self = null;
32
+ connectionState = "connecting";
33
+ roomState = { locked: false, chat: true, spotlight: [], waitingRoom: false, breakouts: [] };
34
+ waiting = [];
35
+ chat = [];
36
+ recording = null;
37
+ #opts;
38
+ #webrtc;
39
+ #token;
40
+ #ws = null;
41
+ #left = false;
42
+ #attempt = 0;
43
+ #signalLostAt = null;
44
+ #reconnectTimer = null;
45
+ #pingTimer = null;
46
+ #statsTimer = null;
47
+ #levelTimer = null;
48
+ #iceServers = [];
49
+ #session = null;
50
+ #sessionGen = 0;
51
+ #remotes = new Map();
52
+ #local = { mic: null, camera: null };
53
+ #senders = {};
54
+ #announced = {};
55
+ #wanted;
56
+ #localState = { micOn: false, cameraOn: false, screenOn: false };
57
+ #customState;
58
+ #facing;
59
+ #vad = new VoiceActivity();
60
+ #speakers = new SpeakerTracker();
61
+ #lastLevelSentAt = 0;
62
+ #qualities = new Map();
63
+ #myQuality = null;
64
+ #statsCursor = null;
65
+ #handsKey = "";
66
+ #audioOnly = false;
67
+ #cameraBeforeAudioOnly = true;
68
+ #saver = resolveDataSaver("off");
69
+ #fallback = initialFallbackState();
70
+ #fallbackOn = true;
71
+ #recentSpeakers = [];
72
+ #videoAllow = null;
73
+ #usage = initialUsageMeter(Date.now());
74
+ #cameraLayers = ["f", "h", "q"];
75
+ /** @internal use joinNativeRoom() */
76
+ constructor(options) {
77
+ super();
78
+ const webrtc = options.webrtc ?? defaultWebRTC();
79
+ if (!webrtc)
80
+ throw new Error("No WebRTC implementation: pass options.webrtc (react-native-webrtc)");
81
+ this.#webrtc = webrtc;
82
+ this.#opts = { ...options, url: options.url.replace(/\/+$/, "") };
83
+ this.#token = options.token;
84
+ const claims = decodeClaims(options.token);
85
+ if (claims.e2ee) {
86
+ throw new Error("This room requires end-to-end encryption, which the React Native client doesn't support yet");
87
+ }
88
+ this.roomId = claims.room;
89
+ this.userId = claims.sub;
90
+ this.role = claimsRole(claims);
91
+ this.permissions = claimsPermissions(claims);
92
+ this.#wanted = { mic: options.audio ?? true, camera: options.video ?? true };
93
+ this.#facing = options.facingMode ?? "user";
94
+ this.#saver = resolveDataSaver(options.dataSaver ?? claims.ds ?? "off");
95
+ this.#cameraBeforeAudioOnly = this.#wanted.camera;
96
+ if (this.#saver.settings.audioOnly)
97
+ this.#wanted.camera = false;
98
+ this.#fallbackOn = options.audioFallback !== false;
99
+ this.#localState = { ...this.#localState, ...this.#lowBandwidthState() };
100
+ }
101
+ // ---------------- state ----------------
102
+ get participants() {
103
+ return [...this.#remotes.values()].map((r) => r.participant);
104
+ }
105
+ get localState() {
106
+ return { ...this.#localState };
107
+ }
108
+ get canPublish() {
109
+ return canPublishAny(this.permissions);
110
+ }
111
+ get metadata() {
112
+ return this.roomState.metadata;
113
+ }
114
+ get raisedHands() {
115
+ const all = this.self ? [this.self, ...this.participants] : this.participants;
116
+ return all.filter((p) => p.handRaisedAt != null).sort((a, b) => a.handRaisedAt - b.handRaisedAt);
117
+ }
118
+ get agents() {
119
+ return this.participants.filter((p) => p.kind === "agent");
120
+ }
121
+ get activeSpeakers() {
122
+ return this.#speakers.active(Date.now());
123
+ }
124
+ /** Audio only by choice: setAudioOnly(true) or the "audio" data saver. See also audioFallback. */
125
+ get audioOnly() {
126
+ return this.#chosenAudioOnly();
127
+ }
128
+ /** True while your connection has fallen back to audio only. */
129
+ get audioFallback() {
130
+ return this.#fallback.active;
131
+ }
132
+ get dataSaver() {
133
+ return { mode: this.#saver.mode, settings: { ...this.#saver.settings } };
134
+ }
135
+ /** Media data used in this call so far, current rates and MB per hour. */
136
+ get dataUsage() {
137
+ return { ...this.#usage.usage };
138
+ }
139
+ getConnectionQuality(participantId) {
140
+ if (participantId === this.self?.id)
141
+ return this.#myQuality ?? undefined;
142
+ return this.#qualities.get(participantId);
143
+ }
144
+ getTrack(participantId, kind) {
145
+ return this.#remotes.get(participantId)?.tracks[kind];
146
+ }
147
+ /** Your camera preview (null while off). Screen sharing isn't available on React Native. */
148
+ getLocalTrack(kind) {
149
+ return kind === "video" && this.#localState.cameraOn ? this.#local.camera : null;
150
+ }
151
+ // ---------------- your devices ----------------
152
+ toggleMic() {
153
+ this.setMic(!this.#localState.micOn);
154
+ }
155
+ setMic(on) {
156
+ this.#wanted.mic = on;
157
+ void this.#applyMic();
158
+ }
159
+ toggleCamera() {
160
+ this.setCamera(!this.#localState.cameraOn);
161
+ }
162
+ setCamera(on) {
163
+ this.#wanted.camera = on;
164
+ void this.#applyCamera();
165
+ }
166
+ /** Flip between the front and back camera. */
167
+ switchCamera() {
168
+ this.#facing = this.#facing === "user" ? "environment" : "user";
169
+ const t = this.#local.camera;
170
+ if (t?._switchCamera) {
171
+ t._switchCamera(); // react-native-webrtc: switches in place
172
+ return;
173
+ }
174
+ if (this.#local.camera) {
175
+ // Elsewhere: reopen the camera with the other facing mode.
176
+ const old = this.#local.camera;
177
+ this.#local.camera = null;
178
+ old.stop();
179
+ void this.#applyCamera();
180
+ }
181
+ }
182
+ /** Pick a specific mic/camera by device. */
183
+ setMicDevice(device) {
184
+ this.#micDeviceId = device.deviceId;
185
+ if (this.#local.mic) {
186
+ this.#local.mic.stop();
187
+ this.#local.mic = null;
188
+ void this.#applyMic();
189
+ }
190
+ }
191
+ setCameraDevice(device) {
192
+ this.#cameraDeviceId = device.deviceId;
193
+ if (this.#local.camera) {
194
+ this.#local.camera.stop();
195
+ this.#local.camera = null;
196
+ void this.#applyCamera();
197
+ }
198
+ }
199
+ #micDeviceId;
200
+ #cameraDeviceId;
201
+ setCustomState(custom) {
202
+ this.#customState = custom;
203
+ this.#send({ type: "state", state: { custom } });
204
+ }
205
+ // ---------------- moderation & classroom (same as the web Room) ----------------
206
+ #mod() {
207
+ if (!this.permissions.moderate)
208
+ throw new Error("not allowed to moderate");
209
+ }
210
+ setPermissions(userId, permissions) {
211
+ this.#mod();
212
+ this.#send({ type: "set-permissions", userId, permissions });
213
+ }
214
+ mute(userId, devices = ["mic"], options = {}) {
215
+ this.#mod();
216
+ this.#send({ type: "mute", userId, devices, ...(options.lock ? { lock: true } : {}) });
217
+ }
218
+ muteAll(devices = ["mic"], options = {}) {
219
+ this.mute("*", devices, options);
220
+ }
221
+ stopScreenShare(userId) {
222
+ this.mute(userId, ["screen"]);
223
+ }
224
+ remove(userId, options = {}) {
225
+ this.#mod();
226
+ this.#send({ type: "remove", userId, ...(options.ban ? { ban: true } : {}) });
227
+ }
228
+ raiseHand() {
229
+ this.#send({ type: "hand", raised: true });
230
+ }
231
+ lowerHand(userId) {
232
+ if (!userId || userId === this.userId)
233
+ this.#send({ type: "hand", raised: false });
234
+ else
235
+ this.#send({ type: "lower-hand", userId });
236
+ }
237
+ lowerAllHands() {
238
+ this.lowerHand("*");
239
+ }
240
+ sendChat(text, options = {}) {
241
+ this.#send({ type: "chat", text, ...(options.to ? { to: options.to } : {}), ...(options.everywhere ? { everywhere: true } : {}) });
242
+ }
243
+ deleteChat(id) {
244
+ this.#send({ type: "chat-delete", id });
245
+ }
246
+ setChatEnabled(enabled) {
247
+ this.#mod();
248
+ this.#send({ type: "room", chat: enabled });
249
+ }
250
+ setSpotlight(userIds) {
251
+ this.#mod();
252
+ this.#send({ type: "room", spotlight: userIds });
253
+ }
254
+ setWaitingRoom(on) {
255
+ this.#mod();
256
+ this.#send({ type: "room", waitingRoom: on });
257
+ }
258
+ lockRoom(locked = true) {
259
+ this.#mod();
260
+ this.#send({ type: "room", locked });
261
+ }
262
+ admit(userId) {
263
+ this.#mod();
264
+ this.#send({ type: "admit", userId });
265
+ }
266
+ deny(userId) {
267
+ this.#mod();
268
+ this.#send({ type: "deny", userId });
269
+ }
270
+ startRecording(options = {}) {
271
+ this.#mod();
272
+ this.#send({ type: "start-recording", video: options.video === true });
273
+ }
274
+ stopRecording() {
275
+ this.#mod();
276
+ this.#send({ type: "stop-recording" });
277
+ }
278
+ sendToAgent(agentId, text) {
279
+ this.#send({ type: "agent-text", agentId, text });
280
+ }
281
+ /** App message to everyone else (room.on("message") on their side). */
282
+ send(data) {
283
+ this.#send({ type: "broadcast", data });
284
+ }
285
+ updateToken(token) {
286
+ const c = decodeClaims(token);
287
+ if (c.room !== this.roomId || c.sub !== this.userId)
288
+ throw new Error("new token is for a different room or user");
289
+ this.#token = token;
290
+ this.#send({ type: "token", token }); // the room keeps this connection past the old expiry
291
+ }
292
+ // ---------------- what you receive ----------------
293
+ setSubscribed(participantId, kinds, on) {
294
+ const r = this.#remotes.get(participantId);
295
+ if (!r)
296
+ return;
297
+ for (const k of Array.isArray(kinds) ? kinds : [kinds])
298
+ r.subscribed[k] = on;
299
+ this.#syncPulls(r);
300
+ }
301
+ isSubscribed(participantId, kind) {
302
+ const r = this.#remotes.get(participantId);
303
+ return !!r && this.#wants(r, kind);
304
+ }
305
+ /** "high" | "medium" | "low" | "off" video from one participant. */
306
+ setVideoQuality(participantId, quality) {
307
+ const r = this.#remotes.get(participantId);
308
+ if (!r || r.quality === quality)
309
+ return;
310
+ r.quality = quality;
311
+ this.#applyQuality(r);
312
+ }
313
+ /** Stop everyone's camera video (and your own camera); screen shares still come through. */
314
+ setAudioOnly(on) {
315
+ if (on === this.#audioOnly)
316
+ return;
317
+ const before = this.#chosenAudioOnly();
318
+ this.#audioOnly = on;
319
+ this.#afterAudioOnlyChange(before);
320
+ }
321
+ /**
322
+ * Data saver for costly or weak mobile data: "off", "low", "minimal",
323
+ * "audio", or custom settings like `{ base: "low", activeSpeakerVideo: 2 }`.
324
+ */
325
+ setDataSaver(input) {
326
+ const next = resolveDataSaver(input);
327
+ if (this.#left)
328
+ return;
329
+ const before = this.#chosenAudioOnly();
330
+ this.#saver = next;
331
+ this.#afterAudioOnlyChange(before);
332
+ this.emit("data-saver", next.mode, { ...next.settings });
333
+ }
334
+ /** Turn the audio-first fallback on or off; off while active brings video back now. */
335
+ setAudioFallback(enabled) {
336
+ this.#fallbackOn = enabled;
337
+ if (!enabled && this.#fallback.active) {
338
+ this.#fallback = { ...this.#fallback, active: false, good: 0, lastRecoveredAt: Date.now() };
339
+ this.#afterFallbackChange();
340
+ }
341
+ }
342
+ // ---------------- low bandwidth ----------------
343
+ #chosenAudioOnly() {
344
+ return this.#audioOnly || this.#saver.settings.audioOnly;
345
+ }
346
+ #afterAudioOnlyChange(before) {
347
+ const now = this.#chosenAudioOnly();
348
+ if (now && !before) {
349
+ this.#cameraBeforeAudioOnly = this.#wanted.camera;
350
+ this.setCamera(false);
351
+ }
352
+ else if (!now && before && this.#cameraBeforeAudioOnly) {
353
+ this.setCamera(true);
354
+ }
355
+ this.#refreshVideoAllow(false);
356
+ this.#applyAllQualities();
357
+ void this.#applySendParams();
358
+ this.#sendLowBandwidthState();
359
+ }
360
+ #afterFallbackChange() {
361
+ this.#applyAllQualities();
362
+ void this.#applySendParams();
363
+ this.#sendLowBandwidthState();
364
+ this.emit("audio-fallback", this.#fallback.active);
365
+ }
366
+ #lowBandwidthState() {
367
+ return {
368
+ audioOnly: this.#chosenAudioOnly() || this.#fallback.active,
369
+ audioFallback: this.#fallback.active,
370
+ dataSaver: this.#saver.mode === "custom" ? "low" : this.#saver.mode,
371
+ };
372
+ }
373
+ #sendLowBandwidthState() {
374
+ const next = this.#lowBandwidthState();
375
+ const cur = this.#localState;
376
+ if (cur.audioOnly === next.audioOnly && cur.audioFallback === next.audioFallback && cur.dataSaver === next.dataSaver)
377
+ return;
378
+ this.#localState = { ...cur, ...next };
379
+ this.#send({ type: "state", state: next });
380
+ this.emit("local-state", this.localState);
381
+ }
382
+ #effectiveQuality(r) {
383
+ if (this.#chosenAudioOnly() || this.#fallback.active)
384
+ return "off";
385
+ if (this.#videoAllow && !this.#videoAllow.has(r.participant.id))
386
+ return "off";
387
+ return minQuality(r.quality, this.#saver.settings.maxReceiveQuality);
388
+ }
389
+ #applyQuality(r) {
390
+ const next = this.#effectiveQuality(r);
391
+ const prev = r.effective;
392
+ if (next === prev)
393
+ return this.#syncPulls(r);
394
+ r.effective = next;
395
+ const pulled = r.pulled.video;
396
+ if (next !== "off" && prev !== "off" && pulled && r.participant.tracks.video?.layers?.length) {
397
+ void this.#session?.setLayer(pulled.mid, pulled.ref, pickLayer(QUALITY_TO_LAYER[next], r.participant.tracks.video.layers)).catch(() => { });
398
+ }
399
+ this.#syncPulls(r);
400
+ if ((prev === "off") !== (next === "off") && r.participant.tracks.video)
401
+ this.emit("video-paused", r.participant, next === "off");
402
+ this.emit("video-quality", r.participant, next);
403
+ }
404
+ #applyAllQualities() {
405
+ for (const r of this.#remotes.values())
406
+ this.#applyQuality(r);
407
+ }
408
+ #refreshVideoAllow(apply = true) {
409
+ const present = [...this.#remotes.keys()];
410
+ const spot = new Set(this.roomState.spotlight ?? []);
411
+ const always = [...this.#remotes.values()].filter((r) => spot.has(r.participant.userId)).map((r) => r.participant.id);
412
+ this.#recentSpeakers = this.#recentSpeakers.filter((id) => this.#remotes.has(id));
413
+ const next = videoAllowList(this.#saver.settings.activeSpeakerVideo, this.#recentSpeakers, present, always);
414
+ const prev = this.#videoAllow;
415
+ const same = next === prev || (!!next && !!prev && next.size === prev.size && [...next].every((id) => prev.has(id)));
416
+ this.#videoAllow = next;
417
+ if (!same && apply)
418
+ this.#applyAllQualities();
419
+ return !same;
420
+ }
421
+ /** Camera layers/bitrate/pause and mic bitrate, from the data saver and fallback. */
422
+ async #applySendParams() {
423
+ const s = this.#saver.settings;
424
+ const cam = this.#senders.camera;
425
+ if (cam) {
426
+ try {
427
+ const params = cam.sender.getParameters();
428
+ const encs = params.encodings ?? [];
429
+ const simulcast = encs.length > 1;
430
+ const sending = !this.#fallback.active && !s.audioOnly && s.maxSendHeight !== 0;
431
+ const height = this.#local.camera?.getSettings?.().height ?? 720;
432
+ const rids = encs.map((e) => e.rid).filter((r) => r === "f" || r === "h" || r === "q");
433
+ const { encodings, layers } = cameraEncodings(simulcast, s, sending, height, simulcast ? rids : undefined);
434
+ params.encodings = encs.map((e, i) => {
435
+ const want = (simulcast ? encodings.find((x) => x.rid === e.rid) : encodings[0]) ?? encodings[i] ?? {};
436
+ const out = { ...e, active: want.active ?? true };
437
+ for (const k of ["maxBitrate", "maxFramerate", "scaleResolutionDownBy"]) {
438
+ if (want[k] !== undefined)
439
+ out[k] = want[k];
440
+ else
441
+ delete out[k];
442
+ }
443
+ return out;
444
+ });
445
+ await cam.sender.setParameters(params);
446
+ if (simulcast && layers.join() !== this.#cameraLayers.join()) {
447
+ this.#cameraLayers = layers;
448
+ const v = this.#announced.video;
449
+ if (v) {
450
+ v.layers = [...layers];
451
+ this.#send({ type: "tracks", tracks: { video: v } });
452
+ }
453
+ }
454
+ const paused = !sending;
455
+ if (!!this.#localState.videoPaused !== paused) {
456
+ this.#localState = { ...this.#localState, videoPaused: paused };
457
+ this.#send({ type: "state", state: { videoPaused: paused } });
458
+ this.emit("video-paused", null, paused);
459
+ this.emit("local-state", this.localState);
460
+ }
461
+ }
462
+ catch {
463
+ /* not supported by this WebRTC build */
464
+ }
465
+ }
466
+ const mic = this.#senders.mic;
467
+ if (mic) {
468
+ try {
469
+ const params = mic.sender.getParameters();
470
+ const cap = micBitrateCap(s.maxAudioBitrate, this.#fallback.active);
471
+ params.encodings = (params.encodings?.length ? params.encodings : [{}]).map((e) => {
472
+ const out = { ...e };
473
+ if (cap > 0)
474
+ out.maxBitrate = cap;
475
+ else
476
+ delete out.maxBitrate;
477
+ return out;
478
+ });
479
+ await mic.sender.setParameters(params);
480
+ }
481
+ catch {
482
+ /* ignore */
483
+ }
484
+ }
485
+ }
486
+ // ---------------- leave ----------------
487
+ leave(reason = "left") {
488
+ if (this.#left)
489
+ return;
490
+ this.#left = true;
491
+ for (const t of [this.#reconnectTimer, this.#pingTimer, this.#statsTimer, this.#levelTimer]) {
492
+ if (t)
493
+ clearTimeout(t);
494
+ }
495
+ if (this.#pingTimer)
496
+ clearInterval(this.#pingTimer);
497
+ if (this.#statsTimer)
498
+ clearInterval(this.#statsTimer);
499
+ if (this.#levelTimer)
500
+ clearInterval(this.#levelTimer);
501
+ try {
502
+ this.#ws?.close(1000, "leaving");
503
+ }
504
+ catch {
505
+ /* ignore */
506
+ }
507
+ this.#ws = null;
508
+ for (const id of [...this.#remotes.keys()])
509
+ this.#removeRemote(id);
510
+ this.#local.mic?.stop();
511
+ this.#local.camera?.stop();
512
+ this.#local = { mic: null, camera: null };
513
+ this.#session?.destroy();
514
+ this.#session = null;
515
+ this.#setConnection("disconnected");
516
+ this.emit("left", reason);
517
+ this.removeAllListeners();
518
+ }
519
+ // ---------------- startup ----------------
520
+ /** @internal */
521
+ async _start() {
522
+ await new Promise((resolve, reject) => {
523
+ let timer = null;
524
+ const arm = () => {
525
+ timer = setTimeout(() => {
526
+ reject(new Error("timed out joining room"));
527
+ this.leave("join-timeout");
528
+ }, this.#opts.joinTimeoutMs ?? 15000);
529
+ };
530
+ arm();
531
+ this.#connect({
532
+ onWelcome: () => {
533
+ if (timer)
534
+ clearTimeout(timer);
535
+ resolve();
536
+ },
537
+ onFatal: (reason) => {
538
+ if (timer)
539
+ clearTimeout(timer);
540
+ reject(new Error(reason));
541
+ },
542
+ onWaiting: () => {
543
+ if (timer)
544
+ clearTimeout(timer); // no timeout while waiting to be admitted
545
+ this.#opts.onWaiting?.();
546
+ this.emit("waiting");
547
+ },
548
+ });
549
+ });
550
+ this.#iceServers = this.#opts.iceServers ?? (await this.#fetchIceServers());
551
+ if (this.#left)
552
+ return;
553
+ await this.#newSession();
554
+ this.#levelTimer = setInterval(() => void this.#measureLevel(), LEVEL_MS);
555
+ this.#statsTimer = setInterval(() => void this.#measureLink(), STATS_MS);
556
+ }
557
+ #headers = () => ({ [TOKEN_HEADER]: this.#token });
558
+ async #fetchIceServers() {
559
+ try {
560
+ const f = this.#opts.fetch ?? fetch;
561
+ const res = await f(`${this.#opts.url}/partytracks/generate-ice-servers`, { headers: this.#headers() });
562
+ if (!res.ok)
563
+ throw new Error(`HTTP ${res.status}`);
564
+ const body = (await res.json());
565
+ if (Array.isArray(body.iceServers) && body.iceServers.length)
566
+ return body.iceServers;
567
+ throw new Error("no ICE servers in response");
568
+ }
569
+ catch (err) {
570
+ this.emit("error", new Error(`Could not load ICE servers, using public STUN only (${String(err)})`));
571
+ return [{ urls: ["stun:stun.cloudflare.com:3478"] }];
572
+ }
573
+ }
574
+ /** Create (or replace, after a failure) the media session; re-publish and re-pull everything. */
575
+ async #newSession() {
576
+ const gen = ++this.#sessionGen;
577
+ this.#session?.destroy();
578
+ const s = new SfuSession(`${this.#opts.url}/partytracks`, this.#headers, this.#iceServers, this.#webrtc, this.#opts.fetch);
579
+ this.#session = s;
580
+ s.on("state", (st) => this.emit("media-connection", st));
581
+ s.on("failed", (why) => {
582
+ if (this.#left || gen !== this.#sessionGen)
583
+ return;
584
+ this.emit("error", new Error(`Media connection lost (${why}), reconnecting`));
585
+ if (this.connectionState === "connected")
586
+ this.#setConnection("reconnecting");
587
+ setTimeout(() => {
588
+ if (!this.#left && gen === this.#sessionGen)
589
+ void this.#newSession().then(() => this.#ws && this.#setConnection("connected"));
590
+ }, 1000);
591
+ });
592
+ try {
593
+ await s.start();
594
+ }
595
+ catch (err) {
596
+ if (gen !== this.#sessionGen || this.#left)
597
+ return;
598
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
599
+ setTimeout(() => !this.#left && gen === this.#sessionGen && void this.#newSession(), 3000);
600
+ return;
601
+ }
602
+ if (gen !== this.#sessionGen || this.#left)
603
+ return;
604
+ this.#senders = {};
605
+ this.#announced = {};
606
+ for (const r of this.#remotes.values()) {
607
+ r.pulled = {};
608
+ r.pulling = {};
609
+ }
610
+ await Promise.all([this.#applyMic(), this.#applyCamera()]);
611
+ for (const r of this.#remotes.values())
612
+ this.#syncPulls(r);
613
+ }
614
+ // ---------------- publishing ----------------
615
+ async #applyMic() {
616
+ const allowed = this.permissions.mic;
617
+ const want = this.#wanted.mic && allowed;
618
+ if (!allowed && this.#local.mic) {
619
+ await this.#unpublish("mic");
620
+ }
621
+ if (want && !this.#local.mic) {
622
+ try {
623
+ const stream = await this.#webrtc.mediaDevices.getUserMedia({
624
+ audio: this.#micDeviceId ? { deviceId: { exact: this.#micDeviceId } } : true,
625
+ video: false,
626
+ });
627
+ if (this.#left)
628
+ return stream.getTracks().forEach((t) => t.stop());
629
+ this.#local.mic = stream.getAudioTracks()[0] ?? null;
630
+ }
631
+ catch (err) {
632
+ this.#wanted.mic = false;
633
+ this.emit("error", new Error(`Could not start the microphone (${errText(err)})`));
634
+ }
635
+ }
636
+ if (this.#local.mic)
637
+ this.#local.mic.enabled = want;
638
+ if (allowed && this.#local.mic && this.#session && !this.#senders.mic) {
639
+ await this.#publish("mic", this.#local.mic);
640
+ }
641
+ else if (this.#senders.mic && this.#local.mic && this.#senders.mic.sender.track !== this.#local.mic) {
642
+ await this.#senders.mic.sender.replaceTrack(this.#local.mic);
643
+ }
644
+ this.#setLocal({ micOn: want && !!this.#local.mic });
645
+ }
646
+ async #applyCamera() {
647
+ const allowed = this.permissions.camera;
648
+ const want = this.#wanted.camera && allowed;
649
+ if (!allowed && (this.#local.camera || this.#senders.camera)) {
650
+ await this.#unpublish("camera");
651
+ }
652
+ if (want && !this.#local.camera) {
653
+ try {
654
+ const stream = await this.#webrtc.mediaDevices.getUserMedia({
655
+ audio: false,
656
+ video: this.#cameraDeviceId
657
+ ? { deviceId: { exact: this.#cameraDeviceId }, width: { ideal: 1280 }, height: { ideal: 720 } }
658
+ : { facingMode: this.#facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
659
+ });
660
+ if (this.#left || !this.#wanted.camera)
661
+ return stream.getTracks().forEach((t) => t.stop());
662
+ this.#local.camera = stream.getVideoTracks()[0] ?? null;
663
+ }
664
+ catch (err) {
665
+ this.#wanted.camera = false;
666
+ this.emit("error", new Error(`Could not start the camera (${errText(err)})`));
667
+ }
668
+ }
669
+ if (!want && this.#local.camera) {
670
+ // Camera off: stop capturing (camera light off), keep the track slot published.
671
+ this.#local.camera.stop();
672
+ this.#local.camera = null;
673
+ await this.#senders.camera?.sender.replaceTrack(null).catch(() => { });
674
+ }
675
+ if (want && this.#local.camera && this.#session) {
676
+ if (!this.#senders.camera)
677
+ await this.#publish("camera", this.#local.camera);
678
+ else if (this.#senders.camera.sender.track !== this.#local.camera)
679
+ await this.#senders.camera.sender.replaceTrack(this.#local.camera);
680
+ }
681
+ const on = want && !!this.#local.camera;
682
+ this.#setLocal({ cameraOn: on });
683
+ this.emit("local-track", "video", on ? this.#local.camera : null);
684
+ }
685
+ async #publish(group, track) {
686
+ const s = this.#session;
687
+ if (!s)
688
+ return;
689
+ const kind = group === "mic" ? "audio" : "video";
690
+ const simulcast = group === "camera" && this.#opts.simulcast !== false;
691
+ try {
692
+ const { trackName, transceiver } = await s.push(track, randomId(), simulcast ? CAMERA_SIMULCAST : undefined);
693
+ if (s !== this.#session)
694
+ return;
695
+ this.#senders[group] = transceiver;
696
+ const usedSimulcast = simulcast && (transceiver.sender.getParameters().encodings?.length ?? 0) > 1;
697
+ if (usedSimulcast)
698
+ this.#cameraLayers = ["f", "h", "q"];
699
+ const meta = {
700
+ location: "remote",
701
+ trackName,
702
+ sessionId: s.sessionId,
703
+ ...(usedSimulcast ? { layers: ["f", "h", "q"] } : {}),
704
+ };
705
+ // Data saver / fallback: fewer layers, caps, pause. Announce after that.
706
+ const limited = this.#saver.mode !== "off" || this.#fallback.active;
707
+ if (limited) {
708
+ await this.#applySendParams();
709
+ if (usedSimulcast)
710
+ meta.layers = [...this.#cameraLayers];
711
+ }
712
+ this.#announced[kind] = meta;
713
+ this.#send({ type: "tracks", tracks: { [kind]: meta } });
714
+ }
715
+ catch (err) {
716
+ this.emit("error", new Error(`Could not publish the ${group === "mic" ? "microphone" : "camera"} (${errText(err)})`));
717
+ }
718
+ }
719
+ async #unpublish(group) {
720
+ const kind = group === "mic" ? "audio" : "video";
721
+ const tr = this.#senders[group];
722
+ delete this.#senders[group];
723
+ this.#local[group]?.stop();
724
+ this.#local[group] = null;
725
+ delete this.#announced[kind];
726
+ this.#send({ type: "tracks", tracks: { [kind]: null } });
727
+ if (tr?.mid)
728
+ await this.#session?.close([tr.mid]).catch(() => { });
729
+ if (group === "camera")
730
+ this.emit("local-track", "video", null);
731
+ }
732
+ #setLocal(patch) {
733
+ const next = { ...this.#localState, ...patch, custom: this.#customState };
734
+ if (JSON.stringify(next) === JSON.stringify(this.#localState))
735
+ return;
736
+ this.#localState = next;
737
+ const { micOn, cameraOn, screenOn } = next;
738
+ this.#send({ type: "state", state: { micOn, cameraOn, screenOn } });
739
+ this.emit("local-state", this.localState);
740
+ }
741
+ #applyPermissions(next) {
742
+ const prev = this.permissions;
743
+ this.permissions = { ...next };
744
+ if (JSON.stringify(prev) === JSON.stringify(next))
745
+ return;
746
+ if (this.#session) {
747
+ if (prev.mic !== next.mic)
748
+ void this.#applyMic();
749
+ if (prev.camera !== next.camera)
750
+ void this.#applyCamera();
751
+ }
752
+ this.emit("permissions", this.permissions, prev);
753
+ }
754
+ // ---------------- receiving ----------------
755
+ #wants(r, kind) {
756
+ const explicit = r.subscribed[kind];
757
+ if (explicit !== undefined)
758
+ return explicit;
759
+ const auto = this.#opts.autoSubscribe ?? true;
760
+ const isVideo = kind === "video" || kind === "screenVideo";
761
+ if (auto === false)
762
+ return false;
763
+ if (auto !== true && (isVideo ? auto.video === false : auto.audio === false))
764
+ return false;
765
+ if (kind === "video" && r.effective === "off")
766
+ return false;
767
+ if (kind === "screenVideo" && this.#fallback.active)
768
+ return false;
769
+ return true;
770
+ }
771
+ #syncPulls(r) {
772
+ const s = this.#session;
773
+ if (!s || !s.sessionId)
774
+ return;
775
+ for (const kind of PULL_KINDS) {
776
+ const meta = r.participant.tracks[kind];
777
+ const key = meta?.sessionId && meta.trackName ? `${meta.sessionId}/${meta.trackName}` : null;
778
+ const want = !!key && this.#wants(r, kind);
779
+ const current = r.pulled[kind];
780
+ if (current && (!want || current.key !== key))
781
+ this.#stopPull(r, kind);
782
+ if (want && key && !r.pulled[kind] && r.pulling[kind] !== key) {
783
+ r.pulling[kind] = key;
784
+ const ref = { sessionId: meta.sessionId, trackName: meta.trackName };
785
+ const rid = kind === "video" && meta.layers?.length && r.effective !== "off" ? pickLayer(QUALITY_TO_LAYER[r.effective], meta.layers) : undefined;
786
+ s.pull({ ...ref, ...(rid ? { preferredRid: rid } : {}) }).then(({ track, mid }) => {
787
+ if (s !== this.#session || this.#remotes.get(r.participant.id) !== r || r.pulling[kind] !== key) {
788
+ void s.close([mid]).catch(() => { });
789
+ return;
790
+ }
791
+ delete r.pulling[kind];
792
+ r.pulled[kind] = { key, mid, ref };
793
+ r.tracks[kind] = track;
794
+ this.emit("track", r.participant, kind, track);
795
+ // Things may have changed while pulling.
796
+ this.#syncPulls(r);
797
+ }, (err) => {
798
+ if (r.pulling[kind] === key)
799
+ delete r.pulling[kind];
800
+ if (s === this.#session && !this.#left)
801
+ this.emit("error", new Error(`Could not receive ${kind} from ${r.participant.name} (${err.message})`));
802
+ });
803
+ }
804
+ }
805
+ }
806
+ #stopPull(r, kind) {
807
+ const p = r.pulled[kind];
808
+ delete r.pulled[kind];
809
+ delete r.pulling[kind];
810
+ const had = !!r.tracks[kind];
811
+ delete r.tracks[kind];
812
+ if (p)
813
+ void this.#session?.close([p.mid]).catch(() => { });
814
+ if (had)
815
+ this.emit("track-removed", r.participant, kind);
816
+ }
817
+ #upsertRemote(p, isNew) {
818
+ let r = this.#remotes.get(p.id);
819
+ const prevLayers = r?.participant.tracks.video?.layers?.join();
820
+ if (!r) {
821
+ r = { participant: p, pulled: {}, pulling: {}, tracks: {}, subscribed: {}, quality: "high", effective: "high" };
822
+ this.#remotes.set(p.id, r);
823
+ r.participant = p;
824
+ const changed = this.#refreshVideoAllow(false);
825
+ r.effective = this.#effectiveQuality(r);
826
+ if (changed)
827
+ this.#applyAllQualities();
828
+ }
829
+ r.participant = p;
830
+ // A publisher on a data saver announced different layers: ask for one it sends.
831
+ const pulled = r.pulled.video;
832
+ if (pulled && r.effective !== "off" && p.tracks.video?.layers?.length && p.tracks.video.layers.join() !== prevLayers) {
833
+ void this.#session?.setLayer(pulled.mid, pulled.ref, pickLayer(QUALITY_TO_LAYER[r.effective], p.tracks.video.layers)).catch(() => { });
834
+ }
835
+ const q = p.state.connectionQuality;
836
+ if (q && this.#qualities.get(p.id) !== q) {
837
+ this.#qualities.set(p.id, q);
838
+ this.emit("connection-quality", p.id, q);
839
+ }
840
+ this.emit(isNew ? "participant-joined" : "participant-updated", p);
841
+ this.#syncPulls(r);
842
+ }
843
+ #removeRemote(id) {
844
+ const r = this.#remotes.get(id);
845
+ if (!r)
846
+ return;
847
+ for (const kind of PULL_KINDS)
848
+ if (r.pulled[kind] || r.tracks[kind])
849
+ this.#stopPull(r, kind);
850
+ this.#remotes.delete(id);
851
+ this.#qualities.delete(id);
852
+ this.#speakers.remove(id);
853
+ this.#refreshVideoAllow();
854
+ this.emit("participant-left", r.participant);
855
+ }
856
+ #reconcile(list) {
857
+ const ids = new Set(list.map((p) => p.id));
858
+ for (const id of [...this.#remotes.keys()])
859
+ if (!ids.has(id))
860
+ this.#removeRemote(id);
861
+ for (const p of list)
862
+ this.#upsertRemote(p, !this.#remotes.has(p.id));
863
+ }
864
+ // ---------------- speaking & quality (from WebRTC stats; no Web Audio on React Native) ----------------
865
+ async #measureLevel() {
866
+ const s = this.#session;
867
+ if (!s?.pc || this.#left || !this.self)
868
+ return;
869
+ let level = 0;
870
+ if (this.#localState.micOn) {
871
+ try {
872
+ const stats = await s.getStats();
873
+ stats.forEach((st) => {
874
+ if (st.type === "media-source" && st.kind === "audio" && typeof st.audioLevel === "number")
875
+ level = Math.max(level, st.audioLevel);
876
+ });
877
+ }
878
+ catch {
879
+ return;
880
+ }
881
+ }
882
+ const now = Date.now();
883
+ const flipped = this.#vad.update(level, now);
884
+ if (this.#vad.speaking && (flipped || now - this.#lastLevelSentAt >= SPEAKER.sendEveryMs)) {
885
+ const shared = Math.max(level, 0.01);
886
+ this.#send({ type: "speaking", level: shared });
887
+ this.#lastLevelSentAt = now;
888
+ this.#speakers.set(this.self.id, shared, now);
889
+ }
890
+ else if (flipped && !this.#vad.speaking) {
891
+ this.#send({ type: "speaking", level: 0 });
892
+ this.#speakers.set(this.self.id, 0, now);
893
+ }
894
+ this.#emitSpeakers();
895
+ }
896
+ #emitSpeakers() {
897
+ const changed = this.#speakers.changed(Date.now());
898
+ if (!changed)
899
+ return;
900
+ this.emit("active-speakers", changed);
901
+ if (this.#saver.settings.activeSpeakerVideo > 0) {
902
+ const talking = changed.filter((id) => this.#remotes.has(id));
903
+ if (talking.length) {
904
+ this.#recentSpeakers = [...talking, ...this.#recentSpeakers.filter((id) => !talking.includes(id))].slice(0, 16);
905
+ this.#refreshVideoAllow();
906
+ }
907
+ }
908
+ }
909
+ async #measureLink() {
910
+ const s = this.#session;
911
+ if (!s?.pc || this.#left || s.pc.connectionState !== "connected")
912
+ return;
913
+ try {
914
+ const report = await s.getStats();
915
+ const bytes = bytesFromStats(report);
916
+ if (bytes) {
917
+ this.#usage = nextUsage(this.#usage, bytes, Date.now());
918
+ this.emit("data-usage", this.dataUsage);
919
+ }
920
+ const first = this.#statsCursor === null;
921
+ const { sample, cursor } = sampleFromStats(report, this.#statsCursor);
922
+ this.#statsCursor = cursor;
923
+ if (!first && this.#fallbackOn) {
924
+ const was = this.#fallback.active;
925
+ // No adaptive steps on React Native: only severe samples count.
926
+ this.#fallback = nextFallbackState(this.#fallback, sample, initialAdaptiveState(), Date.now());
927
+ if (this.#fallback.active !== was)
928
+ this.#afterFallbackChange();
929
+ }
930
+ const q = connectionQualityFromSample(sample);
931
+ if (q !== this.#myQuality) {
932
+ this.#myQuality = q;
933
+ this.#localState = { ...this.#localState, connectionQuality: q };
934
+ this.#send({ type: "state", state: { connectionQuality: q } });
935
+ if (this.self)
936
+ this.emit("connection-quality", this.self.id, q);
937
+ }
938
+ }
939
+ catch {
940
+ /* stats not available yet */
941
+ }
942
+ }
943
+ // ---------------- room WebSocket ----------------
944
+ #connect(hooks) {
945
+ const WS = this.#opts.WebSocket ?? WebSocket;
946
+ const url = this.#opts.url.replace(/^http/, "ws") +
947
+ `/rooms/${encodeURIComponent(this.roomId)}/ws?token=${encodeURIComponent(this.#token)}`;
948
+ const ws = new WS(url);
949
+ this.#ws = ws;
950
+ let welcomed = false;
951
+ ws.onmessage = (ev) => {
952
+ let msg;
953
+ try {
954
+ msg = JSON.parse(String(ev.data));
955
+ }
956
+ catch {
957
+ return;
958
+ }
959
+ try {
960
+ if (msg.type === "welcome") {
961
+ welcomed = true;
962
+ this.#onWelcome(msg);
963
+ hooks?.onWelcome();
964
+ }
965
+ else if (msg.type === "waiting") {
966
+ hooks?.onWaiting();
967
+ }
968
+ else if (msg.type === "bye") {
969
+ const fatal = closeReason(msg.code) ?? "closed-by-server";
970
+ this.#ws = null;
971
+ try {
972
+ ws.close();
973
+ }
974
+ catch {
975
+ /* ignore */
976
+ }
977
+ hooks?.onFatal(fatal);
978
+ this.leave(fatal);
979
+ }
980
+ else
981
+ this.#handle(msg);
982
+ }
983
+ catch (err) {
984
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
985
+ }
986
+ };
987
+ ws.onclose = (ev) => {
988
+ if (this.#pingTimer)
989
+ clearInterval(this.#pingTimer);
990
+ if (this.#left || ws !== this.#ws)
991
+ return;
992
+ const fatal = closeReason(ev.code);
993
+ if (fatal) {
994
+ hooks?.onFatal(fatal);
995
+ return this.leave(fatal);
996
+ }
997
+ if (!welcomed && hooks) {
998
+ hooks.onFatal("could not connect to room");
999
+ return this.leave("connect-failed");
1000
+ }
1001
+ this.#signalLostAt ??= Date.now();
1002
+ const limit = this.#opts.reconnectTimeoutMs ?? 120_000;
1003
+ if (this.#opts.reconnect === false || Date.now() - this.#signalLostAt > limit)
1004
+ return this.leave("connection-lost");
1005
+ this.#setConnection("reconnecting");
1006
+ const delay = Math.min(500 * 2 ** this.#attempt, 8000);
1007
+ this.#attempt++;
1008
+ this.#reconnectTimer = setTimeout(() => this.#connect(), delay);
1009
+ };
1010
+ }
1011
+ #onWelcome(msg) {
1012
+ this.#attempt = 0;
1013
+ this.#signalLostAt = null;
1014
+ this.self = msg.self;
1015
+ this.emit("self", msg.self);
1016
+ this.#applyRole(msg.self.role);
1017
+ if (msg.self.permissions)
1018
+ this.#applyPermissions(msg.self.permissions);
1019
+ this.#setRoomState(msg.room);
1020
+ if (msg.recording !== undefined)
1021
+ this.#setRecording(msg.recording ?? null);
1022
+ this.#reconcile(msg.participants);
1023
+ this.#checkHands();
1024
+ // Re-announce after a reconnect.
1025
+ if (Object.keys(this.#announced).length)
1026
+ this.#send({ type: "tracks", tracks: this.#announced });
1027
+ this.#send({ type: "state", state: { ...this.#localState, custom: this.#customState } });
1028
+ this.#setConnection("connected");
1029
+ if (this.#pingTimer)
1030
+ clearInterval(this.#pingTimer);
1031
+ this.#pingTimer = setInterval(() => this.#send({ type: "ping" }), PING_MS);
1032
+ }
1033
+ #handle(msg) {
1034
+ switch (msg.type) {
1035
+ case "participant-joined":
1036
+ this.#upsertRemote(msg.participant, true);
1037
+ this.#checkHands();
1038
+ break;
1039
+ case "participant-updated":
1040
+ this.#upsertRemote(msg.participant, false);
1041
+ this.#checkHands();
1042
+ break;
1043
+ case "participant-left":
1044
+ this.#removeRemote(msg.id);
1045
+ this.#checkHands();
1046
+ this.#emitSpeakers();
1047
+ break;
1048
+ case "self-updated":
1049
+ this.self = msg.self;
1050
+ this.emit("self", msg.self);
1051
+ this.#checkHands();
1052
+ this.#applyRole(msg.self.role);
1053
+ this.#applyPermissions(msg.self.permissions);
1054
+ break;
1055
+ case "chat":
1056
+ this.#addChat(msg.message);
1057
+ break;
1058
+ case "chat-history":
1059
+ for (const m of msg.messages)
1060
+ this.#addChat(m);
1061
+ break;
1062
+ case "chat-deleted":
1063
+ if (this.chat.some((m) => m.id === msg.id)) {
1064
+ this.chat = this.chat.filter((m) => m.id !== msg.id);
1065
+ this.emit("chat-deleted", msg.id);
1066
+ }
1067
+ break;
1068
+ case "group-changed":
1069
+ if (this.self)
1070
+ this.self = { ...this.self, group: msg.group };
1071
+ this.#reconcile(msg.participants);
1072
+ this.#checkHands();
1073
+ this.emit("group-changed", msg.group);
1074
+ break;
1075
+ case "waiting-list":
1076
+ this.waiting = msg.waiting;
1077
+ this.emit("waiting-list", msg.waiting);
1078
+ break;
1079
+ case "room-updated":
1080
+ this.#setRoomState(msg.room);
1081
+ break;
1082
+ case "muted":
1083
+ for (const d of msg.devices) {
1084
+ if (d === "mic")
1085
+ this.setMic(false);
1086
+ else if (d === "camera")
1087
+ this.setCamera(false);
1088
+ }
1089
+ this.emit("muted", msg.devices, msg.by ? this.#remotes.get(msg.by)?.participant : undefined);
1090
+ break;
1091
+ case "broadcast":
1092
+ this.emit("message", msg.data, this.#remotes.get(msg.from)?.participant);
1093
+ break;
1094
+ case "speaking":
1095
+ this.#speakers.set(msg.from, msg.level, Date.now());
1096
+ this.#emitSpeakers();
1097
+ break;
1098
+ case "recording":
1099
+ this.#setRecording(msg.recording);
1100
+ break;
1101
+ case "transcript":
1102
+ this.emit("transcript", msg.segment);
1103
+ break;
1104
+ case "error":
1105
+ this.emit("error", new Error(msg.message));
1106
+ break;
1107
+ default:
1108
+ break;
1109
+ }
1110
+ }
1111
+ #applyRole(role) {
1112
+ if (!role || role === this.role)
1113
+ return;
1114
+ const prev = this.role;
1115
+ this.role = role;
1116
+ this.emit("role", role, prev);
1117
+ }
1118
+ #setRoomState(state) {
1119
+ if (JSON.stringify(state) === JSON.stringify(this.roomState))
1120
+ return;
1121
+ const spotChanged = JSON.stringify(state.spotlight ?? []) !== JSON.stringify(this.roomState.spotlight ?? []);
1122
+ this.roomState = { ...state };
1123
+ if (spotChanged)
1124
+ this.#refreshVideoAllow();
1125
+ this.emit("room-state", this.roomState);
1126
+ }
1127
+ #setRecording(rec) {
1128
+ if (JSON.stringify(rec) === JSON.stringify(this.recording))
1129
+ return;
1130
+ this.recording = rec;
1131
+ this.emit("recording", rec);
1132
+ }
1133
+ #addChat(m) {
1134
+ if (this.chat.some((x) => x.id === m.id))
1135
+ return;
1136
+ this.chat.push(m);
1137
+ this.chat.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1138
+ if (this.chat.length > 200)
1139
+ this.chat.splice(0, this.chat.length - 200);
1140
+ this.emit("chat", m);
1141
+ }
1142
+ #checkHands() {
1143
+ const list = this.raisedHands;
1144
+ const key = list.map((p) => `${p.id}@${p.handRaisedAt}`).join(",");
1145
+ if (key === this.#handsKey)
1146
+ return;
1147
+ this.#handsKey = key;
1148
+ this.emit("hands", list);
1149
+ }
1150
+ #send(msg) {
1151
+ if (this.#ws && this.#ws.readyState === 1)
1152
+ this.#ws.send(JSON.stringify(msg));
1153
+ }
1154
+ #setConnection(state) {
1155
+ if (this.connectionState === state)
1156
+ return;
1157
+ this.connectionState = state;
1158
+ this.emit("connection", state);
1159
+ }
1160
+ }
1161
+ // ---------------- helpers ----------------
1162
+ function defaultWebRTC() {
1163
+ const g = globalThis;
1164
+ if (!g.RTCPeerConnection || !g.MediaStream || !g.navigator?.mediaDevices)
1165
+ return null;
1166
+ return { RTCPeerConnection: g.RTCPeerConnection, MediaStream: g.MediaStream, mediaDevices: g.navigator.mediaDevices };
1167
+ }
1168
+ function randomId() {
1169
+ const c = globalThis.crypto;
1170
+ if (c?.randomUUID)
1171
+ return c.randomUUID();
1172
+ return "t-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 12);
1173
+ }
1174
+ function errText(err) {
1175
+ return err instanceof Error ? `${err.name}${err.message ? ": " + err.message : ""}` : String(err);
1176
+ }
1177
+ /** Read (not verify) the claims in a join token. Works without atob/TextDecoder. */
1178
+ export function decodeClaims(token) {
1179
+ const part = token.split(".")[1];
1180
+ if (!part)
1181
+ throw new Error("invalid join token");
1182
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
1183
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1184
+ const bytes = [];
1185
+ let buf = 0;
1186
+ let bits = 0;
1187
+ for (const ch of b64) {
1188
+ const v = chars.indexOf(ch);
1189
+ if (v < 0)
1190
+ continue;
1191
+ buf = (buf << 6) | v;
1192
+ bits += 6;
1193
+ if (bits >= 8) {
1194
+ bits -= 8;
1195
+ bytes.push((buf >> bits) & 0xff);
1196
+ }
1197
+ }
1198
+ // UTF-8 decode.
1199
+ let json = "";
1200
+ for (let i = 0; i < bytes.length;) {
1201
+ const b = bytes[i++];
1202
+ let cp;
1203
+ if (b < 0x80)
1204
+ cp = b;
1205
+ else if (b < 0xe0)
1206
+ cp = ((b & 0x1f) << 6) | (bytes[i++] & 0x3f);
1207
+ else if (b < 0xf0)
1208
+ cp = ((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f);
1209
+ else
1210
+ cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f);
1211
+ json += String.fromCodePoint(cp);
1212
+ }
1213
+ const claims = JSON.parse(json);
1214
+ if (!claims.room || !claims.sub)
1215
+ throw new Error("invalid join token");
1216
+ return claims;
1217
+ }
1218
+ function closeReason(code) {
1219
+ switch (code) {
1220
+ case CloseCodes.Kicked:
1221
+ return "kicked";
1222
+ case CloseCodes.RoomFull:
1223
+ return "room-full";
1224
+ case CloseCodes.Unauthorized:
1225
+ return "unauthorized";
1226
+ case CloseCodes.RoomClosed:
1227
+ return "room-closed";
1228
+ case CloseCodes.RoomLocked:
1229
+ return "room-locked";
1230
+ case CloseCodes.Denied:
1231
+ return "denied";
1232
+ default:
1233
+ return null;
1234
+ }
1235
+ }
1236
+ //# sourceMappingURL=room.js.map