@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/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CameraRecording, type RecordingOptions, type RecordingFile } from './recording.js';
|
|
1
2
|
/**
|
|
2
3
|
* Thin client SDK over the orchestrator's signaling protocol.
|
|
3
4
|
*
|
|
@@ -34,6 +35,12 @@ import {
|
|
|
34
35
|
allowedQuality,
|
|
35
36
|
DEFAULT_VIDEO_POLICY,
|
|
36
37
|
sameLayout,
|
|
38
|
+
describeVideoLayers,
|
|
39
|
+
receiveSpatialLayer,
|
|
40
|
+
screenEncodings,
|
|
41
|
+
type ScreenMode,
|
|
42
|
+
type ReceiveQuality,
|
|
43
|
+
type VideoLayer,
|
|
37
44
|
topLayerSize,
|
|
38
45
|
videoEncodings,
|
|
39
46
|
type LayerCount,
|
|
@@ -52,13 +59,15 @@ import {
|
|
|
52
59
|
type VideoSendStats,
|
|
53
60
|
} from './stats.js';
|
|
54
61
|
|
|
62
|
+
export { CameraRecording, RecordingUploader, RecordingError } from './recording.js';
|
|
63
|
+
export type { RecordingOptions, RecordingEvent, RecordingState, RecordingSession, RecordingFile } from './recording.js';
|
|
55
64
|
export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
|
|
56
65
|
export { AudioMixer, MusicSource } from './music.js';
|
|
57
66
|
export type { MusicInput, MusicOptions, TrackSource } from './music.js';
|
|
58
67
|
export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
|
|
59
68
|
export { ChatClient, ChatRpcError } from './chat.js';
|
|
60
69
|
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
61
|
-
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality } from './quality.js';
|
|
70
|
+
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
|
|
62
71
|
export type { CodecPreference } from './device.js';
|
|
63
72
|
export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
|
|
64
73
|
export type {
|
|
@@ -98,12 +107,14 @@ export interface JoinResult {
|
|
|
98
107
|
blockedSlots?: string[];
|
|
99
108
|
/**
|
|
100
109
|
* The highest video quality this project may publish, and the server's cap
|
|
101
|
-
* on a publisher's incoming bitrate. Absent from older servers:
|
|
110
|
+
* on a publisher's incoming bitrate. Absent from older servers: 1080p.
|
|
102
111
|
*/
|
|
103
112
|
video?: VideoPolicy;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
export interface PublishOptions {
|
|
116
|
+
/** 30 by default. Only the top layer uses 60; capture at 60 too. */
|
|
117
|
+
frameRate?: 30 | 60;
|
|
107
118
|
/**
|
|
108
119
|
* The video quality to send: 1080p, 720p or 480p. Defaults to the highest
|
|
109
120
|
* the project allows (`videoPolicy.maxQuality`), and is lowered to it if
|
|
@@ -140,6 +151,13 @@ export interface PublishOptions {
|
|
|
140
151
|
degradationPreference?: 'maintain-resolution' | 'maintain-framerate' | 'balanced';
|
|
141
152
|
}
|
|
142
153
|
|
|
154
|
+
export interface ScreenPublishOptions {
|
|
155
|
+
mode?: ScreenMode;
|
|
156
|
+
audio?: boolean;
|
|
157
|
+
/** An existing screen stream; otherwise capture is requested from this user gesture. */
|
|
158
|
+
stream?: MediaStream;
|
|
159
|
+
}
|
|
160
|
+
|
|
143
161
|
export interface SlotClaim {
|
|
144
162
|
key: string;
|
|
145
163
|
identity: string;
|
|
@@ -153,7 +171,9 @@ export interface ParticipantInfo {
|
|
|
153
171
|
identity: string;
|
|
154
172
|
role: Role;
|
|
155
173
|
handRaised: boolean;
|
|
174
|
+
handMetadata?: Record<string, unknown>;
|
|
156
175
|
muted: boolean;
|
|
176
|
+
videoBlocked?: boolean;
|
|
157
177
|
metadata?: unknown;
|
|
158
178
|
}
|
|
159
179
|
|
|
@@ -164,6 +184,8 @@ export interface RemoteProducer {
|
|
|
164
184
|
paused: boolean;
|
|
165
185
|
/** What the track carries. A host playing music publishes two audio tracks: `mic` and `music`. */
|
|
166
186
|
source?: TrackSource;
|
|
187
|
+
videoLayers?: VideoLayer[];
|
|
188
|
+
appData?: { videoLayers?: VideoLayer[] };
|
|
167
189
|
}
|
|
168
190
|
|
|
169
191
|
const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio' ? 'mic' : 'camera');
|
|
@@ -185,8 +207,9 @@ export interface ClientEvents extends ChatEvents {
|
|
|
185
207
|
trackMuted: (info: { identity: string; kind: 'audio' | 'video'; source: TrackSource; producerId: string; paused: boolean }) => void;
|
|
186
208
|
participantJoined: (p: ParticipantInfo) => void;
|
|
187
209
|
participantLeft: (p: { identity: string; reason?: string }) => void;
|
|
210
|
+
participantMuted: (e: { identity: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
|
|
188
211
|
roleChanged: (e: { identity: string; role: Role }) => void;
|
|
189
|
-
handRaised: (e: { identity: string; handRaised: boolean }) => void;
|
|
212
|
+
handRaised: (e: { identity: string; handRaised: boolean; metadata?: Record<string, unknown>; reason?: string }) => void;
|
|
190
213
|
/**
|
|
191
214
|
* A moderator switched your microphone (`audio`), camera (`video`) or both
|
|
192
215
|
* (no kind) off, or released it (`paused: false`). While paused you cannot
|
|
@@ -196,6 +219,8 @@ export interface ClientEvents extends ChatEvents {
|
|
|
196
219
|
mustReconnect: (e: { reason: string; token?: string; retry: boolean }) => void;
|
|
197
220
|
roomClosed: (e: { reason: string }) => void;
|
|
198
221
|
kicked: (e: { by: string; reason?: string }) => void;
|
|
222
|
+
screenShareStarted: (e: { stream: MediaStream; mode: ScreenMode }) => void;
|
|
223
|
+
screenShareEnded: (e: { reason: 'stopped' | 'track-ended' }) => void;
|
|
199
224
|
error: (err: Error) => void;
|
|
200
225
|
localMediaChanged: (state: { micEnabled: boolean; camEnabled: boolean; musicEnabled: boolean }) => void;
|
|
201
226
|
/** The local stream was replaced, typically after reacquiring devices on a reconnect. */
|
|
@@ -278,7 +303,7 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
|
|
|
278
303
|
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
279
304
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
280
305
|
*/
|
|
281
|
-
const VIDEO_START_BITRATE_KBPS =
|
|
306
|
+
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
282
307
|
|
|
283
308
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
284
309
|
const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published`;
|
|
@@ -302,6 +327,7 @@ export class StreamClient {
|
|
|
302
327
|
private token: string;
|
|
303
328
|
private joinResult: JoinResult | null = null;
|
|
304
329
|
private localStream: MediaStream | null = null;
|
|
330
|
+
private recording: CameraRecording | null = null;
|
|
305
331
|
// A reconnect tears down every transport and producer. Without remembering
|
|
306
332
|
// that this client was publishing -- and what it was publishing with -- a
|
|
307
333
|
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
@@ -312,6 +338,20 @@ export class StreamClient {
|
|
|
312
338
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
313
339
|
private music: MusicSource | null = null;
|
|
314
340
|
private musicProducerId: string | null = null;
|
|
341
|
+
private screenStream: MediaStream | null = null;
|
|
342
|
+
private screenMode: ScreenMode = 'detail';
|
|
343
|
+
private screenAudioEnabled = true;
|
|
344
|
+
private screenGeneration = 0;
|
|
345
|
+
private screenPending = false;
|
|
346
|
+
private screenStarted = false;
|
|
347
|
+
private screenEndedListener: (() => void) | null = null;
|
|
348
|
+
private readonly screenProducerIds = new Set<string>();
|
|
349
|
+
private readonly remoteVideoLayers = new Map<string, VideoLayer[]>();
|
|
350
|
+
private readonly receiveQualities = new Map<string, ReceiveQuality>();
|
|
351
|
+
private cameraFrameRate: 30 | 60 = 30;
|
|
352
|
+
private frameRateFallback = false;
|
|
353
|
+
private frameRateLowSamples = 0;
|
|
354
|
+
private frameRateWarmupSamples = 2;
|
|
315
355
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
316
356
|
private readonly remoteSources = new Map<string, TrackSource>();
|
|
317
357
|
/**
|
|
@@ -339,7 +379,7 @@ export class StreamClient {
|
|
|
339
379
|
private readonly sendStatsBaseline: StatsBaseline = new Map();
|
|
340
380
|
private readonly recvStatsBaseline: StatsBaseline = new Map();
|
|
341
381
|
private readonly limitWatch = new VideoLimitWatch(
|
|
342
|
-
(baseline) => this.
|
|
382
|
+
(baseline) => this.sampleCamera(baseline),
|
|
343
383
|
(event) => this.emit('videoLimited', event),
|
|
344
384
|
);
|
|
345
385
|
|
|
@@ -401,15 +441,29 @@ export class StreamClient {
|
|
|
401
441
|
this.reconnectAttempt = 0;
|
|
402
442
|
this.joinedOnce = true;
|
|
403
443
|
|
|
444
|
+
// A role or mute can change while the socket is away. The fresh roster
|
|
445
|
+
// decides what may return; reconnecting never turns a blocked device on.
|
|
446
|
+
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
447
|
+
await this.unpublish();
|
|
448
|
+
}
|
|
449
|
+
const own = result.participants.find((p) => p.identity === result.identity);
|
|
450
|
+
if (own?.muted) this.applyLocalModeration('audio');
|
|
451
|
+
if (own?.videoBlocked) this.applyLocalModeration('video');
|
|
404
452
|
this.emit('joined', result);
|
|
405
453
|
|
|
406
454
|
// A video that ended while this client was away sends it no
|
|
407
455
|
// producerClosed: forget the layers asked for it.
|
|
408
456
|
const present = new Set(result.producers.map((p) => p.producerId));
|
|
409
457
|
for (const producerId of this.preferredLayers.keys()) {
|
|
410
|
-
if (!present.has(producerId))
|
|
458
|
+
if (!present.has(producerId)) {
|
|
459
|
+
this.preferredLayers.delete(producerId);
|
|
460
|
+
this.receiveQualities.delete(producerId);
|
|
461
|
+
this.remoteVideoLayers.delete(producerId);
|
|
462
|
+
}
|
|
411
463
|
}
|
|
412
464
|
|
|
465
|
+
for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
|
|
466
|
+
|
|
413
467
|
// Subscribe to everything already publishing before we announce readiness.
|
|
414
468
|
for (const p of result.producers) {
|
|
415
469
|
await this.consume(p).catch((err) => this.emit('error', err as Error));
|
|
@@ -549,13 +603,20 @@ export class StreamClient {
|
|
|
549
603
|
}
|
|
550
604
|
|
|
551
605
|
close(): void {
|
|
606
|
+
void this.stopRecording().catch(() => undefined);
|
|
552
607
|
// Only an app closing a live session is 'left'; kicked, room-closed and the
|
|
553
608
|
// others have already reported themselves and set closedByUs.
|
|
554
609
|
const wasOpen = !this.closedByUs;
|
|
555
610
|
this.closedByUs = true;
|
|
611
|
+
this.publishGeneration += 1;
|
|
556
612
|
this.wasPublishing = false;
|
|
557
613
|
this.teardownMedia();
|
|
558
614
|
this.preferredLayers.clear();
|
|
615
|
+
this.receiveQualities.clear();
|
|
616
|
+
this.remoteVideoLayers.clear();
|
|
617
|
+
this.releaseScreen();
|
|
618
|
+
this.music?.close();
|
|
619
|
+
this.music = null;
|
|
559
620
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
560
621
|
this.limitWatch.reset();
|
|
561
622
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
@@ -574,6 +635,7 @@ export class StreamClient {
|
|
|
574
635
|
for (const p of this.producers.values()) p.close();
|
|
575
636
|
this.producers.clear();
|
|
576
637
|
this.musicProducerId = null;
|
|
638
|
+
this.screenProducerIds.clear();
|
|
577
639
|
this.cameraEncodings = null;
|
|
578
640
|
// Paused, not reset: a reconnect's republish starts it again, and a
|
|
579
641
|
// limitation already reported is not reported twice.
|
|
@@ -651,6 +713,8 @@ export class StreamClient {
|
|
|
651
713
|
case 'producerClosed': {
|
|
652
714
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
653
715
|
this.preferredLayers.delete(producerId);
|
|
716
|
+
this.receiveQualities.delete(producerId);
|
|
717
|
+
this.remoteVideoLayers.delete(producerId);
|
|
654
718
|
const consumer = this.consumers.get(producerId);
|
|
655
719
|
if (!consumer) break; // never consumed it, or already cleaned up
|
|
656
720
|
consumer.close();
|
|
@@ -673,16 +737,28 @@ export class StreamClient {
|
|
|
673
737
|
case 'participantJoined':
|
|
674
738
|
this.emit('participantJoined', data as ParticipantInfo);
|
|
675
739
|
break;
|
|
740
|
+
case 'participantMuted': {
|
|
741
|
+
const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
|
|
742
|
+
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
743
|
+
if (this.joinResult) this.joinResult.participants = this.joinResult.participants.map((p) =>
|
|
744
|
+
p.identity !== e.identity ? p : { ...p,
|
|
745
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
746
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
747
|
+
});
|
|
748
|
+
this.emit('participantMuted', e);
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
676
751
|
case 'participantLeft':
|
|
677
752
|
this.emit('participantLeft', data as { identity: string; reason?: string });
|
|
678
753
|
break;
|
|
679
754
|
case 'roleChanged': {
|
|
680
755
|
const e = data as { identity: string; role: Role };
|
|
756
|
+
if (e.identity === this.joinResult?.identity) this.joinResult.role = e.role;
|
|
681
757
|
this.emit('roleChanged', e);
|
|
682
758
|
// Demoted: the server has already closed our producers. Release the
|
|
683
759
|
// camera and microphone too, instead of capturing for nobody.
|
|
684
760
|
const canPublish = e.role === 'host' || e.role === 'cohost';
|
|
685
|
-
if (e.identity === this.joinResult?.identity && !canPublish && this.
|
|
761
|
+
if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
|
|
686
762
|
void this.unpublish().catch(() => undefined);
|
|
687
763
|
}
|
|
688
764
|
break;
|
|
@@ -697,9 +773,7 @@ export class StreamClient {
|
|
|
697
773
|
// changes nothing here -- switching back on is the user's choice.
|
|
698
774
|
const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
|
|
699
775
|
if (e.paused) {
|
|
700
|
-
|
|
701
|
-
if (!e.kind || p.kind === e.kind) p.pause();
|
|
702
|
-
}
|
|
776
|
+
this.applyLocalModeration(e.kind);
|
|
703
777
|
this.emitLocalMedia();
|
|
704
778
|
}
|
|
705
779
|
this.emit('forceMuted', e);
|
|
@@ -787,7 +861,7 @@ export class StreamClient {
|
|
|
787
861
|
.catch(fail);
|
|
788
862
|
});
|
|
789
863
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
790
|
-
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
|
|
864
|
+
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
|
|
791
865
|
.then((res) => ok({ id: (res as { producerId: string }).producerId }))
|
|
792
866
|
.catch(fail);
|
|
793
867
|
});
|
|
@@ -844,7 +918,11 @@ export class StreamClient {
|
|
|
844
918
|
this.localStream = stream;
|
|
845
919
|
this.wasPublishing = true;
|
|
846
920
|
if (constraints) this.publishConstraints = constraints;
|
|
847
|
-
if (options)
|
|
921
|
+
if (options) {
|
|
922
|
+
this.publishOptions = { ...options };
|
|
923
|
+
this.frameRateFallback = false;
|
|
924
|
+
this.frameRateLowSamples = 0;
|
|
925
|
+
}
|
|
848
926
|
|
|
849
927
|
const audio = stream.getAudioTracks()[0];
|
|
850
928
|
if (audio) {
|
|
@@ -869,7 +947,7 @@ export class StreamClient {
|
|
|
869
947
|
}
|
|
870
948
|
}
|
|
871
949
|
|
|
872
|
-
/** What this project may publish. Known once joined;
|
|
950
|
+
/** What this project may publish. Known once joined; 1080p before that. */
|
|
873
951
|
get videoPolicy(): VideoPolicy {
|
|
874
952
|
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
875
953
|
}
|
|
@@ -896,11 +974,27 @@ export class StreamClient {
|
|
|
896
974
|
return this.videoQuality;
|
|
897
975
|
}
|
|
898
976
|
|
|
977
|
+
/** Change the camera frame rate without stopping screen, microphone or music. */
|
|
978
|
+
async setVideoFrameRate(frameRate: 30 | 60): Promise<30 | 60> {
|
|
979
|
+
if (frameRate !== 30 && frameRate !== 60) throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
980
|
+
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
981
|
+
this.frameRateFallback = false;
|
|
982
|
+
this.frameRateLowSamples = 0;
|
|
983
|
+
await this.cameraTask(async () => {
|
|
984
|
+
const current = this.cameraProducer();
|
|
985
|
+
const track = current?.track;
|
|
986
|
+
if (!current || !track || track.readyState !== 'live') return;
|
|
987
|
+
try { await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } }); }
|
|
988
|
+
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
989
|
+
await this.reproduceCamera(current, track);
|
|
990
|
+
});
|
|
991
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
|
|
992
|
+
}
|
|
993
|
+
|
|
899
994
|
/**
|
|
900
995
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
901
996
|
* 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
|
|
997
|
+
* same dimensions and layers as the current one, viewers see no pause, and the layers
|
|
904
998
|
* take the new picture's bitrates. Otherwise the camera producer is
|
|
905
999
|
* replaced, as `setVideoQuality` does. Either way a paused camera stays
|
|
906
1000
|
* paused.
|
|
@@ -937,8 +1031,10 @@ export class StreamClient {
|
|
|
937
1031
|
const paused = current.paused;
|
|
938
1032
|
try {
|
|
939
1033
|
const { width = 0, height = 0 } = track.getSettings();
|
|
940
|
-
const next = videoEncodings(width, height, this.videoQuality, this.layerCount());
|
|
941
|
-
|
|
1034
|
+
const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
|
|
1035
|
+
const previousSize = current.track?.getSettings();
|
|
1036
|
+
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
1037
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
942
1038
|
await current.replaceTrack({ track });
|
|
943
1039
|
await this.applyLayerBitrates(current, next);
|
|
944
1040
|
return;
|
|
@@ -1036,6 +1132,7 @@ export class StreamClient {
|
|
|
1036
1132
|
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
1037
1133
|
}
|
|
1038
1134
|
const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
1135
|
+
this.receiveQualities.delete(producerId);
|
|
1039
1136
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
1040
1137
|
this.preferredLayers.set(producerId, layers);
|
|
1041
1138
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
@@ -1056,6 +1153,22 @@ export class StreamClient {
|
|
|
1056
1153
|
return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
1057
1154
|
}
|
|
1058
1155
|
|
|
1156
|
+
/** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
|
|
1157
|
+
async setReceiveQuality(producerId: string, quality: ReceiveQuality): Promise<void> {
|
|
1158
|
+
if (!['auto', '1080p', '720p', '480p'].includes(quality)) throw new RangeError('setReceiveQuality: unsupported quality');
|
|
1159
|
+
const consumer = this.consumers.get(producerId);
|
|
1160
|
+
if (!consumer || consumer.kind !== 'video') throw new Error('setReceiveQuality: this client is not receiving that video');
|
|
1161
|
+
let layers: PreferredLayers;
|
|
1162
|
+
if (quality === 'auto') layers = { spatialLayer: -1, temporalLayer: -1 };
|
|
1163
|
+
else {
|
|
1164
|
+
try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; }
|
|
1165
|
+
catch { throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto'); }
|
|
1166
|
+
}
|
|
1167
|
+
this.receiveQualities.set(producerId, quality);
|
|
1168
|
+
this.preferredLayers.set(producerId, layers);
|
|
1169
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1059
1172
|
private cameraProducer(): Producer | undefined {
|
|
1060
1173
|
for (const p of this.producers.values()) {
|
|
1061
1174
|
if (p.kind === 'video' && (p.appData as { source?: string }).source === 'camera') return p;
|
|
@@ -1119,7 +1232,8 @@ export class StreamClient {
|
|
|
1119
1232
|
// videoEncodings).
|
|
1120
1233
|
const { width = 0, height = 0 } = track.getSettings();
|
|
1121
1234
|
const quality = this.videoQuality;
|
|
1122
|
-
|
|
1235
|
+
this.cameraFrameRate = this.frameRateFor(track);
|
|
1236
|
+
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1123
1237
|
let codecs: RtpCodecCapability[] | undefined;
|
|
1124
1238
|
try {
|
|
1125
1239
|
codecs = this.device?.sendRtpCapabilities.codecs;
|
|
@@ -1134,7 +1248,7 @@ export class StreamClient {
|
|
|
1134
1248
|
encodings,
|
|
1135
1249
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1136
1250
|
...(codec ? { codec } : {}),
|
|
1137
|
-
appData: { source: 'camera' },
|
|
1251
|
+
appData: { source: 'camera', videoLayers: describeVideoLayers(width, height, encodings, quality) },
|
|
1138
1252
|
});
|
|
1139
1253
|
let producer: Producer;
|
|
1140
1254
|
try {
|
|
@@ -1150,6 +1264,8 @@ export class StreamClient {
|
|
|
1150
1264
|
// quality change or a track swap under way).
|
|
1151
1265
|
await this.keepProducer(producer, generation, 'camera');
|
|
1152
1266
|
this.cameraEncodings = encodings;
|
|
1267
|
+
this.frameRateLowSamples = 0;
|
|
1268
|
+
this.frameRateWarmupSamples = 2;
|
|
1153
1269
|
await this.pauseOnServer(producer);
|
|
1154
1270
|
await this.applyDegradationPreference(producer);
|
|
1155
1271
|
this.syncLimitWatch();
|
|
@@ -1233,7 +1349,7 @@ export class StreamClient {
|
|
|
1233
1349
|
|
|
1234
1350
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1235
1351
|
private syncLimitWatch(): void {
|
|
1236
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
|
|
1352
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
1237
1353
|
}
|
|
1238
1354
|
|
|
1239
1355
|
/**
|
|
@@ -1249,6 +1365,11 @@ export class StreamClient {
|
|
|
1249
1365
|
const generation = this.publishGeneration;
|
|
1250
1366
|
// Music is not a device and cannot be "gone": publish it again whatever
|
|
1251
1367
|
// happens to the camera and microphone below.
|
|
1368
|
+
const screen = this.screenStream;
|
|
1369
|
+
if (screen) await this.restoreScreen().catch(async (err) => {
|
|
1370
|
+
if (this.screenStream === screen) await this.stopScreen('stopped');
|
|
1371
|
+
this.emit('error', err as Error);
|
|
1372
|
+
});
|
|
1252
1373
|
if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
|
|
1253
1374
|
if (generation !== this.publishGeneration) return;
|
|
1254
1375
|
|
|
@@ -1281,6 +1402,150 @@ export class StreamClient {
|
|
|
1281
1402
|
await this.publish(stream);
|
|
1282
1403
|
}
|
|
1283
1404
|
|
|
1405
|
+
// ------------------------------------------------------------- screen sharing
|
|
1406
|
+
|
|
1407
|
+
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1408
|
+
async publishScreen(options: ScreenPublishOptions = {}): Promise<MediaStream> {
|
|
1409
|
+
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost') throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1410
|
+
if (this.screenStream || this.screenPending) throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1411
|
+
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion') throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1412
|
+
const mode = options.mode ?? 'detail';
|
|
1413
|
+
if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
|
|
1414
|
+
throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
|
|
1415
|
+
}
|
|
1416
|
+
const generation = this.screenGeneration;
|
|
1417
|
+
this.screenPending = true;
|
|
1418
|
+
let stream: MediaStream | undefined;
|
|
1419
|
+
try {
|
|
1420
|
+
stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
|
|
1421
|
+
video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
|
|
1422
|
+
audio: options.audio ?? false,
|
|
1423
|
+
});
|
|
1424
|
+
const video = stream.getVideoTracks()[0];
|
|
1425
|
+
if (!video || video.readyState !== 'live') throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1426
|
+
if (generation !== this.screenGeneration || this.closedByUs) throw new Error(unpublishedWhile('screen'));
|
|
1427
|
+
this.screenStream = stream;
|
|
1428
|
+
this.screenMode = mode;
|
|
1429
|
+
this.screenAudioEnabled = options.audio !== false;
|
|
1430
|
+
video.contentHint = mode;
|
|
1431
|
+
const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err as Error)); };
|
|
1432
|
+
this.screenEndedListener = onEnded;
|
|
1433
|
+
video.addEventListener('ended', onEnded, { once: true });
|
|
1434
|
+
await this.restoreScreen();
|
|
1435
|
+
if (generation !== this.screenGeneration || video.readyState !== 'live') throw new Error(unpublishedWhile('screen'));
|
|
1436
|
+
this.screenStarted = true;
|
|
1437
|
+
this.wasPublishing = true;
|
|
1438
|
+
this.emit('screenShareStarted', { stream, mode });
|
|
1439
|
+
return stream;
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
if (generation === this.screenGeneration) await this.stopScreen('stopped');
|
|
1442
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1443
|
+
throw error;
|
|
1444
|
+
} finally {
|
|
1445
|
+
if (generation === this.screenGeneration) this.screenPending = false;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
|
|
1450
|
+
async unpublishScreen(): Promise<void> {
|
|
1451
|
+
await this.stopScreen('stopped');
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
private releaseScreen(): void {
|
|
1455
|
+
this.screenGeneration += 1;
|
|
1456
|
+
this.screenPending = false;
|
|
1457
|
+
const stream = this.screenStream;
|
|
1458
|
+
if (this.screenEndedListener) stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
|
|
1459
|
+
this.screenEndedListener = null;
|
|
1460
|
+
this.screenStream = null;
|
|
1461
|
+
this.screenStarted = false;
|
|
1462
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
private async stopScreen(reason: 'stopped' | 'track-ended'): Promise<void> {
|
|
1466
|
+
const started = this.screenStarted;
|
|
1467
|
+
const ids = [...this.screenProducerIds];
|
|
1468
|
+
this.screenProducerIds.clear();
|
|
1469
|
+
this.releaseScreen();
|
|
1470
|
+
for (const id of ids) { this.producers.get(id)?.close(); this.producers.delete(id); }
|
|
1471
|
+
if (!this.localStream && !this.music) this.wasPublishing = false;
|
|
1472
|
+
if (started && !this.closedByUs) this.emit('screenShareEnded', { reason });
|
|
1473
|
+
await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
private async restoreScreen(): Promise<void> {
|
|
1477
|
+
const stream = this.screenStream;
|
|
1478
|
+
const video = stream?.getVideoTracks()[0];
|
|
1479
|
+
if (!stream || !video) return;
|
|
1480
|
+
if (video.readyState !== 'live') { await this.stopScreen('track-ended'); return; }
|
|
1481
|
+
const generation = this.screenGeneration;
|
|
1482
|
+
const publishing = this.publishGeneration;
|
|
1483
|
+
const transport = await this.ensureSendTransport();
|
|
1484
|
+
if (generation !== this.screenGeneration) return;
|
|
1485
|
+
const { width = 0, height = 0 } = video.getSettings();
|
|
1486
|
+
const encodings = screenEncodings(width, height, this.screenMode);
|
|
1487
|
+
// A project's explicit safety restriction also applies to screen video.
|
|
1488
|
+
const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
|
|
1489
|
+
const screenScale = encodings.at(-1)!.scaleResolutionDownBy;
|
|
1490
|
+
if (cap.scale > screenScale) encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
1491
|
+
const keep = async (producer: Producer) => {
|
|
1492
|
+
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1493
|
+
producer.close();
|
|
1494
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1495
|
+
throw new Error(unpublishedWhile('screen'));
|
|
1496
|
+
}
|
|
1497
|
+
this.producers.set(producer.id, producer);
|
|
1498
|
+
this.screenProducerIds.add(producer.id);
|
|
1499
|
+
await this.pauseOnServer(producer);
|
|
1500
|
+
};
|
|
1501
|
+
await keep(await transport.produce({
|
|
1502
|
+
track: video, stopTracks: false, encodings,
|
|
1503
|
+
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1504
|
+
appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
|
|
1505
|
+
}));
|
|
1506
|
+
const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1507
|
+
if (audio && this.screenAudioEnabled && generation === this.screenGeneration) await keep(await transport.produce({
|
|
1508
|
+
track: audio, stopTracks: false,
|
|
1509
|
+
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
|
|
1510
|
+
appData: { source: 'screen-audio' },
|
|
1511
|
+
}));
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
private frameRateFor(track: MediaStreamTrack): 30 | 60 {
|
|
1515
|
+
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback) return 30;
|
|
1516
|
+
const captured = track.getSettings().frameRate;
|
|
1517
|
+
if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
|
|
1518
|
+
this.frameRateFallback = true;
|
|
1519
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
|
|
1520
|
+
return 30;
|
|
1521
|
+
}
|
|
1522
|
+
return 60;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
private async sampleCamera(baseline: StatsBaseline): Promise<VideoSendStats | null> {
|
|
1526
|
+
const stats = await this.collectVideoStats(baseline);
|
|
1527
|
+
if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats) return stats;
|
|
1528
|
+
if (this.cameraProducer()?.paused) return stats;
|
|
1529
|
+
// The lower simulcast copies intentionally run at 30 fps. During the
|
|
1530
|
+
// initial bandwidth ramp they may be the only active copies, which does
|
|
1531
|
+
// not establish that the camera cannot sustain 60. Match native warm-up:
|
|
1532
|
+
// two 2-second samples, then require three consecutive limited samples.
|
|
1533
|
+
if (this.frameRateWarmupSamples-- > 0) return stats;
|
|
1534
|
+
const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
|
|
1535
|
+
this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
|
|
1536
|
+
if (this.frameRateLowSamples >= 3) {
|
|
1537
|
+
this.frameRateFallback = true;
|
|
1538
|
+
void this.cameraTask(async () => {
|
|
1539
|
+
const producer = this.cameraProducer();
|
|
1540
|
+
if (!producer?.track || producer.closed) return;
|
|
1541
|
+
await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1542
|
+
await this.reproduceCamera(producer, producer.track);
|
|
1543
|
+
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1544
|
+
}).catch((error) => this.emit('error', error as Error));
|
|
1545
|
+
}
|
|
1546
|
+
return stats;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1284
1549
|
// ------------------------------------------------------------------ music
|
|
1285
1550
|
|
|
1286
1551
|
/**
|
|
@@ -1392,11 +1657,17 @@ export class StreamClient {
|
|
|
1392
1657
|
|
|
1393
1658
|
const consumer = await transport.consume(params);
|
|
1394
1659
|
this.consumers.set(remote.producerId, consumer);
|
|
1660
|
+
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
|
|
1395
1661
|
|
|
1396
1662
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
1397
1663
|
// its first frame instead of starting at the full picture. Not getting
|
|
1398
1664
|
// them costs data, not the track, so a refusal is only reported.
|
|
1399
|
-
|
|
1665
|
+
let layers = this.preferredLayers.get(remote.producerId);
|
|
1666
|
+
const receiveQuality = this.receiveQualities.get(remote.producerId);
|
|
1667
|
+
if (receiveQuality && receiveQuality !== 'auto') {
|
|
1668
|
+
try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) }; }
|
|
1669
|
+
catch { layers = undefined; this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto')); }
|
|
1670
|
+
}
|
|
1400
1671
|
if (layers) {
|
|
1401
1672
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
1402
1673
|
}
|
|
@@ -1436,7 +1707,7 @@ export class StreamClient {
|
|
|
1436
1707
|
|
|
1437
1708
|
private trackEnabled(kind: 'audio' | 'video'): boolean {
|
|
1438
1709
|
for (const p of this.producers.values()) {
|
|
1439
|
-
if (p.kind === kind && p.
|
|
1710
|
+
if (p.kind === kind && p.appData.source === defaultSource(kind)) return !p.paused;
|
|
1440
1711
|
}
|
|
1441
1712
|
return false;
|
|
1442
1713
|
}
|
|
@@ -1454,24 +1725,40 @@ export class StreamClient {
|
|
|
1454
1725
|
await this.setKindEnabled('video', enabled);
|
|
1455
1726
|
}
|
|
1456
1727
|
|
|
1728
|
+
private applyLocalModeration(kind?: 'audio' | 'video' | ''): void {
|
|
1729
|
+
for (const producer of this.producers.values()) {
|
|
1730
|
+
if (!kind || producer.kind === kind) producer.pause();
|
|
1731
|
+
}
|
|
1732
|
+
// During reconnect there are no producers, but the retained tracks still
|
|
1733
|
+
// capture. Keep their muted state for republish as well.
|
|
1734
|
+
for (const stream of [this.localStream, this.screenStream]) {
|
|
1735
|
+
for (const track of stream?.getTracks() ?? []) if (!kind || track.kind === kind) track.enabled = false;
|
|
1736
|
+
}
|
|
1737
|
+
if (!kind || kind === 'audio') this.music?.setEnabled(false);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1457
1740
|
private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
|
|
1458
1741
|
for (const producer of this.producers.values()) {
|
|
1459
1742
|
// 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();
|
|
1743
|
+
if (producer.kind !== kind || producer.appData.source !== defaultSource(kind)) continue;
|
|
1744
|
+
if (!enabled) producer.pause();
|
|
1463
1745
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1746
|
+
if (enabled) producer.resume();
|
|
1464
1747
|
}
|
|
1465
1748
|
this.emitLocalMedia();
|
|
1466
1749
|
}
|
|
1467
1750
|
|
|
1468
1751
|
/** Stop publishing entirely and release the local devices. */
|
|
1469
1752
|
async unpublish(): Promise<void> {
|
|
1753
|
+
void this.stopRecording().catch(error => this.emit('error', error as Error));
|
|
1470
1754
|
// All of it takes effect now; only telling the server is waited for. So
|
|
1471
1755
|
// publish() and unpublish() act in the order they are called: a publish
|
|
1472
1756
|
// called while this still waits is not undone by it, and one already
|
|
1473
1757
|
// under way stops at its next step (keepProducer).
|
|
1474
1758
|
this.publishGeneration += 1;
|
|
1759
|
+
const screenWasStarted = this.screenStarted;
|
|
1760
|
+
this.releaseScreen();
|
|
1761
|
+
if (screenWasStarted) this.emit('screenShareEnded', { reason: 'stopped' });
|
|
1475
1762
|
const ids = [...this.producers.keys()];
|
|
1476
1763
|
for (const producer of this.producers.values()) producer.close();
|
|
1477
1764
|
this.producers.clear();
|
|
@@ -1496,14 +1783,27 @@ export class StreamClient {
|
|
|
1496
1783
|
}
|
|
1497
1784
|
}
|
|
1498
1785
|
|
|
1786
|
+
/** Paid camera recording; project opt-in, quotas and quality are checked by the backend. */
|
|
1787
|
+
async startRecording(options: RecordingOptions): Promise<void> {
|
|
1788
|
+
if (this.recording) throw new Error('Recording is already active');
|
|
1789
|
+
if (!this.localStream || !this.joinResult || !['host', 'cohost'].includes(this.joinResult.role)) throw new Error('Publish your camera before recording');
|
|
1790
|
+
const recording = new CameraRecording(() => this.camEnabled ? this.localStream : null, () => this.token, options);
|
|
1791
|
+
this.recording = recording;
|
|
1792
|
+
try { await recording.start(); } catch (error) { if (this.recording === recording) this.recording = null; throw error; }
|
|
1793
|
+
}
|
|
1794
|
+
async stopRecording(): Promise<RecordingFile[]> {
|
|
1795
|
+
const recording = this.recording; this.recording = null;
|
|
1796
|
+
return recording ? recording.stop() : [];
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1499
1799
|
get local(): MediaStream | null {
|
|
1500
1800
|
return this.localStream;
|
|
1501
1801
|
}
|
|
1502
1802
|
|
|
1503
1803
|
// ------------------------------------------------------------- moderation
|
|
1504
1804
|
|
|
1505
|
-
raiseHand(): Promise<unknown> { return this.rpc('raiseHand'); }
|
|
1506
|
-
lowerHand(): Promise<unknown> { return this.rpc('lowerHand'); }
|
|
1805
|
+
raiseHand(metadata?: Record<string, unknown>): Promise<unknown> { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
|
|
1806
|
+
lowerHand(identity?: string): Promise<unknown> { return this.rpc('lowerHand', identity ? { identity } : {}); }
|
|
1507
1807
|
muteParticipant(identity: string): Promise<unknown> { return this.rpc('requestMute', { identity }); }
|
|
1508
1808
|
/**
|
|
1509
1809
|
* 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. */
|