@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/src/index.ts
CHANGED
|
@@ -349,7 +349,11 @@ export interface ClientEvents extends ChatEvents {
|
|
|
349
349
|
localStreamChanged: (stream: MediaStream | null) => void;
|
|
350
350
|
/** Media stopped arriving but the connection is intact -- the publisher is reconnecting. */
|
|
351
351
|
streamInterrupted: (e: { reason: string }) => void;
|
|
352
|
-
/**
|
|
352
|
+
/**
|
|
353
|
+
* The session ended and will not recover. Nothing further will be emitted.
|
|
354
|
+
* Whatever the reason, the camera, microphone, screen, music and recording
|
|
355
|
+
* are released with it, as close() does.
|
|
356
|
+
*/
|
|
353
357
|
closed: (reason: ClosedReason) => void;
|
|
354
358
|
/**
|
|
355
359
|
* The account behind this session ran out of minutes or reached a usage
|
|
@@ -411,6 +415,8 @@ interface Pending {
|
|
|
411
415
|
resolve: (v: unknown) => void;
|
|
412
416
|
reject: (e: Error) => void;
|
|
413
417
|
timer: ReturnType<typeof setTimeout>;
|
|
418
|
+
/** The socket it was sent on: only that socket can answer it. */
|
|
419
|
+
ws: WebSocket;
|
|
414
420
|
}
|
|
415
421
|
|
|
416
422
|
const RPC_TIMEOUT_MS = 15_000;
|
|
@@ -491,6 +497,16 @@ export class StreamClient {
|
|
|
491
497
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
492
498
|
private music: MusicSource | null = null;
|
|
493
499
|
private musicProducerId: string | null = null;
|
|
500
|
+
/**
|
|
501
|
+
* Bumped by every publishMusic() and stopMusic(). A music producer still
|
|
502
|
+
* being made when it changes belongs to a publish that is over: it is
|
|
503
|
+
* closed, not kept. publishGeneration alone missed stopMusic(), and the
|
|
504
|
+
* music went out after stopMusic() had returned.
|
|
505
|
+
*/
|
|
506
|
+
private musicOperation = 0;
|
|
507
|
+
// Music publishes run one at a time: the server takes one producer per
|
|
508
|
+
// source, so the next is produced only once the last one is settled.
|
|
509
|
+
private musicQueue: Promise<unknown> = Promise.resolve();
|
|
494
510
|
private screenStream: MediaStream | null = null;
|
|
495
511
|
private screenMode: ScreenMode = 'detail';
|
|
496
512
|
private screenAudioEnabled = true;
|
|
@@ -507,6 +523,8 @@ export class StreamClient {
|
|
|
507
523
|
private frameRateWarmupSamples = 2;
|
|
508
524
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
509
525
|
private readonly remoteSources = new Map<string, TrackSource>();
|
|
526
|
+
/** Identity of each pending/active subscription; cleared on close/reconnect. */
|
|
527
|
+
private readonly subscriptions = new Map<string, object>();
|
|
510
528
|
/**
|
|
511
529
|
* The layers asked for each remote video, by producerId. A reconnect
|
|
512
530
|
* consumes everything again, and the new consumer would otherwise start at
|
|
@@ -550,6 +568,17 @@ export class StreamClient {
|
|
|
550
568
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
551
569
|
private tokenExpiry: number | null;
|
|
552
570
|
private tokenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
571
|
+
/**
|
|
572
|
+
* Bumped whenever the server hands this client a token (its own refresh, a
|
|
573
|
+
* promotion's). An updateToken() that started before then keeps the token
|
|
574
|
+
* it was given only if nothing newer arrived while it waited: its reply,
|
|
575
|
+
* or its fallback once the socket closed, used to replace a promotion's
|
|
576
|
+
* token, and the reconnect joined with the old one.
|
|
577
|
+
*/
|
|
578
|
+
private serverTokenRevision = 0;
|
|
579
|
+
/** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
|
|
580
|
+
private tokenRequests = 0;
|
|
581
|
+
private tokenRequestHeld = 0;
|
|
553
582
|
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
554
583
|
private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
|
|
555
584
|
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
@@ -613,6 +642,18 @@ export class StreamClient {
|
|
|
613
642
|
this.emit('closed', reason);
|
|
614
643
|
}
|
|
615
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Over for good without the app asking: finish, then release everything
|
|
647
|
+
* local as close() does -- camera, microphone, screen, music, recording.
|
|
648
|
+
* kicked and roomClosed always did; a replaced socket, a close without
|
|
649
|
+
* roomClosed and a reconnect that gave up left them capturing (and the
|
|
650
|
+
* recording uploading) after `closed`, until the app called close().
|
|
651
|
+
*/
|
|
652
|
+
private end(reason: ClosedReason): void {
|
|
653
|
+
this.finish(reason);
|
|
654
|
+
this.close();
|
|
655
|
+
}
|
|
656
|
+
|
|
616
657
|
private stopTimers(): void {
|
|
617
658
|
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
618
659
|
this.tokenTimer = null;
|
|
@@ -640,8 +681,13 @@ export class StreamClient {
|
|
|
640
681
|
const closed = () => lifecycle !== this.lifecycle;
|
|
641
682
|
let result: JoinResult;
|
|
642
683
|
if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
|
|
684
|
+
// The socket this call opens. A later connect() replaces it, and this
|
|
685
|
+
// call's failure must then leave the newer socket, and the state, alone.
|
|
686
|
+
let socket: WebSocket | null = null;
|
|
643
687
|
try {
|
|
644
|
-
|
|
688
|
+
const opening = this.openSocket();
|
|
689
|
+
socket = this.ws;
|
|
690
|
+
await opening;
|
|
645
691
|
result = (await this.rpc('join', { token: this.token })) as JoinResult;
|
|
646
692
|
this.joinResult = result;
|
|
647
693
|
|
|
@@ -654,7 +700,8 @@ export class StreamClient {
|
|
|
654
700
|
// A session that never got going: the app has the error. Left open,
|
|
655
701
|
// the socket would be closed by the server's join deadline instead, and
|
|
656
702
|
// that close would start reconnecting a session that never began.
|
|
657
|
-
|
|
703
|
+
const superseded = this.ws !== null && this.ws !== socket;
|
|
704
|
+
if (!this.joinedOnce && !superseded) {
|
|
658
705
|
this.detachSocket();
|
|
659
706
|
if (this.state === 'connecting') this.setState('idle');
|
|
660
707
|
}
|
|
@@ -756,17 +803,16 @@ export class StreamClient {
|
|
|
756
803
|
// Another session for this identity is live. Reconnecting would
|
|
757
804
|
// displace it, and it would displace us right back, forever.
|
|
758
805
|
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
759
|
-
// The
|
|
760
|
-
// videoLimited sampling must not, or it reports after `closed`.
|
|
806
|
+
// The videoLimited sampling stops first, or it reports after `closed`.
|
|
761
807
|
this.limitWatch.reset();
|
|
762
|
-
this.
|
|
808
|
+
this.end('replaced');
|
|
763
809
|
return;
|
|
764
810
|
}
|
|
765
811
|
if (ev.code === CLOSE_ENDED) {
|
|
766
812
|
// Normally roomClosed has already ended the session; this covers a
|
|
767
813
|
// close that arrived without it.
|
|
768
814
|
this.limitWatch.reset();
|
|
769
|
-
this.
|
|
815
|
+
this.end('room-closed');
|
|
770
816
|
return;
|
|
771
817
|
}
|
|
772
818
|
// Before a first successful join the app is still waiting on
|
|
@@ -779,16 +825,21 @@ export class StreamClient {
|
|
|
779
825
|
});
|
|
780
826
|
}
|
|
781
827
|
|
|
782
|
-
private detachSocket(reason = 'superseded'): void {
|
|
828
|
+
private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
|
|
783
829
|
const ws = this.ws;
|
|
784
830
|
this.ws = null;
|
|
785
|
-
if (ws
|
|
831
|
+
if (!ws) return;
|
|
832
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
786
833
|
try {
|
|
787
834
|
ws.close(1000, reason);
|
|
788
835
|
} catch {
|
|
789
836
|
/* already closing */
|
|
790
837
|
}
|
|
791
838
|
}
|
|
839
|
+
// Its replies are no longer read, so what waits on it would only wait out
|
|
840
|
+
// its 15 s timeout: it fails now, as on a close. Never replayed -- the
|
|
841
|
+
// server may already have acted on it.
|
|
842
|
+
this.failPending(ws, new RpcError('not-connected', why));
|
|
792
843
|
}
|
|
793
844
|
|
|
794
845
|
/**
|
|
@@ -798,7 +849,7 @@ export class StreamClient {
|
|
|
798
849
|
* still sending.
|
|
799
850
|
*/
|
|
800
851
|
private releaseSocket(): void {
|
|
801
|
-
this.detachSocket('gave up');
|
|
852
|
+
this.detachSocket('gave up', 'the session has ended');
|
|
802
853
|
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
803
854
|
}
|
|
804
855
|
|
|
@@ -819,7 +870,7 @@ export class StreamClient {
|
|
|
819
870
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
820
871
|
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
821
872
|
this.releaseSocket();
|
|
822
|
-
this.
|
|
873
|
+
this.end('gave-up');
|
|
823
874
|
return;
|
|
824
875
|
}
|
|
825
876
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -843,7 +894,7 @@ export class StreamClient {
|
|
|
843
894
|
// attempt forever and drowns out whatever actually went wrong.
|
|
844
895
|
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
845
896
|
this.releaseSocket();
|
|
846
|
-
this.
|
|
897
|
+
this.end('gave-up');
|
|
847
898
|
return;
|
|
848
899
|
}
|
|
849
900
|
}
|
|
@@ -888,6 +939,7 @@ export class StreamClient {
|
|
|
888
939
|
}
|
|
889
940
|
|
|
890
941
|
private teardownMedia(): void {
|
|
942
|
+
this.subscriptions.clear();
|
|
891
943
|
for (const c of this.consumers.values()) c.close();
|
|
892
944
|
this.consumers.clear();
|
|
893
945
|
this.remoteInfo.clear();
|
|
@@ -962,17 +1014,27 @@ export class StreamClient {
|
|
|
962
1014
|
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
963
1015
|
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
964
1016
|
* session then keeps its token); otherwise it is kept for the next join,
|
|
965
|
-
* which checks it there. Resolves to when
|
|
1017
|
+
* which checks it there. Resolves to when the token now held expires (unix
|
|
1018
|
+
* ms), when known. A newer token that arrives while this waits is kept
|
|
1019
|
+
* instead of this one: the server's (its own refresh, or a promotion's,
|
|
1020
|
+
* which carries the new role) or a later updateToken()'s.
|
|
966
1021
|
*/
|
|
967
1022
|
async updateToken(token: string): Promise<{ expiresAt: number | null }> {
|
|
968
1023
|
if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
|
|
969
1024
|
if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
1025
|
+
const request = ++this.tokenRequests;
|
|
1026
|
+
const revision = this.serverTokenRevision;
|
|
1027
|
+
const keep = (expiresAt: number | null) => {
|
|
1028
|
+
if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
|
|
1029
|
+
this.tokenRequestHeld = request;
|
|
1030
|
+
this.setToken(token, expiresAt);
|
|
1031
|
+
}
|
|
1032
|
+
return { expiresAt: this.tokenExpiry };
|
|
1033
|
+
};
|
|
970
1034
|
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
971
1035
|
try {
|
|
972
1036
|
const res = (await this.rpc('refreshToken', { token })) as { expiresAt?: unknown } | undefined;
|
|
973
|
-
|
|
974
|
-
this.setToken(token, at);
|
|
975
|
-
return { expiresAt: this.tokenExpiry };
|
|
1037
|
+
return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
|
|
976
1038
|
} catch (err) {
|
|
977
1039
|
// An older server has no refreshToken, and a socket that dropped
|
|
978
1040
|
// during the call is reconnecting: either way keep the token for the
|
|
@@ -980,8 +1042,7 @@ export class StreamClient {
|
|
|
980
1042
|
if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
|
|
981
1043
|
}
|
|
982
1044
|
}
|
|
983
|
-
|
|
984
|
-
return { expiresAt: this.tokenExpiry };
|
|
1045
|
+
return keep(null);
|
|
985
1046
|
}
|
|
986
1047
|
|
|
987
1048
|
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
@@ -1045,11 +1106,21 @@ export class StreamClient {
|
|
|
1045
1106
|
this.pending.delete(id);
|
|
1046
1107
|
reject(new RpcError('timeout', `${method} timed out`));
|
|
1047
1108
|
}, RPC_TIMEOUT_MS);
|
|
1048
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
1109
|
+
this.pending.set(id, { resolve, reject, timer, ws: this.ws });
|
|
1049
1110
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
1050
1111
|
});
|
|
1051
1112
|
}
|
|
1052
1113
|
|
|
1114
|
+
/** Fail what was sent on `ws` and is still waiting. */
|
|
1115
|
+
private failPending(ws: WebSocket, err: Error): void {
|
|
1116
|
+
for (const [id, p] of this.pending) {
|
|
1117
|
+
if (p.ws !== ws) continue;
|
|
1118
|
+
clearTimeout(p.timer);
|
|
1119
|
+
this.pending.delete(id);
|
|
1120
|
+
p.reject(err);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1053
1124
|
private failAllPending(err: Error): void {
|
|
1054
1125
|
for (const p of this.pending.values()) {
|
|
1055
1126
|
clearTimeout(p.timer);
|
|
@@ -1093,21 +1164,20 @@ export class StreamClient {
|
|
|
1093
1164
|
break;
|
|
1094
1165
|
case 'producerClosed': {
|
|
1095
1166
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
1167
|
+
this.subscriptions.delete(producerId);
|
|
1096
1168
|
this.preferredLayers.delete(producerId);
|
|
1097
1169
|
this.receiveQualities.delete(producerId);
|
|
1098
1170
|
this.remoteVideoLayers.delete(producerId);
|
|
1099
1171
|
this.skippedVideos.delete(producerId);
|
|
1100
1172
|
const consumer = this.consumers.get(producerId);
|
|
1101
|
-
|
|
1102
|
-
this.videoSlots.delete(producerId);
|
|
1103
|
-
consumer.close();
|
|
1173
|
+
consumer?.close();
|
|
1104
1174
|
this.consumers.delete(producerId);
|
|
1105
1175
|
this.remoteSources.delete(producerId);
|
|
1106
1176
|
this.remoteInfo.delete(producerId);
|
|
1107
|
-
this.emit('trackEnded', { producerId, identity });
|
|
1177
|
+
if (consumer) this.emit('trackEnded', { producerId, identity });
|
|
1108
1178
|
// A video slot opened under the fan-out limit: the oldest video left
|
|
1109
1179
|
// waiting takes it.
|
|
1110
|
-
|
|
1180
|
+
this.releaseVideoSlot(producerId);
|
|
1111
1181
|
break;
|
|
1112
1182
|
}
|
|
1113
1183
|
case 'producerPaused': {
|
|
@@ -1203,13 +1273,19 @@ export class StreamClient {
|
|
|
1203
1273
|
// fresh one periodically so a reconnect at any point still works --
|
|
1204
1274
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
1205
1275
|
const e = data as { token?: string; role?: Role; expiresAt?: number };
|
|
1206
|
-
if (e.token)
|
|
1276
|
+
if (e.token) {
|
|
1277
|
+
this.serverTokenRevision += 1;
|
|
1278
|
+
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
1279
|
+
}
|
|
1207
1280
|
break;
|
|
1208
1281
|
}
|
|
1209
1282
|
case 'mustReconnect': {
|
|
1210
1283
|
const e = data as { reason: string; token?: string; retry: boolean };
|
|
1211
1284
|
// A promotion hands us a fresh token carrying the new role.
|
|
1212
|
-
if (e.token)
|
|
1285
|
+
if (e.token) {
|
|
1286
|
+
this.serverTokenRevision += 1;
|
|
1287
|
+
this.setToken(e.token, null);
|
|
1288
|
+
}
|
|
1213
1289
|
this.emit('mustReconnect', e);
|
|
1214
1290
|
if (e.retry) {
|
|
1215
1291
|
this.teardownMedia();
|
|
@@ -1972,7 +2048,11 @@ export class StreamClient {
|
|
|
1972
2048
|
if (this.screenStream === screen) await this.stopScreen('stopped');
|
|
1973
2049
|
this.emit('error', err as Error);
|
|
1974
2050
|
});
|
|
1975
|
-
if (this.music)
|
|
2051
|
+
if (this.music) {
|
|
2052
|
+
const music = this.music;
|
|
2053
|
+
const operation = this.musicOperation;
|
|
2054
|
+
await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err as Error));
|
|
2055
|
+
}
|
|
1976
2056
|
if (generation !== this.publishGeneration) return;
|
|
1977
2057
|
|
|
1978
2058
|
let stream = this.localStream;
|
|
@@ -2183,26 +2263,41 @@ export class StreamClient {
|
|
|
2183
2263
|
async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
|
|
2184
2264
|
if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
2185
2265
|
const generation = this.publishGeneration;
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2266
|
+
// Taken now, in call order: a music publish still under way is over, and
|
|
2267
|
+
// a stopMusic() from here on ends this one.
|
|
2268
|
+
const operation = ++this.musicOperation;
|
|
2269
|
+
await this.musicTask(async () => {
|
|
2270
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
|
|
2271
|
+
if (this.music) await this.closeMusic();
|
|
2272
|
+
const music = new MusicSource(input, options);
|
|
2273
|
+
try {
|
|
2274
|
+
await this.produceMusic(music, generation, operation);
|
|
2275
|
+
// Checked again here: an unpublish() or stopMusic() after the
|
|
2276
|
+
// producer was kept has already closed it, and the music must not be
|
|
2277
|
+
// remembered for a reconnect to publish again.
|
|
2278
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
music.close();
|
|
2281
|
+
throw err;
|
|
2282
|
+
}
|
|
2283
|
+
this.music = music;
|
|
2284
|
+
this.wasPublishing = true;
|
|
2285
|
+
this.emitLocalMedia();
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
private musicTask<T>(task: () => Promise<T>): Promise<T> {
|
|
2290
|
+
const run = this.musicQueue.then(task, task);
|
|
2291
|
+
this.musicQueue = run.catch(() => undefined);
|
|
2292
|
+
return run;
|
|
2201
2293
|
}
|
|
2202
2294
|
|
|
2203
|
-
private async produceMusic(music: MusicSource, generation: number): Promise<void> {
|
|
2295
|
+
private async produceMusic(music: MusicSource, generation: number, operation: number): Promise<void> {
|
|
2296
|
+
// unpublish(), stopMusic() or a newer publishMusic() can run at any wait
|
|
2297
|
+
// below, and wins.
|
|
2298
|
+
const current = () => generation === this.publishGeneration && operation === this.musicOperation;
|
|
2204
2299
|
const transport = await this.ensureSendTransport();
|
|
2205
|
-
if (
|
|
2300
|
+
if (!current()) throw unpublishedWhile('music');
|
|
2206
2301
|
const producer = await transport.produce({
|
|
2207
2302
|
track: music.track,
|
|
2208
2303
|
stopTracks: false,
|
|
@@ -2211,7 +2306,14 @@ export class StreamClient {
|
|
|
2211
2306
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
2212
2307
|
appData: { source: 'music' },
|
|
2213
2308
|
});
|
|
2214
|
-
|
|
2309
|
+
if (!current()) {
|
|
2310
|
+
producer.close();
|
|
2311
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
2312
|
+
throw unpublishedWhile('music');
|
|
2313
|
+
}
|
|
2314
|
+
// Kept and named the music in one step, with no wait between: stopMusic()
|
|
2315
|
+
// finds the music producer by that name.
|
|
2316
|
+
this.producers.set(producer.id, producer);
|
|
2215
2317
|
this.musicProducerId = producer.id;
|
|
2216
2318
|
// Music switched off before a reconnect comes back paused here.
|
|
2217
2319
|
await this.pauseOnServer(producer);
|
|
@@ -2242,8 +2344,18 @@ export class StreamClient {
|
|
|
2242
2344
|
this.emitLocalMedia();
|
|
2243
2345
|
}
|
|
2244
2346
|
|
|
2245
|
-
/**
|
|
2347
|
+
/**
|
|
2348
|
+
* Stop publishing music. The element or stream it came from is left as it
|
|
2349
|
+
* was. A publishMusic() still under way is stopped too: it rejects with
|
|
2350
|
+
* `unpublished` and publishes nothing.
|
|
2351
|
+
*/
|
|
2246
2352
|
async stopMusic(): Promise<void> {
|
|
2353
|
+
this.musicOperation += 1;
|
|
2354
|
+
await this.closeMusic();
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/** Close the music being published, here and at the server. */
|
|
2358
|
+
private async closeMusic(): Promise<void> {
|
|
2247
2359
|
const id = this.musicProducerId;
|
|
2248
2360
|
this.musicProducerId = null;
|
|
2249
2361
|
this.music?.close();
|
|
@@ -2265,8 +2377,9 @@ export class StreamClient {
|
|
|
2265
2377
|
}
|
|
2266
2378
|
|
|
2267
2379
|
private async consume(remote: RemoteProducer): Promise<void> {
|
|
2380
|
+
if (this.closedByUs) return;
|
|
2268
2381
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
2269
|
-
if (this.
|
|
2382
|
+
if (this.subscriptions.has(remote.producerId)) return;
|
|
2270
2383
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
2271
2384
|
// consumer and shows you a delayed copy of yourself.
|
|
2272
2385
|
if (remote.identity === this.joinResult?.identity) return;
|
|
@@ -2285,34 +2398,67 @@ export class StreamClient {
|
|
|
2285
2398
|
}
|
|
2286
2399
|
this.videoSlots.add(remote.producerId);
|
|
2287
2400
|
}
|
|
2401
|
+
const operation = {};
|
|
2402
|
+
this.subscriptions.set(remote.producerId, operation);
|
|
2288
2403
|
try {
|
|
2289
|
-
await this.consumeNow(remote);
|
|
2404
|
+
await this.consumeNow(remote, operation);
|
|
2290
2405
|
} catch (err) {
|
|
2291
|
-
|
|
2406
|
+
// A closed producer or replaced session has already released its slot.
|
|
2407
|
+
// Its late failure must neither release a newer slot nor report a ghost error.
|
|
2408
|
+
if (this.subscriptions.get(remote.producerId) !== operation) return;
|
|
2409
|
+
this.subscriptions.delete(remote.producerId);
|
|
2410
|
+
this.consumers.get(remote.producerId)?.close();
|
|
2411
|
+
this.consumers.delete(remote.producerId);
|
|
2412
|
+
this.remoteVideoLayers.delete(remote.producerId);
|
|
2413
|
+
this.remoteSources.delete(remote.producerId);
|
|
2414
|
+
this.remoteInfo.delete(remote.producerId);
|
|
2415
|
+
this.releaseVideoSlot(remote.producerId);
|
|
2292
2416
|
throw err;
|
|
2293
2417
|
}
|
|
2294
2418
|
}
|
|
2295
2419
|
|
|
2420
|
+
/**
|
|
2421
|
+
* Free a video slot, and let the oldest video waiting take it -- whatever
|
|
2422
|
+
* freed it: an ended video, or a subscription that failed. Not while
|
|
2423
|
+
* reconnecting: a failure caused by the socket dropping would otherwise
|
|
2424
|
+
* start every waiting video on a dead connection, each failing with
|
|
2425
|
+
* not-connected; the reconnect's join lists them all again anyway.
|
|
2426
|
+
*/
|
|
2427
|
+
private releaseVideoSlot(producerId: string): void {
|
|
2428
|
+
if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting') this.consumeSkipped();
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2296
2431
|
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
2297
|
-
private
|
|
2432
|
+
private consumeSkipped(): void {
|
|
2298
2433
|
for (const [producerId, remote] of this.skippedVideos) {
|
|
2299
2434
|
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
2300
2435
|
if (limit !== null && this.videoSlots.size >= limit) return;
|
|
2301
2436
|
this.skippedVideos.delete(producerId);
|
|
2302
|
-
await
|
|
2437
|
+
// consume reserves synchronously, before its first await. Consecutive
|
|
2438
|
+
// releases cannot admit the same producer or exceed the limit.
|
|
2439
|
+
void this.consume(remote).catch((err) => this.emit('error', err as Error));
|
|
2303
2440
|
}
|
|
2304
2441
|
}
|
|
2305
2442
|
|
|
2306
|
-
private async consumeNow(remote: RemoteProducer): Promise<void> {
|
|
2443
|
+
private async consumeNow(remote: RemoteProducer, operation: object): Promise<void> {
|
|
2307
2444
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
2445
|
+
const device = this.device;
|
|
2446
|
+
const current = (made?: Consumer) => {
|
|
2447
|
+
if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation) return;
|
|
2448
|
+
made?.close();
|
|
2449
|
+
throw new RpcError('not-connected', 'subscription ended during setup');
|
|
2450
|
+
};
|
|
2308
2451
|
const transport = await this.ensureRecvTransport();
|
|
2452
|
+
current();
|
|
2309
2453
|
const params = (await this.rpc('consume', {
|
|
2310
2454
|
transportId: transport.id,
|
|
2311
2455
|
producerId: remote.producerId,
|
|
2312
|
-
rtpCapabilities:
|
|
2456
|
+
rtpCapabilities: device.rtpCapabilities,
|
|
2313
2457
|
})) as { id: string; producerId: string; kind: 'audio' | 'video'; rtpParameters: never };
|
|
2314
2458
|
|
|
2459
|
+
current();
|
|
2315
2460
|
const consumer = await transport.consume(params);
|
|
2461
|
+
current(consumer);
|
|
2316
2462
|
this.consumers.set(remote.producerId, consumer);
|
|
2317
2463
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
|
|
2318
2464
|
|
|
@@ -2328,10 +2474,12 @@ export class StreamClient {
|
|
|
2328
2474
|
if (layers) {
|
|
2329
2475
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
2330
2476
|
}
|
|
2477
|
+
current(consumer);
|
|
2331
2478
|
|
|
2332
2479
|
// The server created it paused. Resume only now that our end exists,
|
|
2333
2480
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
2334
2481
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2482
|
+
current(consumer);
|
|
2335
2483
|
|
|
2336
2484
|
const kind = consumer.kind as 'audio' | 'video';
|
|
2337
2485
|
const source = remote.source ?? defaultSource(kind);
|