@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/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;
|
|
@@ -440,6 +446,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
|
|
|
440
446
|
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
441
447
|
|
|
442
448
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
449
|
+
/**
|
|
450
|
+
* The constraints that capture again what two publishes captured: each kind
|
|
451
|
+
* as the later publish asked for it, or as the earlier one did if the later
|
|
452
|
+
* one did not ask for it.
|
|
453
|
+
*/
|
|
454
|
+
function mergeConstraints(earlier: MediaStreamConstraints | null, later: MediaStreamConstraints): MediaStreamConstraints {
|
|
455
|
+
if (!earlier) return later;
|
|
456
|
+
return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
|
|
457
|
+
}
|
|
458
|
+
|
|
443
459
|
const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
444
460
|
|
|
445
461
|
/**
|
|
@@ -486,11 +502,30 @@ export class StreamClient {
|
|
|
486
502
|
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
487
503
|
// disconnected: the room looks healthy and shows nothing.
|
|
488
504
|
private wasPublishing = false;
|
|
505
|
+
/** The deadline of the socket being opened (openSocket). */
|
|
506
|
+
private openDeadline: ReturnType<typeof setTimeout> | undefined;
|
|
507
|
+
/**
|
|
508
|
+
* This client's own moderation per kind: whether a moderator holds it off,
|
|
509
|
+
* and a revision every decision bumps. A switch-on that was waiting for the
|
|
510
|
+
* server when a decision arrived does not act on its reply (V072-06).
|
|
511
|
+
*/
|
|
512
|
+
private forcedOff = { audio: false, video: false };
|
|
513
|
+
private moderationRevision = { audio: 0, video: 0 };
|
|
489
514
|
private publishConstraints: MediaStreamConstraints | null = null;
|
|
490
515
|
private publishOptions: PublishOptions = {};
|
|
491
516
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
492
517
|
private music: MusicSource | null = null;
|
|
493
518
|
private musicProducerId: string | null = null;
|
|
519
|
+
/**
|
|
520
|
+
* Bumped by every publishMusic() and stopMusic(). A music producer still
|
|
521
|
+
* being made when it changes belongs to a publish that is over: it is
|
|
522
|
+
* closed, not kept. publishGeneration alone missed stopMusic(), and the
|
|
523
|
+
* music went out after stopMusic() had returned.
|
|
524
|
+
*/
|
|
525
|
+
private musicOperation = 0;
|
|
526
|
+
// Music publishes run one at a time: the server takes one producer per
|
|
527
|
+
// source, so the next is produced only once the last one is settled.
|
|
528
|
+
private musicQueue: Promise<unknown> = Promise.resolve();
|
|
494
529
|
private screenStream: MediaStream | null = null;
|
|
495
530
|
private screenMode: ScreenMode = 'detail';
|
|
496
531
|
private screenAudioEnabled = true;
|
|
@@ -507,6 +542,8 @@ export class StreamClient {
|
|
|
507
542
|
private frameRateWarmupSamples = 2;
|
|
508
543
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
509
544
|
private readonly remoteSources = new Map<string, TrackSource>();
|
|
545
|
+
/** Identity of each pending/active subscription; cleared on close/reconnect. */
|
|
546
|
+
private readonly subscriptions = new Map<string, object>();
|
|
510
547
|
/**
|
|
511
548
|
* The layers asked for each remote video, by producerId. A reconnect
|
|
512
549
|
* consumes everything again, and the new consumer would otherwise start at
|
|
@@ -550,6 +587,17 @@ export class StreamClient {
|
|
|
550
587
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
551
588
|
private tokenExpiry: number | null;
|
|
552
589
|
private tokenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
590
|
+
/**
|
|
591
|
+
* Bumped whenever the server hands this client a token (its own refresh, a
|
|
592
|
+
* promotion's). An updateToken() that started before then keeps the token
|
|
593
|
+
* it was given only if nothing newer arrived while it waited: its reply,
|
|
594
|
+
* or its fallback once the socket closed, used to replace a promotion's
|
|
595
|
+
* token, and the reconnect joined with the old one.
|
|
596
|
+
*/
|
|
597
|
+
private serverTokenRevision = 0;
|
|
598
|
+
/** updateToken() calls in call order, and the newest one whose token is held: a later call wins over an earlier one answered after it. */
|
|
599
|
+
private tokenRequests = 0;
|
|
600
|
+
private tokenRequestHeld = 0;
|
|
553
601
|
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
554
602
|
private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
|
|
555
603
|
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
@@ -613,6 +661,18 @@ export class StreamClient {
|
|
|
613
661
|
this.emit('closed', reason);
|
|
614
662
|
}
|
|
615
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Over for good without the app asking: finish, then release everything
|
|
666
|
+
* local as close() does -- camera, microphone, screen, music, recording.
|
|
667
|
+
* kicked and roomClosed always did; a replaced socket, a close without
|
|
668
|
+
* roomClosed and a reconnect that gave up left them capturing (and the
|
|
669
|
+
* recording uploading) after `closed`, until the app called close().
|
|
670
|
+
*/
|
|
671
|
+
private end(reason: ClosedReason): void {
|
|
672
|
+
this.finish(reason);
|
|
673
|
+
this.close();
|
|
674
|
+
}
|
|
675
|
+
|
|
616
676
|
private stopTimers(): void {
|
|
617
677
|
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
618
678
|
this.tokenTimer = null;
|
|
@@ -640,8 +700,13 @@ export class StreamClient {
|
|
|
640
700
|
const closed = () => lifecycle !== this.lifecycle;
|
|
641
701
|
let result: JoinResult;
|
|
642
702
|
if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
|
|
703
|
+
// The socket this call opens. A later connect() replaces it, and this
|
|
704
|
+
// call's failure must then leave the newer socket, and the state, alone.
|
|
705
|
+
let socket: WebSocket | null = null;
|
|
643
706
|
try {
|
|
644
|
-
|
|
707
|
+
const opening = this.openSocket();
|
|
708
|
+
socket = this.ws;
|
|
709
|
+
await opening;
|
|
645
710
|
result = (await this.rpc('join', { token: this.token })) as JoinResult;
|
|
646
711
|
this.joinResult = result;
|
|
647
712
|
|
|
@@ -654,7 +719,11 @@ export class StreamClient {
|
|
|
654
719
|
// A session that never got going: the app has the error. Left open,
|
|
655
720
|
// the socket would be closed by the server's join deadline instead, and
|
|
656
721
|
// that close would start reconnecting a session that never began.
|
|
657
|
-
|
|
722
|
+
const superseded = this.ws !== null && this.ws !== socket;
|
|
723
|
+
// A reconnect attempt that failed (its join timed out, say) lets its
|
|
724
|
+
// socket go now too, so nothing it says later reaches the session while
|
|
725
|
+
// the next attempt waits its backoff (V072-05).
|
|
726
|
+
if ((!this.joinedOnce || this.reconnecting) && !superseded) {
|
|
658
727
|
this.detachSocket();
|
|
659
728
|
if (this.state === 'connecting') this.setState('idle');
|
|
660
729
|
}
|
|
@@ -677,6 +746,8 @@ export class StreamClient {
|
|
|
677
746
|
if (closed()) throw closedWhileConnecting();
|
|
678
747
|
}
|
|
679
748
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
749
|
+
this.moderate('audio', Boolean(own?.muted), true);
|
|
750
|
+
this.moderate('video', Boolean(own?.videoBlocked), true);
|
|
680
751
|
if (own?.muted) this.applyLocalModeration('audio');
|
|
681
752
|
if (own?.videoBlocked) this.applyLocalModeration('video');
|
|
682
753
|
this.emit('joined', result);
|
|
@@ -732,11 +803,22 @@ export class StreamClient {
|
|
|
732
803
|
const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
|
|
733
804
|
this.ws = ws;
|
|
734
805
|
|
|
806
|
+
// Bounded like every request (V072-05): a socket that does not open in
|
|
807
|
+
// time fails the attempt with `timeout` and is closed, instead of
|
|
808
|
+
// holding connect() -- or a reconnect attempt -- indefinitely.
|
|
809
|
+
this.openDeadline = setTimeout(() => {
|
|
810
|
+
reject(new RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
|
|
811
|
+
if (this.ws === ws) this.detachSocket('open timed out', 'the connection did not open in time');
|
|
812
|
+
}, RPC_TIMEOUT_MS);
|
|
735
813
|
const onOpen = () => {
|
|
814
|
+
clearTimeout(this.openDeadline);
|
|
736
815
|
this.emit('connected');
|
|
737
816
|
resolve();
|
|
738
817
|
};
|
|
739
|
-
const onError = () =>
|
|
818
|
+
const onError = () => {
|
|
819
|
+
clearTimeout(this.openDeadline);
|
|
820
|
+
reject(new RpcError('connect-failed', 'websocket failed to open'));
|
|
821
|
+
};
|
|
740
822
|
|
|
741
823
|
ws.addEventListener('open', onOpen, { once: true });
|
|
742
824
|
ws.addEventListener('error', onError, { once: true });
|
|
@@ -756,17 +838,16 @@ export class StreamClient {
|
|
|
756
838
|
// Another session for this identity is live. Reconnecting would
|
|
757
839
|
// displace it, and it would displace us right back, forever.
|
|
758
840
|
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
759
|
-
// The
|
|
760
|
-
// videoLimited sampling must not, or it reports after `closed`.
|
|
841
|
+
// The videoLimited sampling stops first, or it reports after `closed`.
|
|
761
842
|
this.limitWatch.reset();
|
|
762
|
-
this.
|
|
843
|
+
this.end('replaced');
|
|
763
844
|
return;
|
|
764
845
|
}
|
|
765
846
|
if (ev.code === CLOSE_ENDED) {
|
|
766
847
|
// Normally roomClosed has already ended the session; this covers a
|
|
767
848
|
// close that arrived without it.
|
|
768
849
|
this.limitWatch.reset();
|
|
769
|
-
this.
|
|
850
|
+
this.end('room-closed');
|
|
770
851
|
return;
|
|
771
852
|
}
|
|
772
853
|
// Before a first successful join the app is still waiting on
|
|
@@ -779,16 +860,22 @@ export class StreamClient {
|
|
|
779
860
|
});
|
|
780
861
|
}
|
|
781
862
|
|
|
782
|
-
private detachSocket(reason = 'superseded'): void {
|
|
863
|
+
private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
|
|
864
|
+
clearTimeout(this.openDeadline);
|
|
783
865
|
const ws = this.ws;
|
|
784
866
|
this.ws = null;
|
|
785
|
-
if (ws
|
|
867
|
+
if (!ws) return;
|
|
868
|
+
if (ws.readyState <= WebSocket.OPEN) {
|
|
786
869
|
try {
|
|
787
870
|
ws.close(1000, reason);
|
|
788
871
|
} catch {
|
|
789
872
|
/* already closing */
|
|
790
873
|
}
|
|
791
874
|
}
|
|
875
|
+
// Its replies are no longer read, so what waits on it would only wait out
|
|
876
|
+
// its 15 s timeout: it fails now, as on a close. Never replayed -- the
|
|
877
|
+
// server may already have acted on it.
|
|
878
|
+
this.failPending(ws, new RpcError('not-connected', why));
|
|
792
879
|
}
|
|
793
880
|
|
|
794
881
|
/**
|
|
@@ -798,7 +885,7 @@ export class StreamClient {
|
|
|
798
885
|
* still sending.
|
|
799
886
|
*/
|
|
800
887
|
private releaseSocket(): void {
|
|
801
|
-
this.detachSocket('gave up');
|
|
888
|
+
this.detachSocket('gave up', 'the session has ended');
|
|
802
889
|
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
803
890
|
}
|
|
804
891
|
|
|
@@ -819,7 +906,7 @@ export class StreamClient {
|
|
|
819
906
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
820
907
|
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
821
908
|
this.releaseSocket();
|
|
822
|
-
this.
|
|
909
|
+
this.end('gave-up');
|
|
823
910
|
return;
|
|
824
911
|
}
|
|
825
912
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -843,7 +930,7 @@ export class StreamClient {
|
|
|
843
930
|
// attempt forever and drowns out whatever actually went wrong.
|
|
844
931
|
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
845
932
|
this.releaseSocket();
|
|
846
|
-
this.
|
|
933
|
+
this.end('gave-up');
|
|
847
934
|
return;
|
|
848
935
|
}
|
|
849
936
|
}
|
|
@@ -888,6 +975,7 @@ export class StreamClient {
|
|
|
888
975
|
}
|
|
889
976
|
|
|
890
977
|
private teardownMedia(): void {
|
|
978
|
+
this.subscriptions.clear();
|
|
891
979
|
for (const c of this.consumers.values()) c.close();
|
|
892
980
|
this.consumers.clear();
|
|
893
981
|
this.remoteInfo.clear();
|
|
@@ -962,17 +1050,27 @@ export class StreamClient {
|
|
|
962
1050
|
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
963
1051
|
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
964
1052
|
* session then keeps its token); otherwise it is kept for the next join,
|
|
965
|
-
* which checks it there. Resolves to when
|
|
1053
|
+
* which checks it there. Resolves to when the token now held expires (unix
|
|
1054
|
+
* ms), when known. A newer token that arrives while this waits is kept
|
|
1055
|
+
* instead of this one: the server's (its own refresh, or a promotion's,
|
|
1056
|
+
* which carries the new role) or a later updateToken()'s.
|
|
966
1057
|
*/
|
|
967
1058
|
async updateToken(token: string): Promise<{ expiresAt: number | null }> {
|
|
968
1059
|
if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
|
|
969
1060
|
if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
1061
|
+
const request = ++this.tokenRequests;
|
|
1062
|
+
const revision = this.serverTokenRevision;
|
|
1063
|
+
const keep = (expiresAt: number | null) => {
|
|
1064
|
+
if (revision === this.serverTokenRevision && request > this.tokenRequestHeld) {
|
|
1065
|
+
this.tokenRequestHeld = request;
|
|
1066
|
+
this.setToken(token, expiresAt);
|
|
1067
|
+
}
|
|
1068
|
+
return { expiresAt: this.tokenExpiry };
|
|
1069
|
+
};
|
|
970
1070
|
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
971
1071
|
try {
|
|
972
1072
|
const res = (await this.rpc('refreshToken', { token })) as { expiresAt?: unknown } | undefined;
|
|
973
|
-
|
|
974
|
-
this.setToken(token, at);
|
|
975
|
-
return { expiresAt: this.tokenExpiry };
|
|
1073
|
+
return keep(typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null);
|
|
976
1074
|
} catch (err) {
|
|
977
1075
|
// An older server has no refreshToken, and a socket that dropped
|
|
978
1076
|
// during the call is reconnecting: either way keep the token for the
|
|
@@ -980,8 +1078,7 @@ export class StreamClient {
|
|
|
980
1078
|
if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
|
|
981
1079
|
}
|
|
982
1080
|
}
|
|
983
|
-
|
|
984
|
-
return { expiresAt: this.tokenExpiry };
|
|
1081
|
+
return keep(null);
|
|
985
1082
|
}
|
|
986
1083
|
|
|
987
1084
|
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
@@ -1045,11 +1142,21 @@ export class StreamClient {
|
|
|
1045
1142
|
this.pending.delete(id);
|
|
1046
1143
|
reject(new RpcError('timeout', `${method} timed out`));
|
|
1047
1144
|
}, RPC_TIMEOUT_MS);
|
|
1048
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
1145
|
+
this.pending.set(id, { resolve, reject, timer, ws: this.ws });
|
|
1049
1146
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
1050
1147
|
});
|
|
1051
1148
|
}
|
|
1052
1149
|
|
|
1150
|
+
/** Fail what was sent on `ws` and is still waiting. */
|
|
1151
|
+
private failPending(ws: WebSocket, err: Error): void {
|
|
1152
|
+
for (const [id, p] of this.pending) {
|
|
1153
|
+
if (p.ws !== ws) continue;
|
|
1154
|
+
clearTimeout(p.timer);
|
|
1155
|
+
this.pending.delete(id);
|
|
1156
|
+
p.reject(err);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1053
1160
|
private failAllPending(err: Error): void {
|
|
1054
1161
|
for (const p of this.pending.values()) {
|
|
1055
1162
|
clearTimeout(p.timer);
|
|
@@ -1093,21 +1200,20 @@ export class StreamClient {
|
|
|
1093
1200
|
break;
|
|
1094
1201
|
case 'producerClosed': {
|
|
1095
1202
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
1203
|
+
this.subscriptions.delete(producerId);
|
|
1096
1204
|
this.preferredLayers.delete(producerId);
|
|
1097
1205
|
this.receiveQualities.delete(producerId);
|
|
1098
1206
|
this.remoteVideoLayers.delete(producerId);
|
|
1099
1207
|
this.skippedVideos.delete(producerId);
|
|
1100
1208
|
const consumer = this.consumers.get(producerId);
|
|
1101
|
-
|
|
1102
|
-
this.videoSlots.delete(producerId);
|
|
1103
|
-
consumer.close();
|
|
1209
|
+
consumer?.close();
|
|
1104
1210
|
this.consumers.delete(producerId);
|
|
1105
1211
|
this.remoteSources.delete(producerId);
|
|
1106
1212
|
this.remoteInfo.delete(producerId);
|
|
1107
|
-
this.emit('trackEnded', { producerId, identity });
|
|
1213
|
+
if (consumer) this.emit('trackEnded', { producerId, identity });
|
|
1108
1214
|
// A video slot opened under the fan-out limit: the oldest video left
|
|
1109
1215
|
// waiting takes it.
|
|
1110
|
-
|
|
1216
|
+
this.releaseVideoSlot(producerId);
|
|
1111
1217
|
break;
|
|
1112
1218
|
}
|
|
1113
1219
|
case 'producerPaused': {
|
|
@@ -1133,6 +1239,7 @@ export class StreamClient {
|
|
|
1133
1239
|
case 'participantMuted': {
|
|
1134
1240
|
const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
|
|
1135
1241
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
1242
|
+
if (e.identity && e.identity === this.joinResult?.identity) this.moderate(e.kind, e.paused, true);
|
|
1136
1243
|
this.updateRoster((list) => list.map((p) =>
|
|
1137
1244
|
p.identity !== e.identity ? p : { ...p,
|
|
1138
1245
|
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
@@ -1179,6 +1286,7 @@ export class StreamClient {
|
|
|
1179
1286
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
1180
1287
|
// changes nothing here -- switching back on is the user's choice.
|
|
1181
1288
|
const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
|
|
1289
|
+
this.moderate(e.kind, Boolean(e.paused));
|
|
1182
1290
|
if (e.paused) {
|
|
1183
1291
|
this.applyLocalModeration(e.kind);
|
|
1184
1292
|
this.emitLocalMedia();
|
|
@@ -1203,13 +1311,19 @@ export class StreamClient {
|
|
|
1203
1311
|
// fresh one periodically so a reconnect at any point still works --
|
|
1204
1312
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
1205
1313
|
const e = data as { token?: string; role?: Role; expiresAt?: number };
|
|
1206
|
-
if (e.token)
|
|
1314
|
+
if (e.token) {
|
|
1315
|
+
this.serverTokenRevision += 1;
|
|
1316
|
+
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
1317
|
+
}
|
|
1207
1318
|
break;
|
|
1208
1319
|
}
|
|
1209
1320
|
case 'mustReconnect': {
|
|
1210
1321
|
const e = data as { reason: string; token?: string; retry: boolean };
|
|
1211
1322
|
// A promotion hands us a fresh token carrying the new role.
|
|
1212
|
-
if (e.token)
|
|
1323
|
+
if (e.token) {
|
|
1324
|
+
this.serverTokenRevision += 1;
|
|
1325
|
+
this.setToken(e.token, null);
|
|
1326
|
+
}
|
|
1213
1327
|
this.emit('mustReconnect', e);
|
|
1214
1328
|
if (e.retry) {
|
|
1215
1329
|
this.teardownMedia();
|
|
@@ -1377,9 +1491,26 @@ export class StreamClient {
|
|
|
1377
1491
|
// unpublish() ran first: this publish is over before it began, and must
|
|
1378
1492
|
// leave nothing behind that a reconnect would publish again.
|
|
1379
1493
|
if (generation !== this.publishGeneration) throw unpublishedWhile('stream');
|
|
1380
|
-
|
|
1494
|
+
// A second publish adds to the first (the microphone, then the camera):
|
|
1495
|
+
// one local stream carries every live track, so unpublish() and close()
|
|
1496
|
+
// stop them all and a reconnect publishes each again. Replacing it
|
|
1497
|
+
// orphaned the first stream's tracks, still capturing (V072-01).
|
|
1498
|
+
const previous = this.localStream;
|
|
1499
|
+
const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
|
|
1500
|
+
if (merged) {
|
|
1501
|
+
const ids = new Set(stream.getTracks().map((t) => t.id));
|
|
1502
|
+
// The new stream's tracks first: of one kind, the newer track is the one
|
|
1503
|
+
// published (as before), and the older one is still stopped with it.
|
|
1504
|
+
this.localStream = new MediaStream([
|
|
1505
|
+
...stream.getTracks(),
|
|
1506
|
+
...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
|
|
1507
|
+
]);
|
|
1508
|
+
this.emit('localStreamChanged', this.localStream);
|
|
1509
|
+
} else {
|
|
1510
|
+
this.localStream = stream;
|
|
1511
|
+
}
|
|
1381
1512
|
this.wasPublishing = true;
|
|
1382
|
-
if (constraints) this.publishConstraints = constraints;
|
|
1513
|
+
if (constraints) this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
|
|
1383
1514
|
if (options) {
|
|
1384
1515
|
this.publishOptions = { ...options };
|
|
1385
1516
|
this.frameRateFallback = false;
|
|
@@ -1972,7 +2103,11 @@ export class StreamClient {
|
|
|
1972
2103
|
if (this.screenStream === screen) await this.stopScreen('stopped');
|
|
1973
2104
|
this.emit('error', err as Error);
|
|
1974
2105
|
});
|
|
1975
|
-
if (this.music)
|
|
2106
|
+
if (this.music) {
|
|
2107
|
+
const music = this.music;
|
|
2108
|
+
const operation = this.musicOperation;
|
|
2109
|
+
await this.musicTask(() => this.produceMusic(music, generation, operation)).catch((err) => this.emit('error', err as Error));
|
|
2110
|
+
}
|
|
1976
2111
|
if (generation !== this.publishGeneration) return;
|
|
1977
2112
|
|
|
1978
2113
|
let stream = this.localStream;
|
|
@@ -2183,26 +2318,41 @@ export class StreamClient {
|
|
|
2183
2318
|
async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
|
|
2184
2319
|
if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
2185
2320
|
const generation = this.publishGeneration;
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2321
|
+
// Taken now, in call order: a music publish still under way is over, and
|
|
2322
|
+
// a stopMusic() from here on ends this one.
|
|
2323
|
+
const operation = ++this.musicOperation;
|
|
2324
|
+
await this.musicTask(async () => {
|
|
2325
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
|
|
2326
|
+
if (this.music) await this.closeMusic();
|
|
2327
|
+
const music = new MusicSource(input, options);
|
|
2328
|
+
try {
|
|
2329
|
+
await this.produceMusic(music, generation, operation);
|
|
2330
|
+
// Checked again here: an unpublish() or stopMusic() after the
|
|
2331
|
+
// producer was kept has already closed it, and the music must not be
|
|
2332
|
+
// remembered for a reconnect to publish again.
|
|
2333
|
+
if (generation !== this.publishGeneration || operation !== this.musicOperation) throw unpublishedWhile('music');
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
music.close();
|
|
2336
|
+
throw err;
|
|
2337
|
+
}
|
|
2338
|
+
this.music = music;
|
|
2339
|
+
this.wasPublishing = true;
|
|
2340
|
+
this.emitLocalMedia();
|
|
2341
|
+
});
|
|
2201
2342
|
}
|
|
2202
2343
|
|
|
2203
|
-
private
|
|
2344
|
+
private musicTask<T>(task: () => Promise<T>): Promise<T> {
|
|
2345
|
+
const run = this.musicQueue.then(task, task);
|
|
2346
|
+
this.musicQueue = run.catch(() => undefined);
|
|
2347
|
+
return run;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
private async produceMusic(music: MusicSource, generation: number, operation: number): Promise<void> {
|
|
2351
|
+
// unpublish(), stopMusic() or a newer publishMusic() can run at any wait
|
|
2352
|
+
// below, and wins.
|
|
2353
|
+
const current = () => generation === this.publishGeneration && operation === this.musicOperation;
|
|
2204
2354
|
const transport = await this.ensureSendTransport();
|
|
2205
|
-
if (
|
|
2355
|
+
if (!current()) throw unpublishedWhile('music');
|
|
2206
2356
|
const producer = await transport.produce({
|
|
2207
2357
|
track: music.track,
|
|
2208
2358
|
stopTracks: false,
|
|
@@ -2211,7 +2361,14 @@ export class StreamClient {
|
|
|
2211
2361
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
2212
2362
|
appData: { source: 'music' },
|
|
2213
2363
|
});
|
|
2214
|
-
|
|
2364
|
+
if (!current()) {
|
|
2365
|
+
producer.close();
|
|
2366
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
2367
|
+
throw unpublishedWhile('music');
|
|
2368
|
+
}
|
|
2369
|
+
// Kept and named the music in one step, with no wait between: stopMusic()
|
|
2370
|
+
// finds the music producer by that name.
|
|
2371
|
+
this.producers.set(producer.id, producer);
|
|
2215
2372
|
this.musicProducerId = producer.id;
|
|
2216
2373
|
// Music switched off before a reconnect comes back paused here.
|
|
2217
2374
|
await this.pauseOnServer(producer);
|
|
@@ -2242,8 +2399,18 @@ export class StreamClient {
|
|
|
2242
2399
|
this.emitLocalMedia();
|
|
2243
2400
|
}
|
|
2244
2401
|
|
|
2245
|
-
/**
|
|
2402
|
+
/**
|
|
2403
|
+
* Stop publishing music. The element or stream it came from is left as it
|
|
2404
|
+
* was. A publishMusic() still under way is stopped too: it rejects with
|
|
2405
|
+
* `unpublished` and publishes nothing.
|
|
2406
|
+
*/
|
|
2246
2407
|
async stopMusic(): Promise<void> {
|
|
2408
|
+
this.musicOperation += 1;
|
|
2409
|
+
await this.closeMusic();
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/** Close the music being published, here and at the server. */
|
|
2413
|
+
private async closeMusic(): Promise<void> {
|
|
2247
2414
|
const id = this.musicProducerId;
|
|
2248
2415
|
this.musicProducerId = null;
|
|
2249
2416
|
this.music?.close();
|
|
@@ -2265,8 +2432,9 @@ export class StreamClient {
|
|
|
2265
2432
|
}
|
|
2266
2433
|
|
|
2267
2434
|
private async consume(remote: RemoteProducer): Promise<void> {
|
|
2435
|
+
if (this.closedByUs) return;
|
|
2268
2436
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
2269
|
-
if (this.
|
|
2437
|
+
if (this.subscriptions.has(remote.producerId)) return;
|
|
2270
2438
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
2271
2439
|
// consumer and shows you a delayed copy of yourself.
|
|
2272
2440
|
if (remote.identity === this.joinResult?.identity) return;
|
|
@@ -2285,34 +2453,67 @@ export class StreamClient {
|
|
|
2285
2453
|
}
|
|
2286
2454
|
this.videoSlots.add(remote.producerId);
|
|
2287
2455
|
}
|
|
2456
|
+
const operation = {};
|
|
2457
|
+
this.subscriptions.set(remote.producerId, operation);
|
|
2288
2458
|
try {
|
|
2289
|
-
await this.consumeNow(remote);
|
|
2459
|
+
await this.consumeNow(remote, operation);
|
|
2290
2460
|
} catch (err) {
|
|
2291
|
-
|
|
2461
|
+
// A closed producer or replaced session has already released its slot.
|
|
2462
|
+
// Its late failure must neither release a newer slot nor report a ghost error.
|
|
2463
|
+
if (this.subscriptions.get(remote.producerId) !== operation) return;
|
|
2464
|
+
this.subscriptions.delete(remote.producerId);
|
|
2465
|
+
this.consumers.get(remote.producerId)?.close();
|
|
2466
|
+
this.consumers.delete(remote.producerId);
|
|
2467
|
+
this.remoteVideoLayers.delete(remote.producerId);
|
|
2468
|
+
this.remoteSources.delete(remote.producerId);
|
|
2469
|
+
this.remoteInfo.delete(remote.producerId);
|
|
2470
|
+
this.releaseVideoSlot(remote.producerId);
|
|
2292
2471
|
throw err;
|
|
2293
2472
|
}
|
|
2294
2473
|
}
|
|
2295
2474
|
|
|
2475
|
+
/**
|
|
2476
|
+
* Free a video slot, and let the oldest video waiting take it -- whatever
|
|
2477
|
+
* freed it: an ended video, or a subscription that failed. Not while
|
|
2478
|
+
* reconnecting: a failure caused by the socket dropping would otherwise
|
|
2479
|
+
* start every waiting video on a dead connection, each failing with
|
|
2480
|
+
* not-connected; the reconnect's join lists them all again anyway.
|
|
2481
|
+
*/
|
|
2482
|
+
private releaseVideoSlot(producerId: string): void {
|
|
2483
|
+
if (this.videoSlots.delete(producerId) && !this.closedByUs && this.state !== 'reconnecting') this.consumeSkipped();
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2296
2486
|
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
2297
|
-
private
|
|
2487
|
+
private consumeSkipped(): void {
|
|
2298
2488
|
for (const [producerId, remote] of this.skippedVideos) {
|
|
2299
2489
|
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
2300
2490
|
if (limit !== null && this.videoSlots.size >= limit) return;
|
|
2301
2491
|
this.skippedVideos.delete(producerId);
|
|
2302
|
-
await
|
|
2492
|
+
// consume reserves synchronously, before its first await. Consecutive
|
|
2493
|
+
// releases cannot admit the same producer or exceed the limit.
|
|
2494
|
+
void this.consume(remote).catch((err) => this.emit('error', err as Error));
|
|
2303
2495
|
}
|
|
2304
2496
|
}
|
|
2305
2497
|
|
|
2306
|
-
private async consumeNow(remote: RemoteProducer): Promise<void> {
|
|
2498
|
+
private async consumeNow(remote: RemoteProducer, operation: object): Promise<void> {
|
|
2307
2499
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
2500
|
+
const device = this.device;
|
|
2501
|
+
const current = (made?: Consumer) => {
|
|
2502
|
+
if (!this.closedByUs && this.subscriptions.get(remote.producerId) === operation) return;
|
|
2503
|
+
made?.close();
|
|
2504
|
+
throw new RpcError('not-connected', 'subscription ended during setup');
|
|
2505
|
+
};
|
|
2308
2506
|
const transport = await this.ensureRecvTransport();
|
|
2507
|
+
current();
|
|
2309
2508
|
const params = (await this.rpc('consume', {
|
|
2310
2509
|
transportId: transport.id,
|
|
2311
2510
|
producerId: remote.producerId,
|
|
2312
|
-
rtpCapabilities:
|
|
2511
|
+
rtpCapabilities: device.rtpCapabilities,
|
|
2313
2512
|
})) as { id: string; producerId: string; kind: 'audio' | 'video'; rtpParameters: never };
|
|
2314
2513
|
|
|
2514
|
+
current();
|
|
2315
2515
|
const consumer = await transport.consume(params);
|
|
2516
|
+
current(consumer);
|
|
2316
2517
|
this.consumers.set(remote.producerId, consumer);
|
|
2317
2518
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
|
|
2318
2519
|
|
|
@@ -2328,10 +2529,12 @@ export class StreamClient {
|
|
|
2328
2529
|
if (layers) {
|
|
2329
2530
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
2330
2531
|
}
|
|
2532
|
+
current(consumer);
|
|
2331
2533
|
|
|
2332
2534
|
// The server created it paused. Resume only now that our end exists,
|
|
2333
2535
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
2334
2536
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2537
|
+
current(consumer);
|
|
2335
2538
|
|
|
2336
2539
|
const kind = consumer.kind as 'audio' | 'video';
|
|
2337
2540
|
const source = remote.source ?? defaultSource(kind);
|
|
@@ -2396,16 +2599,47 @@ export class StreamClient {
|
|
|
2396
2599
|
}
|
|
2397
2600
|
|
|
2398
2601
|
private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2602
|
+
const generation = this.publishGeneration;
|
|
2603
|
+
// Unpublished or closed while waiting: nothing is left to switch, and the
|
|
2604
|
+
// call resolves, as it does with nothing published (V072-03).
|
|
2605
|
+
const ended = () => generation !== this.publishGeneration || this.closedByUs;
|
|
2606
|
+
// The music has its own switch. Muting the microphone must not stop the song.
|
|
2607
|
+
const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
|
|
2608
|
+
for (const producer of targets) {
|
|
2609
|
+
if (ended()) return;
|
|
2610
|
+
if (this.producers.get(producer.id) !== producer || producer.closed) continue;
|
|
2611
|
+
const revision = this.moderationRevision[kind];
|
|
2402
2612
|
if (!enabled) producer.pause();
|
|
2403
|
-
|
|
2404
|
-
|
|
2613
|
+
try {
|
|
2614
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
2615
|
+
} catch (err) {
|
|
2616
|
+
if (ended()) return;
|
|
2617
|
+
throw err;
|
|
2618
|
+
}
|
|
2619
|
+
if (ended()) return;
|
|
2620
|
+
if (!enabled || this.producers.get(producer.id) !== producer || producer.closed) continue;
|
|
2621
|
+
if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
|
|
2622
|
+
// A moderator decided while this waited: the older request does not
|
|
2623
|
+
// undo it. It stays off here, and the server is told so too, since
|
|
2624
|
+
// which of the two it applied last cannot be known from here.
|
|
2625
|
+
void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
|
|
2626
|
+
this.emitLocalMedia();
|
|
2627
|
+
throw new RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
|
|
2628
|
+
}
|
|
2629
|
+
producer.resume();
|
|
2405
2630
|
}
|
|
2406
2631
|
this.emitLocalMedia();
|
|
2407
2632
|
}
|
|
2408
2633
|
|
|
2634
|
+
/** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
|
|
2635
|
+
private moderate(kind: 'audio' | 'video' | '' | undefined, paused: boolean, snapshot = false): void {
|
|
2636
|
+
for (const k of kind ? [kind] : (['audio', 'video'] as const)) {
|
|
2637
|
+
if (snapshot && this.forcedOff[k] === paused) continue;
|
|
2638
|
+
this.forcedOff[k] = paused;
|
|
2639
|
+
this.moderationRevision[k] += 1;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2409
2643
|
/** Stop publishing entirely and release the local devices. */
|
|
2410
2644
|
async unpublish(): Promise<void> {
|
|
2411
2645
|
void this.stopRecording().catch(error => this.emit('error', error as Error));
|