@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/esm/index.js
CHANGED
|
@@ -58,6 +58,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
|
|
|
58
58
|
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
59
59
|
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
60
60
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
61
|
+
/**
|
|
62
|
+
* The constraints that capture again what two publishes captured: each kind
|
|
63
|
+
* as the later publish asked for it, or as the earlier one did if the later
|
|
64
|
+
* one did not ask for it.
|
|
65
|
+
*/
|
|
66
|
+
function mergeConstraints(earlier, later) {
|
|
67
|
+
if (!earlier)
|
|
68
|
+
return later;
|
|
69
|
+
return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
|
|
70
|
+
}
|
|
61
71
|
const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
62
72
|
/**
|
|
63
73
|
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
@@ -105,11 +115,30 @@ export class StreamClient {
|
|
|
105
115
|
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
106
116
|
// disconnected: the room looks healthy and shows nothing.
|
|
107
117
|
wasPublishing = false;
|
|
118
|
+
/** The deadline of the socket being opened (openSocket). */
|
|
119
|
+
openDeadline;
|
|
120
|
+
/**
|
|
121
|
+
* This client's own moderation per kind: whether a moderator holds it off,
|
|
122
|
+
* and a revision every decision bumps. A switch-on that was waiting for the
|
|
123
|
+
* server when a decision arrived does not act on its reply (V072-06).
|
|
124
|
+
*/
|
|
125
|
+
forcedOff = { audio: false, video: false };
|
|
126
|
+
moderationRevision = { audio: 0, video: 0 };
|
|
108
127
|
publishConstraints = null;
|
|
109
128
|
publishOptions = {};
|
|
110
129
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
111
130
|
music = null;
|
|
112
131
|
musicProducerId = null;
|
|
132
|
+
/**
|
|
133
|
+
* Bumped by every publishMusic() and stopMusic(). A music producer still
|
|
134
|
+
* being made when it changes belongs to a publish that is over: it is
|
|
135
|
+
* closed, not kept. publishGeneration alone missed stopMusic(), and the
|
|
136
|
+
* music went out after stopMusic() had returned.
|
|
137
|
+
*/
|
|
138
|
+
musicOperation = 0;
|
|
139
|
+
// Music publishes run one at a time: the server takes one producer per
|
|
140
|
+
// source, so the next is produced only once the last one is settled.
|
|
141
|
+
musicQueue = Promise.resolve();
|
|
113
142
|
screenStream = null;
|
|
114
143
|
screenMode = 'detail';
|
|
115
144
|
screenAudioEnabled = true;
|
|
@@ -126,6 +155,8 @@ export class StreamClient {
|
|
|
126
155
|
frameRateWarmupSamples = 2;
|
|
127
156
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
128
157
|
remoteSources = new Map();
|
|
158
|
+
/** Identity of each pending/active subscription; cleared on close/reconnect. */
|
|
159
|
+
subscriptions = new Map();
|
|
129
160
|
/**
|
|
130
161
|
* The layers asked for each remote video, by producerId. A reconnect
|
|
131
162
|
* consumes everything again, and the new consumer would otherwise start at
|
|
@@ -164,6 +195,17 @@ export class StreamClient {
|
|
|
164
195
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
165
196
|
tokenExpiry;
|
|
166
197
|
tokenTimer = null;
|
|
198
|
+
/**
|
|
199
|
+
* Bumped whenever the server hands this client a token (its own refresh, a
|
|
200
|
+
* promotion's). An updateToken() that started before then keeps the token
|
|
201
|
+
* it was given only if nothing newer arrived while it waited: its reply,
|
|
202
|
+
* or its fallback once the socket closed, used to replace a promotion's
|
|
203
|
+
* token, and the reconnect joined with the old one.
|
|
204
|
+
*/
|
|
205
|
+
serverTokenRevision = 0;
|
|
206
|
+
/** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
|
|
207
|
+
tokenRequests = 0;
|
|
208
|
+
tokenRequestHeld = 0;
|
|
167
209
|
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
168
210
|
remoteInfo = new Map();
|
|
169
211
|
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
@@ -225,6 +267,17 @@ export class StreamClient {
|
|
|
225
267
|
this.setState('closed');
|
|
226
268
|
this.emit('closed', reason);
|
|
227
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Over for good without the app asking: finish, then release everything
|
|
272
|
+
* local as close() does -- camera, microphone, screen, music, recording.
|
|
273
|
+
* kicked and roomClosed always did; a replaced socket, a close without
|
|
274
|
+
* roomClosed and a reconnect that gave up left them capturing (and the
|
|
275
|
+
* recording uploading) after `closed`, until the app called close().
|
|
276
|
+
*/
|
|
277
|
+
end(reason) {
|
|
278
|
+
this.finish(reason);
|
|
279
|
+
this.close();
|
|
280
|
+
}
|
|
228
281
|
stopTimers() {
|
|
229
282
|
if (this.tokenTimer)
|
|
230
283
|
clearTimeout(this.tokenTimer);
|
|
@@ -251,8 +304,13 @@ export class StreamClient {
|
|
|
251
304
|
let result;
|
|
252
305
|
if (this.state === 'idle' || this.state === 'closed')
|
|
253
306
|
this.setState('connecting');
|
|
307
|
+
// The socket this call opens. A later connect() replaces it, and this
|
|
308
|
+
// call's failure must then leave the newer socket, and the state, alone.
|
|
309
|
+
let socket = null;
|
|
254
310
|
try {
|
|
255
|
-
|
|
311
|
+
const opening = this.openSocket();
|
|
312
|
+
socket = this.ws;
|
|
313
|
+
await opening;
|
|
256
314
|
result = (await this.rpc('join', { token: this.token }));
|
|
257
315
|
this.joinResult = result;
|
|
258
316
|
this.device = this.createDevice();
|
|
@@ -266,7 +324,11 @@ export class StreamClient {
|
|
|
266
324
|
// A session that never got going: the app has the error. Left open,
|
|
267
325
|
// the socket would be closed by the server's join deadline instead, and
|
|
268
326
|
// that close would start reconnecting a session that never began.
|
|
269
|
-
|
|
327
|
+
const superseded = this.ws !== null && this.ws !== socket;
|
|
328
|
+
// A reconnect attempt that failed (its join timed out, say) lets its
|
|
329
|
+
// socket go now too, so nothing it says later reaches the session while
|
|
330
|
+
// the next attempt waits its backoff (V072-05).
|
|
331
|
+
if ((!this.joinedOnce || this.reconnecting) && !superseded) {
|
|
270
332
|
this.detachSocket();
|
|
271
333
|
if (this.state === 'connecting')
|
|
272
334
|
this.setState('idle');
|
|
@@ -290,6 +352,8 @@ export class StreamClient {
|
|
|
290
352
|
throw closedWhileConnecting();
|
|
291
353
|
}
|
|
292
354
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
355
|
+
this.moderate('audio', Boolean(own?.muted), true);
|
|
356
|
+
this.moderate('video', Boolean(own?.videoBlocked), true);
|
|
293
357
|
if (own?.muted)
|
|
294
358
|
this.applyLocalModeration('audio');
|
|
295
359
|
if (own?.videoBlocked)
|
|
@@ -345,11 +409,23 @@ export class StreamClient {
|
|
|
345
409
|
return new Promise((resolve, reject) => {
|
|
346
410
|
const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
|
|
347
411
|
this.ws = ws;
|
|
412
|
+
// Bounded like every request (V072-05): a socket that does not open in
|
|
413
|
+
// time fails the attempt with `timeout` and is closed, instead of
|
|
414
|
+
// holding connect() -- or a reconnect attempt -- indefinitely.
|
|
415
|
+
this.openDeadline = setTimeout(() => {
|
|
416
|
+
reject(new RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
|
|
417
|
+
if (this.ws === ws)
|
|
418
|
+
this.detachSocket('open timed out', 'the connection did not open in time');
|
|
419
|
+
}, RPC_TIMEOUT_MS);
|
|
348
420
|
const onOpen = () => {
|
|
421
|
+
clearTimeout(this.openDeadline);
|
|
349
422
|
this.emit('connected');
|
|
350
423
|
resolve();
|
|
351
424
|
};
|
|
352
|
-
const onError = () =>
|
|
425
|
+
const onError = () => {
|
|
426
|
+
clearTimeout(this.openDeadline);
|
|
427
|
+
reject(new RpcError('connect-failed', 'websocket failed to open'));
|
|
428
|
+
};
|
|
353
429
|
ws.addEventListener('open', onOpen, { once: true });
|
|
354
430
|
ws.addEventListener('error', onError, { once: true });
|
|
355
431
|
ws.addEventListener('message', (ev) => {
|
|
@@ -370,17 +446,16 @@ export class StreamClient {
|
|
|
370
446
|
// Another session for this identity is live. Reconnecting would
|
|
371
447
|
// displace it, and it would displace us right back, forever.
|
|
372
448
|
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
373
|
-
// The
|
|
374
|
-
// videoLimited sampling must not, or it reports after `closed`.
|
|
449
|
+
// The videoLimited sampling stops first, or it reports after `closed`.
|
|
375
450
|
this.limitWatch.reset();
|
|
376
|
-
this.
|
|
451
|
+
this.end('replaced');
|
|
377
452
|
return;
|
|
378
453
|
}
|
|
379
454
|
if (ev.code === CLOSE_ENDED) {
|
|
380
455
|
// Normally roomClosed has already ended the session; this covers a
|
|
381
456
|
// close that arrived without it.
|
|
382
457
|
this.limitWatch.reset();
|
|
383
|
-
this.
|
|
458
|
+
this.end('room-closed');
|
|
384
459
|
return;
|
|
385
460
|
}
|
|
386
461
|
// Before a first successful join the app is still waiting on
|
|
@@ -393,10 +468,13 @@ export class StreamClient {
|
|
|
393
468
|
});
|
|
394
469
|
});
|
|
395
470
|
}
|
|
396
|
-
detachSocket(reason = 'superseded') {
|
|
471
|
+
detachSocket(reason = 'superseded', why = 'the connection was replaced') {
|
|
472
|
+
clearTimeout(this.openDeadline);
|
|
397
473
|
const ws = this.ws;
|
|
398
474
|
this.ws = null;
|
|
399
|
-
if (ws
|
|
475
|
+
if (!ws)
|
|
476
|
+
return;
|
|
477
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
400
478
|
try {
|
|
401
479
|
ws.close(1000, reason);
|
|
402
480
|
}
|
|
@@ -404,6 +482,10 @@ export class StreamClient {
|
|
|
404
482
|
/* already closing */
|
|
405
483
|
}
|
|
406
484
|
}
|
|
485
|
+
// Its replies are no longer read, so what waits on it would only wait out
|
|
486
|
+
// its 15 s timeout: it fails now, as on a close. Never replayed -- the
|
|
487
|
+
// server may already have acted on it.
|
|
488
|
+
this.failPending(ws, new RpcError('not-connected', why));
|
|
407
489
|
}
|
|
408
490
|
/**
|
|
409
491
|
* A reconnect that ends the session lets go of its socket. A refused join
|
|
@@ -412,7 +494,7 @@ export class StreamClient {
|
|
|
412
494
|
* still sending.
|
|
413
495
|
*/
|
|
414
496
|
releaseSocket() {
|
|
415
|
-
this.detachSocket('gave up');
|
|
497
|
+
this.detachSocket('gave up', 'the session has ended');
|
|
416
498
|
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
417
499
|
}
|
|
418
500
|
/**
|
|
@@ -433,7 +515,7 @@ export class StreamClient {
|
|
|
433
515
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
434
516
|
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
435
517
|
this.releaseSocket();
|
|
436
|
-
this.
|
|
518
|
+
this.end('gave-up');
|
|
437
519
|
return;
|
|
438
520
|
}
|
|
439
521
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -461,7 +543,7 @@ export class StreamClient {
|
|
|
461
543
|
// attempt forever and drowns out whatever actually went wrong.
|
|
462
544
|
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
463
545
|
this.releaseSocket();
|
|
464
|
-
this.
|
|
546
|
+
this.end('gave-up');
|
|
465
547
|
return;
|
|
466
548
|
}
|
|
467
549
|
}
|
|
@@ -506,6 +588,7 @@ export class StreamClient {
|
|
|
506
588
|
this.emit('closed', 'left');
|
|
507
589
|
}
|
|
508
590
|
teardownMedia() {
|
|
591
|
+
this.subscriptions.clear();
|
|
509
592
|
for (const c of this.consumers.values())
|
|
510
593
|
c.close();
|
|
511
594
|
this.consumers.clear();
|
|
@@ -576,19 +659,29 @@ export class StreamClient {
|
|
|
576
659
|
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
577
660
|
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
578
661
|
* session then keeps its token); otherwise it is kept for the next join,
|
|
579
|
-
* which checks it there. Resolves to when
|
|
662
|
+
* which checks it there. Resolves to when the token now held expires (unix
|
|
663
|
+
* ms), when known. A newer token that arrives while this waits is kept
|
|
664
|
+
* instead of this one: the server's (its own refresh, or a promotion's,
|
|
665
|
+
* which carries the new role) or a later updateToken()'s.
|
|
580
666
|
*/
|
|
581
667
|
async updateToken(token) {
|
|
582
668
|
if (typeof token !== 'string' || token.trim() === '')
|
|
583
669
|
throw new TypeError('updateToken needs a token');
|
|
584
670
|
if (this.state === 'closed')
|
|
585
671
|
throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
672
|
+
const request = ++this.tokenRequests;
|
|
673
|
+
const revision = this.serverTokenRevision;
|
|
674
|
+
const keep = (expiresAt) => {
|
|
675
|
+
if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
|
|
676
|
+
this.tokenRequestHeld = request;
|
|
677
|
+
this.setToken(token, expiresAt);
|
|
678
|
+
}
|
|
679
|
+
return { expiresAt: this.tokenExpiry };
|
|
680
|
+
};
|
|
586
681
|
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
587
682
|
try {
|
|
588
683
|
const res = (await this.rpc('refreshToken', { token }));
|
|
589
|
-
|
|
590
|
-
this.setToken(token, at);
|
|
591
|
-
return { expiresAt: this.tokenExpiry };
|
|
684
|
+
return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
|
|
592
685
|
}
|
|
593
686
|
catch (err) {
|
|
594
687
|
// An older server has no refreshToken, and a socket that dropped
|
|
@@ -598,8 +691,7 @@ export class StreamClient {
|
|
|
598
691
|
throw err;
|
|
599
692
|
}
|
|
600
693
|
}
|
|
601
|
-
|
|
602
|
-
return { expiresAt: this.tokenExpiry };
|
|
694
|
+
return keep(null);
|
|
603
695
|
}
|
|
604
696
|
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
605
697
|
setToken(token, expiresAt) {
|
|
@@ -660,10 +752,20 @@ export class StreamClient {
|
|
|
660
752
|
this.pending.delete(id);
|
|
661
753
|
reject(new RpcError('timeout', `${method} timed out`));
|
|
662
754
|
}, RPC_TIMEOUT_MS);
|
|
663
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
755
|
+
this.pending.set(id, { resolve, reject, timer, ws: this.ws });
|
|
664
756
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
665
757
|
});
|
|
666
758
|
}
|
|
759
|
+
/** Fail what was sent on `ws` and is still waiting. */
|
|
760
|
+
failPending(ws, err) {
|
|
761
|
+
for (const [id, p] of this.pending) {
|
|
762
|
+
if (p.ws !== ws)
|
|
763
|
+
continue;
|
|
764
|
+
clearTimeout(p.timer);
|
|
765
|
+
this.pending.delete(id);
|
|
766
|
+
p.reject(err);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
667
769
|
failAllPending(err) {
|
|
668
770
|
for (const p of this.pending.values()) {
|
|
669
771
|
clearTimeout(p.timer);
|
|
@@ -708,23 +810,21 @@ export class StreamClient {
|
|
|
708
810
|
break;
|
|
709
811
|
case 'producerClosed': {
|
|
710
812
|
const { producerId, identity } = data;
|
|
813
|
+
this.subscriptions.delete(producerId);
|
|
711
814
|
this.preferredLayers.delete(producerId);
|
|
712
815
|
this.receiveQualities.delete(producerId);
|
|
713
816
|
this.remoteVideoLayers.delete(producerId);
|
|
714
817
|
this.skippedVideos.delete(producerId);
|
|
715
818
|
const consumer = this.consumers.get(producerId);
|
|
716
|
-
|
|
717
|
-
break; // never consumed it, or already cleaned up
|
|
718
|
-
this.videoSlots.delete(producerId);
|
|
719
|
-
consumer.close();
|
|
819
|
+
consumer?.close();
|
|
720
820
|
this.consumers.delete(producerId);
|
|
721
821
|
this.remoteSources.delete(producerId);
|
|
722
822
|
this.remoteInfo.delete(producerId);
|
|
723
|
-
|
|
823
|
+
if (consumer)
|
|
824
|
+
this.emit('trackEnded', { producerId, identity });
|
|
724
825
|
// A video slot opened under the fan-out limit: the oldest video left
|
|
725
826
|
// waiting takes it.
|
|
726
|
-
|
|
727
|
-
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
827
|
+
this.releaseVideoSlot(producerId);
|
|
728
828
|
break;
|
|
729
829
|
}
|
|
730
830
|
case 'producerPaused': {
|
|
@@ -751,6 +851,8 @@ export class StreamClient {
|
|
|
751
851
|
case 'participantMuted': {
|
|
752
852
|
const wire = data;
|
|
753
853
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
854
|
+
if (e.identity && e.identity === this.joinResult?.identity)
|
|
855
|
+
this.moderate(e.kind, e.paused, true);
|
|
754
856
|
this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
755
857
|
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
756
858
|
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
@@ -797,6 +899,7 @@ export class StreamClient {
|
|
|
797
899
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
798
900
|
// changes nothing here -- switching back on is the user's choice.
|
|
799
901
|
const e = data;
|
|
902
|
+
this.moderate(e.kind, Boolean(e.paused));
|
|
800
903
|
if (e.paused) {
|
|
801
904
|
this.applyLocalModeration(e.kind);
|
|
802
905
|
this.emitLocalMedia();
|
|
@@ -822,15 +925,19 @@ export class StreamClient {
|
|
|
822
925
|
// fresh one periodically so a reconnect at any point still works --
|
|
823
926
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
824
927
|
const e = data;
|
|
825
|
-
if (e.token)
|
|
928
|
+
if (e.token) {
|
|
929
|
+
this.serverTokenRevision += 1;
|
|
826
930
|
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
931
|
+
}
|
|
827
932
|
break;
|
|
828
933
|
}
|
|
829
934
|
case 'mustReconnect': {
|
|
830
935
|
const e = data;
|
|
831
936
|
// A promotion hands us a fresh token carrying the new role.
|
|
832
|
-
if (e.token)
|
|
937
|
+
if (e.token) {
|
|
938
|
+
this.serverTokenRevision += 1;
|
|
833
939
|
this.setToken(e.token, null);
|
|
940
|
+
}
|
|
834
941
|
this.emit('mustReconnect', e);
|
|
835
942
|
if (e.retry) {
|
|
836
943
|
this.teardownMedia();
|
|
@@ -996,10 +1103,28 @@ export class StreamClient {
|
|
|
996
1103
|
// leave nothing behind that a reconnect would publish again.
|
|
997
1104
|
if (generation !== this.publishGeneration)
|
|
998
1105
|
throw unpublishedWhile('stream');
|
|
999
|
-
|
|
1106
|
+
// A second publish adds to the first (the microphone, then the camera):
|
|
1107
|
+
// one local stream carries every live track, so unpublish() and close()
|
|
1108
|
+
// stop them all and a reconnect publishes each again. Replacing it
|
|
1109
|
+
// orphaned the first stream's tracks, still capturing (V072-01).
|
|
1110
|
+
const previous = this.localStream;
|
|
1111
|
+
const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
|
|
1112
|
+
if (merged) {
|
|
1113
|
+
const ids = new Set(stream.getTracks().map((t) => t.id));
|
|
1114
|
+
// The new stream's tracks first: of one kind, the newer track is the one
|
|
1115
|
+
// published (as before), and the older one is still stopped with it.
|
|
1116
|
+
this.localStream = new MediaStream([
|
|
1117
|
+
...stream.getTracks(),
|
|
1118
|
+
...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
|
|
1119
|
+
]);
|
|
1120
|
+
this.emit('localStreamChanged', this.localStream);
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
this.localStream = stream;
|
|
1124
|
+
}
|
|
1000
1125
|
this.wasPublishing = true;
|
|
1001
1126
|
if (constraints)
|
|
1002
|
-
this.publishConstraints = constraints;
|
|
1127
|
+
this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
|
|
1003
1128
|
if (options) {
|
|
1004
1129
|
this.publishOptions = { ...options };
|
|
1005
1130
|
this.frameRateFallback = false;
|
|
@@ -1594,8 +1719,11 @@ export class StreamClient {
|
|
|
1594
1719
|
await this.stopScreen('stopped');
|
|
1595
1720
|
this.emit('error', err);
|
|
1596
1721
|
});
|
|
1597
|
-
if (this.music)
|
|
1598
|
-
|
|
1722
|
+
if (this.music) {
|
|
1723
|
+
const music = this.music;
|
|
1724
|
+
const operation = this.musicOperation;
|
|
1725
|
+
await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err));
|
|
1726
|
+
}
|
|
1599
1727
|
if (generation !== this.publishGeneration)
|
|
1600
1728
|
return;
|
|
1601
1729
|
let stream = this.localStream;
|
|
@@ -1829,28 +1957,43 @@ export class StreamClient {
|
|
|
1829
1957
|
if (!this.mediaPermissions.audio)
|
|
1830
1958
|
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1831
1959
|
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)
|
|
1960
|
+
// Taken now, in call order: a music publish still under way is over, and
|
|
1961
|
+
// a stopMusic() from here on ends this one.
|
|
1962
|
+
const operation = ++this.musicOperation;
|
|
1963
|
+
await this.musicTask(async () => {
|
|
1964
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation)
|
|
1841
1965
|
throw unpublishedWhile('music');
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
music
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1966
|
+
if (this.music)
|
|
1967
|
+
await this.closeMusic();
|
|
1968
|
+
const music = new MusicSource(input, options);
|
|
1969
|
+
try {
|
|
1970
|
+
await this.produceMusic(music, generation, operation);
|
|
1971
|
+
// Checked again here: an unpublish() or stopMusic() after the
|
|
1972
|
+
// producer was kept has already closed it, and the music must not be
|
|
1973
|
+
// remembered for a reconnect to publish again.
|
|
1974
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation)
|
|
1975
|
+
throw unpublishedWhile('music');
|
|
1976
|
+
}
|
|
1977
|
+
catch (err) {
|
|
1978
|
+
music.close();
|
|
1979
|
+
throw err;
|
|
1980
|
+
}
|
|
1981
|
+
this.music = music;
|
|
1982
|
+
this.wasPublishing = true;
|
|
1983
|
+
this.emitLocalMedia();
|
|
1984
|
+
});
|
|
1850
1985
|
}
|
|
1851
|
-
|
|
1986
|
+
musicTask(task) {
|
|
1987
|
+
const run = this.musicQueue.then(task, task);
|
|
1988
|
+
this.musicQueue = run.catch(() => undefined);
|
|
1989
|
+
return run;
|
|
1990
|
+
}
|
|
1991
|
+
async produceMusic(music, generation, operation) {
|
|
1992
|
+
// unpublish(), stopMusic() or a newer publishMusic() can run at any wait
|
|
1993
|
+
// below, and wins.
|
|
1994
|
+
const current = () => generation === this.publishGeneration && operation === this.musicOperation;
|
|
1852
1995
|
const transport = await this.ensureSendTransport();
|
|
1853
|
-
if (
|
|
1996
|
+
if (!current())
|
|
1854
1997
|
throw unpublishedWhile('music');
|
|
1855
1998
|
const producer = await transport.produce({
|
|
1856
1999
|
track: music.track,
|
|
@@ -1860,7 +2003,14 @@ export class StreamClient {
|
|
|
1860
2003
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
1861
2004
|
appData: { source: 'music' },
|
|
1862
2005
|
});
|
|
1863
|
-
|
|
2006
|
+
if (!current()) {
|
|
2007
|
+
producer.close();
|
|
2008
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
2009
|
+
throw unpublishedWhile('music');
|
|
2010
|
+
}
|
|
2011
|
+
// Kept and named the music in one step, with no wait between: stopMusic()
|
|
2012
|
+
// finds the music producer by that name.
|
|
2013
|
+
this.producers.set(producer.id, producer);
|
|
1864
2014
|
this.musicProducerId = producer.id;
|
|
1865
2015
|
// Music switched off before a reconnect comes back paused here.
|
|
1866
2016
|
await this.pauseOnServer(producer);
|
|
@@ -1889,8 +2039,17 @@ export class StreamClient {
|
|
|
1889
2039
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1890
2040
|
this.emitLocalMedia();
|
|
1891
2041
|
}
|
|
1892
|
-
/**
|
|
2042
|
+
/**
|
|
2043
|
+
* Stop publishing music. The element or stream it came from is left as it
|
|
2044
|
+
* was. A publishMusic() still under way is stopped too: it rejects with
|
|
2045
|
+
* `unpublished` and publishes nothing.
|
|
2046
|
+
*/
|
|
1893
2047
|
async stopMusic() {
|
|
2048
|
+
this.musicOperation += 1;
|
|
2049
|
+
await this.closeMusic();
|
|
2050
|
+
}
|
|
2051
|
+
/** Close the music being published, here and at the server. */
|
|
2052
|
+
async closeMusic() {
|
|
1894
2053
|
const id = this.musicProducerId;
|
|
1895
2054
|
this.musicProducerId = null;
|
|
1896
2055
|
this.music?.close();
|
|
@@ -1910,9 +2069,11 @@ export class StreamClient {
|
|
|
1910
2069
|
});
|
|
1911
2070
|
}
|
|
1912
2071
|
async consume(remote) {
|
|
2072
|
+
if (this.closedByUs)
|
|
2073
|
+
return;
|
|
1913
2074
|
if (!this.device)
|
|
1914
2075
|
throw new RpcError('not-connected', 'not connected');
|
|
1915
|
-
if (this.
|
|
2076
|
+
if (this.subscriptions.has(remote.producerId))
|
|
1916
2077
|
return;
|
|
1917
2078
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1918
2079
|
// consumer and shows you a delayed copy of yourself.
|
|
@@ -1932,35 +2093,69 @@ export class StreamClient {
|
|
|
1932
2093
|
}
|
|
1933
2094
|
this.videoSlots.add(remote.producerId);
|
|
1934
2095
|
}
|
|
2096
|
+
const operation = {};
|
|
2097
|
+
this.subscriptions.set(remote.producerId, operation);
|
|
1935
2098
|
try {
|
|
1936
|
-
await this.consumeNow(remote);
|
|
2099
|
+
await this.consumeNow(remote, operation);
|
|
1937
2100
|
}
|
|
1938
2101
|
catch (err) {
|
|
1939
|
-
|
|
1940
|
-
|
|
2102
|
+
// A closed producer or replaced session has already released its slot.
|
|
2103
|
+
// Its late failure must neither release a newer slot nor report a ghost error.
|
|
2104
|
+
if (this.subscriptions.get(remote.producerId) !== operation)
|
|
2105
|
+
return;
|
|
2106
|
+
this.subscriptions.delete(remote.producerId);
|
|
2107
|
+
this.consumers.get(remote.producerId)?.close();
|
|
2108
|
+
this.consumers.delete(remote.producerId);
|
|
2109
|
+
this.remoteVideoLayers.delete(remote.producerId);
|
|
2110
|
+
this.remoteSources.delete(remote.producerId);
|
|
2111
|
+
this.remoteInfo.delete(remote.producerId);
|
|
2112
|
+
this.releaseVideoSlot(remote.producerId);
|
|
1941
2113
|
throw err;
|
|
1942
2114
|
}
|
|
1943
2115
|
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Free a video slot, and let the oldest video waiting take it -- whatever
|
|
2118
|
+
* freed it: an ended video, or a subscription that failed. Not while
|
|
2119
|
+
* reconnecting: a failure caused by the socket dropping would otherwise
|
|
2120
|
+
* start every waiting video on a dead connection, each failing with
|
|
2121
|
+
* not-connected; the reconnect's join lists them all again anyway.
|
|
2122
|
+
*/
|
|
2123
|
+
releaseVideoSlot(producerId) {
|
|
2124
|
+
if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting')
|
|
2125
|
+
this.consumeSkipped();
|
|
2126
|
+
}
|
|
1944
2127
|
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1945
|
-
|
|
2128
|
+
consumeSkipped() {
|
|
1946
2129
|
for (const [producerId, remote] of this.skippedVideos) {
|
|
1947
2130
|
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1948
2131
|
if (limit !== null && this.videoSlots.size >= limit)
|
|
1949
2132
|
return;
|
|
1950
2133
|
this.skippedVideos.delete(producerId);
|
|
1951
|
-
await
|
|
2134
|
+
// consume reserves synchronously, before its first await. Consecutive
|
|
2135
|
+
// releases cannot admit the same producer or exceed the limit.
|
|
2136
|
+
void this.consume(remote).catch((err) => this.emit('error', err));
|
|
1952
2137
|
}
|
|
1953
2138
|
}
|
|
1954
|
-
async consumeNow(remote) {
|
|
2139
|
+
async consumeNow(remote, operation) {
|
|
1955
2140
|
if (!this.device)
|
|
1956
2141
|
throw new RpcError('not-connected', 'not connected');
|
|
2142
|
+
const device = this.device;
|
|
2143
|
+
const current = (made) => {
|
|
2144
|
+
if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation)
|
|
2145
|
+
return;
|
|
2146
|
+
made?.close();
|
|
2147
|
+
throw new RpcError('not-connected', 'subscription ended during setup');
|
|
2148
|
+
};
|
|
1957
2149
|
const transport = await this.ensureRecvTransport();
|
|
2150
|
+
current();
|
|
1958
2151
|
const params = (await this.rpc('consume', {
|
|
1959
2152
|
transportId: transport.id,
|
|
1960
2153
|
producerId: remote.producerId,
|
|
1961
|
-
rtpCapabilities:
|
|
2154
|
+
rtpCapabilities: device.rtpCapabilities,
|
|
1962
2155
|
}));
|
|
2156
|
+
current();
|
|
1963
2157
|
const consumer = await transport.consume(params);
|
|
2158
|
+
current(consumer);
|
|
1964
2159
|
this.consumers.set(remote.producerId, consumer);
|
|
1965
2160
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
1966
2161
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
@@ -1980,9 +2175,11 @@ export class StreamClient {
|
|
|
1980
2175
|
if (layers) {
|
|
1981
2176
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
1982
2177
|
}
|
|
2178
|
+
current(consumer);
|
|
1983
2179
|
// The server created it paused. Resume only now that our end exists,
|
|
1984
2180
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
1985
2181
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2182
|
+
current(consumer);
|
|
1986
2183
|
const kind = consumer.kind;
|
|
1987
2184
|
const source = remote.source ?? defaultSource(kind);
|
|
1988
2185
|
this.remoteSources.set(remote.producerId, source);
|
|
@@ -2041,18 +2238,53 @@ export class StreamClient {
|
|
|
2041
2238
|
this.music?.setEnabled(false);
|
|
2042
2239
|
}
|
|
2043
2240
|
async setKindEnabled(kind, enabled) {
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2241
|
+
const generation = this.publishGeneration;
|
|
2242
|
+
// Unpublished or closed while waiting: nothing is left to switch, and the
|
|
2243
|
+
// call resolves, as it does with nothing published (V072-03).
|
|
2244
|
+
const ended = () => generation !== this.publishGeneration || this.closedByUs;
|
|
2245
|
+
// The music has its own switch. Muting the microphone must not stop the song.
|
|
2246
|
+
const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
|
|
2247
|
+
for (const producer of targets) {
|
|
2248
|
+
if (ended())
|
|
2249
|
+
return;
|
|
2250
|
+
if (this.producers.get(producer.id) !== producer || producer.closed)
|
|
2047
2251
|
continue;
|
|
2252
|
+
const revision = this.moderationRevision[kind];
|
|
2048
2253
|
if (!enabled)
|
|
2049
2254
|
producer.pause();
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2255
|
+
try {
|
|
2256
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
2257
|
+
}
|
|
2258
|
+
catch (err) {
|
|
2259
|
+
if (ended())
|
|
2260
|
+
return;
|
|
2261
|
+
throw err;
|
|
2262
|
+
}
|
|
2263
|
+
if (ended())
|
|
2264
|
+
return;
|
|
2265
|
+
if (!enabled || this.producers.get(producer.id) !== producer || producer.closed)
|
|
2266
|
+
continue;
|
|
2267
|
+
if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
|
|
2268
|
+
// A moderator decided while this waited: the older request does not
|
|
2269
|
+
// undo it. It stays off here, and the server is told so too, since
|
|
2270
|
+
// which of the two it applied last cannot be known from here.
|
|
2271
|
+
void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
|
|
2272
|
+
this.emitLocalMedia();
|
|
2273
|
+
throw new RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
|
|
2274
|
+
}
|
|
2275
|
+
producer.resume();
|
|
2053
2276
|
}
|
|
2054
2277
|
this.emitLocalMedia();
|
|
2055
2278
|
}
|
|
2279
|
+
/** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
|
|
2280
|
+
moderate(kind, paused, snapshot = false) {
|
|
2281
|
+
for (const k of kind ? [kind] : ['audio', 'video']) {
|
|
2282
|
+
if (snapshot && this.forcedOff[k] === paused)
|
|
2283
|
+
continue;
|
|
2284
|
+
this.forcedOff[k] = paused;
|
|
2285
|
+
this.moderationRevision[k] += 1;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2056
2288
|
/** Stop publishing entirely and release the local devices. */
|
|
2057
2289
|
async unpublish() {
|
|
2058
2290
|
void this.stopRecording().catch(error => this.emit('error', error));
|