@voltkit-rtc/client 0.4.0 → 0.5.0
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 +15 -0
- package/README.md +90 -29
- package/dist/cjs/index.d.ts +63 -8
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +326 -26
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/music.d.ts +1 -1
- package/dist/cjs/music.d.ts.map +1 -1
- package/dist/cjs/quality.d.ts +19 -21
- package/dist/cjs/quality.d.ts.map +1 -1
- package/dist/cjs/quality.js +46 -28
- package/dist/cjs/quality.js.map +1 -1
- package/dist/esm/index.d.ts +63 -8
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +327 -27
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/music.d.ts +1 -1
- package/dist/esm/music.d.ts.map +1 -1
- package/dist/esm/quality.d.ts +19 -21
- package/dist/esm/quality.d.ts.map +1 -1
- package/dist/esm/quality.js +43 -28
- package/dist/esm/quality.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +308 -27
- package/src/music.ts +1 -1
- package/src/quality.ts +58 -29
package/dist/cjs/index.js
CHANGED
|
@@ -71,7 +71,7 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
71
71
|
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
72
72
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
73
73
|
*/
|
|
74
|
-
const VIDEO_START_BITRATE_KBPS =
|
|
74
|
+
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
75
75
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
76
76
|
const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
|
|
77
77
|
class StreamClient {
|
|
@@ -104,6 +104,20 @@ class StreamClient {
|
|
|
104
104
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
105
105
|
music = null;
|
|
106
106
|
musicProducerId = null;
|
|
107
|
+
screenStream = null;
|
|
108
|
+
screenMode = 'detail';
|
|
109
|
+
screenAudioEnabled = true;
|
|
110
|
+
screenGeneration = 0;
|
|
111
|
+
screenPending = false;
|
|
112
|
+
screenStarted = false;
|
|
113
|
+
screenEndedListener = null;
|
|
114
|
+
screenProducerIds = new Set();
|
|
115
|
+
remoteVideoLayers = new Map();
|
|
116
|
+
receiveQualities = new Map();
|
|
117
|
+
cameraFrameRate = 30;
|
|
118
|
+
frameRateFallback = false;
|
|
119
|
+
frameRateLowSamples = 0;
|
|
120
|
+
frameRateWarmupSamples = 2;
|
|
107
121
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
108
122
|
remoteSources = new Map();
|
|
109
123
|
/**
|
|
@@ -130,7 +144,7 @@ class StreamClient {
|
|
|
130
144
|
codecFallbackLogged = false;
|
|
131
145
|
sendStatsBaseline = new Map();
|
|
132
146
|
recvStatsBaseline = new Map();
|
|
133
|
-
limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.
|
|
147
|
+
limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
|
|
134
148
|
constructor(wsUrl, token) {
|
|
135
149
|
this.wsUrl = wsUrl;
|
|
136
150
|
this.token = token;
|
|
@@ -188,14 +202,30 @@ class StreamClient {
|
|
|
188
202
|
// retried about twice a second, forever.
|
|
189
203
|
this.reconnectAttempt = 0;
|
|
190
204
|
this.joinedOnce = true;
|
|
205
|
+
// A role or mute can change while the socket is away. The fresh roster
|
|
206
|
+
// decides what may return; reconnecting never turns a blocked device on.
|
|
207
|
+
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
208
|
+
await this.unpublish();
|
|
209
|
+
}
|
|
210
|
+
const own = result.participants.find((p) => p.identity === result.identity);
|
|
211
|
+
if (own?.muted)
|
|
212
|
+
this.applyLocalModeration('audio');
|
|
213
|
+
if (own?.videoBlocked)
|
|
214
|
+
this.applyLocalModeration('video');
|
|
191
215
|
this.emit('joined', result);
|
|
192
216
|
// A video that ended while this client was away sends it no
|
|
193
217
|
// producerClosed: forget the layers asked for it.
|
|
194
218
|
const present = new Set(result.producers.map((p) => p.producerId));
|
|
195
219
|
for (const producerId of this.preferredLayers.keys()) {
|
|
196
|
-
if (!present.has(producerId))
|
|
220
|
+
if (!present.has(producerId)) {
|
|
197
221
|
this.preferredLayers.delete(producerId);
|
|
222
|
+
this.receiveQualities.delete(producerId);
|
|
223
|
+
this.remoteVideoLayers.delete(producerId);
|
|
224
|
+
}
|
|
198
225
|
}
|
|
226
|
+
for (const producerId of this.remoteVideoLayers.keys())
|
|
227
|
+
if (!present.has(producerId))
|
|
228
|
+
this.remoteVideoLayers.delete(producerId);
|
|
199
229
|
// Subscribe to everything already publishing before we announce readiness.
|
|
200
230
|
for (const p of result.producers) {
|
|
201
231
|
await this.consume(p).catch((err) => this.emit('error', err));
|
|
@@ -339,9 +369,15 @@ class StreamClient {
|
|
|
339
369
|
// others have already reported themselves and set closedByUs.
|
|
340
370
|
const wasOpen = !this.closedByUs;
|
|
341
371
|
this.closedByUs = true;
|
|
372
|
+
this.publishGeneration += 1;
|
|
342
373
|
this.wasPublishing = false;
|
|
343
374
|
this.teardownMedia();
|
|
344
375
|
this.preferredLayers.clear();
|
|
376
|
+
this.receiveQualities.clear();
|
|
377
|
+
this.remoteVideoLayers.clear();
|
|
378
|
+
this.releaseScreen();
|
|
379
|
+
this.music?.close();
|
|
380
|
+
this.music = null;
|
|
345
381
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
346
382
|
this.limitWatch.reset();
|
|
347
383
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
@@ -362,6 +398,7 @@ class StreamClient {
|
|
|
362
398
|
p.close();
|
|
363
399
|
this.producers.clear();
|
|
364
400
|
this.musicProducerId = null;
|
|
401
|
+
this.screenProducerIds.clear();
|
|
365
402
|
this.cameraEncodings = null;
|
|
366
403
|
// Paused, not reset: a reconnect's republish starts it again, and a
|
|
367
404
|
// limitation already reported is not reported twice.
|
|
@@ -435,6 +472,8 @@ class StreamClient {
|
|
|
435
472
|
case 'producerClosed': {
|
|
436
473
|
const { producerId, identity } = data;
|
|
437
474
|
this.preferredLayers.delete(producerId);
|
|
475
|
+
this.receiveQualities.delete(producerId);
|
|
476
|
+
this.remoteVideoLayers.delete(producerId);
|
|
438
477
|
const consumer = this.consumers.get(producerId);
|
|
439
478
|
if (!consumer)
|
|
440
479
|
break; // never consumed it, or already cleaned up
|
|
@@ -458,16 +497,29 @@ class StreamClient {
|
|
|
458
497
|
case 'participantJoined':
|
|
459
498
|
this.emit('participantJoined', data);
|
|
460
499
|
break;
|
|
500
|
+
case 'participantMuted': {
|
|
501
|
+
const wire = data;
|
|
502
|
+
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
503
|
+
if (this.joinResult)
|
|
504
|
+
this.joinResult.participants = this.joinResult.participants.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
505
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
506
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
507
|
+
});
|
|
508
|
+
this.emit('participantMuted', e);
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
461
511
|
case 'participantLeft':
|
|
462
512
|
this.emit('participantLeft', data);
|
|
463
513
|
break;
|
|
464
514
|
case 'roleChanged': {
|
|
465
515
|
const e = data;
|
|
516
|
+
if (e.identity === this.joinResult?.identity)
|
|
517
|
+
this.joinResult.role = e.role;
|
|
466
518
|
this.emit('roleChanged', e);
|
|
467
519
|
// Demoted: the server has already closed our producers. Release the
|
|
468
520
|
// camera and microphone too, instead of capturing for nobody.
|
|
469
521
|
const canPublish = e.role === 'host' || e.role === 'cohost';
|
|
470
|
-
if (e.identity === this.joinResult?.identity && !canPublish && this.
|
|
522
|
+
if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
|
|
471
523
|
void this.unpublish().catch(() => undefined);
|
|
472
524
|
}
|
|
473
525
|
break;
|
|
@@ -482,10 +534,7 @@ class StreamClient {
|
|
|
482
534
|
// changes nothing here -- switching back on is the user's choice.
|
|
483
535
|
const e = data;
|
|
484
536
|
if (e.paused) {
|
|
485
|
-
|
|
486
|
-
if (!e.kind || p.kind === e.kind)
|
|
487
|
-
p.pause();
|
|
488
|
-
}
|
|
537
|
+
this.applyLocalModeration(e.kind);
|
|
489
538
|
this.emitLocalMedia();
|
|
490
539
|
}
|
|
491
540
|
this.emit('forceMuted', e);
|
|
@@ -573,7 +622,7 @@ class StreamClient {
|
|
|
573
622
|
.catch(fail);
|
|
574
623
|
});
|
|
575
624
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
576
|
-
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
|
|
625
|
+
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
|
|
577
626
|
.then((res) => ok({ id: res.producerId }))
|
|
578
627
|
.catch(fail);
|
|
579
628
|
});
|
|
@@ -629,8 +678,11 @@ class StreamClient {
|
|
|
629
678
|
this.wasPublishing = true;
|
|
630
679
|
if (constraints)
|
|
631
680
|
this.publishConstraints = constraints;
|
|
632
|
-
if (options)
|
|
681
|
+
if (options) {
|
|
633
682
|
this.publishOptions = { ...options };
|
|
683
|
+
this.frameRateFallback = false;
|
|
684
|
+
this.frameRateLowSamples = 0;
|
|
685
|
+
}
|
|
634
686
|
const audio = stream.getAudioTracks()[0];
|
|
635
687
|
if (audio) {
|
|
636
688
|
const producer = await transport.produce({
|
|
@@ -652,7 +704,7 @@ class StreamClient {
|
|
|
652
704
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
653
705
|
}
|
|
654
706
|
}
|
|
655
|
-
/** What this project may publish. Known once joined;
|
|
707
|
+
/** What this project may publish. Known once joined; 1080p before that. */
|
|
656
708
|
get videoPolicy() {
|
|
657
709
|
return this.joinResult?.video ?? quality_js_1.DEFAULT_VIDEO_POLICY;
|
|
658
710
|
}
|
|
@@ -677,11 +729,30 @@ class StreamClient {
|
|
|
677
729
|
});
|
|
678
730
|
return this.videoQuality;
|
|
679
731
|
}
|
|
732
|
+
/** Change the camera frame rate without stopping screen, microphone or music. */
|
|
733
|
+
async setVideoFrameRate(frameRate) {
|
|
734
|
+
if (frameRate !== 30 && frameRate !== 60)
|
|
735
|
+
throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
736
|
+
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
737
|
+
this.frameRateFallback = false;
|
|
738
|
+
this.frameRateLowSamples = 0;
|
|
739
|
+
await this.cameraTask(async () => {
|
|
740
|
+
const current = this.cameraProducer();
|
|
741
|
+
const track = current?.track;
|
|
742
|
+
if (!current || !track || track.readyState !== 'live')
|
|
743
|
+
return;
|
|
744
|
+
try {
|
|
745
|
+
await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
|
|
746
|
+
}
|
|
747
|
+
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
748
|
+
await this.reproduceCamera(current, track);
|
|
749
|
+
});
|
|
750
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
|
|
751
|
+
}
|
|
680
752
|
/**
|
|
681
753
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
682
754
|
* it, the raw camera again, another camera. When the new track needs the
|
|
683
|
-
* same layers as the current one
|
|
684
|
-
* project's quality scales alike), viewers see no pause, and the layers
|
|
755
|
+
* same dimensions and layers as the current one, viewers see no pause, and the layers
|
|
685
756
|
* take the new picture's bitrates. Otherwise the camera producer is
|
|
686
757
|
* replaced, as `setVideoQuality` does. Either way a paused camera stays
|
|
687
758
|
* paused.
|
|
@@ -722,8 +793,10 @@ class StreamClient {
|
|
|
722
793
|
const paused = current.paused;
|
|
723
794
|
try {
|
|
724
795
|
const { width = 0, height = 0 } = track.getSettings();
|
|
725
|
-
const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount());
|
|
726
|
-
|
|
796
|
+
const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
|
|
797
|
+
const previousSize = current.track?.getSettings();
|
|
798
|
+
if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
|
|
799
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
727
800
|
await current.replaceTrack({ track });
|
|
728
801
|
await this.applyLayerBitrates(current, next);
|
|
729
802
|
return;
|
|
@@ -825,6 +898,7 @@ class StreamClient {
|
|
|
825
898
|
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
826
899
|
}
|
|
827
900
|
const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
901
|
+
this.receiveQualities.delete(producerId);
|
|
828
902
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
829
903
|
this.preferredLayers.set(producerId, layers);
|
|
830
904
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
@@ -846,6 +920,28 @@ class StreamClient {
|
|
|
846
920
|
(0, stats_js_1.pruneBaseline)(baseline, (key) => key.startsWith(prefix));
|
|
847
921
|
return (0, stats_js_1.parseSendStats)(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
848
922
|
}
|
|
923
|
+
/** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
|
|
924
|
+
async setReceiveQuality(producerId, quality) {
|
|
925
|
+
if (!['auto', '1080p', '720p', '480p'].includes(quality))
|
|
926
|
+
throw new RangeError('setReceiveQuality: unsupported quality');
|
|
927
|
+
const consumer = this.consumers.get(producerId);
|
|
928
|
+
if (!consumer || consumer.kind !== 'video')
|
|
929
|
+
throw new Error('setReceiveQuality: this client is not receiving that video');
|
|
930
|
+
let layers;
|
|
931
|
+
if (quality === 'auto')
|
|
932
|
+
layers = { spatialLayer: -1, temporalLayer: -1 };
|
|
933
|
+
else {
|
|
934
|
+
try {
|
|
935
|
+
layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
this.receiveQualities.set(producerId, quality);
|
|
942
|
+
this.preferredLayers.set(producerId, layers);
|
|
943
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
944
|
+
}
|
|
849
945
|
cameraProducer() {
|
|
850
946
|
for (const p of this.producers.values()) {
|
|
851
947
|
if (p.kind === 'video' && p.appData.source === 'camera')
|
|
@@ -902,7 +998,8 @@ class StreamClient {
|
|
|
902
998
|
// videoEncodings).
|
|
903
999
|
const { width = 0, height = 0 } = track.getSettings();
|
|
904
1000
|
const quality = this.videoQuality;
|
|
905
|
-
|
|
1001
|
+
this.cameraFrameRate = this.frameRateFor(track);
|
|
1002
|
+
const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
906
1003
|
let codecs;
|
|
907
1004
|
try {
|
|
908
1005
|
codecs = this.device?.sendRtpCapabilities.codecs;
|
|
@@ -917,7 +1014,7 @@ class StreamClient {
|
|
|
917
1014
|
encodings,
|
|
918
1015
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
919
1016
|
...(codec ? { codec } : {}),
|
|
920
|
-
appData: { source: 'camera' },
|
|
1017
|
+
appData: { source: 'camera', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings, quality) },
|
|
921
1018
|
});
|
|
922
1019
|
let producer;
|
|
923
1020
|
try {
|
|
@@ -935,6 +1032,8 @@ class StreamClient {
|
|
|
935
1032
|
// quality change or a track swap under way).
|
|
936
1033
|
await this.keepProducer(producer, generation, 'camera');
|
|
937
1034
|
this.cameraEncodings = encodings;
|
|
1035
|
+
this.frameRateLowSamples = 0;
|
|
1036
|
+
this.frameRateWarmupSamples = 2;
|
|
938
1037
|
await this.pauseOnServer(producer);
|
|
939
1038
|
await this.applyDegradationPreference(producer);
|
|
940
1039
|
this.syncLimitWatch();
|
|
@@ -1014,7 +1113,7 @@ class StreamClient {
|
|
|
1014
1113
|
}
|
|
1015
1114
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1016
1115
|
syncLimitWatch() {
|
|
1017
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
|
|
1116
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
1018
1117
|
}
|
|
1019
1118
|
/**
|
|
1020
1119
|
* Re-establish this client's broadcast after a reconnect.
|
|
@@ -1029,6 +1128,13 @@ class StreamClient {
|
|
|
1029
1128
|
const generation = this.publishGeneration;
|
|
1030
1129
|
// Music is not a device and cannot be "gone": publish it again whatever
|
|
1031
1130
|
// happens to the camera and microphone below.
|
|
1131
|
+
const screen = this.screenStream;
|
|
1132
|
+
if (screen)
|
|
1133
|
+
await this.restoreScreen().catch(async (err) => {
|
|
1134
|
+
if (this.screenStream === screen)
|
|
1135
|
+
await this.stopScreen('stopped');
|
|
1136
|
+
this.emit('error', err);
|
|
1137
|
+
});
|
|
1032
1138
|
if (this.music)
|
|
1033
1139
|
await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
|
|
1034
1140
|
if (generation !== this.publishGeneration)
|
|
@@ -1063,6 +1169,170 @@ class StreamClient {
|
|
|
1063
1169
|
return;
|
|
1064
1170
|
await this.publish(stream);
|
|
1065
1171
|
}
|
|
1172
|
+
// ------------------------------------------------------------- screen sharing
|
|
1173
|
+
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1174
|
+
async publishScreen(options = {}) {
|
|
1175
|
+
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
|
|
1176
|
+
throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1177
|
+
if (this.screenStream || this.screenPending)
|
|
1178
|
+
throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1179
|
+
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
|
|
1180
|
+
throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1181
|
+
const mode = options.mode ?? 'detail';
|
|
1182
|
+
if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
|
|
1183
|
+
throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
|
|
1184
|
+
}
|
|
1185
|
+
const generation = this.screenGeneration;
|
|
1186
|
+
this.screenPending = true;
|
|
1187
|
+
let stream;
|
|
1188
|
+
try {
|
|
1189
|
+
stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
|
|
1190
|
+
video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
|
|
1191
|
+
audio: options.audio ?? false,
|
|
1192
|
+
});
|
|
1193
|
+
const video = stream.getVideoTracks()[0];
|
|
1194
|
+
if (!video || video.readyState !== 'live')
|
|
1195
|
+
throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1196
|
+
if (generation !== this.screenGeneration || this.closedByUs)
|
|
1197
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1198
|
+
this.screenStream = stream;
|
|
1199
|
+
this.screenMode = mode;
|
|
1200
|
+
this.screenAudioEnabled = options.audio !== false;
|
|
1201
|
+
video.contentHint = mode;
|
|
1202
|
+
const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err)); };
|
|
1203
|
+
this.screenEndedListener = onEnded;
|
|
1204
|
+
video.addEventListener('ended', onEnded, { once: true });
|
|
1205
|
+
await this.restoreScreen();
|
|
1206
|
+
if (generation !== this.screenGeneration || video.readyState !== 'live')
|
|
1207
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1208
|
+
this.screenStarted = true;
|
|
1209
|
+
this.wasPublishing = true;
|
|
1210
|
+
this.emit('screenShareStarted', { stream, mode });
|
|
1211
|
+
return stream;
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
if (generation === this.screenGeneration)
|
|
1215
|
+
await this.stopScreen('stopped');
|
|
1216
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1217
|
+
throw error;
|
|
1218
|
+
}
|
|
1219
|
+
finally {
|
|
1220
|
+
if (generation === this.screenGeneration)
|
|
1221
|
+
this.screenPending = false;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
|
|
1225
|
+
async unpublishScreen() {
|
|
1226
|
+
await this.stopScreen('stopped');
|
|
1227
|
+
}
|
|
1228
|
+
releaseScreen() {
|
|
1229
|
+
this.screenGeneration += 1;
|
|
1230
|
+
this.screenPending = false;
|
|
1231
|
+
const stream = this.screenStream;
|
|
1232
|
+
if (this.screenEndedListener)
|
|
1233
|
+
stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
|
|
1234
|
+
this.screenEndedListener = null;
|
|
1235
|
+
this.screenStream = null;
|
|
1236
|
+
this.screenStarted = false;
|
|
1237
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1238
|
+
}
|
|
1239
|
+
async stopScreen(reason) {
|
|
1240
|
+
const started = this.screenStarted;
|
|
1241
|
+
const ids = [...this.screenProducerIds];
|
|
1242
|
+
this.screenProducerIds.clear();
|
|
1243
|
+
this.releaseScreen();
|
|
1244
|
+
for (const id of ids) {
|
|
1245
|
+
this.producers.get(id)?.close();
|
|
1246
|
+
this.producers.delete(id);
|
|
1247
|
+
}
|
|
1248
|
+
if (!this.localStream && !this.music)
|
|
1249
|
+
this.wasPublishing = false;
|
|
1250
|
+
if (started && !this.closedByUs)
|
|
1251
|
+
this.emit('screenShareEnded', { reason });
|
|
1252
|
+
await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
|
|
1253
|
+
}
|
|
1254
|
+
async restoreScreen() {
|
|
1255
|
+
const stream = this.screenStream;
|
|
1256
|
+
const video = stream?.getVideoTracks()[0];
|
|
1257
|
+
if (!stream || !video)
|
|
1258
|
+
return;
|
|
1259
|
+
if (video.readyState !== 'live') {
|
|
1260
|
+
await this.stopScreen('track-ended');
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
const generation = this.screenGeneration;
|
|
1264
|
+
const publishing = this.publishGeneration;
|
|
1265
|
+
const transport = await this.ensureSendTransport();
|
|
1266
|
+
if (generation !== this.screenGeneration)
|
|
1267
|
+
return;
|
|
1268
|
+
const { width = 0, height = 0 } = video.getSettings();
|
|
1269
|
+
const encodings = (0, quality_js_1.screenEncodings)(width, height, this.screenMode);
|
|
1270
|
+
// A project's explicit safety restriction also applies to screen video.
|
|
1271
|
+
const cap = (0, quality_js_1.topLayerSize)(width, height, this.videoPolicy.maxQuality);
|
|
1272
|
+
const screenScale = encodings.at(-1).scaleResolutionDownBy;
|
|
1273
|
+
if (cap.scale > screenScale)
|
|
1274
|
+
encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
1275
|
+
const keep = async (producer) => {
|
|
1276
|
+
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1277
|
+
producer.close();
|
|
1278
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1279
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1280
|
+
}
|
|
1281
|
+
this.producers.set(producer.id, producer);
|
|
1282
|
+
this.screenProducerIds.add(producer.id);
|
|
1283
|
+
await this.pauseOnServer(producer);
|
|
1284
|
+
};
|
|
1285
|
+
await keep(await transport.produce({
|
|
1286
|
+
track: video, stopTracks: false, encodings,
|
|
1287
|
+
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1288
|
+
appData: { source: 'screen', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings) },
|
|
1289
|
+
}));
|
|
1290
|
+
const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1291
|
+
if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
|
|
1292
|
+
await keep(await transport.produce({
|
|
1293
|
+
track: audio, stopTracks: false,
|
|
1294
|
+
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
|
|
1295
|
+
appData: { source: 'screen-audio' },
|
|
1296
|
+
}));
|
|
1297
|
+
}
|
|
1298
|
+
frameRateFor(track) {
|
|
1299
|
+
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
|
|
1300
|
+
return 30;
|
|
1301
|
+
const captured = track.getSettings().frameRate;
|
|
1302
|
+
if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
|
|
1303
|
+
this.frameRateFallback = true;
|
|
1304
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
|
|
1305
|
+
return 30;
|
|
1306
|
+
}
|
|
1307
|
+
return 60;
|
|
1308
|
+
}
|
|
1309
|
+
async sampleCamera(baseline) {
|
|
1310
|
+
const stats = await this.collectVideoStats(baseline);
|
|
1311
|
+
if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
|
|
1312
|
+
return stats;
|
|
1313
|
+
if (this.cameraProducer()?.paused)
|
|
1314
|
+
return stats;
|
|
1315
|
+
// The lower simulcast copies intentionally run at 30 fps. During the
|
|
1316
|
+
// initial bandwidth ramp they may be the only active copies, which does
|
|
1317
|
+
// not establish that the camera cannot sustain 60. Match native warm-up:
|
|
1318
|
+
// two 2-second samples, then require three consecutive limited samples.
|
|
1319
|
+
if (this.frameRateWarmupSamples-- > 0)
|
|
1320
|
+
return stats;
|
|
1321
|
+
const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
|
|
1322
|
+
this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
|
|
1323
|
+
if (this.frameRateLowSamples >= 3) {
|
|
1324
|
+
this.frameRateFallback = true;
|
|
1325
|
+
void this.cameraTask(async () => {
|
|
1326
|
+
const producer = this.cameraProducer();
|
|
1327
|
+
if (!producer?.track || producer.closed)
|
|
1328
|
+
return;
|
|
1329
|
+
await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1330
|
+
await this.reproduceCamera(producer, producer.track);
|
|
1331
|
+
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1332
|
+
}).catch((error) => this.emit('error', error));
|
|
1333
|
+
}
|
|
1334
|
+
return stats;
|
|
1335
|
+
}
|
|
1066
1336
|
// ------------------------------------------------------------------ music
|
|
1067
1337
|
/**
|
|
1068
1338
|
* Publish music playing inside the page, as its own track.
|
|
@@ -1173,10 +1443,21 @@ class StreamClient {
|
|
|
1173
1443
|
}));
|
|
1174
1444
|
const consumer = await transport.consume(params);
|
|
1175
1445
|
this.consumers.set(remote.producerId, consumer);
|
|
1446
|
+
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
1176
1447
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
1177
1448
|
// its first frame instead of starting at the full picture. Not getting
|
|
1178
1449
|
// them costs data, not the track, so a refusal is only reported.
|
|
1179
|
-
|
|
1450
|
+
let layers = this.preferredLayers.get(remote.producerId);
|
|
1451
|
+
const receiveQuality = this.receiveQualities.get(remote.producerId);
|
|
1452
|
+
if (receiveQuality && receiveQuality !== 'auto') {
|
|
1453
|
+
try {
|
|
1454
|
+
layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) };
|
|
1455
|
+
}
|
|
1456
|
+
catch {
|
|
1457
|
+
layers = undefined;
|
|
1458
|
+
this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1180
1461
|
if (layers) {
|
|
1181
1462
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
1182
1463
|
}
|
|
@@ -1208,7 +1489,7 @@ class StreamClient {
|
|
|
1208
1489
|
}
|
|
1209
1490
|
trackEnabled(kind) {
|
|
1210
1491
|
for (const p of this.producers.values()) {
|
|
1211
|
-
if (p.kind === kind && p.
|
|
1492
|
+
if (p.kind === kind && p.appData.source === defaultSource(kind))
|
|
1212
1493
|
return !p.paused;
|
|
1213
1494
|
}
|
|
1214
1495
|
return false;
|
|
@@ -1224,16 +1505,31 @@ class StreamClient {
|
|
|
1224
1505
|
async setCamEnabled(enabled) {
|
|
1225
1506
|
await this.setKindEnabled('video', enabled);
|
|
1226
1507
|
}
|
|
1508
|
+
applyLocalModeration(kind) {
|
|
1509
|
+
for (const producer of this.producers.values()) {
|
|
1510
|
+
if (!kind || producer.kind === kind)
|
|
1511
|
+
producer.pause();
|
|
1512
|
+
}
|
|
1513
|
+
// During reconnect there are no producers, but the retained tracks still
|
|
1514
|
+
// capture. Keep their muted state for republish as well.
|
|
1515
|
+
for (const stream of [this.localStream, this.screenStream]) {
|
|
1516
|
+
for (const track of stream?.getTracks() ?? [])
|
|
1517
|
+
if (!kind || track.kind === kind)
|
|
1518
|
+
track.enabled = false;
|
|
1519
|
+
}
|
|
1520
|
+
if (!kind || kind === 'audio')
|
|
1521
|
+
this.music?.setEnabled(false);
|
|
1522
|
+
}
|
|
1227
1523
|
async setKindEnabled(kind, enabled) {
|
|
1228
1524
|
for (const producer of this.producers.values()) {
|
|
1229
1525
|
// The music has its own switch. Muting the microphone must not stop the song.
|
|
1230
|
-
if (producer.kind !== kind || producer.
|
|
1526
|
+
if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
|
|
1231
1527
|
continue;
|
|
1232
|
-
if (enabled)
|
|
1233
|
-
producer.resume();
|
|
1234
|
-
else
|
|
1528
|
+
if (!enabled)
|
|
1235
1529
|
producer.pause();
|
|
1236
1530
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1531
|
+
if (enabled)
|
|
1532
|
+
producer.resume();
|
|
1237
1533
|
}
|
|
1238
1534
|
this.emitLocalMedia();
|
|
1239
1535
|
}
|
|
@@ -1244,6 +1540,10 @@ class StreamClient {
|
|
|
1244
1540
|
// called while this still waits is not undone by it, and one already
|
|
1245
1541
|
// under way stops at its next step (keepProducer).
|
|
1246
1542
|
this.publishGeneration += 1;
|
|
1543
|
+
const screenWasStarted = this.screenStarted;
|
|
1544
|
+
this.releaseScreen();
|
|
1545
|
+
if (screenWasStarted)
|
|
1546
|
+
this.emit('screenShareEnded', { reason: 'stopped' });
|
|
1247
1547
|
const ids = [...this.producers.keys()];
|
|
1248
1548
|
for (const producer of this.producers.values())
|
|
1249
1549
|
producer.close();
|
|
@@ -1273,8 +1573,8 @@ class StreamClient {
|
|
|
1273
1573
|
return this.localStream;
|
|
1274
1574
|
}
|
|
1275
1575
|
// ------------------------------------------------------------- moderation
|
|
1276
|
-
raiseHand() { return this.rpc('raiseHand'); }
|
|
1277
|
-
lowerHand() { return this.rpc('lowerHand'); }
|
|
1576
|
+
raiseHand(metadata) { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
|
|
1577
|
+
lowerHand(identity) { return this.rpc('lowerHand', identity ? { identity } : {}); }
|
|
1278
1578
|
muteParticipant(identity) { return this.rpc('requestMute', { identity }); }
|
|
1279
1579
|
/**
|
|
1280
1580
|
* Moderating roles: switch someone's microphone (`audio`), camera (`video`)
|