@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/esm/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { Device } from 'mediasoup-client';
|
|
9
9
|
import { chooseCodec, currentEnv, defaultLayerCount, findCodec, h264HardwareEncode, isAppleMobile, isPhoneOrTablet, normalizeCodec, } from './device.js';
|
|
10
10
|
import { MusicSource } from './music.js';
|
|
11
|
-
import { allowedQuality, DEFAULT_VIDEO_POLICY, sameLayout, topLayerSize, videoEncodings, } from './quality.js';
|
|
11
|
+
import { allowedQuality, DEFAULT_VIDEO_POLICY, sameLayout, describeVideoLayers, receiveSpatialLayer, screenEncodings, topLayerSize, videoEncodings, } from './quality.js';
|
|
12
12
|
import { parseReceiveStats, parseSendStats, pruneBaseline, VideoLimitWatch, } from './stats.js';
|
|
13
13
|
export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
|
|
14
14
|
export { AudioMixer, MusicSource } from './music.js';
|
|
@@ -55,7 +55,7 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
55
55
|
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
56
56
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
57
57
|
*/
|
|
58
|
-
const VIDEO_START_BITRATE_KBPS =
|
|
58
|
+
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
59
59
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
60
60
|
const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
|
|
61
61
|
export class StreamClient {
|
|
@@ -88,6 +88,20 @@ export class StreamClient {
|
|
|
88
88
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
89
89
|
music = null;
|
|
90
90
|
musicProducerId = null;
|
|
91
|
+
screenStream = null;
|
|
92
|
+
screenMode = 'detail';
|
|
93
|
+
screenAudioEnabled = true;
|
|
94
|
+
screenGeneration = 0;
|
|
95
|
+
screenPending = false;
|
|
96
|
+
screenStarted = false;
|
|
97
|
+
screenEndedListener = null;
|
|
98
|
+
screenProducerIds = new Set();
|
|
99
|
+
remoteVideoLayers = new Map();
|
|
100
|
+
receiveQualities = new Map();
|
|
101
|
+
cameraFrameRate = 30;
|
|
102
|
+
frameRateFallback = false;
|
|
103
|
+
frameRateLowSamples = 0;
|
|
104
|
+
frameRateWarmupSamples = 2;
|
|
91
105
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
92
106
|
remoteSources = new Map();
|
|
93
107
|
/**
|
|
@@ -114,7 +128,7 @@ export class StreamClient {
|
|
|
114
128
|
codecFallbackLogged = false;
|
|
115
129
|
sendStatsBaseline = new Map();
|
|
116
130
|
recvStatsBaseline = new Map();
|
|
117
|
-
limitWatch = new VideoLimitWatch((baseline) => this.
|
|
131
|
+
limitWatch = new VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
|
|
118
132
|
constructor(wsUrl, token) {
|
|
119
133
|
this.wsUrl = wsUrl;
|
|
120
134
|
this.token = token;
|
|
@@ -172,14 +186,30 @@ export class StreamClient {
|
|
|
172
186
|
// retried about twice a second, forever.
|
|
173
187
|
this.reconnectAttempt = 0;
|
|
174
188
|
this.joinedOnce = true;
|
|
189
|
+
// A role or mute can change while the socket is away. The fresh roster
|
|
190
|
+
// decides what may return; reconnecting never turns a blocked device on.
|
|
191
|
+
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
192
|
+
await this.unpublish();
|
|
193
|
+
}
|
|
194
|
+
const own = result.participants.find((p) => p.identity === result.identity);
|
|
195
|
+
if (own?.muted)
|
|
196
|
+
this.applyLocalModeration('audio');
|
|
197
|
+
if (own?.videoBlocked)
|
|
198
|
+
this.applyLocalModeration('video');
|
|
175
199
|
this.emit('joined', result);
|
|
176
200
|
// A video that ended while this client was away sends it no
|
|
177
201
|
// producerClosed: forget the layers asked for it.
|
|
178
202
|
const present = new Set(result.producers.map((p) => p.producerId));
|
|
179
203
|
for (const producerId of this.preferredLayers.keys()) {
|
|
180
|
-
if (!present.has(producerId))
|
|
204
|
+
if (!present.has(producerId)) {
|
|
181
205
|
this.preferredLayers.delete(producerId);
|
|
206
|
+
this.receiveQualities.delete(producerId);
|
|
207
|
+
this.remoteVideoLayers.delete(producerId);
|
|
208
|
+
}
|
|
182
209
|
}
|
|
210
|
+
for (const producerId of this.remoteVideoLayers.keys())
|
|
211
|
+
if (!present.has(producerId))
|
|
212
|
+
this.remoteVideoLayers.delete(producerId);
|
|
183
213
|
// Subscribe to everything already publishing before we announce readiness.
|
|
184
214
|
for (const p of result.producers) {
|
|
185
215
|
await this.consume(p).catch((err) => this.emit('error', err));
|
|
@@ -323,9 +353,15 @@ export class StreamClient {
|
|
|
323
353
|
// others have already reported themselves and set closedByUs.
|
|
324
354
|
const wasOpen = !this.closedByUs;
|
|
325
355
|
this.closedByUs = true;
|
|
356
|
+
this.publishGeneration += 1;
|
|
326
357
|
this.wasPublishing = false;
|
|
327
358
|
this.teardownMedia();
|
|
328
359
|
this.preferredLayers.clear();
|
|
360
|
+
this.receiveQualities.clear();
|
|
361
|
+
this.remoteVideoLayers.clear();
|
|
362
|
+
this.releaseScreen();
|
|
363
|
+
this.music?.close();
|
|
364
|
+
this.music = null;
|
|
329
365
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
330
366
|
this.limitWatch.reset();
|
|
331
367
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
@@ -346,6 +382,7 @@ export class StreamClient {
|
|
|
346
382
|
p.close();
|
|
347
383
|
this.producers.clear();
|
|
348
384
|
this.musicProducerId = null;
|
|
385
|
+
this.screenProducerIds.clear();
|
|
349
386
|
this.cameraEncodings = null;
|
|
350
387
|
// Paused, not reset: a reconnect's republish starts it again, and a
|
|
351
388
|
// limitation already reported is not reported twice.
|
|
@@ -419,6 +456,8 @@ export class StreamClient {
|
|
|
419
456
|
case 'producerClosed': {
|
|
420
457
|
const { producerId, identity } = data;
|
|
421
458
|
this.preferredLayers.delete(producerId);
|
|
459
|
+
this.receiveQualities.delete(producerId);
|
|
460
|
+
this.remoteVideoLayers.delete(producerId);
|
|
422
461
|
const consumer = this.consumers.get(producerId);
|
|
423
462
|
if (!consumer)
|
|
424
463
|
break; // never consumed it, or already cleaned up
|
|
@@ -442,16 +481,29 @@ export class StreamClient {
|
|
|
442
481
|
case 'participantJoined':
|
|
443
482
|
this.emit('participantJoined', data);
|
|
444
483
|
break;
|
|
484
|
+
case 'participantMuted': {
|
|
485
|
+
const wire = data;
|
|
486
|
+
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
487
|
+
if (this.joinResult)
|
|
488
|
+
this.joinResult.participants = this.joinResult.participants.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
489
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
490
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
491
|
+
});
|
|
492
|
+
this.emit('participantMuted', e);
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
445
495
|
case 'participantLeft':
|
|
446
496
|
this.emit('participantLeft', data);
|
|
447
497
|
break;
|
|
448
498
|
case 'roleChanged': {
|
|
449
499
|
const e = data;
|
|
500
|
+
if (e.identity === this.joinResult?.identity)
|
|
501
|
+
this.joinResult.role = e.role;
|
|
450
502
|
this.emit('roleChanged', e);
|
|
451
503
|
// Demoted: the server has already closed our producers. Release the
|
|
452
504
|
// camera and microphone too, instead of capturing for nobody.
|
|
453
505
|
const canPublish = e.role === 'host' || e.role === 'cohost';
|
|
454
|
-
if (e.identity === this.joinResult?.identity && !canPublish && this.
|
|
506
|
+
if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
|
|
455
507
|
void this.unpublish().catch(() => undefined);
|
|
456
508
|
}
|
|
457
509
|
break;
|
|
@@ -466,10 +518,7 @@ export class StreamClient {
|
|
|
466
518
|
// changes nothing here -- switching back on is the user's choice.
|
|
467
519
|
const e = data;
|
|
468
520
|
if (e.paused) {
|
|
469
|
-
|
|
470
|
-
if (!e.kind || p.kind === e.kind)
|
|
471
|
-
p.pause();
|
|
472
|
-
}
|
|
521
|
+
this.applyLocalModeration(e.kind);
|
|
473
522
|
this.emitLocalMedia();
|
|
474
523
|
}
|
|
475
524
|
this.emit('forceMuted', e);
|
|
@@ -557,7 +606,7 @@ export class StreamClient {
|
|
|
557
606
|
.catch(fail);
|
|
558
607
|
});
|
|
559
608
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
560
|
-
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
|
|
609
|
+
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
|
|
561
610
|
.then((res) => ok({ id: res.producerId }))
|
|
562
611
|
.catch(fail);
|
|
563
612
|
});
|
|
@@ -613,8 +662,11 @@ export class StreamClient {
|
|
|
613
662
|
this.wasPublishing = true;
|
|
614
663
|
if (constraints)
|
|
615
664
|
this.publishConstraints = constraints;
|
|
616
|
-
if (options)
|
|
665
|
+
if (options) {
|
|
617
666
|
this.publishOptions = { ...options };
|
|
667
|
+
this.frameRateFallback = false;
|
|
668
|
+
this.frameRateLowSamples = 0;
|
|
669
|
+
}
|
|
618
670
|
const audio = stream.getAudioTracks()[0];
|
|
619
671
|
if (audio) {
|
|
620
672
|
const producer = await transport.produce({
|
|
@@ -636,7 +688,7 @@ export class StreamClient {
|
|
|
636
688
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
637
689
|
}
|
|
638
690
|
}
|
|
639
|
-
/** What this project may publish. Known once joined;
|
|
691
|
+
/** What this project may publish. Known once joined; 1080p before that. */
|
|
640
692
|
get videoPolicy() {
|
|
641
693
|
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
642
694
|
}
|
|
@@ -661,11 +713,30 @@ export class StreamClient {
|
|
|
661
713
|
});
|
|
662
714
|
return this.videoQuality;
|
|
663
715
|
}
|
|
716
|
+
/** Change the camera frame rate without stopping screen, microphone or music. */
|
|
717
|
+
async setVideoFrameRate(frameRate) {
|
|
718
|
+
if (frameRate !== 30 && frameRate !== 60)
|
|
719
|
+
throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
720
|
+
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
721
|
+
this.frameRateFallback = false;
|
|
722
|
+
this.frameRateLowSamples = 0;
|
|
723
|
+
await this.cameraTask(async () => {
|
|
724
|
+
const current = this.cameraProducer();
|
|
725
|
+
const track = current?.track;
|
|
726
|
+
if (!current || !track || track.readyState !== 'live')
|
|
727
|
+
return;
|
|
728
|
+
try {
|
|
729
|
+
await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
|
|
730
|
+
}
|
|
731
|
+
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
732
|
+
await this.reproduceCamera(current, track);
|
|
733
|
+
});
|
|
734
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
|
|
735
|
+
}
|
|
664
736
|
/**
|
|
665
737
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
666
738
|
* it, the raw camera again, another camera. When the new track needs the
|
|
667
|
-
* same layers as the current one
|
|
668
|
-
* project's quality scales alike), viewers see no pause, and the layers
|
|
739
|
+
* same dimensions and layers as the current one, viewers see no pause, and the layers
|
|
669
740
|
* take the new picture's bitrates. Otherwise the camera producer is
|
|
670
741
|
* replaced, as `setVideoQuality` does. Either way a paused camera stays
|
|
671
742
|
* paused.
|
|
@@ -706,8 +777,10 @@ export class StreamClient {
|
|
|
706
777
|
const paused = current.paused;
|
|
707
778
|
try {
|
|
708
779
|
const { width = 0, height = 0 } = track.getSettings();
|
|
709
|
-
const next = videoEncodings(width, height, this.videoQuality, this.layerCount());
|
|
710
|
-
|
|
780
|
+
const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
|
|
781
|
+
const previousSize = current.track?.getSettings();
|
|
782
|
+
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
783
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
711
784
|
await current.replaceTrack({ track });
|
|
712
785
|
await this.applyLayerBitrates(current, next);
|
|
713
786
|
return;
|
|
@@ -809,6 +882,7 @@ export class StreamClient {
|
|
|
809
882
|
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
810
883
|
}
|
|
811
884
|
const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
885
|
+
this.receiveQualities.delete(producerId);
|
|
812
886
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
813
887
|
this.preferredLayers.set(producerId, layers);
|
|
814
888
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
@@ -830,6 +904,28 @@ export class StreamClient {
|
|
|
830
904
|
pruneBaseline(baseline, (key) => key.startsWith(prefix));
|
|
831
905
|
return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
832
906
|
}
|
|
907
|
+
/** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
|
|
908
|
+
async setReceiveQuality(producerId, quality) {
|
|
909
|
+
if (!['auto', '1080p', '720p', '480p'].includes(quality))
|
|
910
|
+
throw new RangeError('setReceiveQuality: unsupported quality');
|
|
911
|
+
const consumer = this.consumers.get(producerId);
|
|
912
|
+
if (!consumer || consumer.kind !== 'video')
|
|
913
|
+
throw new Error('setReceiveQuality: this client is not receiving that video');
|
|
914
|
+
let layers;
|
|
915
|
+
if (quality === 'auto')
|
|
916
|
+
layers = { spatialLayer: -1, temporalLayer: -1 };
|
|
917
|
+
else {
|
|
918
|
+
try {
|
|
919
|
+
layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) };
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
this.receiveQualities.set(producerId, quality);
|
|
926
|
+
this.preferredLayers.set(producerId, layers);
|
|
927
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
928
|
+
}
|
|
833
929
|
cameraProducer() {
|
|
834
930
|
for (const p of this.producers.values()) {
|
|
835
931
|
if (p.kind === 'video' && p.appData.source === 'camera')
|
|
@@ -886,7 +982,8 @@ export class StreamClient {
|
|
|
886
982
|
// videoEncodings).
|
|
887
983
|
const { width = 0, height = 0 } = track.getSettings();
|
|
888
984
|
const quality = this.videoQuality;
|
|
889
|
-
|
|
985
|
+
this.cameraFrameRate = this.frameRateFor(track);
|
|
986
|
+
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
890
987
|
let codecs;
|
|
891
988
|
try {
|
|
892
989
|
codecs = this.device?.sendRtpCapabilities.codecs;
|
|
@@ -901,7 +998,7 @@ export class StreamClient {
|
|
|
901
998
|
encodings,
|
|
902
999
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
903
1000
|
...(codec ? { codec } : {}),
|
|
904
|
-
appData: { source: 'camera' },
|
|
1001
|
+
appData: { source: 'camera', videoLayers: describeVideoLayers(width, height, encodings, quality) },
|
|
905
1002
|
});
|
|
906
1003
|
let producer;
|
|
907
1004
|
try {
|
|
@@ -919,6 +1016,8 @@ export class StreamClient {
|
|
|
919
1016
|
// quality change or a track swap under way).
|
|
920
1017
|
await this.keepProducer(producer, generation, 'camera');
|
|
921
1018
|
this.cameraEncodings = encodings;
|
|
1019
|
+
this.frameRateLowSamples = 0;
|
|
1020
|
+
this.frameRateWarmupSamples = 2;
|
|
922
1021
|
await this.pauseOnServer(producer);
|
|
923
1022
|
await this.applyDegradationPreference(producer);
|
|
924
1023
|
this.syncLimitWatch();
|
|
@@ -998,7 +1097,7 @@ export class StreamClient {
|
|
|
998
1097
|
}
|
|
999
1098
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1000
1099
|
syncLimitWatch() {
|
|
1001
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
|
|
1100
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
1002
1101
|
}
|
|
1003
1102
|
/**
|
|
1004
1103
|
* Re-establish this client's broadcast after a reconnect.
|
|
@@ -1013,6 +1112,13 @@ export class StreamClient {
|
|
|
1013
1112
|
const generation = this.publishGeneration;
|
|
1014
1113
|
// Music is not a device and cannot be "gone": publish it again whatever
|
|
1015
1114
|
// happens to the camera and microphone below.
|
|
1115
|
+
const screen = this.screenStream;
|
|
1116
|
+
if (screen)
|
|
1117
|
+
await this.restoreScreen().catch(async (err) => {
|
|
1118
|
+
if (this.screenStream === screen)
|
|
1119
|
+
await this.stopScreen('stopped');
|
|
1120
|
+
this.emit('error', err);
|
|
1121
|
+
});
|
|
1016
1122
|
if (this.music)
|
|
1017
1123
|
await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
|
|
1018
1124
|
if (generation !== this.publishGeneration)
|
|
@@ -1047,6 +1153,170 @@ export class StreamClient {
|
|
|
1047
1153
|
return;
|
|
1048
1154
|
await this.publish(stream);
|
|
1049
1155
|
}
|
|
1156
|
+
// ------------------------------------------------------------- screen sharing
|
|
1157
|
+
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1158
|
+
async publishScreen(options = {}) {
|
|
1159
|
+
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
|
|
1160
|
+
throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1161
|
+
if (this.screenStream || this.screenPending)
|
|
1162
|
+
throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1163
|
+
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
|
|
1164
|
+
throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1165
|
+
const mode = options.mode ?? 'detail';
|
|
1166
|
+
if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
|
|
1167
|
+
throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
|
|
1168
|
+
}
|
|
1169
|
+
const generation = this.screenGeneration;
|
|
1170
|
+
this.screenPending = true;
|
|
1171
|
+
let stream;
|
|
1172
|
+
try {
|
|
1173
|
+
stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
|
|
1174
|
+
video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
|
|
1175
|
+
audio: options.audio ?? false,
|
|
1176
|
+
});
|
|
1177
|
+
const video = stream.getVideoTracks()[0];
|
|
1178
|
+
if (!video || video.readyState !== 'live')
|
|
1179
|
+
throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1180
|
+
if (generation !== this.screenGeneration || this.closedByUs)
|
|
1181
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1182
|
+
this.screenStream = stream;
|
|
1183
|
+
this.screenMode = mode;
|
|
1184
|
+
this.screenAudioEnabled = options.audio !== false;
|
|
1185
|
+
video.contentHint = mode;
|
|
1186
|
+
const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err)); };
|
|
1187
|
+
this.screenEndedListener = onEnded;
|
|
1188
|
+
video.addEventListener('ended', onEnded, { once: true });
|
|
1189
|
+
await this.restoreScreen();
|
|
1190
|
+
if (generation !== this.screenGeneration || video.readyState !== 'live')
|
|
1191
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1192
|
+
this.screenStarted = true;
|
|
1193
|
+
this.wasPublishing = true;
|
|
1194
|
+
this.emit('screenShareStarted', { stream, mode });
|
|
1195
|
+
return stream;
|
|
1196
|
+
}
|
|
1197
|
+
catch (error) {
|
|
1198
|
+
if (generation === this.screenGeneration)
|
|
1199
|
+
await this.stopScreen('stopped');
|
|
1200
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1201
|
+
throw error;
|
|
1202
|
+
}
|
|
1203
|
+
finally {
|
|
1204
|
+
if (generation === this.screenGeneration)
|
|
1205
|
+
this.screenPending = false;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
|
|
1209
|
+
async unpublishScreen() {
|
|
1210
|
+
await this.stopScreen('stopped');
|
|
1211
|
+
}
|
|
1212
|
+
releaseScreen() {
|
|
1213
|
+
this.screenGeneration += 1;
|
|
1214
|
+
this.screenPending = false;
|
|
1215
|
+
const stream = this.screenStream;
|
|
1216
|
+
if (this.screenEndedListener)
|
|
1217
|
+
stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
|
|
1218
|
+
this.screenEndedListener = null;
|
|
1219
|
+
this.screenStream = null;
|
|
1220
|
+
this.screenStarted = false;
|
|
1221
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1222
|
+
}
|
|
1223
|
+
async stopScreen(reason) {
|
|
1224
|
+
const started = this.screenStarted;
|
|
1225
|
+
const ids = [...this.screenProducerIds];
|
|
1226
|
+
this.screenProducerIds.clear();
|
|
1227
|
+
this.releaseScreen();
|
|
1228
|
+
for (const id of ids) {
|
|
1229
|
+
this.producers.get(id)?.close();
|
|
1230
|
+
this.producers.delete(id);
|
|
1231
|
+
}
|
|
1232
|
+
if (!this.localStream && !this.music)
|
|
1233
|
+
this.wasPublishing = false;
|
|
1234
|
+
if (started && !this.closedByUs)
|
|
1235
|
+
this.emit('screenShareEnded', { reason });
|
|
1236
|
+
await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
|
|
1237
|
+
}
|
|
1238
|
+
async restoreScreen() {
|
|
1239
|
+
const stream = this.screenStream;
|
|
1240
|
+
const video = stream?.getVideoTracks()[0];
|
|
1241
|
+
if (!stream || !video)
|
|
1242
|
+
return;
|
|
1243
|
+
if (video.readyState !== 'live') {
|
|
1244
|
+
await this.stopScreen('track-ended');
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const generation = this.screenGeneration;
|
|
1248
|
+
const publishing = this.publishGeneration;
|
|
1249
|
+
const transport = await this.ensureSendTransport();
|
|
1250
|
+
if (generation !== this.screenGeneration)
|
|
1251
|
+
return;
|
|
1252
|
+
const { width = 0, height = 0 } = video.getSettings();
|
|
1253
|
+
const encodings = screenEncodings(width, height, this.screenMode);
|
|
1254
|
+
// A project's explicit safety restriction also applies to screen video.
|
|
1255
|
+
const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
|
|
1256
|
+
const screenScale = encodings.at(-1).scaleResolutionDownBy;
|
|
1257
|
+
if (cap.scale > screenScale)
|
|
1258
|
+
encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
1259
|
+
const keep = async (producer) => {
|
|
1260
|
+
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1261
|
+
producer.close();
|
|
1262
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1263
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1264
|
+
}
|
|
1265
|
+
this.producers.set(producer.id, producer);
|
|
1266
|
+
this.screenProducerIds.add(producer.id);
|
|
1267
|
+
await this.pauseOnServer(producer);
|
|
1268
|
+
};
|
|
1269
|
+
await keep(await transport.produce({
|
|
1270
|
+
track: video, stopTracks: false, encodings,
|
|
1271
|
+
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1272
|
+
appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
|
|
1273
|
+
}));
|
|
1274
|
+
const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1275
|
+
if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
|
|
1276
|
+
await keep(await transport.produce({
|
|
1277
|
+
track: audio, stopTracks: false,
|
|
1278
|
+
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
|
|
1279
|
+
appData: { source: 'screen-audio' },
|
|
1280
|
+
}));
|
|
1281
|
+
}
|
|
1282
|
+
frameRateFor(track) {
|
|
1283
|
+
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
|
|
1284
|
+
return 30;
|
|
1285
|
+
const captured = track.getSettings().frameRate;
|
|
1286
|
+
if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
|
|
1287
|
+
this.frameRateFallback = true;
|
|
1288
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
|
|
1289
|
+
return 30;
|
|
1290
|
+
}
|
|
1291
|
+
return 60;
|
|
1292
|
+
}
|
|
1293
|
+
async sampleCamera(baseline) {
|
|
1294
|
+
const stats = await this.collectVideoStats(baseline);
|
|
1295
|
+
if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
|
|
1296
|
+
return stats;
|
|
1297
|
+
if (this.cameraProducer()?.paused)
|
|
1298
|
+
return stats;
|
|
1299
|
+
// The lower simulcast copies intentionally run at 30 fps. During the
|
|
1300
|
+
// initial bandwidth ramp they may be the only active copies, which does
|
|
1301
|
+
// not establish that the camera cannot sustain 60. Match native warm-up:
|
|
1302
|
+
// two 2-second samples, then require three consecutive limited samples.
|
|
1303
|
+
if (this.frameRateWarmupSamples-- > 0)
|
|
1304
|
+
return stats;
|
|
1305
|
+
const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
|
|
1306
|
+
this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
|
|
1307
|
+
if (this.frameRateLowSamples >= 3) {
|
|
1308
|
+
this.frameRateFallback = true;
|
|
1309
|
+
void this.cameraTask(async () => {
|
|
1310
|
+
const producer = this.cameraProducer();
|
|
1311
|
+
if (!producer?.track || producer.closed)
|
|
1312
|
+
return;
|
|
1313
|
+
await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1314
|
+
await this.reproduceCamera(producer, producer.track);
|
|
1315
|
+
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1316
|
+
}).catch((error) => this.emit('error', error));
|
|
1317
|
+
}
|
|
1318
|
+
return stats;
|
|
1319
|
+
}
|
|
1050
1320
|
// ------------------------------------------------------------------ music
|
|
1051
1321
|
/**
|
|
1052
1322
|
* Publish music playing inside the page, as its own track.
|
|
@@ -1157,10 +1427,21 @@ export class StreamClient {
|
|
|
1157
1427
|
}));
|
|
1158
1428
|
const consumer = await transport.consume(params);
|
|
1159
1429
|
this.consumers.set(remote.producerId, consumer);
|
|
1430
|
+
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
1160
1431
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
1161
1432
|
// its first frame instead of starting at the full picture. Not getting
|
|
1162
1433
|
// them costs data, not the track, so a refusal is only reported.
|
|
1163
|
-
|
|
1434
|
+
let layers = this.preferredLayers.get(remote.producerId);
|
|
1435
|
+
const receiveQuality = this.receiveQualities.get(remote.producerId);
|
|
1436
|
+
if (receiveQuality && receiveQuality !== 'auto') {
|
|
1437
|
+
try {
|
|
1438
|
+
layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) };
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
layers = undefined;
|
|
1442
|
+
this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1164
1445
|
if (layers) {
|
|
1165
1446
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
1166
1447
|
}
|
|
@@ -1192,7 +1473,7 @@ export class StreamClient {
|
|
|
1192
1473
|
}
|
|
1193
1474
|
trackEnabled(kind) {
|
|
1194
1475
|
for (const p of this.producers.values()) {
|
|
1195
|
-
if (p.kind === kind && p.
|
|
1476
|
+
if (p.kind === kind && p.appData.source === defaultSource(kind))
|
|
1196
1477
|
return !p.paused;
|
|
1197
1478
|
}
|
|
1198
1479
|
return false;
|
|
@@ -1208,16 +1489,31 @@ export class StreamClient {
|
|
|
1208
1489
|
async setCamEnabled(enabled) {
|
|
1209
1490
|
await this.setKindEnabled('video', enabled);
|
|
1210
1491
|
}
|
|
1492
|
+
applyLocalModeration(kind) {
|
|
1493
|
+
for (const producer of this.producers.values()) {
|
|
1494
|
+
if (!kind || producer.kind === kind)
|
|
1495
|
+
producer.pause();
|
|
1496
|
+
}
|
|
1497
|
+
// During reconnect there are no producers, but the retained tracks still
|
|
1498
|
+
// capture. Keep their muted state for republish as well.
|
|
1499
|
+
for (const stream of [this.localStream, this.screenStream]) {
|
|
1500
|
+
for (const track of stream?.getTracks() ?? [])
|
|
1501
|
+
if (!kind || track.kind === kind)
|
|
1502
|
+
track.enabled = false;
|
|
1503
|
+
}
|
|
1504
|
+
if (!kind || kind === 'audio')
|
|
1505
|
+
this.music?.setEnabled(false);
|
|
1506
|
+
}
|
|
1211
1507
|
async setKindEnabled(kind, enabled) {
|
|
1212
1508
|
for (const producer of this.producers.values()) {
|
|
1213
1509
|
// The music has its own switch. Muting the microphone must not stop the song.
|
|
1214
|
-
if (producer.kind !== kind || producer.
|
|
1510
|
+
if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
|
|
1215
1511
|
continue;
|
|
1216
|
-
if (enabled)
|
|
1217
|
-
producer.resume();
|
|
1218
|
-
else
|
|
1512
|
+
if (!enabled)
|
|
1219
1513
|
producer.pause();
|
|
1220
1514
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1515
|
+
if (enabled)
|
|
1516
|
+
producer.resume();
|
|
1221
1517
|
}
|
|
1222
1518
|
this.emitLocalMedia();
|
|
1223
1519
|
}
|
|
@@ -1228,6 +1524,10 @@ export class StreamClient {
|
|
|
1228
1524
|
// called while this still waits is not undone by it, and one already
|
|
1229
1525
|
// under way stops at its next step (keepProducer).
|
|
1230
1526
|
this.publishGeneration += 1;
|
|
1527
|
+
const screenWasStarted = this.screenStarted;
|
|
1528
|
+
this.releaseScreen();
|
|
1529
|
+
if (screenWasStarted)
|
|
1530
|
+
this.emit('screenShareEnded', { reason: 'stopped' });
|
|
1231
1531
|
const ids = [...this.producers.keys()];
|
|
1232
1532
|
for (const producer of this.producers.values())
|
|
1233
1533
|
producer.close();
|
|
@@ -1257,8 +1557,8 @@ export class StreamClient {
|
|
|
1257
1557
|
return this.localStream;
|
|
1258
1558
|
}
|
|
1259
1559
|
// ------------------------------------------------------------- moderation
|
|
1260
|
-
raiseHand() { return this.rpc('raiseHand'); }
|
|
1261
|
-
lowerHand() { return this.rpc('lowerHand'); }
|
|
1560
|
+
raiseHand(metadata) { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
|
|
1561
|
+
lowerHand(identity) { return this.rpc('lowerHand', identity ? { identity } : {}); }
|
|
1262
1562
|
muteParticipant(identity) { return this.rpc('requestMute', { identity }); }
|
|
1263
1563
|
/**
|
|
1264
1564
|
* Moderating roles: switch someone's microphone (`audio`), camera (`video`)
|