@voltkit-rtc/client 0.7.1 → 0.7.2
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 +58 -0
- package/README.md +25 -2
- 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 +56 -3
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +197 -58
- package/dist/cjs/index.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 +56 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +197 -58
- package/dist/esm/index.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 +201 -53
package/dist/esm/index.js
CHANGED
|
@@ -110,6 +110,16 @@ export class StreamClient {
|
|
|
110
110
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
111
111
|
music = null;
|
|
112
112
|
musicProducerId = null;
|
|
113
|
+
/**
|
|
114
|
+
* Bumped by every publishMusic() and stopMusic(). A music producer still
|
|
115
|
+
* being made when it changes belongs to a publish that is over: it is
|
|
116
|
+
* closed, not kept. publishGeneration alone missed stopMusic(), and the
|
|
117
|
+
* music went out after stopMusic() had returned.
|
|
118
|
+
*/
|
|
119
|
+
musicOperation = 0;
|
|
120
|
+
// Music publishes run one at a time: the server takes one producer per
|
|
121
|
+
// source, so the next is produced only once the last one is settled.
|
|
122
|
+
musicQueue = Promise.resolve();
|
|
113
123
|
screenStream = null;
|
|
114
124
|
screenMode = 'detail';
|
|
115
125
|
screenAudioEnabled = true;
|
|
@@ -126,6 +136,8 @@ export class StreamClient {
|
|
|
126
136
|
frameRateWarmupSamples = 2;
|
|
127
137
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
128
138
|
remoteSources = new Map();
|
|
139
|
+
/** Identity of each pending/active subscription; cleared on close/reconnect. */
|
|
140
|
+
subscriptions = new Map();
|
|
129
141
|
/**
|
|
130
142
|
* The layers asked for each remote video, by producerId. A reconnect
|
|
131
143
|
* consumes everything again, and the new consumer would otherwise start at
|
|
@@ -164,6 +176,17 @@ export class StreamClient {
|
|
|
164
176
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
165
177
|
tokenExpiry;
|
|
166
178
|
tokenTimer = null;
|
|
179
|
+
/**
|
|
180
|
+
* Bumped whenever the server hands this client a token (its own refresh, a
|
|
181
|
+
* promotion's). An updateToken() that started before then keeps the token
|
|
182
|
+
* it was given only if nothing newer arrived while it waited: its reply,
|
|
183
|
+
* or its fallback once the socket closed, used to replace a promotion's
|
|
184
|
+
* token, and the reconnect joined with the old one.
|
|
185
|
+
*/
|
|
186
|
+
serverTokenRevision = 0;
|
|
187
|
+
/** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
|
|
188
|
+
tokenRequests = 0;
|
|
189
|
+
tokenRequestHeld = 0;
|
|
167
190
|
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
168
191
|
remoteInfo = new Map();
|
|
169
192
|
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
@@ -225,6 +248,17 @@ export class StreamClient {
|
|
|
225
248
|
this.setState('closed');
|
|
226
249
|
this.emit('closed', reason);
|
|
227
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Over for good without the app asking: finish, then release everything
|
|
253
|
+
* local as close() does -- camera, microphone, screen, music, recording.
|
|
254
|
+
* kicked and roomClosed always did; a replaced socket, a close without
|
|
255
|
+
* roomClosed and a reconnect that gave up left them capturing (and the
|
|
256
|
+
* recording uploading) after `closed`, until the app called close().
|
|
257
|
+
*/
|
|
258
|
+
end(reason) {
|
|
259
|
+
this.finish(reason);
|
|
260
|
+
this.close();
|
|
261
|
+
}
|
|
228
262
|
stopTimers() {
|
|
229
263
|
if (this.tokenTimer)
|
|
230
264
|
clearTimeout(this.tokenTimer);
|
|
@@ -251,8 +285,13 @@ export class StreamClient {
|
|
|
251
285
|
let result;
|
|
252
286
|
if (this.state === 'idle' || this.state === 'closed')
|
|
253
287
|
this.setState('connecting');
|
|
288
|
+
// The socket this call opens. A later connect() replaces it, and this
|
|
289
|
+
// call's failure must then leave the newer socket, and the state, alone.
|
|
290
|
+
let socket = null;
|
|
254
291
|
try {
|
|
255
|
-
|
|
292
|
+
const opening = this.openSocket();
|
|
293
|
+
socket = this.ws;
|
|
294
|
+
await opening;
|
|
256
295
|
result = (await this.rpc('join', { token: this.token }));
|
|
257
296
|
this.joinResult = result;
|
|
258
297
|
this.device = this.createDevice();
|
|
@@ -266,7 +305,8 @@ export class StreamClient {
|
|
|
266
305
|
// A session that never got going: the app has the error. Left open,
|
|
267
306
|
// the socket would be closed by the server's join deadline instead, and
|
|
268
307
|
// that close would start reconnecting a session that never began.
|
|
269
|
-
|
|
308
|
+
const superseded = this.ws !== null && this.ws !== socket;
|
|
309
|
+
if (!this.joinedOnce && !superseded) {
|
|
270
310
|
this.detachSocket();
|
|
271
311
|
if (this.state === 'connecting')
|
|
272
312
|
this.setState('idle');
|
|
@@ -370,17 +410,16 @@ export class StreamClient {
|
|
|
370
410
|
// Another session for this identity is live. Reconnecting would
|
|
371
411
|
// displace it, and it would displace us right back, forever.
|
|
372
412
|
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
373
|
-
// The
|
|
374
|
-
// videoLimited sampling must not, or it reports after `closed`.
|
|
413
|
+
// The videoLimited sampling stops first, or it reports after `closed`.
|
|
375
414
|
this.limitWatch.reset();
|
|
376
|
-
this.
|
|
415
|
+
this.end('replaced');
|
|
377
416
|
return;
|
|
378
417
|
}
|
|
379
418
|
if (ev.code === CLOSE_ENDED) {
|
|
380
419
|
// Normally roomClosed has already ended the session; this covers a
|
|
381
420
|
// close that arrived without it.
|
|
382
421
|
this.limitWatch.reset();
|
|
383
|
-
this.
|
|
422
|
+
this.end('room-closed');
|
|
384
423
|
return;
|
|
385
424
|
}
|
|
386
425
|
// Before a first successful join the app is still waiting on
|
|
@@ -393,10 +432,12 @@ export class StreamClient {
|
|
|
393
432
|
});
|
|
394
433
|
});
|
|
395
434
|
}
|
|
396
|
-
detachSocket(reason = 'superseded') {
|
|
435
|
+
detachSocket(reason = 'superseded', why = 'the connection was replaced') {
|
|
397
436
|
const ws = this.ws;
|
|
398
437
|
this.ws = null;
|
|
399
|
-
if (ws
|
|
438
|
+
if (!ws)
|
|
439
|
+
return;
|
|
440
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
400
441
|
try {
|
|
401
442
|
ws.close(1000, reason);
|
|
402
443
|
}
|
|
@@ -404,6 +445,10 @@ export class StreamClient {
|
|
|
404
445
|
/* already closing */
|
|
405
446
|
}
|
|
406
447
|
}
|
|
448
|
+
// Its replies are no longer read, so what waits on it would only wait out
|
|
449
|
+
// its 15 s timeout: it fails now, as on a close. Never replayed -- the
|
|
450
|
+
// server may already have acted on it.
|
|
451
|
+
this.failPending(ws, new RpcError('not-connected', why));
|
|
407
452
|
}
|
|
408
453
|
/**
|
|
409
454
|
* A reconnect that ends the session lets go of its socket. A refused join
|
|
@@ -412,7 +457,7 @@ export class StreamClient {
|
|
|
412
457
|
* still sending.
|
|
413
458
|
*/
|
|
414
459
|
releaseSocket() {
|
|
415
|
-
this.detachSocket('gave up');
|
|
460
|
+
this.detachSocket('gave up', 'the session has ended');
|
|
416
461
|
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
417
462
|
}
|
|
418
463
|
/**
|
|
@@ -433,7 +478,7 @@ export class StreamClient {
|
|
|
433
478
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
434
479
|
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
435
480
|
this.releaseSocket();
|
|
436
|
-
this.
|
|
481
|
+
this.end('gave-up');
|
|
437
482
|
return;
|
|
438
483
|
}
|
|
439
484
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -461,7 +506,7 @@ export class StreamClient {
|
|
|
461
506
|
// attempt forever and drowns out whatever actually went wrong.
|
|
462
507
|
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
463
508
|
this.releaseSocket();
|
|
464
|
-
this.
|
|
509
|
+
this.end('gave-up');
|
|
465
510
|
return;
|
|
466
511
|
}
|
|
467
512
|
}
|
|
@@ -506,6 +551,7 @@ export class StreamClient {
|
|
|
506
551
|
this.emit('closed', 'left');
|
|
507
552
|
}
|
|
508
553
|
teardownMedia() {
|
|
554
|
+
this.subscriptions.clear();
|
|
509
555
|
for (const c of this.consumers.values())
|
|
510
556
|
c.close();
|
|
511
557
|
this.consumers.clear();
|
|
@@ -576,19 +622,29 @@ export class StreamClient {
|
|
|
576
622
|
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
577
623
|
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
578
624
|
* session then keeps its token); otherwise it is kept for the next join,
|
|
579
|
-
* which checks it there. Resolves to when
|
|
625
|
+
* which checks it there. Resolves to when the token now held expires (unix
|
|
626
|
+
* ms), when known. A newer token that arrives while this waits is kept
|
|
627
|
+
* instead of this one: the server's (its own refresh, or a promotion's,
|
|
628
|
+
* which carries the new role) or a later updateToken()'s.
|
|
580
629
|
*/
|
|
581
630
|
async updateToken(token) {
|
|
582
631
|
if (typeof token !== 'string' || token.trim() === '')
|
|
583
632
|
throw new TypeError('updateToken needs a token');
|
|
584
633
|
if (this.state === 'closed')
|
|
585
634
|
throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
635
|
+
const request = ++this.tokenRequests;
|
|
636
|
+
const revision = this.serverTokenRevision;
|
|
637
|
+
const keep = (expiresAt) => {
|
|
638
|
+
if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
|
|
639
|
+
this.tokenRequestHeld = request;
|
|
640
|
+
this.setToken(token, expiresAt);
|
|
641
|
+
}
|
|
642
|
+
return { expiresAt: this.tokenExpiry };
|
|
643
|
+
};
|
|
586
644
|
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
587
645
|
try {
|
|
588
646
|
const res = (await this.rpc('refreshToken', { token }));
|
|
589
|
-
|
|
590
|
-
this.setToken(token, at);
|
|
591
|
-
return { expiresAt: this.tokenExpiry };
|
|
647
|
+
return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
|
|
592
648
|
}
|
|
593
649
|
catch (err) {
|
|
594
650
|
// An older server has no refreshToken, and a socket that dropped
|
|
@@ -598,8 +654,7 @@ export class StreamClient {
|
|
|
598
654
|
throw err;
|
|
599
655
|
}
|
|
600
656
|
}
|
|
601
|
-
|
|
602
|
-
return { expiresAt: this.tokenExpiry };
|
|
657
|
+
return keep(null);
|
|
603
658
|
}
|
|
604
659
|
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
605
660
|
setToken(token, expiresAt) {
|
|
@@ -660,10 +715,20 @@ export class StreamClient {
|
|
|
660
715
|
this.pending.delete(id);
|
|
661
716
|
reject(new RpcError('timeout', `${method} timed out`));
|
|
662
717
|
}, RPC_TIMEOUT_MS);
|
|
663
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
718
|
+
this.pending.set(id, { resolve, reject, timer, ws: this.ws });
|
|
664
719
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
665
720
|
});
|
|
666
721
|
}
|
|
722
|
+
/** Fail what was sent on `ws` and is still waiting. */
|
|
723
|
+
failPending(ws, err) {
|
|
724
|
+
for (const [id, p] of this.pending) {
|
|
725
|
+
if (p.ws !== ws)
|
|
726
|
+
continue;
|
|
727
|
+
clearTimeout(p.timer);
|
|
728
|
+
this.pending.delete(id);
|
|
729
|
+
p.reject(err);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
667
732
|
failAllPending(err) {
|
|
668
733
|
for (const p of this.pending.values()) {
|
|
669
734
|
clearTimeout(p.timer);
|
|
@@ -708,23 +773,21 @@ export class StreamClient {
|
|
|
708
773
|
break;
|
|
709
774
|
case 'producerClosed': {
|
|
710
775
|
const { producerId, identity } = data;
|
|
776
|
+
this.subscriptions.delete(producerId);
|
|
711
777
|
this.preferredLayers.delete(producerId);
|
|
712
778
|
this.receiveQualities.delete(producerId);
|
|
713
779
|
this.remoteVideoLayers.delete(producerId);
|
|
714
780
|
this.skippedVideos.delete(producerId);
|
|
715
781
|
const consumer = this.consumers.get(producerId);
|
|
716
|
-
|
|
717
|
-
break; // never consumed it, or already cleaned up
|
|
718
|
-
this.videoSlots.delete(producerId);
|
|
719
|
-
consumer.close();
|
|
782
|
+
consumer?.close();
|
|
720
783
|
this.consumers.delete(producerId);
|
|
721
784
|
this.remoteSources.delete(producerId);
|
|
722
785
|
this.remoteInfo.delete(producerId);
|
|
723
|
-
|
|
786
|
+
if (consumer)
|
|
787
|
+
this.emit('trackEnded', { producerId, identity });
|
|
724
788
|
// A video slot opened under the fan-out limit: the oldest video left
|
|
725
789
|
// waiting takes it.
|
|
726
|
-
|
|
727
|
-
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
790
|
+
this.releaseVideoSlot(producerId);
|
|
728
791
|
break;
|
|
729
792
|
}
|
|
730
793
|
case 'producerPaused': {
|
|
@@ -822,15 +885,19 @@ export class StreamClient {
|
|
|
822
885
|
// fresh one periodically so a reconnect at any point still works --
|
|
823
886
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
824
887
|
const e = data;
|
|
825
|
-
if (e.token)
|
|
888
|
+
if (e.token) {
|
|
889
|
+
this.serverTokenRevision += 1;
|
|
826
890
|
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
891
|
+
}
|
|
827
892
|
break;
|
|
828
893
|
}
|
|
829
894
|
case 'mustReconnect': {
|
|
830
895
|
const e = data;
|
|
831
896
|
// A promotion hands us a fresh token carrying the new role.
|
|
832
|
-
if (e.token)
|
|
897
|
+
if (e.token) {
|
|
898
|
+
this.serverTokenRevision += 1;
|
|
833
899
|
this.setToken(e.token, null);
|
|
900
|
+
}
|
|
834
901
|
this.emit('mustReconnect', e);
|
|
835
902
|
if (e.retry) {
|
|
836
903
|
this.teardownMedia();
|
|
@@ -1594,8 +1661,11 @@ export class StreamClient {
|
|
|
1594
1661
|
await this.stopScreen('stopped');
|
|
1595
1662
|
this.emit('error', err);
|
|
1596
1663
|
});
|
|
1597
|
-
if (this.music)
|
|
1598
|
-
|
|
1664
|
+
if (this.music) {
|
|
1665
|
+
const music = this.music;
|
|
1666
|
+
const operation = this.musicOperation;
|
|
1667
|
+
await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
|
|
1668
|
+
}
|
|
1599
1669
|
if (generation !== this.publishGeneration)
|
|
1600
1670
|
return;
|
|
1601
1671
|
let stream = this.localStream;
|
|
@@ -1829,28 +1899,43 @@ export class StreamClient {
|
|
|
1829
1899
|
if (!this.mediaPermissions.audio)
|
|
1830
1900
|
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1831
1901
|
const generation = this.publishGeneration;
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
// Checked again here: an unpublish() after the producer was kept has
|
|
1838
|
-
// already closed it, and the music must not be remembered for a
|
|
1839
|
-
// reconnect to publish again.
|
|
1840
|
-
if (generation !== this.publishGeneration)
|
|
1902
|
+
// Taken now, in call order: a music publish still under way is over, and
|
|
1903
|
+
// a stopMusic() from here on ends this one.
|
|
1904
|
+
const operation = ++this.musicOperation;
|
|
1905
|
+
await this.musicTask(async () => {
|
|
1906
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation)
|
|
1841
1907
|
throw unpublishedWhile('music');
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
music
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1908
|
+
if (this.music)
|
|
1909
|
+
await this.closeMusic();
|
|
1910
|
+
const music = new MusicSource(input, options);
|
|
1911
|
+
try {
|
|
1912
|
+
await this.produceMusic(music, generation, operation);
|
|
1913
|
+
// Checked again here: an unpublish() or stopMusic() after the
|
|
1914
|
+
// producer was kept has already closed it, and the music must not be
|
|
1915
|
+
// remembered for a reconnect to publish again.
|
|
1916
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation)
|
|
1917
|
+
throw unpublishedWhile('music');
|
|
1918
|
+
}
|
|
1919
|
+
catch (err) {
|
|
1920
|
+
music.close();
|
|
1921
|
+
throw err;
|
|
1922
|
+
}
|
|
1923
|
+
this.music = music;
|
|
1924
|
+
this.wasPublishing = true;
|
|
1925
|
+
this.emitLocalMedia();
|
|
1926
|
+
});
|
|
1850
1927
|
}
|
|
1851
|
-
|
|
1928
|
+
musicTask(task) {
|
|
1929
|
+
const run = this.musicQueue.then(task, task);
|
|
1930
|
+
this.musicQueue = run.catch(() => undefined);
|
|
1931
|
+
return run;
|
|
1932
|
+
}
|
|
1933
|
+
async produceMusic(music, generation, operation) {
|
|
1934
|
+
// unpublish(), stopMusic() or a newer publishMusic() can run at any wait
|
|
1935
|
+
// below, and wins.
|
|
1936
|
+
const current = () => generation === this.publishGeneration && operation === this.musicOperation;
|
|
1852
1937
|
const transport = await this.ensureSendTransport();
|
|
1853
|
-
if (
|
|
1938
|
+
if (!current())
|
|
1854
1939
|
throw unpublishedWhile('music');
|
|
1855
1940
|
const producer = await transport.produce({
|
|
1856
1941
|
track: music.track,
|
|
@@ -1860,7 +1945,14 @@ export class StreamClient {
|
|
|
1860
1945
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
1861
1946
|
appData: { source: 'music' },
|
|
1862
1947
|
});
|
|
1863
|
-
|
|
1948
|
+
if (!current()) {
|
|
1949
|
+
producer.close();
|
|
1950
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1951
|
+
throw unpublishedWhile('music');
|
|
1952
|
+
}
|
|
1953
|
+
// Kept and named the music in one step, with no wait between: stopMusic()
|
|
1954
|
+
// finds the music producer by that name.
|
|
1955
|
+
this.producers.set(producer.id, producer);
|
|
1864
1956
|
this.musicProducerId = producer.id;
|
|
1865
1957
|
// Music switched off before a reconnect comes back paused here.
|
|
1866
1958
|
await this.pauseOnServer(producer);
|
|
@@ -1889,8 +1981,17 @@ export class StreamClient {
|
|
|
1889
1981
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1890
1982
|
this.emitLocalMedia();
|
|
1891
1983
|
}
|
|
1892
|
-
/**
|
|
1984
|
+
/**
|
|
1985
|
+
* Stop publishing music. The element or stream it came from is left as it
|
|
1986
|
+
* was. A publishMusic() still under way is stopped too: it rejects with
|
|
1987
|
+
* `unpublished` and publishes nothing.
|
|
1988
|
+
*/
|
|
1893
1989
|
async stopMusic() {
|
|
1990
|
+
this.musicOperation += 1;
|
|
1991
|
+
await this.closeMusic();
|
|
1992
|
+
}
|
|
1993
|
+
/** Close the music being published, here and at the server. */
|
|
1994
|
+
async closeMusic() {
|
|
1894
1995
|
const id = this.musicProducerId;
|
|
1895
1996
|
this.musicProducerId = null;
|
|
1896
1997
|
this.music?.close();
|
|
@@ -1910,9 +2011,11 @@ export class StreamClient {
|
|
|
1910
2011
|
});
|
|
1911
2012
|
}
|
|
1912
2013
|
async consume(remote) {
|
|
2014
|
+
if (this.closedByUs)
|
|
2015
|
+
return;
|
|
1913
2016
|
if (!this.device)
|
|
1914
2017
|
throw new RpcError('not-connected', 'not connected');
|
|
1915
|
-
if (this.
|
|
2018
|
+
if (this.subscriptions.has(remote.producerId))
|
|
1916
2019
|
return;
|
|
1917
2020
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1918
2021
|
// consumer and shows you a delayed copy of yourself.
|
|
@@ -1932,35 +2035,69 @@ export class StreamClient {
|
|
|
1932
2035
|
}
|
|
1933
2036
|
this.videoSlots.add(remote.producerId);
|
|
1934
2037
|
}
|
|
2038
|
+
const operation = {};
|
|
2039
|
+
this.subscriptions.set(remote.producerId, operation);
|
|
1935
2040
|
try {
|
|
1936
|
-
await this.consumeNow(remote);
|
|
2041
|
+
await this.consumeNow(remote, operation);
|
|
1937
2042
|
}
|
|
1938
2043
|
catch (err) {
|
|
1939
|
-
|
|
1940
|
-
|
|
2044
|
+
// A closed producer or replaced session has already released its slot.
|
|
2045
|
+
// Its late failure must neither release a newer slot nor report a ghost error.
|
|
2046
|
+
if (this.subscriptions.get(remote.producerId) !== operation)
|
|
2047
|
+
return;
|
|
2048
|
+
this.subscriptions.delete(remote.producerId);
|
|
2049
|
+
this.consumers.get(remote.producerId)?.close();
|
|
2050
|
+
this.consumers.delete(remote.producerId);
|
|
2051
|
+
this.remoteVideoLayers.delete(remote.producerId);
|
|
2052
|
+
this.remoteSources.delete(remote.producerId);
|
|
2053
|
+
this.remoteInfo.delete(remote.producerId);
|
|
2054
|
+
this.releaseVideoSlot(remote.producerId);
|
|
1941
2055
|
throw err;
|
|
1942
2056
|
}
|
|
1943
2057
|
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Free a video slot, and let the oldest video waiting take it -- whatever
|
|
2060
|
+
* freed it: an ended video, or a subscription that failed. Not while
|
|
2061
|
+
* reconnecting: a failure caused by the socket dropping would otherwise
|
|
2062
|
+
* start every waiting video on a dead connection, each failing with
|
|
2063
|
+
* not-connected; the reconnect's join lists them all again anyway.
|
|
2064
|
+
*/
|
|
2065
|
+
releaseVideoSlot(producerId) {
|
|
2066
|
+
if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
|
|
2067
|
+
this.consumeSkipped();
|
|
2068
|
+
}
|
|
1944
2069
|
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1945
|
-
|
|
2070
|
+
consumeSkipped() {
|
|
1946
2071
|
for (const [producerId, remote] of this.skippedVideos) {
|
|
1947
2072
|
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1948
2073
|
if (limit !== null && this.videoSlots.size >= limit)
|
|
1949
2074
|
return;
|
|
1950
2075
|
this.skippedVideos.delete(producerId);
|
|
1951
|
-
await
|
|
2076
|
+
// consume reserves synchronously, before its first await. Consecutive
|
|
2077
|
+
// releases cannot admit the same producer or exceed the limit.
|
|
2078
|
+
void this.consume(remote).catch((err) => this.emit('error', err));
|
|
1952
2079
|
}
|
|
1953
2080
|
}
|
|
1954
|
-
async consumeNow(remote) {
|
|
2081
|
+
async consumeNow(remote, operation) {
|
|
1955
2082
|
if (!this.device)
|
|
1956
2083
|
throw new RpcError('not-connected', 'not connected');
|
|
2084
|
+
const device = this.device;
|
|
2085
|
+
const current = (made) => {
|
|
2086
|
+
if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
|
|
2087
|
+
return;
|
|
2088
|
+
made?.close();
|
|
2089
|
+
throw new RpcError('not-connected', 'subscription ended during setup');
|
|
2090
|
+
};
|
|
1957
2091
|
const transport = await this.ensureRecvTransport();
|
|
2092
|
+
current();
|
|
1958
2093
|
const params = (await this.rpc('consume', {
|
|
1959
2094
|
transportId: transport.id,
|
|
1960
2095
|
producerId: remote.producerId,
|
|
1961
|
-
rtpCapabilities:
|
|
2096
|
+
rtpCapabilities: device.rtpCapabilities,
|
|
1962
2097
|
}));
|
|
2098
|
+
current();
|
|
1963
2099
|
const consumer = await transport.consume(params);
|
|
2100
|
+
current(consumer);
|
|
1964
2101
|
this.consumers.set(remote.producerId, consumer);
|
|
1965
2102
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
1966
2103
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
@@ -1980,9 +2117,11 @@ export class StreamClient {
|
|
|
1980
2117
|
if (layers) {
|
|
1981
2118
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
1982
2119
|
}
|
|
2120
|
+
current(consumer);
|
|
1983
2121
|
// The server created it paused. Resume only now that our end exists,
|
|
1984
2122
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
1985
2123
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2124
|
+
current(consumer);
|
|
1986
2125
|
const kind = consumer.kind;
|
|
1987
2126
|
const source = remote.source ?? defaultSource(kind);
|
|
1988
2127
|
this.remoteSources.set(remote.producerId, source);
|