@voltkit-rtc/client 0.7.1 → 0.7.3
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/CHANGELOG.md +90 -0
- package/README.md +36 -4
- package/dist/cjs/broadcast.d.ts +21 -2
- package/dist/cjs/broadcast.d.ts.map +1 -1
- package/dist/cjs/broadcast.js +59 -4
- package/dist/cjs/broadcast.js.map +1 -1
- package/dist/cjs/chat.d.ts +2 -0
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +27 -6
- package/dist/cjs/chat.js.map +1 -1
- package/dist/cjs/index.d.ts +67 -3
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +299 -67
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/recording.d.ts +14 -0
- package/dist/cjs/recording.d.ts.map +1 -1
- package/dist/cjs/recording.js +51 -5
- package/dist/cjs/recording.js.map +1 -1
- package/dist/esm/broadcast.d.ts +21 -2
- package/dist/esm/broadcast.d.ts.map +1 -1
- package/dist/esm/broadcast.js +59 -4
- package/dist/esm/broadcast.js.map +1 -1
- package/dist/esm/chat.d.ts +2 -0
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +27 -6
- package/dist/esm/chat.js.map +1 -1
- package/dist/esm/index.d.ts +67 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +299 -67
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/recording.d.ts +14 -0
- package/dist/esm/recording.d.ts.map +1 -1
- package/dist/esm/recording.js +51 -5
- package/dist/esm/recording.js.map +1 -1
- package/package.json +1 -1
- package/src/broadcast.ts +52 -4
- package/src/chat.ts +28 -6
- package/src/index.ts +295 -61
- package/src/recording.ts +59 -21
package/dist/cjs/index.js
CHANGED
|
@@ -89,6 +89,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
|
|
|
89
89
|
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
90
90
|
const closedWhileConnecting = () => new errors_js_1.RpcError('not-connected', 'the session was closed while connecting');
|
|
91
91
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
92
|
+
/**
|
|
93
|
+
* The constraints that capture again what two publishes captured: each kind
|
|
94
|
+
* as the later publish asked for it, or as the earlier one did if the later
|
|
95
|
+
* one did not ask for it.
|
|
96
|
+
*/
|
|
97
|
+
function mergeConstraints(earlier, later) {
|
|
98
|
+
if (!earlier)
|
|
99
|
+
return later;
|
|
100
|
+
return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
|
|
101
|
+
}
|
|
92
102
|
const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
93
103
|
/**
|
|
94
104
|
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
@@ -136,11 +146,30 @@ class StreamClient {
|
|
|
136
146
|
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
137
147
|
// disconnected: the room looks healthy and shows nothing.
|
|
138
148
|
wasPublishing = false;
|
|
149
|
+
/** The deadline of the socket being opened (openSocket). */
|
|
150
|
+
openDeadline;
|
|
151
|
+
/**
|
|
152
|
+
* This client's own moderation per kind: whether a moderator holds it off,
|
|
153
|
+
* and a revision every decision bumps. A switch-on that was waiting for the
|
|
154
|
+
* server when a decision arrived does not act on its reply (V072-06).
|
|
155
|
+
*/
|
|
156
|
+
forcedOff = { audio: false, video: false };
|
|
157
|
+
moderationRevision = { audio: 0, video: 0 };
|
|
139
158
|
publishConstraints = null;
|
|
140
159
|
publishOptions = {};
|
|
141
160
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
142
161
|
music = null;
|
|
143
162
|
musicProducerId = null;
|
|
163
|
+
/**
|
|
164
|
+
* Bumped by every publishMusic() and stopMusic(). A music producer still
|
|
165
|
+
* being made when it changes belongs to a publish that is over: it is
|
|
166
|
+
* closed, not kept. publishGeneration alone missed stopMusic(), and the
|
|
167
|
+
* music went out after stopMusic() had returned.
|
|
168
|
+
*/
|
|
169
|
+
musicOperation = 0;
|
|
170
|
+
// Music publishes run one at a time: the server takes one producer per
|
|
171
|
+
// source, so the next is produced only once the last one is settled.
|
|
172
|
+
musicQueue = Promise.resolve();
|
|
144
173
|
screenStream = null;
|
|
145
174
|
screenMode = 'detail';
|
|
146
175
|
screenAudioEnabled = true;
|
|
@@ -157,6 +186,8 @@ class StreamClient {
|
|
|
157
186
|
frameRateWarmupSamples = 2;
|
|
158
187
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
159
188
|
remoteSources = new Map();
|
|
189
|
+
/** Identity of each pending/active subscription; cleared on close/reconnect. */
|
|
190
|
+
subscriptions = new Map();
|
|
160
191
|
/**
|
|
161
192
|
* The layers asked for each remote video, by producerId. A reconnect
|
|
162
193
|
* consumes everything again, and the new consumer would otherwise start at
|
|
@@ -195,6 +226,17 @@ class StreamClient {
|
|
|
195
226
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
196
227
|
tokenExpiry;
|
|
197
228
|
tokenTimer = null;
|
|
229
|
+
/**
|
|
230
|
+
* Bumped whenever the server hands this client a token (its own refresh, a
|
|
231
|
+
* promotion's). An updateToken() that started before then keeps the token
|
|
232
|
+
* it was given only if nothing newer arrived while it waited: its reply,
|
|
233
|
+
* or its fallback once the socket closed, used to replace a promotion's
|
|
234
|
+
* token, and the reconnect joined with the old one.
|
|
235
|
+
*/
|
|
236
|
+
serverTokenRevision = 0;
|
|
237
|
+
/** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
|
|
238
|
+
tokenRequests = 0;
|
|
239
|
+
tokenRequestHeld = 0;
|
|
198
240
|
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
199
241
|
remoteInfo = new Map();
|
|
200
242
|
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
@@ -256,6 +298,17 @@ class StreamClient {
|
|
|
256
298
|
this.setState('closed');
|
|
257
299
|
this.emit('closed', reason);
|
|
258
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Over for good without the app asking: finish, then release everything
|
|
303
|
+
* local as close() does -- camera, microphone, screen, music, recording.
|
|
304
|
+
* kicked and roomClosed always did; a replaced socket, a close without
|
|
305
|
+
* roomClosed and a reconnect that gave up left them capturing (and the
|
|
306
|
+
* recording uploading) after `closed`, until the app called close().
|
|
307
|
+
*/
|
|
308
|
+
end(reason) {
|
|
309
|
+
this.finish(reason);
|
|
310
|
+
this.close();
|
|
311
|
+
}
|
|
259
312
|
stopTimers() {
|
|
260
313
|
if (this.tokenTimer)
|
|
261
314
|
clearTimeout(this.tokenTimer);
|
|
@@ -282,8 +335,13 @@ class StreamClient {
|
|
|
282
335
|
let result;
|
|
283
336
|
if (this.state === 'idle' || this.state === 'closed')
|
|
284
337
|
this.setState('connecting');
|
|
338
|
+
// The socket this call opens. A later connect() replaces it, and this
|
|
339
|
+
// call's failure must then leave the newer socket, and the state, alone.
|
|
340
|
+
let socket = null;
|
|
285
341
|
try {
|
|
286
|
-
|
|
342
|
+
const opening = this.openSocket();
|
|
343
|
+
socket = this.ws;
|
|
344
|
+
await opening;
|
|
287
345
|
result = (await this.rpc('join', { token: this.token }));
|
|
288
346
|
this.joinResult = result;
|
|
289
347
|
this.device = this.createDevice();
|
|
@@ -297,7 +355,11 @@ class StreamClient {
|
|
|
297
355
|
// A session that never got going: the app has the error. Left open,
|
|
298
356
|
// the socket would be closed by the server's join deadline instead, and
|
|
299
357
|
// that close would start reconnecting a session that never began.
|
|
300
|
-
|
|
358
|
+
const superseded = this.ws !== null && this.ws !== socket;
|
|
359
|
+
// A reconnect attempt that failed (its join timed out, say) lets its
|
|
360
|
+
// socket go now too, so nothing it says later reaches the session while
|
|
361
|
+
// the next attempt waits its backoff (V072-05).
|
|
362
|
+
if ((!this.joinedOnce || this.reconnecting) && !superseded) {
|
|
301
363
|
this.detachSocket();
|
|
302
364
|
if (this.state === 'connecting')
|
|
303
365
|
this.setState('idle');
|
|
@@ -321,6 +383,8 @@ class StreamClient {
|
|
|
321
383
|
throw closedWhileConnecting();
|
|
322
384
|
}
|
|
323
385
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
386
|
+
this.moderate('audio', Boolean(own?.muted), true);
|
|
387
|
+
this.moderate('video', Boolean(own?.videoBlocked), true);
|
|
324
388
|
if (own?.muted)
|
|
325
389
|
this.applyLocalModeration('audio');
|
|
326
390
|
if (own?.videoBlocked)
|
|
@@ -376,11 +440,23 @@ class StreamClient {
|
|
|
376
440
|
return new Promise((resolve, reject) => {
|
|
377
441
|
const ws = new WebSocket((0, token_js_1.socketUrl)(this.wsUrl, this.token));
|
|
378
442
|
this.ws = ws;
|
|
443
|
+
// Bounded like every request (V072-05): a socket that does not open in
|
|
444
|
+
// time fails the attempt with `timeout` and is closed, instead of
|
|
445
|
+
// holding connect() -- or a reconnect attempt -- indefinitely.
|
|
446
|
+
this.openDeadline = setTimeout(() => {
|
|
447
|
+
reject(new errors_js_1.RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
|
|
448
|
+
if (this.ws === ws)
|
|
449
|
+
this.detachSocket('open timed out', 'the connection did not open in time');
|
|
450
|
+
}, RPC_TIMEOUT_MS);
|
|
379
451
|
const onOpen = () => {
|
|
452
|
+
clearTimeout(this.openDeadline);
|
|
380
453
|
this.emit('connected');
|
|
381
454
|
resolve();
|
|
382
455
|
};
|
|
383
|
-
const onError = () =>
|
|
456
|
+
const onError = () => {
|
|
457
|
+
clearTimeout(this.openDeadline);
|
|
458
|
+
reject(new errors_js_1.RpcError('connect-failed', 'websocket failed to open'));
|
|
459
|
+
};
|
|
384
460
|
ws.addEventListener('open', onOpen, { once: true });
|
|
385
461
|
ws.addEventListener('error', onError, { once: true });
|
|
386
462
|
ws.addEventListener('message', (ev) => {
|
|
@@ -401,17 +477,16 @@ class StreamClient {
|
|
|
401
477
|
// Another session for this identity is live. Reconnecting would
|
|
402
478
|
// displace it, and it would displace us right back, forever.
|
|
403
479
|
this.emit('error', new errors_js_1.RpcError('replaced', 'this identity connected from somewhere else'));
|
|
404
|
-
// The
|
|
405
|
-
// videoLimited sampling must not, or it reports after `closed`.
|
|
480
|
+
// The videoLimited sampling stops first, or it reports after `closed`.
|
|
406
481
|
this.limitWatch.reset();
|
|
407
|
-
this.
|
|
482
|
+
this.end('replaced');
|
|
408
483
|
return;
|
|
409
484
|
}
|
|
410
485
|
if (ev.code === CLOSE_ENDED) {
|
|
411
486
|
// Normally roomClosed has already ended the session; this covers a
|
|
412
487
|
// close that arrived without it.
|
|
413
488
|
this.limitWatch.reset();
|
|
414
|
-
this.
|
|
489
|
+
this.end('room-closed');
|
|
415
490
|
return;
|
|
416
491
|
}
|
|
417
492
|
// Before a first successful join the app is still waiting on
|
|
@@ -424,10 +499,13 @@ class StreamClient {
|
|
|
424
499
|
});
|
|
425
500
|
});
|
|
426
501
|
}
|
|
427
|
-
detachSocket(reason = 'superseded') {
|
|
502
|
+
detachSocket(reason = 'superseded', why = 'the connection was replaced') {
|
|
503
|
+
clearTimeout(this.openDeadline);
|
|
428
504
|
const ws = this.ws;
|
|
429
505
|
this.ws = null;
|
|
430
|
-
if (ws
|
|
506
|
+
if (!ws)
|
|
507
|
+
return;
|
|
508
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
431
509
|
try {
|
|
432
510
|
ws.close(1000, reason);
|
|
433
511
|
}
|
|
@@ -435,6 +513,10 @@ class StreamClient {
|
|
|
435
513
|
/* already closing */
|
|
436
514
|
}
|
|
437
515
|
}
|
|
516
|
+
// Its replies are no longer read, so what waits on it would only wait out
|
|
517
|
+
// its 15 s timeout: it fails now, as on a close. Never replayed -- the
|
|
518
|
+
// server may already have acted on it.
|
|
519
|
+
this.failPending(ws, new errors_js_1.RpcError('not-connected', why));
|
|
438
520
|
}
|
|
439
521
|
/**
|
|
440
522
|
* A reconnect that ends the session lets go of its socket. A refused join
|
|
@@ -443,7 +525,7 @@ class StreamClient {
|
|
|
443
525
|
* still sending.
|
|
444
526
|
*/
|
|
445
527
|
releaseSocket() {
|
|
446
|
-
this.detachSocket('gave up');
|
|
528
|
+
this.detachSocket('gave up', 'the session has ended');
|
|
447
529
|
this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session has ended'));
|
|
448
530
|
}
|
|
449
531
|
/**
|
|
@@ -464,7 +546,7 @@ class StreamClient {
|
|
|
464
546
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
465
547
|
this.emit('error', new errors_js_1.RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
466
548
|
this.releaseSocket();
|
|
467
|
-
this.
|
|
549
|
+
this.end('gave-up');
|
|
468
550
|
return;
|
|
469
551
|
}
|
|
470
552
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -492,7 +574,7 @@ class StreamClient {
|
|
|
492
574
|
// attempt forever and drowns out whatever actually went wrong.
|
|
493
575
|
if (err instanceof errors_js_1.RpcError && (0, errors_js_1.isTerminalError)(err.code)) {
|
|
494
576
|
this.releaseSocket();
|
|
495
|
-
this.
|
|
577
|
+
this.end('gave-up');
|
|
496
578
|
return;
|
|
497
579
|
}
|
|
498
580
|
}
|
|
@@ -537,6 +619,7 @@ class StreamClient {
|
|
|
537
619
|
this.emit('closed', 'left');
|
|
538
620
|
}
|
|
539
621
|
teardownMedia() {
|
|
622
|
+
this.subscriptions.clear();
|
|
540
623
|
for (const c of this.consumers.values())
|
|
541
624
|
c.close();
|
|
542
625
|
this.consumers.clear();
|
|
@@ -607,19 +690,29 @@ class StreamClient {
|
|
|
607
690
|
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
608
691
|
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
609
692
|
* session then keeps its token); otherwise it is kept for the next join,
|
|
610
|
-
* which checks it there. Resolves to when
|
|
693
|
+
* which checks it there. Resolves to when the token now held expires (unix
|
|
694
|
+
* ms), when known. A newer token that arrives while this waits is kept
|
|
695
|
+
* instead of this one: the server's (its own refresh, or a promotion's,
|
|
696
|
+
* which carries the new role) or a later updateToken()'s.
|
|
611
697
|
*/
|
|
612
698
|
async updateToken(token) {
|
|
613
699
|
if (typeof token !== 'string' || token.trim() === '')
|
|
614
700
|
throw new TypeError('updateToken needs a token');
|
|
615
701
|
if (this.state === 'closed')
|
|
616
702
|
throw new errors_js_1.RpcError('not-connected', 'this session is closed; create a new client');
|
|
703
|
+
const request = ++this.tokenRequests;
|
|
704
|
+
const revision = this.serverTokenRevision;
|
|
705
|
+
const keep = (expiresAt) => {
|
|
706
|
+
if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
|
|
707
|
+
this.tokenRequestHeld = request;
|
|
708
|
+
this.setToken(token, expiresAt);
|
|
709
|
+
}
|
|
710
|
+
return { expiresAt: this.tokenExpiry };
|
|
711
|
+
};
|
|
617
712
|
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
618
713
|
try {
|
|
619
714
|
const res = (await this.rpc('refreshToken', { token }));
|
|
620
|
-
|
|
621
|
-
this.setToken(token, at);
|
|
622
|
-
return { expiresAt: this.tokenExpiry };
|
|
715
|
+
return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
|
|
623
716
|
}
|
|
624
717
|
catch (err) {
|
|
625
718
|
// An older server has no refreshToken, and a socket that dropped
|
|
@@ -629,8 +722,7 @@ class StreamClient {
|
|
|
629
722
|
throw err;
|
|
630
723
|
}
|
|
631
724
|
}
|
|
632
|
-
|
|
633
|
-
return { expiresAt: this.tokenExpiry };
|
|
725
|
+
return keep(null);
|
|
634
726
|
}
|
|
635
727
|
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
636
728
|
setToken(token, expiresAt) {
|
|
@@ -691,10 +783,20 @@ class StreamClient {
|
|
|
691
783
|
this.pending.delete(id);
|
|
692
784
|
reject(new errors_js_1.RpcError('timeout', `${method} timed out`));
|
|
693
785
|
}, RPC_TIMEOUT_MS);
|
|
694
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
786
|
+
this.pending.set(id, { resolve, reject, timer, ws: this.ws });
|
|
695
787
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
696
788
|
});
|
|
697
789
|
}
|
|
790
|
+
/** Fail what was sent on `ws` and is still waiting. */
|
|
791
|
+
failPending(ws, err) {
|
|
792
|
+
for (const [id, p] of this.pending) {
|
|
793
|
+
if (p.ws !== ws)
|
|
794
|
+
continue;
|
|
795
|
+
clearTimeout(p.timer);
|
|
796
|
+
this.pending.delete(id);
|
|
797
|
+
p.reject(err);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
698
800
|
failAllPending(err) {
|
|
699
801
|
for (const p of this.pending.values()) {
|
|
700
802
|
clearTimeout(p.timer);
|
|
@@ -739,23 +841,21 @@ class StreamClient {
|
|
|
739
841
|
break;
|
|
740
842
|
case 'producerClosed': {
|
|
741
843
|
const { producerId, identity } = data;
|
|
844
|
+
this.subscriptions.delete(producerId);
|
|
742
845
|
this.preferredLayers.delete(producerId);
|
|
743
846
|
this.receiveQualities.delete(producerId);
|
|
744
847
|
this.remoteVideoLayers.delete(producerId);
|
|
745
848
|
this.skippedVideos.delete(producerId);
|
|
746
849
|
const consumer = this.consumers.get(producerId);
|
|
747
|
-
|
|
748
|
-
break; // never consumed it, or already cleaned up
|
|
749
|
-
this.videoSlots.delete(producerId);
|
|
750
|
-
consumer.close();
|
|
850
|
+
consumer?.close();
|
|
751
851
|
this.consumers.delete(producerId);
|
|
752
852
|
this.remoteSources.delete(producerId);
|
|
753
853
|
this.remoteInfo.delete(producerId);
|
|
754
|
-
|
|
854
|
+
if (consumer)
|
|
855
|
+
this.emit('trackEnded', { producerId, identity });
|
|
755
856
|
// A video slot opened under the fan-out limit: the oldest video left
|
|
756
857
|
// waiting takes it.
|
|
757
|
-
|
|
758
|
-
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
858
|
+
this.releaseVideoSlot(producerId);
|
|
759
859
|
break;
|
|
760
860
|
}
|
|
761
861
|
case 'producerPaused': {
|
|
@@ -782,6 +882,8 @@ class StreamClient {
|
|
|
782
882
|
case 'participantMuted': {
|
|
783
883
|
const wire = data;
|
|
784
884
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
885
|
+
if (e.identity && e.identity === this.joinResult?.identity)
|
|
886
|
+
this.moderate(e.kind, e.paused, true);
|
|
785
887
|
this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
786
888
|
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
787
889
|
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
@@ -828,6 +930,7 @@ class StreamClient {
|
|
|
828
930
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
829
931
|
// changes nothing here -- switching back on is the user's choice.
|
|
830
932
|
const e = data;
|
|
933
|
+
this.moderate(e.kind, Boolean(e.paused));
|
|
831
934
|
if (e.paused) {
|
|
832
935
|
this.applyLocalModeration(e.kind);
|
|
833
936
|
this.emitLocalMedia();
|
|
@@ -853,15 +956,19 @@ class StreamClient {
|
|
|
853
956
|
// fresh one periodically so a reconnect at any point still works --
|
|
854
957
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
855
958
|
const e = data;
|
|
856
|
-
if (e.token)
|
|
959
|
+
if (e.token) {
|
|
960
|
+
this.serverTokenRevision += 1;
|
|
857
961
|
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
962
|
+
}
|
|
858
963
|
break;
|
|
859
964
|
}
|
|
860
965
|
case 'mustReconnect': {
|
|
861
966
|
const e = data;
|
|
862
967
|
// A promotion hands us a fresh token carrying the new role.
|
|
863
|
-
if (e.token)
|
|
968
|
+
if (e.token) {
|
|
969
|
+
this.serverTokenRevision += 1;
|
|
864
970
|
this.setToken(e.token, null);
|
|
971
|
+
}
|
|
865
972
|
this.emit('mustReconnect', e);
|
|
866
973
|
if (e.retry) {
|
|
867
974
|
this.teardownMedia();
|
|
@@ -1027,10 +1134,28 @@ class StreamClient {
|
|
|
1027
1134
|
// leave nothing behind that a reconnect would publish again.
|
|
1028
1135
|
if (generation !== this.publishGeneration)
|
|
1029
1136
|
throw unpublishedWhile('stream');
|
|
1030
|
-
|
|
1137
|
+
// A second publish adds to the first (the microphone, then the camera):
|
|
1138
|
+
// one local stream carries every live track, so unpublish() and close()
|
|
1139
|
+
// stop them all and a reconnect publishes each again. Replacing it
|
|
1140
|
+
// orphaned the first stream's tracks, still capturing (V072-01).
|
|
1141
|
+
const previous = this.localStream;
|
|
1142
|
+
const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
|
|
1143
|
+
if (merged) {
|
|
1144
|
+
const ids = new Set(stream.getTracks().map((t) => t.id));
|
|
1145
|
+
// The new stream's tracks first: of one kind, the newer track is the one
|
|
1146
|
+
// published (as before), and the older one is still stopped with it.
|
|
1147
|
+
this.localStream = new MediaStream([
|
|
1148
|
+
...stream.getTracks(),
|
|
1149
|
+
...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
|
|
1150
|
+
]);
|
|
1151
|
+
this.emit('localStreamChanged', this.localStream);
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
this.localStream = stream;
|
|
1155
|
+
}
|
|
1031
1156
|
this.wasPublishing = true;
|
|
1032
1157
|
if (constraints)
|
|
1033
|
-
this.publishConstraints = constraints;
|
|
1158
|
+
this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
|
|
1034
1159
|
if (options) {
|
|
1035
1160
|
this.publishOptions = { ...options };
|
|
1036
1161
|
this.frameRateFallback = false;
|
|
@@ -1625,8 +1750,11 @@ class StreamClient {
|
|
|
1625
1750
|
await this.stopScreen('stopped');
|
|
1626
1751
|
this.emit('error', err);
|
|
1627
1752
|
});
|
|
1628
|
-
if (this.music)
|
|
1629
|
-
|
|
1753
|
+
if (this.music) {
|
|
1754
|
+
const music = this.music;
|
|
1755
|
+
const operation = this.musicOperation;
|
|
1756
|
+
await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
|
|
1757
|
+
}
|
|
1630
1758
|
if (generation !== this.publishGeneration)
|
|
1631
1759
|
return;
|
|
1632
1760
|
let stream = this.localStream;
|
|
@@ -1860,28 +1988,43 @@ class StreamClient {
|
|
|
1860
1988
|
if (!this.mediaPermissions.audio)
|
|
1861
1989
|
throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
|
|
1862
1990
|
const generation = this.publishGeneration;
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
const
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
// Checked again here: an unpublish() after the producer was kept has
|
|
1869
|
-
// already closed it, and the music must not be remembered for a
|
|
1870
|
-
// reconnect to publish again.
|
|
1871
|
-
if (generation !== this.publishGeneration)
|
|
1991
|
+
// Taken now, in call order: a music publish still under way is over, and
|
|
1992
|
+
// a stopMusic() from here on ends this one.
|
|
1993
|
+
const operation = ++this.musicOperation;
|
|
1994
|
+
await this.musicTask(async () => {
|
|
1995
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation)
|
|
1872
1996
|
throw unpublishedWhile('music');
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
music.
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1997
|
+
if (this.music)
|
|
1998
|
+
await this.closeMusic();
|
|
1999
|
+
const music = new music_js_1.MusicSource(input, options);
|
|
2000
|
+
try {
|
|
2001
|
+
await this.produceMusic(music, generation, operation);
|
|
2002
|
+
// Checked again here: an unpublish() or stopMusic() after the
|
|
2003
|
+
// producer was kept has already closed it, and the music must not be
|
|
2004
|
+
// remembered for a reconnect to publish again.
|
|
2005
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation)
|
|
2006
|
+
throw unpublishedWhile('music');
|
|
2007
|
+
}
|
|
2008
|
+
catch (err) {
|
|
2009
|
+
music.close();
|
|
2010
|
+
throw err;
|
|
2011
|
+
}
|
|
2012
|
+
this.music = music;
|
|
2013
|
+
this.wasPublishing = true;
|
|
2014
|
+
this.emitLocalMedia();
|
|
2015
|
+
});
|
|
1881
2016
|
}
|
|
1882
|
-
|
|
2017
|
+
musicTask(task) {
|
|
2018
|
+
const run = this.musicQueue.then(task, task);
|
|
2019
|
+
this.musicQueue = run.catch(() => undefined);
|
|
2020
|
+
return run;
|
|
2021
|
+
}
|
|
2022
|
+
async produceMusic(music, generation, operation) {
|
|
2023
|
+
// unpublish(), stopMusic() or a newer publishMusic() can run at any wait
|
|
2024
|
+
// below, and wins.
|
|
2025
|
+
const current = () => generation === this.publishGeneration && operation === this.musicOperation;
|
|
1883
2026
|
const transport = await this.ensureSendTransport();
|
|
1884
|
-
if (
|
|
2027
|
+
if (!current())
|
|
1885
2028
|
throw unpublishedWhile('music');
|
|
1886
2029
|
const producer = await transport.produce({
|
|
1887
2030
|
track: music.track,
|
|
@@ -1891,7 +2034,14 @@ class StreamClient {
|
|
|
1891
2034
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
1892
2035
|
appData: { source: 'music' },
|
|
1893
2036
|
});
|
|
1894
|
-
|
|
2037
|
+
if (!current()) {
|
|
2038
|
+
producer.close();
|
|
2039
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
2040
|
+
throw unpublishedWhile('music');
|
|
2041
|
+
}
|
|
2042
|
+
// Kept and named the music in one step, with no wait between: stopMusic()
|
|
2043
|
+
// finds the music producer by that name.
|
|
2044
|
+
this.producers.set(producer.id, producer);
|
|
1895
2045
|
this.musicProducerId = producer.id;
|
|
1896
2046
|
// Music switched off before a reconnect comes back paused here.
|
|
1897
2047
|
await this.pauseOnServer(producer);
|
|
@@ -1920,8 +2070,17 @@ class StreamClient {
|
|
|
1920
2070
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1921
2071
|
this.emitLocalMedia();
|
|
1922
2072
|
}
|
|
1923
|
-
/**
|
|
2073
|
+
/**
|
|
2074
|
+
* Stop publishing music. The element or stream it came from is left as it
|
|
2075
|
+
* was. A publishMusic() still under way is stopped too: it rejects with
|
|
2076
|
+
* `unpublished` and publishes nothing.
|
|
2077
|
+
*/
|
|
1924
2078
|
async stopMusic() {
|
|
2079
|
+
this.musicOperation += 1;
|
|
2080
|
+
await this.closeMusic();
|
|
2081
|
+
}
|
|
2082
|
+
/** Close the music being published, here and at the server. */
|
|
2083
|
+
async closeMusic() {
|
|
1925
2084
|
const id = this.musicProducerId;
|
|
1926
2085
|
this.musicProducerId = null;
|
|
1927
2086
|
this.music?.close();
|
|
@@ -1941,9 +2100,11 @@ class StreamClient {
|
|
|
1941
2100
|
});
|
|
1942
2101
|
}
|
|
1943
2102
|
async consume(remote) {
|
|
2103
|
+
if (this.closedByUs)
|
|
2104
|
+
return;
|
|
1944
2105
|
if (!this.device)
|
|
1945
2106
|
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1946
|
-
if (this.
|
|
2107
|
+
if (this.subscriptions.has(remote.producerId))
|
|
1947
2108
|
return;
|
|
1948
2109
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1949
2110
|
// consumer and shows you a delayed copy of yourself.
|
|
@@ -1963,35 +2124,69 @@ class StreamClient {
|
|
|
1963
2124
|
}
|
|
1964
2125
|
this.videoSlots.add(remote.producerId);
|
|
1965
2126
|
}
|
|
2127
|
+
const operation = {};
|
|
2128
|
+
this.subscriptions.set(remote.producerId, operation);
|
|
1966
2129
|
try {
|
|
1967
|
-
await this.consumeNow(remote);
|
|
2130
|
+
await this.consumeNow(remote, operation);
|
|
1968
2131
|
}
|
|
1969
2132
|
catch (err) {
|
|
1970
|
-
|
|
1971
|
-
|
|
2133
|
+
// A closed producer or replaced session has already released its slot.
|
|
2134
|
+
// Its late failure must neither release a newer slot nor report a ghost error.
|
|
2135
|
+
if (this.subscriptions.get(remote.producerId) !== operation)
|
|
2136
|
+
return;
|
|
2137
|
+
this.subscriptions.delete(remote.producerId);
|
|
2138
|
+
this.consumers.get(remote.producerId)?.close();
|
|
2139
|
+
this.consumers.delete(remote.producerId);
|
|
2140
|
+
this.remoteVideoLayers.delete(remote.producerId);
|
|
2141
|
+
this.remoteSources.delete(remote.producerId);
|
|
2142
|
+
this.remoteInfo.delete(remote.producerId);
|
|
2143
|
+
this.releaseVideoSlot(remote.producerId);
|
|
1972
2144
|
throw err;
|
|
1973
2145
|
}
|
|
1974
2146
|
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Free a video slot, and let the oldest video waiting take it -- whatever
|
|
2149
|
+
* freed it: an ended video, or a subscription that failed. Not while
|
|
2150
|
+
* reconnecting: a failure caused by the socket dropping would otherwise
|
|
2151
|
+
* start every waiting video on a dead connection, each failing with
|
|
2152
|
+
* not-connected; the reconnect's join lists them all again anyway.
|
|
2153
|
+
*/
|
|
2154
|
+
releaseVideoSlot(producerId) {
|
|
2155
|
+
if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
|
|
2156
|
+
this.consumeSkipped();
|
|
2157
|
+
}
|
|
1975
2158
|
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1976
|
-
|
|
2159
|
+
consumeSkipped() {
|
|
1977
2160
|
for (const [producerId, remote] of this.skippedVideos) {
|
|
1978
2161
|
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1979
2162
|
if (limit !== null && this.videoSlots.size >= limit)
|
|
1980
2163
|
return;
|
|
1981
2164
|
this.skippedVideos.delete(producerId);
|
|
1982
|
-
await
|
|
2165
|
+
// consume reserves synchronously, before its first await. Consecutive
|
|
2166
|
+
// releases cannot admit the same producer or exceed the limit.
|
|
2167
|
+
void this.consume(remote).catch((err) => this.emit('error', err));
|
|
1983
2168
|
}
|
|
1984
2169
|
}
|
|
1985
|
-
async consumeNow(remote) {
|
|
2170
|
+
async consumeNow(remote, operation) {
|
|
1986
2171
|
if (!this.device)
|
|
1987
2172
|
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
2173
|
+
const device = this.device;
|
|
2174
|
+
const current = (made) => {
|
|
2175
|
+
if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
|
|
2176
|
+
return;
|
|
2177
|
+
made?.close();
|
|
2178
|
+
throw new errors_js_1.RpcError('not-connected', 'subscription ended during setup');
|
|
2179
|
+
};
|
|
1988
2180
|
const transport = await this.ensureRecvTransport();
|
|
2181
|
+
current();
|
|
1989
2182
|
const params = (await this.rpc('consume', {
|
|
1990
2183
|
transportId: transport.id,
|
|
1991
2184
|
producerId: remote.producerId,
|
|
1992
|
-
rtpCapabilities:
|
|
2185
|
+
rtpCapabilities: device.rtpCapabilities,
|
|
1993
2186
|
}));
|
|
2187
|
+
current();
|
|
1994
2188
|
const consumer = await transport.consume(params);
|
|
2189
|
+
current(consumer);
|
|
1995
2190
|
this.consumers.set(remote.producerId, consumer);
|
|
1996
2191
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
1997
2192
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
@@ -2011,9 +2206,11 @@ class StreamClient {
|
|
|
2011
2206
|
if (layers) {
|
|
2012
2207
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
2013
2208
|
}
|
|
2209
|
+
current(consumer);
|
|
2014
2210
|
// The server created it paused. Resume only now that our end exists,
|
|
2015
2211
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
2016
2212
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2213
|
+
current(consumer);
|
|
2017
2214
|
const kind = consumer.kind;
|
|
2018
2215
|
const source = remote.source ?? defaultSource(kind);
|
|
2019
2216
|
this.remoteSources.set(remote.producerId, source);
|
|
@@ -2072,18 +2269,53 @@ class StreamClient {
|
|
|
2072
2269
|
this.music?.setEnabled(false);
|
|
2073
2270
|
}
|
|
2074
2271
|
async setKindEnabled(kind, enabled) {
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2272
|
+
const generation = this.publishGeneration;
|
|
2273
|
+
// Unpublished or closed while waiting: nothing is left to switch, and the
|
|
2274
|
+
// call resolves, as it does with nothing published (V072-03).
|
|
2275
|
+
const ended = () => generation !== this.publishGeneration || this.closedByUs;
|
|
2276
|
+
// The music has its own switch. Muting the microphone must not stop the song.
|
|
2277
|
+
const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
|
|
2278
|
+
for (const producer of targets) {
|
|
2279
|
+
if (ended())
|
|
2280
|
+
return;
|
|
2281
|
+
if (this.producers.get(producer.id) !== producer || producer.closed)
|
|
2078
2282
|
continue;
|
|
2283
|
+
const revision = this.moderationRevision[kind];
|
|
2079
2284
|
if (!enabled)
|
|
2080
2285
|
producer.pause();
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2286
|
+
try {
|
|
2287
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
2288
|
+
}
|
|
2289
|
+
catch (err) {
|
|
2290
|
+
if (ended())
|
|
2291
|
+
return;
|
|
2292
|
+
throw err;
|
|
2293
|
+
}
|
|
2294
|
+
if (ended())
|
|
2295
|
+
return;
|
|
2296
|
+
if (!enabled || this.producers.get(producer.id) !== producer || producer.closed)
|
|
2297
|
+
continue;
|
|
2298
|
+
if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
|
|
2299
|
+
// A moderator decided while this waited: the older request does not
|
|
2300
|
+
// undo it. It stays off here, and the server is told so too, since
|
|
2301
|
+
// which of the two it applied last cannot be known from here.
|
|
2302
|
+
void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
|
|
2303
|
+
this.emitLocalMedia();
|
|
2304
|
+
throw new errors_js_1.RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
|
|
2305
|
+
}
|
|
2306
|
+
producer.resume();
|
|
2084
2307
|
}
|
|
2085
2308
|
this.emitLocalMedia();
|
|
2086
2309
|
}
|
|
2310
|
+
/** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
|
|
2311
|
+
moderate(kind, paused, snapshot = false) {
|
|
2312
|
+
for (const k of kind ? [kind] : ['audio', 'video']) {
|
|
2313
|
+
if (snapshot && this.forcedOff[k] === paused)
|
|
2314
|
+
continue;
|
|
2315
|
+
this.forcedOff[k] = paused;
|
|
2316
|
+
this.moderationRevision[k] += 1;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2087
2319
|
/** Stop publishing entirely and release the local devices. */
|
|
2088
2320
|
async unpublish() {
|
|
2089
2321
|
void this.stopRecording().catch(error => this.emit('error', error));
|