@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/cjs/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.StreamClient = exports.RpcError = exports.VIDEO_QUALITIES = exports.videoEncodings = exports.layerBitrate = exports.allowedQuality = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = void 0;
|
|
3
|
+
exports.StreamClient = exports.RpcError = exports.VIDEO_QUALITIES = exports.videoEncodings = exports.layerBitrate = exports.allowedQuality = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = exports.RecordingError = exports.RecordingUploader = exports.CameraRecording = void 0;
|
|
4
|
+
const recording_js_1 = require("./recording.js");
|
|
4
5
|
/**
|
|
5
6
|
* Thin client SDK over the orchestrator's signaling protocol.
|
|
6
7
|
*
|
|
@@ -13,6 +14,10 @@ const device_js_1 = require("./device.js");
|
|
|
13
14
|
const music_js_1 = require("./music.js");
|
|
14
15
|
const quality_js_1 = require("./quality.js");
|
|
15
16
|
const stats_js_1 = require("./stats.js");
|
|
17
|
+
var recording_js_2 = require("./recording.js");
|
|
18
|
+
Object.defineProperty(exports, "CameraRecording", { enumerable: true, get: function () { return recording_js_2.CameraRecording; } });
|
|
19
|
+
Object.defineProperty(exports, "RecordingUploader", { enumerable: true, get: function () { return recording_js_2.RecordingUploader; } });
|
|
20
|
+
Object.defineProperty(exports, "RecordingError", { enumerable: true, get: function () { return recording_js_2.RecordingError; } });
|
|
16
21
|
var broadcast_js_1 = require("./broadcast.js");
|
|
17
22
|
Object.defineProperty(exports, "BroadcastPublisher", { enumerable: true, get: function () { return broadcast_js_1.BroadcastPublisher; } });
|
|
18
23
|
Object.defineProperty(exports, "WhipError", { enumerable: true, get: function () { return broadcast_js_1.WhipError; } });
|
|
@@ -71,7 +76,7 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
71
76
|
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
72
77
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
73
78
|
*/
|
|
74
|
-
const VIDEO_START_BITRATE_KBPS =
|
|
79
|
+
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
75
80
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
76
81
|
const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
|
|
77
82
|
class StreamClient {
|
|
@@ -94,6 +99,7 @@ class StreamClient {
|
|
|
94
99
|
token;
|
|
95
100
|
joinResult = null;
|
|
96
101
|
localStream = null;
|
|
102
|
+
recording = null;
|
|
97
103
|
// A reconnect tears down every transport and producer. Without remembering
|
|
98
104
|
// that this client was publishing -- and what it was publishing with -- a
|
|
99
105
|
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
@@ -104,6 +110,20 @@ class StreamClient {
|
|
|
104
110
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
105
111
|
music = null;
|
|
106
112
|
musicProducerId = null;
|
|
113
|
+
screenStream = null;
|
|
114
|
+
screenMode = 'detail';
|
|
115
|
+
screenAudioEnabled = true;
|
|
116
|
+
screenGeneration = 0;
|
|
117
|
+
screenPending = false;
|
|
118
|
+
screenStarted = false;
|
|
119
|
+
screenEndedListener = null;
|
|
120
|
+
screenProducerIds = new Set();
|
|
121
|
+
remoteVideoLayers = new Map();
|
|
122
|
+
receiveQualities = new Map();
|
|
123
|
+
cameraFrameRate = 30;
|
|
124
|
+
frameRateFallback = false;
|
|
125
|
+
frameRateLowSamples = 0;
|
|
126
|
+
frameRateWarmupSamples = 2;
|
|
107
127
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
108
128
|
remoteSources = new Map();
|
|
109
129
|
/**
|
|
@@ -130,7 +150,7 @@ class StreamClient {
|
|
|
130
150
|
codecFallbackLogged = false;
|
|
131
151
|
sendStatsBaseline = new Map();
|
|
132
152
|
recvStatsBaseline = new Map();
|
|
133
|
-
limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.
|
|
153
|
+
limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
|
|
134
154
|
constructor(wsUrl, token) {
|
|
135
155
|
this.wsUrl = wsUrl;
|
|
136
156
|
this.token = token;
|
|
@@ -188,14 +208,30 @@ class StreamClient {
|
|
|
188
208
|
// retried about twice a second, forever.
|
|
189
209
|
this.reconnectAttempt = 0;
|
|
190
210
|
this.joinedOnce = true;
|
|
211
|
+
// A role or mute can change while the socket is away. The fresh roster
|
|
212
|
+
// decides what may return; reconnecting never turns a blocked device on.
|
|
213
|
+
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
214
|
+
await this.unpublish();
|
|
215
|
+
}
|
|
216
|
+
const own = result.participants.find((p) => p.identity === result.identity);
|
|
217
|
+
if (own?.muted)
|
|
218
|
+
this.applyLocalModeration('audio');
|
|
219
|
+
if (own?.videoBlocked)
|
|
220
|
+
this.applyLocalModeration('video');
|
|
191
221
|
this.emit('joined', result);
|
|
192
222
|
// A video that ended while this client was away sends it no
|
|
193
223
|
// producerClosed: forget the layers asked for it.
|
|
194
224
|
const present = new Set(result.producers.map((p) => p.producerId));
|
|
195
225
|
for (const producerId of this.preferredLayers.keys()) {
|
|
196
|
-
if (!present.has(producerId))
|
|
226
|
+
if (!present.has(producerId)) {
|
|
197
227
|
this.preferredLayers.delete(producerId);
|
|
228
|
+
this.receiveQualities.delete(producerId);
|
|
229
|
+
this.remoteVideoLayers.delete(producerId);
|
|
230
|
+
}
|
|
198
231
|
}
|
|
232
|
+
for (const producerId of this.remoteVideoLayers.keys())
|
|
233
|
+
if (!present.has(producerId))
|
|
234
|
+
this.remoteVideoLayers.delete(producerId);
|
|
199
235
|
// Subscribe to everything already publishing before we announce readiness.
|
|
200
236
|
for (const p of result.producers) {
|
|
201
237
|
await this.consume(p).catch((err) => this.emit('error', err));
|
|
@@ -335,13 +371,20 @@ class StreamClient {
|
|
|
335
371
|
}
|
|
336
372
|
}
|
|
337
373
|
close() {
|
|
374
|
+
void this.stopRecording().catch(() => undefined);
|
|
338
375
|
// Only an app closing a live session is 'left'; kicked, room-closed and the
|
|
339
376
|
// others have already reported themselves and set closedByUs.
|
|
340
377
|
const wasOpen = !this.closedByUs;
|
|
341
378
|
this.closedByUs = true;
|
|
379
|
+
this.publishGeneration += 1;
|
|
342
380
|
this.wasPublishing = false;
|
|
343
381
|
this.teardownMedia();
|
|
344
382
|
this.preferredLayers.clear();
|
|
383
|
+
this.receiveQualities.clear();
|
|
384
|
+
this.remoteVideoLayers.clear();
|
|
385
|
+
this.releaseScreen();
|
|
386
|
+
this.music?.close();
|
|
387
|
+
this.music = null;
|
|
345
388
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
346
389
|
this.limitWatch.reset();
|
|
347
390
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
@@ -362,6 +405,7 @@ class StreamClient {
|
|
|
362
405
|
p.close();
|
|
363
406
|
this.producers.clear();
|
|
364
407
|
this.musicProducerId = null;
|
|
408
|
+
this.screenProducerIds.clear();
|
|
365
409
|
this.cameraEncodings = null;
|
|
366
410
|
// Paused, not reset: a reconnect's republish starts it again, and a
|
|
367
411
|
// limitation already reported is not reported twice.
|
|
@@ -435,6 +479,8 @@ class StreamClient {
|
|
|
435
479
|
case 'producerClosed': {
|
|
436
480
|
const { producerId, identity } = data;
|
|
437
481
|
this.preferredLayers.delete(producerId);
|
|
482
|
+
this.receiveQualities.delete(producerId);
|
|
483
|
+
this.remoteVideoLayers.delete(producerId);
|
|
438
484
|
const consumer = this.consumers.get(producerId);
|
|
439
485
|
if (!consumer)
|
|
440
486
|
break; // never consumed it, or already cleaned up
|
|
@@ -458,16 +504,29 @@ class StreamClient {
|
|
|
458
504
|
case 'participantJoined':
|
|
459
505
|
this.emit('participantJoined', data);
|
|
460
506
|
break;
|
|
507
|
+
case 'participantMuted': {
|
|
508
|
+
const wire = data;
|
|
509
|
+
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
510
|
+
if (this.joinResult)
|
|
511
|
+
this.joinResult.participants = this.joinResult.participants.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
512
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
513
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
514
|
+
});
|
|
515
|
+
this.emit('participantMuted', e);
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
461
518
|
case 'participantLeft':
|
|
462
519
|
this.emit('participantLeft', data);
|
|
463
520
|
break;
|
|
464
521
|
case 'roleChanged': {
|
|
465
522
|
const e = data;
|
|
523
|
+
if (e.identity === this.joinResult?.identity)
|
|
524
|
+
this.joinResult.role = e.role;
|
|
466
525
|
this.emit('roleChanged', e);
|
|
467
526
|
// Demoted: the server has already closed our producers. Release the
|
|
468
527
|
// camera and microphone too, instead of capturing for nobody.
|
|
469
528
|
const canPublish = e.role === 'host' || e.role === 'cohost';
|
|
470
|
-
if (e.identity === this.joinResult?.identity && !canPublish && this.
|
|
529
|
+
if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
|
|
471
530
|
void this.unpublish().catch(() => undefined);
|
|
472
531
|
}
|
|
473
532
|
break;
|
|
@@ -482,10 +541,7 @@ class StreamClient {
|
|
|
482
541
|
// changes nothing here -- switching back on is the user's choice.
|
|
483
542
|
const e = data;
|
|
484
543
|
if (e.paused) {
|
|
485
|
-
|
|
486
|
-
if (!e.kind || p.kind === e.kind)
|
|
487
|
-
p.pause();
|
|
488
|
-
}
|
|
544
|
+
this.applyLocalModeration(e.kind);
|
|
489
545
|
this.emitLocalMedia();
|
|
490
546
|
}
|
|
491
547
|
this.emit('forceMuted', e);
|
|
@@ -573,7 +629,7 @@ class StreamClient {
|
|
|
573
629
|
.catch(fail);
|
|
574
630
|
});
|
|
575
631
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
576
|
-
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
|
|
632
|
+
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
|
|
577
633
|
.then((res) => ok({ id: res.producerId }))
|
|
578
634
|
.catch(fail);
|
|
579
635
|
});
|
|
@@ -629,8 +685,11 @@ class StreamClient {
|
|
|
629
685
|
this.wasPublishing = true;
|
|
630
686
|
if (constraints)
|
|
631
687
|
this.publishConstraints = constraints;
|
|
632
|
-
if (options)
|
|
688
|
+
if (options) {
|
|
633
689
|
this.publishOptions = { ...options };
|
|
690
|
+
this.frameRateFallback = false;
|
|
691
|
+
this.frameRateLowSamples = 0;
|
|
692
|
+
}
|
|
634
693
|
const audio = stream.getAudioTracks()[0];
|
|
635
694
|
if (audio) {
|
|
636
695
|
const producer = await transport.produce({
|
|
@@ -652,7 +711,7 @@ class StreamClient {
|
|
|
652
711
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
653
712
|
}
|
|
654
713
|
}
|
|
655
|
-
/** What this project may publish. Known once joined;
|
|
714
|
+
/** What this project may publish. Known once joined; 1080p before that. */
|
|
656
715
|
get videoPolicy() {
|
|
657
716
|
return this.joinResult?.video ?? quality_js_1.DEFAULT_VIDEO_POLICY;
|
|
658
717
|
}
|
|
@@ -677,11 +736,30 @@ class StreamClient {
|
|
|
677
736
|
});
|
|
678
737
|
return this.videoQuality;
|
|
679
738
|
}
|
|
739
|
+
/** Change the camera frame rate without stopping screen, microphone or music. */
|
|
740
|
+
async setVideoFrameRate(frameRate) {
|
|
741
|
+
if (frameRate !== 30 && frameRate !== 60)
|
|
742
|
+
throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
743
|
+
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
744
|
+
this.frameRateFallback = false;
|
|
745
|
+
this.frameRateLowSamples = 0;
|
|
746
|
+
await this.cameraTask(async () => {
|
|
747
|
+
const current = this.cameraProducer();
|
|
748
|
+
const track = current?.track;
|
|
749
|
+
if (!current || !track || track.readyState !== 'live')
|
|
750
|
+
return;
|
|
751
|
+
try {
|
|
752
|
+
await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
|
|
753
|
+
}
|
|
754
|
+
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
755
|
+
await this.reproduceCamera(current, track);
|
|
756
|
+
});
|
|
757
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
|
|
758
|
+
}
|
|
680
759
|
/**
|
|
681
760
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
682
761
|
* 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
|
|
762
|
+
* same dimensions and layers as the current one, viewers see no pause, and the layers
|
|
685
763
|
* take the new picture's bitrates. Otherwise the camera producer is
|
|
686
764
|
* replaced, as `setVideoQuality` does. Either way a paused camera stays
|
|
687
765
|
* paused.
|
|
@@ -722,8 +800,10 @@ class StreamClient {
|
|
|
722
800
|
const paused = current.paused;
|
|
723
801
|
try {
|
|
724
802
|
const { width = 0, height = 0 } = track.getSettings();
|
|
725
|
-
const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount());
|
|
726
|
-
|
|
803
|
+
const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
|
|
804
|
+
const previousSize = current.track?.getSettings();
|
|
805
|
+
if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
|
|
806
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
727
807
|
await current.replaceTrack({ track });
|
|
728
808
|
await this.applyLayerBitrates(current, next);
|
|
729
809
|
return;
|
|
@@ -825,6 +905,7 @@ class StreamClient {
|
|
|
825
905
|
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
826
906
|
}
|
|
827
907
|
const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
908
|
+
this.receiveQualities.delete(producerId);
|
|
828
909
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
829
910
|
this.preferredLayers.set(producerId, layers);
|
|
830
911
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
@@ -846,6 +927,28 @@ class StreamClient {
|
|
|
846
927
|
(0, stats_js_1.pruneBaseline)(baseline, (key) => key.startsWith(prefix));
|
|
847
928
|
return (0, stats_js_1.parseSendStats)(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
848
929
|
}
|
|
930
|
+
/** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
|
|
931
|
+
async setReceiveQuality(producerId, quality) {
|
|
932
|
+
if (!['auto', '1080p', '720p', '480p'].includes(quality))
|
|
933
|
+
throw new RangeError('setReceiveQuality: unsupported quality');
|
|
934
|
+
const consumer = this.consumers.get(producerId);
|
|
935
|
+
if (!consumer || consumer.kind !== 'video')
|
|
936
|
+
throw new Error('setReceiveQuality: this client is not receiving that video');
|
|
937
|
+
let layers;
|
|
938
|
+
if (quality === 'auto')
|
|
939
|
+
layers = { spatialLayer: -1, temporalLayer: -1 };
|
|
940
|
+
else {
|
|
941
|
+
try {
|
|
942
|
+
layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
this.receiveQualities.set(producerId, quality);
|
|
949
|
+
this.preferredLayers.set(producerId, layers);
|
|
950
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
951
|
+
}
|
|
849
952
|
cameraProducer() {
|
|
850
953
|
for (const p of this.producers.values()) {
|
|
851
954
|
if (p.kind === 'video' && p.appData.source === 'camera')
|
|
@@ -902,7 +1005,8 @@ class StreamClient {
|
|
|
902
1005
|
// videoEncodings).
|
|
903
1006
|
const { width = 0, height = 0 } = track.getSettings();
|
|
904
1007
|
const quality = this.videoQuality;
|
|
905
|
-
|
|
1008
|
+
this.cameraFrameRate = this.frameRateFor(track);
|
|
1009
|
+
const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
906
1010
|
let codecs;
|
|
907
1011
|
try {
|
|
908
1012
|
codecs = this.device?.sendRtpCapabilities.codecs;
|
|
@@ -917,7 +1021,7 @@ class StreamClient {
|
|
|
917
1021
|
encodings,
|
|
918
1022
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
919
1023
|
...(codec ? { codec } : {}),
|
|
920
|
-
appData: { source: 'camera' },
|
|
1024
|
+
appData: { source: 'camera', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings, quality) },
|
|
921
1025
|
});
|
|
922
1026
|
let producer;
|
|
923
1027
|
try {
|
|
@@ -935,6 +1039,8 @@ class StreamClient {
|
|
|
935
1039
|
// quality change or a track swap under way).
|
|
936
1040
|
await this.keepProducer(producer, generation, 'camera');
|
|
937
1041
|
this.cameraEncodings = encodings;
|
|
1042
|
+
this.frameRateLowSamples = 0;
|
|
1043
|
+
this.frameRateWarmupSamples = 2;
|
|
938
1044
|
await this.pauseOnServer(producer);
|
|
939
1045
|
await this.applyDegradationPreference(producer);
|
|
940
1046
|
this.syncLimitWatch();
|
|
@@ -1014,7 +1120,7 @@ class StreamClient {
|
|
|
1014
1120
|
}
|
|
1015
1121
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1016
1122
|
syncLimitWatch() {
|
|
1017
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
|
|
1123
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
1018
1124
|
}
|
|
1019
1125
|
/**
|
|
1020
1126
|
* Re-establish this client's broadcast after a reconnect.
|
|
@@ -1029,6 +1135,13 @@ class StreamClient {
|
|
|
1029
1135
|
const generation = this.publishGeneration;
|
|
1030
1136
|
// Music is not a device and cannot be "gone": publish it again whatever
|
|
1031
1137
|
// happens to the camera and microphone below.
|
|
1138
|
+
const screen = this.screenStream;
|
|
1139
|
+
if (screen)
|
|
1140
|
+
await this.restoreScreen().catch(async (err) => {
|
|
1141
|
+
if (this.screenStream === screen)
|
|
1142
|
+
await this.stopScreen('stopped');
|
|
1143
|
+
this.emit('error', err);
|
|
1144
|
+
});
|
|
1032
1145
|
if (this.music)
|
|
1033
1146
|
await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
|
|
1034
1147
|
if (generation !== this.publishGeneration)
|
|
@@ -1063,6 +1176,170 @@ class StreamClient {
|
|
|
1063
1176
|
return;
|
|
1064
1177
|
await this.publish(stream);
|
|
1065
1178
|
}
|
|
1179
|
+
// ------------------------------------------------------------- screen sharing
|
|
1180
|
+
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1181
|
+
async publishScreen(options = {}) {
|
|
1182
|
+
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
|
|
1183
|
+
throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1184
|
+
if (this.screenStream || this.screenPending)
|
|
1185
|
+
throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1186
|
+
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
|
|
1187
|
+
throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1188
|
+
const mode = options.mode ?? 'detail';
|
|
1189
|
+
if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
|
|
1190
|
+
throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
|
|
1191
|
+
}
|
|
1192
|
+
const generation = this.screenGeneration;
|
|
1193
|
+
this.screenPending = true;
|
|
1194
|
+
let stream;
|
|
1195
|
+
try {
|
|
1196
|
+
stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
|
|
1197
|
+
video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
|
|
1198
|
+
audio: options.audio ?? false,
|
|
1199
|
+
});
|
|
1200
|
+
const video = stream.getVideoTracks()[0];
|
|
1201
|
+
if (!video || video.readyState !== 'live')
|
|
1202
|
+
throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1203
|
+
if (generation !== this.screenGeneration || this.closedByUs)
|
|
1204
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1205
|
+
this.screenStream = stream;
|
|
1206
|
+
this.screenMode = mode;
|
|
1207
|
+
this.screenAudioEnabled = options.audio !== false;
|
|
1208
|
+
video.contentHint = mode;
|
|
1209
|
+
const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err)); };
|
|
1210
|
+
this.screenEndedListener = onEnded;
|
|
1211
|
+
video.addEventListener('ended', onEnded, { once: true });
|
|
1212
|
+
await this.restoreScreen();
|
|
1213
|
+
if (generation !== this.screenGeneration || video.readyState !== 'live')
|
|
1214
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1215
|
+
this.screenStarted = true;
|
|
1216
|
+
this.wasPublishing = true;
|
|
1217
|
+
this.emit('screenShareStarted', { stream, mode });
|
|
1218
|
+
return stream;
|
|
1219
|
+
}
|
|
1220
|
+
catch (error) {
|
|
1221
|
+
if (generation === this.screenGeneration)
|
|
1222
|
+
await this.stopScreen('stopped');
|
|
1223
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1224
|
+
throw error;
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
if (generation === this.screenGeneration)
|
|
1228
|
+
this.screenPending = false;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
|
|
1232
|
+
async unpublishScreen() {
|
|
1233
|
+
await this.stopScreen('stopped');
|
|
1234
|
+
}
|
|
1235
|
+
releaseScreen() {
|
|
1236
|
+
this.screenGeneration += 1;
|
|
1237
|
+
this.screenPending = false;
|
|
1238
|
+
const stream = this.screenStream;
|
|
1239
|
+
if (this.screenEndedListener)
|
|
1240
|
+
stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
|
|
1241
|
+
this.screenEndedListener = null;
|
|
1242
|
+
this.screenStream = null;
|
|
1243
|
+
this.screenStarted = false;
|
|
1244
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1245
|
+
}
|
|
1246
|
+
async stopScreen(reason) {
|
|
1247
|
+
const started = this.screenStarted;
|
|
1248
|
+
const ids = [...this.screenProducerIds];
|
|
1249
|
+
this.screenProducerIds.clear();
|
|
1250
|
+
this.releaseScreen();
|
|
1251
|
+
for (const id of ids) {
|
|
1252
|
+
this.producers.get(id)?.close();
|
|
1253
|
+
this.producers.delete(id);
|
|
1254
|
+
}
|
|
1255
|
+
if (!this.localStream && !this.music)
|
|
1256
|
+
this.wasPublishing = false;
|
|
1257
|
+
if (started && !this.closedByUs)
|
|
1258
|
+
this.emit('screenShareEnded', { reason });
|
|
1259
|
+
await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
|
|
1260
|
+
}
|
|
1261
|
+
async restoreScreen() {
|
|
1262
|
+
const stream = this.screenStream;
|
|
1263
|
+
const video = stream?.getVideoTracks()[0];
|
|
1264
|
+
if (!stream || !video)
|
|
1265
|
+
return;
|
|
1266
|
+
if (video.readyState !== 'live') {
|
|
1267
|
+
await this.stopScreen('track-ended');
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
const generation = this.screenGeneration;
|
|
1271
|
+
const publishing = this.publishGeneration;
|
|
1272
|
+
const transport = await this.ensureSendTransport();
|
|
1273
|
+
if (generation !== this.screenGeneration)
|
|
1274
|
+
return;
|
|
1275
|
+
const { width = 0, height = 0 } = video.getSettings();
|
|
1276
|
+
const encodings = (0, quality_js_1.screenEncodings)(width, height, this.screenMode);
|
|
1277
|
+
// A project's explicit safety restriction also applies to screen video.
|
|
1278
|
+
const cap = (0, quality_js_1.topLayerSize)(width, height, this.videoPolicy.maxQuality);
|
|
1279
|
+
const screenScale = encodings.at(-1).scaleResolutionDownBy;
|
|
1280
|
+
if (cap.scale > screenScale)
|
|
1281
|
+
encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
1282
|
+
const keep = async (producer) => {
|
|
1283
|
+
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1284
|
+
producer.close();
|
|
1285
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1286
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1287
|
+
}
|
|
1288
|
+
this.producers.set(producer.id, producer);
|
|
1289
|
+
this.screenProducerIds.add(producer.id);
|
|
1290
|
+
await this.pauseOnServer(producer);
|
|
1291
|
+
};
|
|
1292
|
+
await keep(await transport.produce({
|
|
1293
|
+
track: video, stopTracks: false, encodings,
|
|
1294
|
+
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1295
|
+
appData: { source: 'screen', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings) },
|
|
1296
|
+
}));
|
|
1297
|
+
const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1298
|
+
if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
|
|
1299
|
+
await keep(await transport.produce({
|
|
1300
|
+
track: audio, stopTracks: false,
|
|
1301
|
+
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
|
|
1302
|
+
appData: { source: 'screen-audio' },
|
|
1303
|
+
}));
|
|
1304
|
+
}
|
|
1305
|
+
frameRateFor(track) {
|
|
1306
|
+
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
|
|
1307
|
+
return 30;
|
|
1308
|
+
const captured = track.getSettings().frameRate;
|
|
1309
|
+
if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
|
|
1310
|
+
this.frameRateFallback = true;
|
|
1311
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
|
|
1312
|
+
return 30;
|
|
1313
|
+
}
|
|
1314
|
+
return 60;
|
|
1315
|
+
}
|
|
1316
|
+
async sampleCamera(baseline) {
|
|
1317
|
+
const stats = await this.collectVideoStats(baseline);
|
|
1318
|
+
if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
|
|
1319
|
+
return stats;
|
|
1320
|
+
if (this.cameraProducer()?.paused)
|
|
1321
|
+
return stats;
|
|
1322
|
+
// The lower simulcast copies intentionally run at 30 fps. During the
|
|
1323
|
+
// initial bandwidth ramp they may be the only active copies, which does
|
|
1324
|
+
// not establish that the camera cannot sustain 60. Match native warm-up:
|
|
1325
|
+
// two 2-second samples, then require three consecutive limited samples.
|
|
1326
|
+
if (this.frameRateWarmupSamples-- > 0)
|
|
1327
|
+
return stats;
|
|
1328
|
+
const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
|
|
1329
|
+
this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
|
|
1330
|
+
if (this.frameRateLowSamples >= 3) {
|
|
1331
|
+
this.frameRateFallback = true;
|
|
1332
|
+
void this.cameraTask(async () => {
|
|
1333
|
+
const producer = this.cameraProducer();
|
|
1334
|
+
if (!producer?.track || producer.closed)
|
|
1335
|
+
return;
|
|
1336
|
+
await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1337
|
+
await this.reproduceCamera(producer, producer.track);
|
|
1338
|
+
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1339
|
+
}).catch((error) => this.emit('error', error));
|
|
1340
|
+
}
|
|
1341
|
+
return stats;
|
|
1342
|
+
}
|
|
1066
1343
|
// ------------------------------------------------------------------ music
|
|
1067
1344
|
/**
|
|
1068
1345
|
* Publish music playing inside the page, as its own track.
|
|
@@ -1173,10 +1450,21 @@ class StreamClient {
|
|
|
1173
1450
|
}));
|
|
1174
1451
|
const consumer = await transport.consume(params);
|
|
1175
1452
|
this.consumers.set(remote.producerId, consumer);
|
|
1453
|
+
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
1176
1454
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
1177
1455
|
// its first frame instead of starting at the full picture. Not getting
|
|
1178
1456
|
// them costs data, not the track, so a refusal is only reported.
|
|
1179
|
-
|
|
1457
|
+
let layers = this.preferredLayers.get(remote.producerId);
|
|
1458
|
+
const receiveQuality = this.receiveQualities.get(remote.producerId);
|
|
1459
|
+
if (receiveQuality && receiveQuality !== 'auto') {
|
|
1460
|
+
try {
|
|
1461
|
+
layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) };
|
|
1462
|
+
}
|
|
1463
|
+
catch {
|
|
1464
|
+
layers = undefined;
|
|
1465
|
+
this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1180
1468
|
if (layers) {
|
|
1181
1469
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
1182
1470
|
}
|
|
@@ -1208,7 +1496,7 @@ class StreamClient {
|
|
|
1208
1496
|
}
|
|
1209
1497
|
trackEnabled(kind) {
|
|
1210
1498
|
for (const p of this.producers.values()) {
|
|
1211
|
-
if (p.kind === kind && p.
|
|
1499
|
+
if (p.kind === kind && p.appData.source === defaultSource(kind))
|
|
1212
1500
|
return !p.paused;
|
|
1213
1501
|
}
|
|
1214
1502
|
return false;
|
|
@@ -1224,26 +1512,46 @@ class StreamClient {
|
|
|
1224
1512
|
async setCamEnabled(enabled) {
|
|
1225
1513
|
await this.setKindEnabled('video', enabled);
|
|
1226
1514
|
}
|
|
1515
|
+
applyLocalModeration(kind) {
|
|
1516
|
+
for (const producer of this.producers.values()) {
|
|
1517
|
+
if (!kind || producer.kind === kind)
|
|
1518
|
+
producer.pause();
|
|
1519
|
+
}
|
|
1520
|
+
// During reconnect there are no producers, but the retained tracks still
|
|
1521
|
+
// capture. Keep their muted state for republish as well.
|
|
1522
|
+
for (const stream of [this.localStream, this.screenStream]) {
|
|
1523
|
+
for (const track of stream?.getTracks() ?? [])
|
|
1524
|
+
if (!kind || track.kind === kind)
|
|
1525
|
+
track.enabled = false;
|
|
1526
|
+
}
|
|
1527
|
+
if (!kind || kind === 'audio')
|
|
1528
|
+
this.music?.setEnabled(false);
|
|
1529
|
+
}
|
|
1227
1530
|
async setKindEnabled(kind, enabled) {
|
|
1228
1531
|
for (const producer of this.producers.values()) {
|
|
1229
1532
|
// The music has its own switch. Muting the microphone must not stop the song.
|
|
1230
|
-
if (producer.kind !== kind || producer.
|
|
1533
|
+
if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
|
|
1231
1534
|
continue;
|
|
1232
|
-
if (enabled)
|
|
1233
|
-
producer.resume();
|
|
1234
|
-
else
|
|
1535
|
+
if (!enabled)
|
|
1235
1536
|
producer.pause();
|
|
1236
1537
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1538
|
+
if (enabled)
|
|
1539
|
+
producer.resume();
|
|
1237
1540
|
}
|
|
1238
1541
|
this.emitLocalMedia();
|
|
1239
1542
|
}
|
|
1240
1543
|
/** Stop publishing entirely and release the local devices. */
|
|
1241
1544
|
async unpublish() {
|
|
1545
|
+
void this.stopRecording().catch(error => this.emit('error', error));
|
|
1242
1546
|
// All of it takes effect now; only telling the server is waited for. So
|
|
1243
1547
|
// publish() and unpublish() act in the order they are called: a publish
|
|
1244
1548
|
// called while this still waits is not undone by it, and one already
|
|
1245
1549
|
// under way stops at its next step (keepProducer).
|
|
1246
1550
|
this.publishGeneration += 1;
|
|
1551
|
+
const screenWasStarted = this.screenStarted;
|
|
1552
|
+
this.releaseScreen();
|
|
1553
|
+
if (screenWasStarted)
|
|
1554
|
+
this.emit('screenShareEnded', { reason: 'stopped' });
|
|
1247
1555
|
const ids = [...this.producers.keys()];
|
|
1248
1556
|
for (const producer of this.producers.values())
|
|
1249
1557
|
producer.close();
|
|
@@ -1269,12 +1577,34 @@ class StreamClient {
|
|
|
1269
1577
|
await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
|
|
1270
1578
|
}
|
|
1271
1579
|
}
|
|
1580
|
+
/** Paid camera recording; project opt-in, quotas and quality are checked by the backend. */
|
|
1581
|
+
async startRecording(options) {
|
|
1582
|
+
if (this.recording)
|
|
1583
|
+
throw new Error('Recording is already active');
|
|
1584
|
+
if (!this.localStream || !this.joinResult || !['host', 'cohost'].includes(this.joinResult.role))
|
|
1585
|
+
throw new Error('Publish your camera before recording');
|
|
1586
|
+
const recording = new recording_js_1.CameraRecording(() => this.camEnabled ? this.localStream : null, () => this.token, options);
|
|
1587
|
+
this.recording = recording;
|
|
1588
|
+
try {
|
|
1589
|
+
await recording.start();
|
|
1590
|
+
}
|
|
1591
|
+
catch (error) {
|
|
1592
|
+
if (this.recording === recording)
|
|
1593
|
+
this.recording = null;
|
|
1594
|
+
throw error;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
async stopRecording() {
|
|
1598
|
+
const recording = this.recording;
|
|
1599
|
+
this.recording = null;
|
|
1600
|
+
return recording ? recording.stop() : [];
|
|
1601
|
+
}
|
|
1272
1602
|
get local() {
|
|
1273
1603
|
return this.localStream;
|
|
1274
1604
|
}
|
|
1275
1605
|
// ------------------------------------------------------------- moderation
|
|
1276
|
-
raiseHand() { return this.rpc('raiseHand'); }
|
|
1277
|
-
lowerHand() { return this.rpc('lowerHand'); }
|
|
1606
|
+
raiseHand(metadata) { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
|
|
1607
|
+
lowerHand(identity) { return this.rpc('lowerHand', identity ? { identity } : {}); }
|
|
1278
1608
|
muteParticipant(identity) { return this.rpc('requestMute', { identity }); }
|
|
1279
1609
|
/**
|
|
1280
1610
|
* Moderating roles: switch someone's microphone (`audio`), camera (`video`)
|