@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/src/index.ts
CHANGED
|
@@ -34,6 +34,12 @@ import {
|
|
|
34
34
|
allowedQuality,
|
|
35
35
|
DEFAULT_VIDEO_POLICY,
|
|
36
36
|
sameLayout,
|
|
37
|
+
describeVideoLayers,
|
|
38
|
+
receiveSpatialLayer,
|
|
39
|
+
screenEncodings,
|
|
40
|
+
type ScreenMode,
|
|
41
|
+
type ReceiveQuality,
|
|
42
|
+
type VideoLayer,
|
|
37
43
|
topLayerSize,
|
|
38
44
|
videoEncodings,
|
|
39
45
|
type LayerCount,
|
|
@@ -58,7 +64,7 @@ export type { MusicInput, MusicOptions, TrackSource } from './music.js';
|
|
|
58
64
|
export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
|
|
59
65
|
export { ChatClient, ChatRpcError } from './chat.js';
|
|
60
66
|
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
61
|
-
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality } from './quality.js';
|
|
67
|
+
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
|
|
62
68
|
export type { CodecPreference } from './device.js';
|
|
63
69
|
export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
|
|
64
70
|
export type {
|
|
@@ -98,12 +104,14 @@ export interface JoinResult {
|
|
|
98
104
|
blockedSlots?: string[];
|
|
99
105
|
/**
|
|
100
106
|
* The highest video quality this project may publish, and the server's cap
|
|
101
|
-
* on a publisher's incoming bitrate. Absent from older servers:
|
|
107
|
+
* on a publisher's incoming bitrate. Absent from older servers: 1080p.
|
|
102
108
|
*/
|
|
103
109
|
video?: VideoPolicy;
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
export interface PublishOptions {
|
|
113
|
+
/** 30 by default. Only the top layer uses 60; capture at 60 too. */
|
|
114
|
+
frameRate?: 30 | 60;
|
|
107
115
|
/**
|
|
108
116
|
* The video quality to send: 1080p, 720p or 480p. Defaults to the highest
|
|
109
117
|
* the project allows (`videoPolicy.maxQuality`), and is lowered to it if
|
|
@@ -140,6 +148,13 @@ export interface PublishOptions {
|
|
|
140
148
|
degradationPreference?: 'maintain-resolution' | 'maintain-framerate' | 'balanced';
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
export interface ScreenPublishOptions {
|
|
152
|
+
mode?: ScreenMode;
|
|
153
|
+
audio?: boolean;
|
|
154
|
+
/** An existing screen stream; otherwise capture is requested from this user gesture. */
|
|
155
|
+
stream?: MediaStream;
|
|
156
|
+
}
|
|
157
|
+
|
|
143
158
|
export interface SlotClaim {
|
|
144
159
|
key: string;
|
|
145
160
|
identity: string;
|
|
@@ -153,7 +168,9 @@ export interface ParticipantInfo {
|
|
|
153
168
|
identity: string;
|
|
154
169
|
role: Role;
|
|
155
170
|
handRaised: boolean;
|
|
171
|
+
handMetadata?: Record<string, unknown>;
|
|
156
172
|
muted: boolean;
|
|
173
|
+
videoBlocked?: boolean;
|
|
157
174
|
metadata?: unknown;
|
|
158
175
|
}
|
|
159
176
|
|
|
@@ -164,6 +181,8 @@ export interface RemoteProducer {
|
|
|
164
181
|
paused: boolean;
|
|
165
182
|
/** What the track carries. A host playing music publishes two audio tracks: `mic` and `music`. */
|
|
166
183
|
source?: TrackSource;
|
|
184
|
+
videoLayers?: VideoLayer[];
|
|
185
|
+
appData?: { videoLayers?: VideoLayer[] };
|
|
167
186
|
}
|
|
168
187
|
|
|
169
188
|
const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio' ? 'mic' : 'camera');
|
|
@@ -185,8 +204,9 @@ export interface ClientEvents extends ChatEvents {
|
|
|
185
204
|
trackMuted: (info: { identity: string; kind: 'audio' | 'video'; source: TrackSource; producerId: string; paused: boolean }) => void;
|
|
186
205
|
participantJoined: (p: ParticipantInfo) => void;
|
|
187
206
|
participantLeft: (p: { identity: string; reason?: string }) => void;
|
|
207
|
+
participantMuted: (e: { identity: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
|
|
188
208
|
roleChanged: (e: { identity: string; role: Role }) => void;
|
|
189
|
-
handRaised: (e: { identity: string; handRaised: boolean }) => void;
|
|
209
|
+
handRaised: (e: { identity: string; handRaised: boolean; metadata?: Record<string, unknown>; reason?: string }) => void;
|
|
190
210
|
/**
|
|
191
211
|
* A moderator switched your microphone (`audio`), camera (`video`) or both
|
|
192
212
|
* (no kind) off, or released it (`paused: false`). While paused you cannot
|
|
@@ -196,6 +216,8 @@ export interface ClientEvents extends ChatEvents {
|
|
|
196
216
|
mustReconnect: (e: { reason: string; token?: string; retry: boolean }) => void;
|
|
197
217
|
roomClosed: (e: { reason: string }) => void;
|
|
198
218
|
kicked: (e: { by: string; reason?: string }) => void;
|
|
219
|
+
screenShareStarted: (e: { stream: MediaStream; mode: ScreenMode }) => void;
|
|
220
|
+
screenShareEnded: (e: { reason: 'stopped' | 'track-ended' }) => void;
|
|
199
221
|
error: (err: Error) => void;
|
|
200
222
|
localMediaChanged: (state: { micEnabled: boolean; camEnabled: boolean; musicEnabled: boolean }) => void;
|
|
201
223
|
/** The local stream was replaced, typically after reacquiring devices on a reconnect. */
|
|
@@ -278,7 +300,7 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
|
|
|
278
300
|
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
279
301
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
280
302
|
*/
|
|
281
|
-
const VIDEO_START_BITRATE_KBPS =
|
|
303
|
+
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
282
304
|
|
|
283
305
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
284
306
|
const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published`;
|
|
@@ -312,6 +334,20 @@ export class StreamClient {
|
|
|
312
334
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
313
335
|
private music: MusicSource | null = null;
|
|
314
336
|
private musicProducerId: string | null = null;
|
|
337
|
+
private screenStream: MediaStream | null = null;
|
|
338
|
+
private screenMode: ScreenMode = 'detail';
|
|
339
|
+
private screenAudioEnabled = true;
|
|
340
|
+
private screenGeneration = 0;
|
|
341
|
+
private screenPending = false;
|
|
342
|
+
private screenStarted = false;
|
|
343
|
+
private screenEndedListener: (() => void) | null = null;
|
|
344
|
+
private readonly screenProducerIds = new Set<string>();
|
|
345
|
+
private readonly remoteVideoLayers = new Map<string, VideoLayer[]>();
|
|
346
|
+
private readonly receiveQualities = new Map<string, ReceiveQuality>();
|
|
347
|
+
private cameraFrameRate: 30 | 60 = 30;
|
|
348
|
+
private frameRateFallback = false;
|
|
349
|
+
private frameRateLowSamples = 0;
|
|
350
|
+
private frameRateWarmupSamples = 2;
|
|
315
351
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
316
352
|
private readonly remoteSources = new Map<string, TrackSource>();
|
|
317
353
|
/**
|
|
@@ -339,7 +375,7 @@ export class StreamClient {
|
|
|
339
375
|
private readonly sendStatsBaseline: StatsBaseline = new Map();
|
|
340
376
|
private readonly recvStatsBaseline: StatsBaseline = new Map();
|
|
341
377
|
private readonly limitWatch = new VideoLimitWatch(
|
|
342
|
-
(baseline) => this.
|
|
378
|
+
(baseline) => this.sampleCamera(baseline),
|
|
343
379
|
(event) => this.emit('videoLimited', event),
|
|
344
380
|
);
|
|
345
381
|
|
|
@@ -401,15 +437,29 @@ export class StreamClient {
|
|
|
401
437
|
this.reconnectAttempt = 0;
|
|
402
438
|
this.joinedOnce = true;
|
|
403
439
|
|
|
440
|
+
// A role or mute can change while the socket is away. The fresh roster
|
|
441
|
+
// decides what may return; reconnecting never turns a blocked device on.
|
|
442
|
+
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
443
|
+
await this.unpublish();
|
|
444
|
+
}
|
|
445
|
+
const own = result.participants.find((p) => p.identity === result.identity);
|
|
446
|
+
if (own?.muted) this.applyLocalModeration('audio');
|
|
447
|
+
if (own?.videoBlocked) this.applyLocalModeration('video');
|
|
404
448
|
this.emit('joined', result);
|
|
405
449
|
|
|
406
450
|
// A video that ended while this client was away sends it no
|
|
407
451
|
// producerClosed: forget the layers asked for it.
|
|
408
452
|
const present = new Set(result.producers.map((p) => p.producerId));
|
|
409
453
|
for (const producerId of this.preferredLayers.keys()) {
|
|
410
|
-
if (!present.has(producerId))
|
|
454
|
+
if (!present.has(producerId)) {
|
|
455
|
+
this.preferredLayers.delete(producerId);
|
|
456
|
+
this.receiveQualities.delete(producerId);
|
|
457
|
+
this.remoteVideoLayers.delete(producerId);
|
|
458
|
+
}
|
|
411
459
|
}
|
|
412
460
|
|
|
461
|
+
for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
|
|
462
|
+
|
|
413
463
|
// Subscribe to everything already publishing before we announce readiness.
|
|
414
464
|
for (const p of result.producers) {
|
|
415
465
|
await this.consume(p).catch((err) => this.emit('error', err as Error));
|
|
@@ -553,9 +603,15 @@ export class StreamClient {
|
|
|
553
603
|
// others have already reported themselves and set closedByUs.
|
|
554
604
|
const wasOpen = !this.closedByUs;
|
|
555
605
|
this.closedByUs = true;
|
|
606
|
+
this.publishGeneration += 1;
|
|
556
607
|
this.wasPublishing = false;
|
|
557
608
|
this.teardownMedia();
|
|
558
609
|
this.preferredLayers.clear();
|
|
610
|
+
this.receiveQualities.clear();
|
|
611
|
+
this.remoteVideoLayers.clear();
|
|
612
|
+
this.releaseScreen();
|
|
613
|
+
this.music?.close();
|
|
614
|
+
this.music = null;
|
|
559
615
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
560
616
|
this.limitWatch.reset();
|
|
561
617
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
@@ -574,6 +630,7 @@ export class StreamClient {
|
|
|
574
630
|
for (const p of this.producers.values()) p.close();
|
|
575
631
|
this.producers.clear();
|
|
576
632
|
this.musicProducerId = null;
|
|
633
|
+
this.screenProducerIds.clear();
|
|
577
634
|
this.cameraEncodings = null;
|
|
578
635
|
// Paused, not reset: a reconnect's republish starts it again, and a
|
|
579
636
|
// limitation already reported is not reported twice.
|
|
@@ -651,6 +708,8 @@ export class StreamClient {
|
|
|
651
708
|
case 'producerClosed': {
|
|
652
709
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
653
710
|
this.preferredLayers.delete(producerId);
|
|
711
|
+
this.receiveQualities.delete(producerId);
|
|
712
|
+
this.remoteVideoLayers.delete(producerId);
|
|
654
713
|
const consumer = this.consumers.get(producerId);
|
|
655
714
|
if (!consumer) break; // never consumed it, or already cleaned up
|
|
656
715
|
consumer.close();
|
|
@@ -673,16 +732,28 @@ export class StreamClient {
|
|
|
673
732
|
case 'participantJoined':
|
|
674
733
|
this.emit('participantJoined', data as ParticipantInfo);
|
|
675
734
|
break;
|
|
735
|
+
case 'participantMuted': {
|
|
736
|
+
const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
|
|
737
|
+
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
738
|
+
if (this.joinResult) this.joinResult.participants = this.joinResult.participants.map((p) =>
|
|
739
|
+
p.identity !== e.identity ? p : { ...p,
|
|
740
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
741
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
742
|
+
});
|
|
743
|
+
this.emit('participantMuted', e);
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
676
746
|
case 'participantLeft':
|
|
677
747
|
this.emit('participantLeft', data as { identity: string; reason?: string });
|
|
678
748
|
break;
|
|
679
749
|
case 'roleChanged': {
|
|
680
750
|
const e = data as { identity: string; role: Role };
|
|
751
|
+
if (e.identity === this.joinResult?.identity) this.joinResult.role = e.role;
|
|
681
752
|
this.emit('roleChanged', e);
|
|
682
753
|
// Demoted: the server has already closed our producers. Release the
|
|
683
754
|
// camera and microphone too, instead of capturing for nobody.
|
|
684
755
|
const canPublish = e.role === 'host' || e.role === 'cohost';
|
|
685
|
-
if (e.identity === this.joinResult?.identity && !canPublish && this.
|
|
756
|
+
if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
|
|
686
757
|
void this.unpublish().catch(() => undefined);
|
|
687
758
|
}
|
|
688
759
|
break;
|
|
@@ -697,9 +768,7 @@ export class StreamClient {
|
|
|
697
768
|
// changes nothing here -- switching back on is the user's choice.
|
|
698
769
|
const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
|
|
699
770
|
if (e.paused) {
|
|
700
|
-
|
|
701
|
-
if (!e.kind || p.kind === e.kind) p.pause();
|
|
702
|
-
}
|
|
771
|
+
this.applyLocalModeration(e.kind);
|
|
703
772
|
this.emitLocalMedia();
|
|
704
773
|
}
|
|
705
774
|
this.emit('forceMuted', e);
|
|
@@ -787,7 +856,7 @@ export class StreamClient {
|
|
|
787
856
|
.catch(fail);
|
|
788
857
|
});
|
|
789
858
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
790
|
-
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
|
|
859
|
+
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
|
|
791
860
|
.then((res) => ok({ id: (res as { producerId: string }).producerId }))
|
|
792
861
|
.catch(fail);
|
|
793
862
|
});
|
|
@@ -844,7 +913,11 @@ export class StreamClient {
|
|
|
844
913
|
this.localStream = stream;
|
|
845
914
|
this.wasPublishing = true;
|
|
846
915
|
if (constraints) this.publishConstraints = constraints;
|
|
847
|
-
if (options)
|
|
916
|
+
if (options) {
|
|
917
|
+
this.publishOptions = { ...options };
|
|
918
|
+
this.frameRateFallback = false;
|
|
919
|
+
this.frameRateLowSamples = 0;
|
|
920
|
+
}
|
|
848
921
|
|
|
849
922
|
const audio = stream.getAudioTracks()[0];
|
|
850
923
|
if (audio) {
|
|
@@ -869,7 +942,7 @@ export class StreamClient {
|
|
|
869
942
|
}
|
|
870
943
|
}
|
|
871
944
|
|
|
872
|
-
/** What this project may publish. Known once joined;
|
|
945
|
+
/** What this project may publish. Known once joined; 1080p before that. */
|
|
873
946
|
get videoPolicy(): VideoPolicy {
|
|
874
947
|
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
875
948
|
}
|
|
@@ -896,11 +969,27 @@ export class StreamClient {
|
|
|
896
969
|
return this.videoQuality;
|
|
897
970
|
}
|
|
898
971
|
|
|
972
|
+
/** Change the camera frame rate without stopping screen, microphone or music. */
|
|
973
|
+
async setVideoFrameRate(frameRate: 30 | 60): Promise<30 | 60> {
|
|
974
|
+
if (frameRate !== 30 && frameRate !== 60) throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
975
|
+
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
976
|
+
this.frameRateFallback = false;
|
|
977
|
+
this.frameRateLowSamples = 0;
|
|
978
|
+
await this.cameraTask(async () => {
|
|
979
|
+
const current = this.cameraProducer();
|
|
980
|
+
const track = current?.track;
|
|
981
|
+
if (!current || !track || track.readyState !== 'live') return;
|
|
982
|
+
try { await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } }); }
|
|
983
|
+
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
984
|
+
await this.reproduceCamera(current, track);
|
|
985
|
+
});
|
|
986
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
|
|
987
|
+
}
|
|
988
|
+
|
|
899
989
|
/**
|
|
900
990
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
901
991
|
* it, the raw camera again, another camera. When the new track needs the
|
|
902
|
-
* same layers as the current one
|
|
903
|
-
* project's quality scales alike), viewers see no pause, and the layers
|
|
992
|
+
* same dimensions and layers as the current one, viewers see no pause, and the layers
|
|
904
993
|
* take the new picture's bitrates. Otherwise the camera producer is
|
|
905
994
|
* replaced, as `setVideoQuality` does. Either way a paused camera stays
|
|
906
995
|
* paused.
|
|
@@ -937,8 +1026,10 @@ export class StreamClient {
|
|
|
937
1026
|
const paused = current.paused;
|
|
938
1027
|
try {
|
|
939
1028
|
const { width = 0, height = 0 } = track.getSettings();
|
|
940
|
-
const next = videoEncodings(width, height, this.videoQuality, this.layerCount());
|
|
941
|
-
|
|
1029
|
+
const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
|
|
1030
|
+
const previousSize = current.track?.getSettings();
|
|
1031
|
+
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
1032
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
942
1033
|
await current.replaceTrack({ track });
|
|
943
1034
|
await this.applyLayerBitrates(current, next);
|
|
944
1035
|
return;
|
|
@@ -1036,6 +1127,7 @@ export class StreamClient {
|
|
|
1036
1127
|
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
1037
1128
|
}
|
|
1038
1129
|
const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
1130
|
+
this.receiveQualities.delete(producerId);
|
|
1039
1131
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
1040
1132
|
this.preferredLayers.set(producerId, layers);
|
|
1041
1133
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
@@ -1056,6 +1148,22 @@ export class StreamClient {
|
|
|
1056
1148
|
return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
1057
1149
|
}
|
|
1058
1150
|
|
|
1151
|
+
/** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
|
|
1152
|
+
async setReceiveQuality(producerId: string, quality: ReceiveQuality): Promise<void> {
|
|
1153
|
+
if (!['auto', '1080p', '720p', '480p'].includes(quality)) throw new RangeError('setReceiveQuality: unsupported quality');
|
|
1154
|
+
const consumer = this.consumers.get(producerId);
|
|
1155
|
+
if (!consumer || consumer.kind !== 'video') throw new Error('setReceiveQuality: this client is not receiving that video');
|
|
1156
|
+
let layers: PreferredLayers;
|
|
1157
|
+
if (quality === 'auto') layers = { spatialLayer: -1, temporalLayer: -1 };
|
|
1158
|
+
else {
|
|
1159
|
+
try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; }
|
|
1160
|
+
catch { throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto'); }
|
|
1161
|
+
}
|
|
1162
|
+
this.receiveQualities.set(producerId, quality);
|
|
1163
|
+
this.preferredLayers.set(producerId, layers);
|
|
1164
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1059
1167
|
private cameraProducer(): Producer | undefined {
|
|
1060
1168
|
for (const p of this.producers.values()) {
|
|
1061
1169
|
if (p.kind === 'video' && (p.appData as { source?: string }).source === 'camera') return p;
|
|
@@ -1119,7 +1227,8 @@ export class StreamClient {
|
|
|
1119
1227
|
// videoEncodings).
|
|
1120
1228
|
const { width = 0, height = 0 } = track.getSettings();
|
|
1121
1229
|
const quality = this.videoQuality;
|
|
1122
|
-
|
|
1230
|
+
this.cameraFrameRate = this.frameRateFor(track);
|
|
1231
|
+
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1123
1232
|
let codecs: RtpCodecCapability[] | undefined;
|
|
1124
1233
|
try {
|
|
1125
1234
|
codecs = this.device?.sendRtpCapabilities.codecs;
|
|
@@ -1134,7 +1243,7 @@ export class StreamClient {
|
|
|
1134
1243
|
encodings,
|
|
1135
1244
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1136
1245
|
...(codec ? { codec } : {}),
|
|
1137
|
-
appData: { source: 'camera' },
|
|
1246
|
+
appData: { source: 'camera', videoLayers: describeVideoLayers(width, height, encodings, quality) },
|
|
1138
1247
|
});
|
|
1139
1248
|
let producer: Producer;
|
|
1140
1249
|
try {
|
|
@@ -1150,6 +1259,8 @@ export class StreamClient {
|
|
|
1150
1259
|
// quality change or a track swap under way).
|
|
1151
1260
|
await this.keepProducer(producer, generation, 'camera');
|
|
1152
1261
|
this.cameraEncodings = encodings;
|
|
1262
|
+
this.frameRateLowSamples = 0;
|
|
1263
|
+
this.frameRateWarmupSamples = 2;
|
|
1153
1264
|
await this.pauseOnServer(producer);
|
|
1154
1265
|
await this.applyDegradationPreference(producer);
|
|
1155
1266
|
this.syncLimitWatch();
|
|
@@ -1233,7 +1344,7 @@ export class StreamClient {
|
|
|
1233
1344
|
|
|
1234
1345
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1235
1346
|
private syncLimitWatch(): void {
|
|
1236
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
|
|
1347
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
1237
1348
|
}
|
|
1238
1349
|
|
|
1239
1350
|
/**
|
|
@@ -1249,6 +1360,11 @@ export class StreamClient {
|
|
|
1249
1360
|
const generation = this.publishGeneration;
|
|
1250
1361
|
// Music is not a device and cannot be "gone": publish it again whatever
|
|
1251
1362
|
// happens to the camera and microphone below.
|
|
1363
|
+
const screen = this.screenStream;
|
|
1364
|
+
if (screen) await this.restoreScreen().catch(async (err) => {
|
|
1365
|
+
if (this.screenStream === screen) await this.stopScreen('stopped');
|
|
1366
|
+
this.emit('error', err as Error);
|
|
1367
|
+
});
|
|
1252
1368
|
if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
|
|
1253
1369
|
if (generation !== this.publishGeneration) return;
|
|
1254
1370
|
|
|
@@ -1281,6 +1397,150 @@ export class StreamClient {
|
|
|
1281
1397
|
await this.publish(stream);
|
|
1282
1398
|
}
|
|
1283
1399
|
|
|
1400
|
+
// ------------------------------------------------------------- screen sharing
|
|
1401
|
+
|
|
1402
|
+
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1403
|
+
async publishScreen(options: ScreenPublishOptions = {}): Promise<MediaStream> {
|
|
1404
|
+
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost') throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1405
|
+
if (this.screenStream || this.screenPending) throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1406
|
+
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion') throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1407
|
+
const mode = options.mode ?? 'detail';
|
|
1408
|
+
if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
|
|
1409
|
+
throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
|
|
1410
|
+
}
|
|
1411
|
+
const generation = this.screenGeneration;
|
|
1412
|
+
this.screenPending = true;
|
|
1413
|
+
let stream: MediaStream | undefined;
|
|
1414
|
+
try {
|
|
1415
|
+
stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
|
|
1416
|
+
video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
|
|
1417
|
+
audio: options.audio ?? false,
|
|
1418
|
+
});
|
|
1419
|
+
const video = stream.getVideoTracks()[0];
|
|
1420
|
+
if (!video || video.readyState !== 'live') throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1421
|
+
if (generation !== this.screenGeneration || this.closedByUs) throw new Error(unpublishedWhile('screen'));
|
|
1422
|
+
this.screenStream = stream;
|
|
1423
|
+
this.screenMode = mode;
|
|
1424
|
+
this.screenAudioEnabled = options.audio !== false;
|
|
1425
|
+
video.contentHint = mode;
|
|
1426
|
+
const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err as Error)); };
|
|
1427
|
+
this.screenEndedListener = onEnded;
|
|
1428
|
+
video.addEventListener('ended', onEnded, { once: true });
|
|
1429
|
+
await this.restoreScreen();
|
|
1430
|
+
if (generation !== this.screenGeneration || video.readyState !== 'live') throw new Error(unpublishedWhile('screen'));
|
|
1431
|
+
this.screenStarted = true;
|
|
1432
|
+
this.wasPublishing = true;
|
|
1433
|
+
this.emit('screenShareStarted', { stream, mode });
|
|
1434
|
+
return stream;
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
if (generation === this.screenGeneration) await this.stopScreen('stopped');
|
|
1437
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1438
|
+
throw error;
|
|
1439
|
+
} finally {
|
|
1440
|
+
if (generation === this.screenGeneration) this.screenPending = false;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
|
|
1445
|
+
async unpublishScreen(): Promise<void> {
|
|
1446
|
+
await this.stopScreen('stopped');
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
private releaseScreen(): void {
|
|
1450
|
+
this.screenGeneration += 1;
|
|
1451
|
+
this.screenPending = false;
|
|
1452
|
+
const stream = this.screenStream;
|
|
1453
|
+
if (this.screenEndedListener) stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
|
|
1454
|
+
this.screenEndedListener = null;
|
|
1455
|
+
this.screenStream = null;
|
|
1456
|
+
this.screenStarted = false;
|
|
1457
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
private async stopScreen(reason: 'stopped' | 'track-ended'): Promise<void> {
|
|
1461
|
+
const started = this.screenStarted;
|
|
1462
|
+
const ids = [...this.screenProducerIds];
|
|
1463
|
+
this.screenProducerIds.clear();
|
|
1464
|
+
this.releaseScreen();
|
|
1465
|
+
for (const id of ids) { this.producers.get(id)?.close(); this.producers.delete(id); }
|
|
1466
|
+
if (!this.localStream && !this.music) this.wasPublishing = false;
|
|
1467
|
+
if (started && !this.closedByUs) this.emit('screenShareEnded', { reason });
|
|
1468
|
+
await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
private async restoreScreen(): Promise<void> {
|
|
1472
|
+
const stream = this.screenStream;
|
|
1473
|
+
const video = stream?.getVideoTracks()[0];
|
|
1474
|
+
if (!stream || !video) return;
|
|
1475
|
+
if (video.readyState !== 'live') { await this.stopScreen('track-ended'); return; }
|
|
1476
|
+
const generation = this.screenGeneration;
|
|
1477
|
+
const publishing = this.publishGeneration;
|
|
1478
|
+
const transport = await this.ensureSendTransport();
|
|
1479
|
+
if (generation !== this.screenGeneration) return;
|
|
1480
|
+
const { width = 0, height = 0 } = video.getSettings();
|
|
1481
|
+
const encodings = screenEncodings(width, height, this.screenMode);
|
|
1482
|
+
// A project's explicit safety restriction also applies to screen video.
|
|
1483
|
+
const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
|
|
1484
|
+
const screenScale = encodings.at(-1)!.scaleResolutionDownBy;
|
|
1485
|
+
if (cap.scale > screenScale) encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
1486
|
+
const keep = async (producer: Producer) => {
|
|
1487
|
+
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1488
|
+
producer.close();
|
|
1489
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1490
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1491
|
+
}
|
|
1492
|
+
this.producers.set(producer.id, producer);
|
|
1493
|
+
this.screenProducerIds.add(producer.id);
|
|
1494
|
+
await this.pauseOnServer(producer);
|
|
1495
|
+
};
|
|
1496
|
+
await keep(await transport.produce({
|
|
1497
|
+
track: video, stopTracks: false, encodings,
|
|
1498
|
+
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1499
|
+
appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
|
|
1500
|
+
}));
|
|
1501
|
+
const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1502
|
+
if (audio && this.screenAudioEnabled && generation === this.screenGeneration) await keep(await transport.produce({
|
|
1503
|
+
track: audio, stopTracks: false,
|
|
1504
|
+
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
|
|
1505
|
+
appData: { source: 'screen-audio' },
|
|
1506
|
+
}));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
private frameRateFor(track: MediaStreamTrack): 30 | 60 {
|
|
1510
|
+
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback) return 30;
|
|
1511
|
+
const captured = track.getSettings().frameRate;
|
|
1512
|
+
if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
|
|
1513
|
+
this.frameRateFallback = true;
|
|
1514
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
|
|
1515
|
+
return 30;
|
|
1516
|
+
}
|
|
1517
|
+
return 60;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
private async sampleCamera(baseline: StatsBaseline): Promise<VideoSendStats | null> {
|
|
1521
|
+
const stats = await this.collectVideoStats(baseline);
|
|
1522
|
+
if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats) return stats;
|
|
1523
|
+
if (this.cameraProducer()?.paused) return stats;
|
|
1524
|
+
// The lower simulcast copies intentionally run at 30 fps. During the
|
|
1525
|
+
// initial bandwidth ramp they may be the only active copies, which does
|
|
1526
|
+
// not establish that the camera cannot sustain 60. Match native warm-up:
|
|
1527
|
+
// two 2-second samples, then require three consecutive limited samples.
|
|
1528
|
+
if (this.frameRateWarmupSamples-- > 0) return stats;
|
|
1529
|
+
const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
|
|
1530
|
+
this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
|
|
1531
|
+
if (this.frameRateLowSamples >= 3) {
|
|
1532
|
+
this.frameRateFallback = true;
|
|
1533
|
+
void this.cameraTask(async () => {
|
|
1534
|
+
const producer = this.cameraProducer();
|
|
1535
|
+
if (!producer?.track || producer.closed) return;
|
|
1536
|
+
await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1537
|
+
await this.reproduceCamera(producer, producer.track);
|
|
1538
|
+
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1539
|
+
}).catch((error) => this.emit('error', error as Error));
|
|
1540
|
+
}
|
|
1541
|
+
return stats;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1284
1544
|
// ------------------------------------------------------------------ music
|
|
1285
1545
|
|
|
1286
1546
|
/**
|
|
@@ -1392,11 +1652,17 @@ export class StreamClient {
|
|
|
1392
1652
|
|
|
1393
1653
|
const consumer = await transport.consume(params);
|
|
1394
1654
|
this.consumers.set(remote.producerId, consumer);
|
|
1655
|
+
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
|
|
1395
1656
|
|
|
1396
1657
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
1397
1658
|
// its first frame instead of starting at the full picture. Not getting
|
|
1398
1659
|
// them costs data, not the track, so a refusal is only reported.
|
|
1399
|
-
|
|
1660
|
+
let layers = this.preferredLayers.get(remote.producerId);
|
|
1661
|
+
const receiveQuality = this.receiveQualities.get(remote.producerId);
|
|
1662
|
+
if (receiveQuality && receiveQuality !== 'auto') {
|
|
1663
|
+
try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) }; }
|
|
1664
|
+
catch { layers = undefined; this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto')); }
|
|
1665
|
+
}
|
|
1400
1666
|
if (layers) {
|
|
1401
1667
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
1402
1668
|
}
|
|
@@ -1436,7 +1702,7 @@ export class StreamClient {
|
|
|
1436
1702
|
|
|
1437
1703
|
private trackEnabled(kind: 'audio' | 'video'): boolean {
|
|
1438
1704
|
for (const p of this.producers.values()) {
|
|
1439
|
-
if (p.kind === kind && p.
|
|
1705
|
+
if (p.kind === kind && p.appData.source === defaultSource(kind)) return !p.paused;
|
|
1440
1706
|
}
|
|
1441
1707
|
return false;
|
|
1442
1708
|
}
|
|
@@ -1454,13 +1720,25 @@ export class StreamClient {
|
|
|
1454
1720
|
await this.setKindEnabled('video', enabled);
|
|
1455
1721
|
}
|
|
1456
1722
|
|
|
1723
|
+
private applyLocalModeration(kind?: 'audio' | 'video' | ''): void {
|
|
1724
|
+
for (const producer of this.producers.values()) {
|
|
1725
|
+
if (!kind || producer.kind === kind) producer.pause();
|
|
1726
|
+
}
|
|
1727
|
+
// During reconnect there are no producers, but the retained tracks still
|
|
1728
|
+
// capture. Keep their muted state for republish as well.
|
|
1729
|
+
for (const stream of [this.localStream, this.screenStream]) {
|
|
1730
|
+
for (const track of stream?.getTracks() ?? []) if (!kind || track.kind === kind) track.enabled = false;
|
|
1731
|
+
}
|
|
1732
|
+
if (!kind || kind === 'audio') this.music?.setEnabled(false);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1457
1735
|
private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
|
|
1458
1736
|
for (const producer of this.producers.values()) {
|
|
1459
1737
|
// The music has its own switch. Muting the microphone must not stop the song.
|
|
1460
|
-
if (producer.kind !== kind || producer.
|
|
1461
|
-
if (enabled) producer.
|
|
1462
|
-
else producer.pause();
|
|
1738
|
+
if (producer.kind !== kind || producer.appData.source !== defaultSource(kind)) continue;
|
|
1739
|
+
if (!enabled) producer.pause();
|
|
1463
1740
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1741
|
+
if (enabled) producer.resume();
|
|
1464
1742
|
}
|
|
1465
1743
|
this.emitLocalMedia();
|
|
1466
1744
|
}
|
|
@@ -1472,6 +1750,9 @@ export class StreamClient {
|
|
|
1472
1750
|
// called while this still waits is not undone by it, and one already
|
|
1473
1751
|
// under way stops at its next step (keepProducer).
|
|
1474
1752
|
this.publishGeneration += 1;
|
|
1753
|
+
const screenWasStarted = this.screenStarted;
|
|
1754
|
+
this.releaseScreen();
|
|
1755
|
+
if (screenWasStarted) this.emit('screenShareEnded', { reason: 'stopped' });
|
|
1475
1756
|
const ids = [...this.producers.keys()];
|
|
1476
1757
|
for (const producer of this.producers.values()) producer.close();
|
|
1477
1758
|
this.producers.clear();
|
|
@@ -1502,8 +1783,8 @@ export class StreamClient {
|
|
|
1502
1783
|
|
|
1503
1784
|
// ------------------------------------------------------------- moderation
|
|
1504
1785
|
|
|
1505
|
-
raiseHand(): Promise<unknown> { return this.rpc('raiseHand'); }
|
|
1506
|
-
lowerHand(): Promise<unknown> { return this.rpc('lowerHand'); }
|
|
1786
|
+
raiseHand(metadata?: Record<string, unknown>): Promise<unknown> { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
|
|
1787
|
+
lowerHand(identity?: string): Promise<unknown> { return this.rpc('lowerHand', identity ? { identity } : {}); }
|
|
1507
1788
|
muteParticipant(identity: string): Promise<unknown> { return this.rpc('requestMute', { identity }); }
|
|
1508
1789
|
/**
|
|
1509
1790
|
* Moderating roles: switch someone's microphone (`audio`), camera (`video`)
|
package/src/music.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface MusicOptions {
|
|
|
22
22
|
monitor?: boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export type TrackSource = 'mic' | 'music' | 'camera' | 'screen';
|
|
25
|
+
export type TrackSource = 'mic' | 'music' | 'camera' | 'screen' | 'screen-audio';
|
|
26
26
|
|
|
27
27
|
let shared: AudioContext | null = null;
|
|
28
28
|
/** A media element can be wired to one audio graph, once, for its whole life. */
|