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