@voltkit-rtc/client 0.3.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 +110 -0
- package/README.md +196 -24
- package/dist/cjs/chat.d.ts +2 -0
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +23 -4
- package/dist/cjs/chat.js.map +1 -1
- package/dist/cjs/device.d.ts +85 -0
- package/dist/cjs/device.d.ts.map +1 -0
- package/dist/cjs/device.js +134 -0
- package/dist/cjs/device.js.map +1 -0
- package/dist/cjs/index.d.ts +212 -6
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +822 -52
- 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 +43 -16
- package/dist/cjs/quality.d.ts.map +1 -1
- package/dist/cjs/quality.js +81 -31
- package/dist/cjs/quality.js.map +1 -1
- package/dist/cjs/stats.d.ts +151 -0
- package/dist/cjs/stats.d.ts.map +1 -0
- package/dist/cjs/stats.js +243 -0
- package/dist/cjs/stats.js.map +1 -0
- package/dist/esm/chat.d.ts +2 -0
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +23 -4
- package/dist/esm/chat.js.map +1 -1
- package/dist/esm/device.d.ts +85 -0
- package/dist/esm/device.d.ts.map +1 -0
- package/dist/esm/device.js +122 -0
- package/dist/esm/device.js.map +1 -0
- package/dist/esm/index.d.ts +212 -6
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +823 -53
- 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 +43 -16
- package/dist/esm/quality.d.ts.map +1 -1
- package/dist/esm/quality.js +76 -31
- package/dist/esm/quality.js.map +1 -1
- package/dist/esm/stats.d.ts +151 -0
- package/dist/esm/stats.d.ts.map +1 -0
- package/dist/esm/stats.js +232 -0
- package/dist/esm/stats.js.map +1 -0
- package/package.json +1 -1
- package/src/chat.ts +21 -4
- package/src/device.ts +168 -0
- package/src/index.ts +879 -58
- package/src/music.ts +1 -1
- package/src/quality.ts +96 -32
- package/src/stats.ts +328 -0
package/src/index.ts
CHANGED
|
@@ -13,12 +13,50 @@ import { Device, type types as ms } from 'mediasoup-client';
|
|
|
13
13
|
type Consumer = ms.Consumer;
|
|
14
14
|
type Producer = ms.Producer;
|
|
15
15
|
type RtpCapabilities = ms.RtpCapabilities;
|
|
16
|
+
type RtpCodecCapability = ms.RtpCodecCapability;
|
|
16
17
|
type Transport = ms.Transport;
|
|
17
18
|
type TransportOptions = ms.TransportOptions;
|
|
18
19
|
|
|
19
20
|
import type { ChatEvents, ChatMessage, ChatState, SendMessageOptions } from './chat.js';
|
|
21
|
+
import {
|
|
22
|
+
chooseCodec,
|
|
23
|
+
currentEnv,
|
|
24
|
+
defaultLayerCount,
|
|
25
|
+
findCodec,
|
|
26
|
+
h264HardwareEncode,
|
|
27
|
+
isAppleMobile,
|
|
28
|
+
isPhoneOrTablet,
|
|
29
|
+
normalizeCodec,
|
|
30
|
+
type CodecPreference,
|
|
31
|
+
} from './device.js';
|
|
20
32
|
import { MusicSource, type MusicInput, type MusicOptions, type TrackSource } from './music.js';
|
|
21
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
allowedQuality,
|
|
35
|
+
DEFAULT_VIDEO_POLICY,
|
|
36
|
+
sameLayout,
|
|
37
|
+
describeVideoLayers,
|
|
38
|
+
receiveSpatialLayer,
|
|
39
|
+
screenEncodings,
|
|
40
|
+
type ScreenMode,
|
|
41
|
+
type ReceiveQuality,
|
|
42
|
+
type VideoLayer,
|
|
43
|
+
topLayerSize,
|
|
44
|
+
videoEncodings,
|
|
45
|
+
type LayerCount,
|
|
46
|
+
type VideoEncoding,
|
|
47
|
+
type VideoPolicy,
|
|
48
|
+
type VideoQuality,
|
|
49
|
+
} from './quality.js';
|
|
50
|
+
import {
|
|
51
|
+
parseReceiveStats,
|
|
52
|
+
parseSendStats,
|
|
53
|
+
pruneBaseline,
|
|
54
|
+
VideoLimitWatch,
|
|
55
|
+
type StatsBaseline,
|
|
56
|
+
type VideoLimitedEvent,
|
|
57
|
+
type VideoReceiveStats,
|
|
58
|
+
type VideoSendStats,
|
|
59
|
+
} from './stats.js';
|
|
22
60
|
|
|
23
61
|
export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
|
|
24
62
|
export { AudioMixer, MusicSource } from './music.js';
|
|
@@ -26,7 +64,9 @@ export type { MusicInput, MusicOptions, TrackSource } from './music.js';
|
|
|
26
64
|
export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
|
|
27
65
|
export { ChatClient, ChatRpcError } from './chat.js';
|
|
28
66
|
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
29
|
-
export type { VideoEncoding, VideoPolicy, VideoQuality } from './quality.js';
|
|
67
|
+
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
|
|
68
|
+
export type { CodecPreference } from './device.js';
|
|
69
|
+
export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
|
|
30
70
|
export type {
|
|
31
71
|
ChatClientEvents,
|
|
32
72
|
ChatEvents,
|
|
@@ -64,18 +104,55 @@ export interface JoinResult {
|
|
|
64
104
|
blockedSlots?: string[];
|
|
65
105
|
/**
|
|
66
106
|
* The highest video quality this project may publish, and the server's cap
|
|
67
|
-
* on a publisher's incoming bitrate. Absent from older servers:
|
|
107
|
+
* on a publisher's incoming bitrate. Absent from older servers: 1080p.
|
|
68
108
|
*/
|
|
69
109
|
video?: VideoPolicy;
|
|
70
110
|
}
|
|
71
111
|
|
|
72
112
|
export interface PublishOptions {
|
|
113
|
+
/** 30 by default. Only the top layer uses 60; capture at 60 too. */
|
|
114
|
+
frameRate?: 30 | 60;
|
|
73
115
|
/**
|
|
74
116
|
* The video quality to send: 1080p, 720p or 480p. Defaults to the highest
|
|
75
117
|
* the project allows (`videoPolicy.maxQuality`), and is lowered to it if
|
|
76
118
|
* higher, and to the camera's own size if that is smaller.
|
|
77
119
|
*/
|
|
78
120
|
quality?: VideoQuality;
|
|
121
|
+
/**
|
|
122
|
+
* The most simulcast layers to send: 1, 2 or 3. Defaults to 2 on phones and
|
|
123
|
+
* tablets and 3 elsewhere. Each layer is a separate encode. When a phone is
|
|
124
|
+
* too busy, the browser shrinks the picture and then sends fewer layers,
|
|
125
|
+
* dropping the top one: from three, viewers were left with a quarter of the
|
|
126
|
+
* picture; from two, the full picture survives every step down to 480×270.
|
|
127
|
+
* Never more than the picture allows: 3 from 960×540 up, 2 from 480×270,
|
|
128
|
+
* else 1. Two layers are half and full size. One is full size only, and
|
|
129
|
+
* every viewer then gets the same stream, whatever their connection.
|
|
130
|
+
*/
|
|
131
|
+
layers?: LayerCount;
|
|
132
|
+
/**
|
|
133
|
+
* The video codec. `'auto'` (the default) sends H.264 on phones and tablets
|
|
134
|
+
* that encode it in hardware and VP8 everywhere else. `'h264'` and `'vp8'`
|
|
135
|
+
* force one; a forced H.264 the browser cannot send falls back to VP8 and
|
|
136
|
+
* fires `error`. A viewer whose browser cannot decode the codec cannot
|
|
137
|
+
* watch, so force VP8 if your audience uses such browsers.
|
|
138
|
+
*/
|
|
139
|
+
codec?: CodecPreference;
|
|
140
|
+
/**
|
|
141
|
+
* What the encoder gives up first when the device cannot keep up:
|
|
142
|
+
* `'maintain-resolution'` lowers the frame rate, `'maintain-framerate'` the
|
|
143
|
+
* picture size, `'balanced'` some of each. Unset leaves the browser's
|
|
144
|
+
* default. Chrome also takes it from the track's `contentHint`: `'detail'`
|
|
145
|
+
* keeps resolution, `'motion'` keeps frame rate. Browsers that do not
|
|
146
|
+
* support it ignore it.
|
|
147
|
+
*/
|
|
148
|
+
degradationPreference?: 'maintain-resolution' | 'maintain-framerate' | 'balanced';
|
|
149
|
+
}
|
|
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;
|
|
79
156
|
}
|
|
80
157
|
|
|
81
158
|
export interface SlotClaim {
|
|
@@ -91,7 +168,9 @@ export interface ParticipantInfo {
|
|
|
91
168
|
identity: string;
|
|
92
169
|
role: Role;
|
|
93
170
|
handRaised: boolean;
|
|
171
|
+
handMetadata?: Record<string, unknown>;
|
|
94
172
|
muted: boolean;
|
|
173
|
+
videoBlocked?: boolean;
|
|
95
174
|
metadata?: unknown;
|
|
96
175
|
}
|
|
97
176
|
|
|
@@ -102,6 +181,8 @@ export interface RemoteProducer {
|
|
|
102
181
|
paused: boolean;
|
|
103
182
|
/** What the track carries. A host playing music publishes two audio tracks: `mic` and `music`. */
|
|
104
183
|
source?: TrackSource;
|
|
184
|
+
videoLayers?: VideoLayer[];
|
|
185
|
+
appData?: { videoLayers?: VideoLayer[] };
|
|
105
186
|
}
|
|
106
187
|
|
|
107
188
|
const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio' ? 'mic' : 'camera');
|
|
@@ -123,8 +204,9 @@ export interface ClientEvents extends ChatEvents {
|
|
|
123
204
|
trackMuted: (info: { identity: string; kind: 'audio' | 'video'; source: TrackSource; producerId: string; paused: boolean }) => void;
|
|
124
205
|
participantJoined: (p: ParticipantInfo) => void;
|
|
125
206
|
participantLeft: (p: { identity: string; reason?: string }) => void;
|
|
207
|
+
participantMuted: (e: { identity: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
|
|
126
208
|
roleChanged: (e: { identity: string; role: Role }) => void;
|
|
127
|
-
handRaised: (e: { identity: string; handRaised: boolean }) => void;
|
|
209
|
+
handRaised: (e: { identity: string; handRaised: boolean; metadata?: Record<string, unknown>; reason?: string }) => void;
|
|
128
210
|
/**
|
|
129
211
|
* A moderator switched your microphone (`audio`), camera (`video`) or both
|
|
130
212
|
* (no kind) off, or released it (`paused: false`). While paused you cannot
|
|
@@ -134,6 +216,8 @@ export interface ClientEvents extends ChatEvents {
|
|
|
134
216
|
mustReconnect: (e: { reason: string; token?: string; retry: boolean }) => void;
|
|
135
217
|
roomClosed: (e: { reason: string }) => void;
|
|
136
218
|
kicked: (e: { by: string; reason?: string }) => void;
|
|
219
|
+
screenShareStarted: (e: { stream: MediaStream; mode: ScreenMode }) => void;
|
|
220
|
+
screenShareEnded: (e: { reason: 'stopped' | 'track-ended' }) => void;
|
|
137
221
|
error: (err: Error) => void;
|
|
138
222
|
localMediaChanged: (state: { micEnabled: boolean; camEnabled: boolean; musicEnabled: boolean }) => void;
|
|
139
223
|
/** The local stream was replaced, typically after reacquiring devices on a reconnect. */
|
|
@@ -148,6 +232,15 @@ export interface ClientEvents extends ChatEvents {
|
|
|
148
232
|
* account is topped up first -- tell the user, and stop asking them to wait.
|
|
149
233
|
*/
|
|
150
234
|
usageLimit: (e: UsageLimitNotice) => void;
|
|
235
|
+
/**
|
|
236
|
+
* Your camera video is being held back by this device (`cpu`: too busy to
|
|
237
|
+
* encode it), by the upload (`bandwidth`), or by something else (`other`);
|
|
238
|
+
* or that has cleared (`none`). Fires when the reason changes and has held
|
|
239
|
+
* for about 4 s, so brief spikes stay quiet; never `none` first. Tell the
|
|
240
|
+
* publisher: "your device is busy" or "your connection is slow". Checked
|
|
241
|
+
* every 2 s, only while publishing a camera and someone listens.
|
|
242
|
+
*/
|
|
243
|
+
videoLimited: (e: VideoLimitedEvent) => void;
|
|
151
244
|
}
|
|
152
245
|
|
|
153
246
|
type Handler = (...args: never[]) => void;
|
|
@@ -195,6 +288,23 @@ const CLOSE_ENDED = 4003;
|
|
|
195
288
|
|
|
196
289
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
197
290
|
|
|
291
|
+
/** What setPreferredLayers sends; no temporal layer means the full frame rate. */
|
|
292
|
+
interface PreferredLayers {
|
|
293
|
+
spatialLayer: number;
|
|
294
|
+
temporalLayer?: number;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const isLayer = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
301
|
+
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
302
|
+
*/
|
|
303
|
+
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
304
|
+
|
|
305
|
+
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
306
|
+
const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published`;
|
|
307
|
+
|
|
198
308
|
export class StreamClient {
|
|
199
309
|
private ws: WebSocket | null = null;
|
|
200
310
|
private device: Device | null = null;
|
|
@@ -224,11 +334,50 @@ export class StreamClient {
|
|
|
224
334
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
225
335
|
private music: MusicSource | null = null;
|
|
226
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;
|
|
227
351
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
228
352
|
private readonly remoteSources = new Map<string, TrackSource>();
|
|
353
|
+
/**
|
|
354
|
+
* The layers asked for each remote video, by producerId. A reconnect
|
|
355
|
+
* consumes everything again, and the new consumer would otherwise start at
|
|
356
|
+
* the full picture.
|
|
357
|
+
*/
|
|
358
|
+
private readonly preferredLayers = new Map<string, PreferredLayers>();
|
|
229
359
|
private closedByUs = false;
|
|
230
360
|
private reconnecting = false;
|
|
231
361
|
private reconnectAttempt = 0;
|
|
362
|
+
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
363
|
+
private joinedOnce = false;
|
|
364
|
+
/** The camera producer's layers, to tell whether a new track fits them. */
|
|
365
|
+
private cameraEncodings: VideoEncoding[] | null = null;
|
|
366
|
+
// Changes to the camera producer run one at a time. Two overlapping swaps
|
|
367
|
+
// (an effect toggled on and off quickly) would otherwise both close the
|
|
368
|
+
// same producer and both produce a new one.
|
|
369
|
+
private cameraQueue: Promise<unknown> = Promise.resolve();
|
|
370
|
+
// Bumped by unpublish(). A producer still being made when it runs (camera,
|
|
371
|
+
// microphone or music) belongs to the publish it ended: it is closed, not
|
|
372
|
+
// kept.
|
|
373
|
+
private publishGeneration = 0;
|
|
374
|
+
private codecFallbackLogged = false;
|
|
375
|
+
private readonly sendStatsBaseline: StatsBaseline = new Map();
|
|
376
|
+
private readonly recvStatsBaseline: StatsBaseline = new Map();
|
|
377
|
+
private readonly limitWatch = new VideoLimitWatch(
|
|
378
|
+
(baseline) => this.sampleCamera(baseline),
|
|
379
|
+
(event) => this.emit('videoLimited', event),
|
|
380
|
+
);
|
|
232
381
|
|
|
233
382
|
constructor(private readonly wsUrl: string, token: string) {
|
|
234
383
|
this.token = token;
|
|
@@ -241,7 +390,16 @@ export class StreamClient {
|
|
|
241
390
|
this.listeners.set(event, set);
|
|
242
391
|
}
|
|
243
392
|
set.add(fn as Handler);
|
|
244
|
-
|
|
393
|
+
// videoLimited costs a getStats every 2 s, so it runs only while heard.
|
|
394
|
+
if (event === 'videoLimited') this.syncLimitWatch();
|
|
395
|
+
return () => {
|
|
396
|
+
set!.delete(fn as Handler);
|
|
397
|
+
if (event === 'videoLimited') this.syncLimitWatch();
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private listenerCount(event: keyof ClientEvents): number {
|
|
402
|
+
return this.listeners.get(event)?.size ?? 0;
|
|
245
403
|
}
|
|
246
404
|
|
|
247
405
|
private emit<K extends keyof ClientEvents>(event: K, ...args: Parameters<ClientEvents[K]>): void {
|
|
@@ -257,15 +415,51 @@ export class StreamClient {
|
|
|
257
415
|
// ------------------------------------------------------------- connection
|
|
258
416
|
|
|
259
417
|
async connect(): Promise<JoinResult> {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
418
|
+
let result: JoinResult;
|
|
419
|
+
try {
|
|
420
|
+
await this.openSocket();
|
|
421
|
+
result = (await this.rpc('join', { token: this.token })) as JoinResult;
|
|
422
|
+
this.joinResult = result;
|
|
266
423
|
|
|
424
|
+
this.device = this.createDevice();
|
|
425
|
+
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
426
|
+
} catch (err) {
|
|
427
|
+
// A session that never got going: the app has the error. Left open,
|
|
428
|
+
// the socket would be closed by the server's join deadline instead, and
|
|
429
|
+
// that close would start reconnecting a session that never began.
|
|
430
|
+
if (!this.joinedOnce) this.detachSocket();
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
434
|
+
// Not when the socket opens -- a join refused with a code worth retrying
|
|
435
|
+
// (no-capacity) never used them up, and every client of a full cluster
|
|
436
|
+
// retried about twice a second, forever.
|
|
437
|
+
this.reconnectAttempt = 0;
|
|
438
|
+
this.joinedOnce = true;
|
|
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');
|
|
267
448
|
this.emit('joined', result);
|
|
268
449
|
|
|
450
|
+
// A video that ended while this client was away sends it no
|
|
451
|
+
// producerClosed: forget the layers asked for it.
|
|
452
|
+
const present = new Set(result.producers.map((p) => p.producerId));
|
|
453
|
+
for (const producerId of this.preferredLayers.keys()) {
|
|
454
|
+
if (!present.has(producerId)) {
|
|
455
|
+
this.preferredLayers.delete(producerId);
|
|
456
|
+
this.receiveQualities.delete(producerId);
|
|
457
|
+
this.remoteVideoLayers.delete(producerId);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
|
|
462
|
+
|
|
269
463
|
// Subscribe to everything already publishing before we announce readiness.
|
|
270
464
|
for (const p of result.producers) {
|
|
271
465
|
await this.consume(p).catch((err) => this.emit('error', err as Error));
|
|
@@ -280,6 +474,11 @@ export class StreamClient {
|
|
|
280
474
|
return result;
|
|
281
475
|
}
|
|
282
476
|
|
|
477
|
+
/** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
|
|
478
|
+
private createDevice(): Device {
|
|
479
|
+
return new Device();
|
|
480
|
+
}
|
|
481
|
+
|
|
283
482
|
private openSocket(): Promise<void> {
|
|
284
483
|
// Abandon any previous socket first. Its close handler checks identity
|
|
285
484
|
// against `this.ws`, so once it is detached it can no longer trigger a
|
|
@@ -293,7 +492,6 @@ export class StreamClient {
|
|
|
293
492
|
this.ws = ws;
|
|
294
493
|
|
|
295
494
|
const onOpen = () => {
|
|
296
|
-
this.reconnectAttempt = 0;
|
|
297
495
|
this.emit('connected');
|
|
298
496
|
resolve();
|
|
299
497
|
};
|
|
@@ -318,6 +516,9 @@ export class StreamClient {
|
|
|
318
516
|
// displace it, and it would displace us right back, forever.
|
|
319
517
|
this.emit('error', new Error('this identity connected from somewhere else'));
|
|
320
518
|
this.closedByUs = true;
|
|
519
|
+
// The producers outlive this close until the app calls close(); the
|
|
520
|
+
// videoLimited sampling must not, or it reports after `closed`.
|
|
521
|
+
this.limitWatch.reset();
|
|
321
522
|
this.emit('closed', 'replaced');
|
|
322
523
|
return;
|
|
323
524
|
}
|
|
@@ -325,9 +526,13 @@ export class StreamClient {
|
|
|
325
526
|
// Normally roomClosed has already ended the session; this covers a
|
|
326
527
|
// close that arrived without it.
|
|
327
528
|
this.closedByUs = true;
|
|
529
|
+
this.limitWatch.reset();
|
|
328
530
|
this.emit('closed', 'room-closed');
|
|
329
531
|
return;
|
|
330
532
|
}
|
|
533
|
+
// Before a first successful join the app is still waiting on
|
|
534
|
+
// connect(), which reports the failure; there is nothing to restore.
|
|
535
|
+
if (!this.joinedOnce) return;
|
|
331
536
|
void this.scheduleReconnect();
|
|
332
537
|
});
|
|
333
538
|
});
|
|
@@ -370,8 +575,12 @@ export class StreamClient {
|
|
|
370
575
|
if (this.closedByUs) return;
|
|
371
576
|
try {
|
|
372
577
|
this.teardownMedia();
|
|
373
|
-
await this.connect();
|
|
374
|
-
|
|
578
|
+
await this.connect(); // which resets the attempt counter once joined
|
|
579
|
+
// The socket can drop again while the session is being restored,
|
|
580
|
+
// and its close handler then finds this loop still running and
|
|
581
|
+
// leaves it to us. Returning would leave the client disconnected
|
|
582
|
+
// for good, with no `closed`.
|
|
583
|
+
if (this.ws) return;
|
|
375
584
|
} catch (err) {
|
|
376
585
|
this.emit('error', err as Error);
|
|
377
586
|
// Stop on failures a retry cannot resolve. Hammering a room that has
|
|
@@ -394,8 +603,17 @@ export class StreamClient {
|
|
|
394
603
|
// others have already reported themselves and set closedByUs.
|
|
395
604
|
const wasOpen = !this.closedByUs;
|
|
396
605
|
this.closedByUs = true;
|
|
606
|
+
this.publishGeneration += 1;
|
|
397
607
|
this.wasPublishing = false;
|
|
398
608
|
this.teardownMedia();
|
|
609
|
+
this.preferredLayers.clear();
|
|
610
|
+
this.receiveQualities.clear();
|
|
611
|
+
this.remoteVideoLayers.clear();
|
|
612
|
+
this.releaseScreen();
|
|
613
|
+
this.music?.close();
|
|
614
|
+
this.music = null;
|
|
615
|
+
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
616
|
+
this.limitWatch.reset();
|
|
399
617
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
400
618
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
401
619
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -412,6 +630,11 @@ export class StreamClient {
|
|
|
412
630
|
for (const p of this.producers.values()) p.close();
|
|
413
631
|
this.producers.clear();
|
|
414
632
|
this.musicProducerId = null;
|
|
633
|
+
this.screenProducerIds.clear();
|
|
634
|
+
this.cameraEncodings = null;
|
|
635
|
+
// Paused, not reset: a reconnect's republish starts it again, and a
|
|
636
|
+
// limitation already reported is not reported twice.
|
|
637
|
+
this.syncLimitWatch();
|
|
415
638
|
this.sendTransport?.close();
|
|
416
639
|
this.recvTransport?.close();
|
|
417
640
|
this.sendTransport = null;
|
|
@@ -484,6 +707,9 @@ export class StreamClient {
|
|
|
484
707
|
break;
|
|
485
708
|
case 'producerClosed': {
|
|
486
709
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
710
|
+
this.preferredLayers.delete(producerId);
|
|
711
|
+
this.receiveQualities.delete(producerId);
|
|
712
|
+
this.remoteVideoLayers.delete(producerId);
|
|
487
713
|
const consumer = this.consumers.get(producerId);
|
|
488
714
|
if (!consumer) break; // never consumed it, or already cleaned up
|
|
489
715
|
consumer.close();
|
|
@@ -506,16 +732,28 @@ export class StreamClient {
|
|
|
506
732
|
case 'participantJoined':
|
|
507
733
|
this.emit('participantJoined', data as ParticipantInfo);
|
|
508
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
|
+
}
|
|
509
746
|
case 'participantLeft':
|
|
510
747
|
this.emit('participantLeft', data as { identity: string; reason?: string });
|
|
511
748
|
break;
|
|
512
749
|
case 'roleChanged': {
|
|
513
750
|
const e = data as { identity: string; role: Role };
|
|
751
|
+
if (e.identity === this.joinResult?.identity) this.joinResult.role = e.role;
|
|
514
752
|
this.emit('roleChanged', e);
|
|
515
753
|
// Demoted: the server has already closed our producers. Release the
|
|
516
754
|
// camera and microphone too, instead of capturing for nobody.
|
|
517
755
|
const canPublish = e.role === 'host' || e.role === 'cohost';
|
|
518
|
-
if (e.identity === this.joinResult?.identity && !canPublish && this.
|
|
756
|
+
if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
|
|
519
757
|
void this.unpublish().catch(() => undefined);
|
|
520
758
|
}
|
|
521
759
|
break;
|
|
@@ -530,9 +768,7 @@ export class StreamClient {
|
|
|
530
768
|
// changes nothing here -- switching back on is the user's choice.
|
|
531
769
|
const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
|
|
532
770
|
if (e.paused) {
|
|
533
|
-
|
|
534
|
-
if (!e.kind || p.kind === e.kind) p.pause();
|
|
535
|
-
}
|
|
771
|
+
this.applyLocalModeration(e.kind);
|
|
536
772
|
this.emitLocalMedia();
|
|
537
773
|
}
|
|
538
774
|
this.emit('forceMuted', e);
|
|
@@ -620,7 +856,7 @@ export class StreamClient {
|
|
|
620
856
|
.catch(fail);
|
|
621
857
|
});
|
|
622
858
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
623
|
-
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 })
|
|
624
860
|
.then((res) => ok({ id: (res as { producerId: string }).producerId }))
|
|
625
861
|
.catch(fail);
|
|
626
862
|
});
|
|
@@ -664,13 +900,24 @@ export class StreamClient {
|
|
|
664
900
|
*
|
|
665
901
|
* `options.quality` picks 1080p, 720p or 480p (see `videoPolicy` for what
|
|
666
902
|
* the project allows). Capture at least that size for the full picture.
|
|
903
|
+
* `options.layers`, `codec` and `degradationPreference` tune how the video
|
|
904
|
+
* is encoded; the defaults suit both phones and computers. The options are
|
|
905
|
+
* kept for a reconnect's republish.
|
|
667
906
|
*/
|
|
668
907
|
async publish(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
|
|
908
|
+
const generation = this.publishGeneration;
|
|
669
909
|
const transport = await this.ensureSendTransport();
|
|
910
|
+
// unpublish() ran first: this publish is over before it began, and must
|
|
911
|
+
// leave nothing behind that a reconnect would publish again.
|
|
912
|
+
if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('stream'));
|
|
670
913
|
this.localStream = stream;
|
|
671
914
|
this.wasPublishing = true;
|
|
672
915
|
if (constraints) this.publishConstraints = constraints;
|
|
673
|
-
if (options)
|
|
916
|
+
if (options) {
|
|
917
|
+
this.publishOptions = { ...options };
|
|
918
|
+
this.frameRateFallback = false;
|
|
919
|
+
this.frameRateLowSamples = 0;
|
|
920
|
+
}
|
|
674
921
|
|
|
675
922
|
const audio = stream.getAudioTracks()[0];
|
|
676
923
|
if (audio) {
|
|
@@ -682,14 +929,20 @@ export class StreamClient {
|
|
|
682
929
|
codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
|
|
683
930
|
appData: { source: 'mic' },
|
|
684
931
|
});
|
|
685
|
-
this.
|
|
932
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
933
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
934
|
+
await this.pauseOnServer(producer);
|
|
686
935
|
}
|
|
687
936
|
|
|
688
937
|
const video = stream.getVideoTracks()[0];
|
|
689
|
-
if (video)
|
|
938
|
+
if (video) {
|
|
939
|
+
// The track is read when the camera's turn comes: one handed to
|
|
940
|
+
// replaceVideoTrack while this publish was under way is the one to send.
|
|
941
|
+
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
942
|
+
}
|
|
690
943
|
}
|
|
691
944
|
|
|
692
|
-
/** What this project may publish. Known once joined;
|
|
945
|
+
/** What this project may publish. Known once joined; 1080p before that. */
|
|
693
946
|
get videoPolicy(): VideoPolicy {
|
|
694
947
|
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
695
948
|
}
|
|
@@ -707,39 +960,393 @@ export class StreamClient {
|
|
|
707
960
|
*/
|
|
708
961
|
async setVideoQuality(quality: VideoQuality): Promise<VideoQuality> {
|
|
709
962
|
this.publishOptions = { ...this.publishOptions, quality };
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
963
|
+
await this.cameraTask(async () => {
|
|
964
|
+
const current = this.cameraProducer();
|
|
965
|
+
const track = current?.track;
|
|
966
|
+
if (!current || !track || track.readyState !== 'live') return;
|
|
967
|
+
await this.reproduceCamera(current, track);
|
|
968
|
+
});
|
|
969
|
+
return this.videoQuality;
|
|
970
|
+
}
|
|
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
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Send a different video track from now on: a canvas with effects drawn on
|
|
991
|
+
* it, the raw camera again, another camera. When the new track needs the
|
|
992
|
+
* same dimensions and layers as the current one, viewers see no pause, and the layers
|
|
993
|
+
* take the new picture's bitrates. Otherwise the camera producer is
|
|
994
|
+
* replaced, as `setVideoQuality` does. Either way a paused camera stays
|
|
995
|
+
* paused.
|
|
996
|
+
*
|
|
997
|
+
* Your own stream is left as it is: the client keeps a stream of its own
|
|
998
|
+
* (`local`, announced by `localStreamChanged`) with the new track and your
|
|
999
|
+
* microphone, and a reconnect republishes that. The track you replaced is
|
|
1000
|
+
* not stopped; it is yours again, switched back on if pausing the camera
|
|
1001
|
+
* had switched it off. Rejects when no camera is being published.
|
|
1002
|
+
*/
|
|
1003
|
+
async replaceVideoTrack(track: MediaStreamTrack): Promise<void> {
|
|
1004
|
+
if (!track || track.kind !== 'video') throw new TypeError('replaceVideoTrack needs a video track');
|
|
1005
|
+
if (track.readyState === 'ended') throw new Error('replaceVideoTrack: that track has ended');
|
|
1006
|
+
await this.cameraTask(async () => {
|
|
1007
|
+
const current = this.cameraProducer();
|
|
1008
|
+
// Between a dropped connection and the republish there is no producer,
|
|
1009
|
+
// but the camera is still being published: the republish sends this.
|
|
1010
|
+
const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
|
1011
|
+
if (!current && !reconnecting) throw new Error('replaceVideoTrack: no camera is being published');
|
|
1012
|
+
if (!current) {
|
|
1013
|
+
// The republish reads the camera's state off the track it sends, and
|
|
1014
|
+
// the track being replaced is switched off exactly when the camera is
|
|
1015
|
+
// paused. Hand that state on, or a camera that was off comes back on;
|
|
1016
|
+
// and the old track back switched on, as below.
|
|
1017
|
+
const replaced = this.localStream?.getVideoTracks()[0];
|
|
1018
|
+
if (replaced && replaced !== track) {
|
|
1019
|
+
track.enabled = replaced.enabled;
|
|
1020
|
+
replaced.enabled = true;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
this.adoptVideoTrack(track);
|
|
1024
|
+
if (!current || current.track === track) return;
|
|
1025
|
+
const previous = current.track;
|
|
1026
|
+
const paused = current.paused;
|
|
1027
|
+
try {
|
|
1028
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
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) {
|
|
1033
|
+
await current.replaceTrack({ track });
|
|
1034
|
+
await this.applyLayerBitrates(current, next);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
await this.reproduceCamera(current, track);
|
|
1038
|
+
} finally {
|
|
1039
|
+
// Pausing switched the old track off (mediasoup-client pauses that
|
|
1040
|
+
// way), and nothing switches it back on once it is replaced. It is the
|
|
1041
|
+
// app's again, often the camera its canvas draws from: handed back
|
|
1042
|
+
// off, that canvas stays black after the camera is resumed.
|
|
1043
|
+
if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Give a camera producer whose track was swapped in place the bitrates of
|
|
1050
|
+
* its new picture. The layout matched, but a bigger picture at the same
|
|
1051
|
+
* scales (a 1280×720 canvas for a 960×540 camera) would otherwise stay
|
|
1052
|
+
* capped at the smaller one's. Best effort: a browser that refuses keeps
|
|
1053
|
+
* the old caps.
|
|
1054
|
+
*/
|
|
1055
|
+
private async applyLayerBitrates(producer: Producer, encodings: VideoEncoding[]): Promise<void> {
|
|
1056
|
+
const before = this.cameraEncodings;
|
|
1057
|
+
if (before && before.every((encoding, i) => encoding.maxBitrate === encodings[i]?.maxBitrate)) {
|
|
1058
|
+
this.cameraEncodings = encodings;
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const sender = producer.rtpSender;
|
|
1062
|
+
if (!sender) return;
|
|
1063
|
+
try {
|
|
1064
|
+
const parameters = sender.getParameters();
|
|
1065
|
+
// One entry per layer, smallest first, as they were produced. A browser
|
|
1066
|
+
// that reports them some other way is left with its own.
|
|
1067
|
+
if (parameters.encodings.length !== encodings.length) return;
|
|
1068
|
+
parameters.encodings.forEach((encoding, i) => {
|
|
1069
|
+
encoding.maxBitrate = encodings[i].maxBitrate;
|
|
1070
|
+
});
|
|
1071
|
+
await sender.setParameters(parameters);
|
|
1072
|
+
this.cameraEncodings = encodings;
|
|
1073
|
+
} catch {
|
|
1074
|
+
// Unsupported here; the layers keep the caps they were produced with.
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* What your camera video is sending now, layer by layer, or null when no
|
|
1080
|
+
* camera is being published. The top-level fields describe the biggest
|
|
1081
|
+
* layer going out. `qualityLimitation` says why it is less than asked:
|
|
1082
|
+
* `cpu` is this device, `bandwidth` the upload, `none` neither (then check
|
|
1083
|
+
* the camera and the light). Bitrates are averaged since the previous call,
|
|
1084
|
+
* so call it every second or two; the first call reports 0.
|
|
1085
|
+
*/
|
|
1086
|
+
async getVideoStats(): Promise<VideoSendStats | null> {
|
|
1087
|
+
return this.collectVideoStats(this.sendStatsBaseline);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* What this client receives of a remote video (by its `producerId`, from
|
|
1092
|
+
* the `track` event), or null when it is not receiving that video. Bitrates
|
|
1093
|
+
* are averaged since the previous call for it; the first call reports 0.
|
|
1094
|
+
*/
|
|
1095
|
+
async getReceiveStats(producerId: string): Promise<VideoReceiveStats | null> {
|
|
1096
|
+
const consumer = this.consumers.get(producerId);
|
|
1097
|
+
if (!consumer || consumer.kind !== 'video') return null;
|
|
1098
|
+
let report: RTCStatsReport;
|
|
1099
|
+
try {
|
|
1100
|
+
report = await consumer.getStats();
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
if (consumer.closed) return null;
|
|
1103
|
+
throw err;
|
|
1104
|
+
}
|
|
1105
|
+
const live = new Set([...this.consumers.values()].map((c) => c.id));
|
|
1106
|
+
pruneBaseline(this.recvStatsBaseline, (key) => live.has(key));
|
|
1107
|
+
return parseReceiveStats(report.values(), { producerId, baseline: this.recvStatsBaseline, key: consumer.id });
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Receive a smaller copy of someone's video (by its `producerId`, from the
|
|
1112
|
+
* `track` event), for a tile that does not need the full picture. Spatial
|
|
1113
|
+
* layer 0 is the smallest copy the publisher sends: a computer sends three
|
|
1114
|
+
* (at 720p 320×180, 640×360 and 1280×720), a phone two (640×360 and
|
|
1115
|
+
* 1280×720), and a layer above the biggest means the biggest. Leave
|
|
1116
|
+
* `temporalLayer` out to keep the full frame rate. It is the most this
|
|
1117
|
+
* client is sent: a connection that cannot carry it still gets less. The
|
|
1118
|
+
* choice is kept across reconnects and forgotten when the track ends.
|
|
1119
|
+
* Rejects for a producer whose video this client is not receiving.
|
|
1120
|
+
*/
|
|
1121
|
+
async setPreferredLayers(producerId: string, spatialLayer: number, temporalLayer?: number): Promise<void> {
|
|
1122
|
+
if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
|
|
1123
|
+
throw new RangeError('setPreferredLayers: layers are whole numbers from 0');
|
|
1124
|
+
}
|
|
1125
|
+
const consumer = this.consumers.get(producerId);
|
|
1126
|
+
if (!consumer || consumer.kind !== 'video') {
|
|
1127
|
+
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
1128
|
+
}
|
|
1129
|
+
const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
1130
|
+
this.receiveQualities.delete(producerId);
|
|
1131
|
+
// Kept first: a reconnect that interrupts the request still applies it.
|
|
1132
|
+
this.preferredLayers.set(producerId, layers);
|
|
1133
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private async collectVideoStats(baseline: StatsBaseline): Promise<VideoSendStats | null> {
|
|
1137
|
+
const producer = this.cameraProducer();
|
|
1138
|
+
if (!producer) return null;
|
|
1139
|
+
let report: RTCStatsReport;
|
|
1140
|
+
try {
|
|
1141
|
+
report = await producer.getStats();
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
if (producer.closed) return null;
|
|
1144
|
+
throw err;
|
|
1145
|
+
}
|
|
1146
|
+
const prefix = `${producer.id}:`;
|
|
1147
|
+
pruneBaseline(baseline, (key) => key.startsWith(prefix));
|
|
1148
|
+
return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
1149
|
+
}
|
|
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
|
+
|
|
1167
|
+
private cameraProducer(): Producer | undefined {
|
|
1168
|
+
for (const p of this.producers.values()) {
|
|
1169
|
+
if (p.kind === 'video' && (p.appData as { source?: string }).source === 'camera') return p;
|
|
1170
|
+
}
|
|
1171
|
+
return undefined;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
private cameraTask<T>(task: () => Promise<T>): Promise<T> {
|
|
1175
|
+
const run = this.cameraQueue.then(task, task);
|
|
1176
|
+
this.cameraQueue = run.catch(() => undefined);
|
|
1177
|
+
return run;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Swap the video in the client's own stream, never the app's. */
|
|
1181
|
+
private adoptVideoTrack(track: MediaStreamTrack): void {
|
|
1182
|
+
const previous = this.localStream;
|
|
1183
|
+
if (previous && previous.getVideoTracks()[0] === track) return;
|
|
1184
|
+
const others = previous ? previous.getTracks().filter((t) => t.kind !== 'video') : [];
|
|
1185
|
+
this.localStream = new MediaStream([...others, track]);
|
|
1186
|
+
this.emit('localStreamChanged', this.localStream);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** Close the camera producer and produce `track` in its place, paused on both ends if it was. */
|
|
1190
|
+
private async reproduceCamera(current: Producer, track: MediaStreamTrack): Promise<Producer> {
|
|
1191
|
+
const generation = this.publishGeneration;
|
|
715
1192
|
const paused = current.paused;
|
|
716
1193
|
current.close();
|
|
717
1194
|
this.producers.delete(current.id);
|
|
1195
|
+
this.cameraEncodings = null;
|
|
1196
|
+
this.syncLimitWatch();
|
|
718
1197
|
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1198
|
+
return this.produceCamera(await this.ensureSendTransport(), track, generation, paused);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** `options.layers` when valid, else 2 on phones and tablets and 3 elsewhere. */
|
|
1202
|
+
private layerCount(): LayerCount {
|
|
1203
|
+
const asked = this.publishOptions.layers;
|
|
1204
|
+
return asked === 1 || asked === 2 || asked === 3 ? asked : defaultLayerCount();
|
|
725
1205
|
}
|
|
726
1206
|
|
|
727
|
-
|
|
1207
|
+
/**
|
|
1208
|
+
* Produce the camera from `track`, paused on both ends when `paused`. By
|
|
1209
|
+
* default the track says: one handed over switched off is a camera that is
|
|
1210
|
+
* off, and so is one a pause switched off before a reconnect.
|
|
1211
|
+
*/
|
|
1212
|
+
private async produceCamera(
|
|
1213
|
+
transport: Transport,
|
|
1214
|
+
track: MediaStreamTrack,
|
|
1215
|
+
generation: number,
|
|
1216
|
+
paused = !track.enabled,
|
|
1217
|
+
): Promise<Producer> {
|
|
1218
|
+
if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('camera'));
|
|
1219
|
+
// mediasoup-client starts a producer paused exactly when its track is
|
|
1220
|
+
// off. Setting the track to the camera's state first makes the producer
|
|
1221
|
+
// start in it, and a camera that is off never sends a frame of the new
|
|
1222
|
+
// track; the server is told below.
|
|
1223
|
+
track.enabled = !paused;
|
|
728
1224
|
// Simulcast from day one: it is what lets one node serve far more viewers.
|
|
729
|
-
// The layers follow the picture's real size
|
|
730
|
-
// the browser sends every layer asked for (see
|
|
1225
|
+
// The layers follow the picture's real size, the project's quality and
|
|
1226
|
+
// the device, so the browser sends every layer asked for (see
|
|
1227
|
+
// videoEncodings).
|
|
731
1228
|
const { width = 0, height = 0 } = track.getSettings();
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
1229
|
+
const quality = this.videoQuality;
|
|
1230
|
+
this.cameraFrameRate = this.frameRateFor(track);
|
|
1231
|
+
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1232
|
+
let codecs: RtpCodecCapability[] | undefined;
|
|
1233
|
+
try {
|
|
1234
|
+
codecs = this.device?.sendRtpCapabilities.codecs;
|
|
1235
|
+
} catch {
|
|
1236
|
+
codecs = undefined;
|
|
1237
|
+
}
|
|
1238
|
+
const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
|
|
1239
|
+
const produce = (codec: RtpCodecCapability | undefined) =>
|
|
1240
|
+
transport.produce({
|
|
1241
|
+
track,
|
|
1242
|
+
stopTracks: false,
|
|
1243
|
+
encodings,
|
|
1244
|
+
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1245
|
+
...(codec ? { codec } : {}),
|
|
1246
|
+
appData: { source: 'camera', videoLayers: describeVideoLayers(width, height, encodings, quality) },
|
|
1247
|
+
});
|
|
1248
|
+
let producer: Producer;
|
|
1249
|
+
try {
|
|
1250
|
+
producer = await produce(chosen.capability);
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
// Never fail a publish over the codec: H.264 refused is VP8 instead.
|
|
1253
|
+
// A closed transport (a reconnect under way) fails either way.
|
|
1254
|
+
if (chosen.codec !== 'h264' || transport.closed) throw err;
|
|
1255
|
+
this.reportCodecFallback(`H.264 was refused (${(err as Error).message}): publishing VP8 instead`);
|
|
1256
|
+
producer = await produce(findCodec(codecs, 'vp8'));
|
|
1257
|
+
}
|
|
1258
|
+
// unpublish() may have run while this was being made (a publish, a
|
|
1259
|
+
// quality change or a track swap under way).
|
|
1260
|
+
await this.keepProducer(producer, generation, 'camera');
|
|
1261
|
+
this.cameraEncodings = encodings;
|
|
1262
|
+
this.frameRateLowSamples = 0;
|
|
1263
|
+
this.frameRateWarmupSamples = 2;
|
|
1264
|
+
await this.pauseOnServer(producer);
|
|
1265
|
+
await this.applyDegradationPreference(producer);
|
|
1266
|
+
this.syncLimitWatch();
|
|
740
1267
|
return producer;
|
|
741
1268
|
}
|
|
742
1269
|
|
|
1270
|
+
/**
|
|
1271
|
+
* Keep a producer just made -- unless unpublish() ran while it was being
|
|
1272
|
+
* made. Kept, it would publish again what was just unpublished, so it is
|
|
1273
|
+
* closed instead, on both ends.
|
|
1274
|
+
*/
|
|
1275
|
+
private async keepProducer(producer: Producer, generation: number, what: string): Promise<void> {
|
|
1276
|
+
if (generation !== this.publishGeneration) {
|
|
1277
|
+
producer.close();
|
|
1278
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1279
|
+
throw new Error(unpublishedWhile(what));
|
|
1280
|
+
}
|
|
1281
|
+
this.producers.set(producer.id, producer);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* mediasoup-client makes a producer paused when its track is off, and the
|
|
1286
|
+
* server makes every producer live. Unless the server is told, viewers get
|
|
1287
|
+
* black video or silence from a camera or microphone they are told is on.
|
|
1288
|
+
*/
|
|
1289
|
+
private async pauseOnServer(producer: Producer): Promise<void> {
|
|
1290
|
+
if (producer.paused) await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* H.264 or VP8 for the camera (see chooseCodec), with the device's own
|
|
1295
|
+
* capability entry for it: produce() must only ever get one of those.
|
|
1296
|
+
*/
|
|
1297
|
+
private async cameraCodec(
|
|
1298
|
+
codecs: RtpCodecCapability[] | undefined,
|
|
1299
|
+
encodings: VideoEncoding[],
|
|
1300
|
+
width: number,
|
|
1301
|
+
height: number,
|
|
1302
|
+
quality: VideoQuality,
|
|
1303
|
+
): Promise<{ codec: 'vp8' | 'h264'; capability?: RtpCodecCapability }> {
|
|
1304
|
+
const requested = normalizeCodec(this.publishOptions.codec);
|
|
1305
|
+
const env = currentEnv();
|
|
1306
|
+
const phoneOrTablet = isPhoneOrTablet(env);
|
|
1307
|
+
const h264 = findCodec(codecs, 'h264');
|
|
1308
|
+
let h264Hardware: boolean | null = null;
|
|
1309
|
+
if (requested === 'auto' && phoneOrTablet && h264) {
|
|
1310
|
+
const top = topLayerSize(width, height, quality);
|
|
1311
|
+
h264Hardware = await h264HardwareEncode({ width: top.width, height: top.height, bitrate: encodings[encodings.length - 1].maxBitrate });
|
|
1312
|
+
}
|
|
1313
|
+
const choice = chooseCodec({ requested, phoneOrTablet, appleMobile: isAppleMobile(env), h264Available: Boolean(h264), h264Hardware });
|
|
1314
|
+
if (choice.warning) this.reportCodecFallback(choice.warning);
|
|
1315
|
+
else if (requested === 'auto' && phoneOrTablet && !h264) this.reportCodecFallback('this browser cannot send H.264 to this server: publishing VP8');
|
|
1316
|
+
if (choice.codec === 'h264' && h264) return { codec: 'h264', capability: h264 };
|
|
1317
|
+
return { codec: 'vp8', capability: findCodec(codecs, 'vp8') };
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
1321
|
+
private reportCodecFallback(message: string): void {
|
|
1322
|
+
if (normalizeCodec(this.publishOptions.codec) === 'h264') {
|
|
1323
|
+
this.emit('error', new Error(message));
|
|
1324
|
+
} else if (!this.codecFallbackLogged) {
|
|
1325
|
+
this.codecFallbackLogged = true;
|
|
1326
|
+
console.warn(`[voltkit-rtc] ${message}`);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** Best effort: a browser that ignores or refuses it keeps its own default. */
|
|
1331
|
+
private async applyDegradationPreference(producer: Producer): Promise<void> {
|
|
1332
|
+
const preference = this.publishOptions.degradationPreference;
|
|
1333
|
+
const sender = producer.rtpSender;
|
|
1334
|
+
if (!sender) return;
|
|
1335
|
+
if (preference !== 'maintain-resolution' && preference !== 'maintain-framerate' && preference !== 'balanced') return;
|
|
1336
|
+
try {
|
|
1337
|
+
const parameters = sender.getParameters();
|
|
1338
|
+
parameters.degradationPreference = preference;
|
|
1339
|
+
await sender.setParameters(parameters);
|
|
1340
|
+
} catch {
|
|
1341
|
+
// Unsupported here; the encoder keeps the browser's own trade-off.
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1346
|
+
private syncLimitWatch(): void {
|
|
1347
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
1348
|
+
}
|
|
1349
|
+
|
|
743
1350
|
/**
|
|
744
1351
|
* Re-establish this client's broadcast after a reconnect.
|
|
745
1352
|
*
|
|
@@ -748,9 +1355,18 @@ export class StreamClient {
|
|
|
748
1355
|
* when the tracks have genuinely ended.
|
|
749
1356
|
*/
|
|
750
1357
|
private async republish(): Promise<void> {
|
|
1358
|
+
// unpublish() can run while this is under way, and wins: nothing it
|
|
1359
|
+
// stopped is published again.
|
|
1360
|
+
const generation = this.publishGeneration;
|
|
751
1361
|
// Music is not a device and cannot be "gone": publish it again whatever
|
|
752
1362
|
// happens to the camera and microphone below.
|
|
753
|
-
|
|
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
|
+
});
|
|
1368
|
+
if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
|
|
1369
|
+
if (generation !== this.publishGeneration) return;
|
|
754
1370
|
|
|
755
1371
|
let stream = this.localStream;
|
|
756
1372
|
if (!stream && !this.publishConstraints) return; // music was all that was being published
|
|
@@ -761,7 +1377,18 @@ export class StreamClient {
|
|
|
761
1377
|
this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
|
|
762
1378
|
return;
|
|
763
1379
|
}
|
|
1380
|
+
const ended = stream;
|
|
764
1381
|
stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
|
|
1382
|
+
if (generation !== this.publishGeneration) {
|
|
1383
|
+
for (const track of stream.getTracks()) track.stop();
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
// New devices come back switched on. A camera or microphone the user
|
|
1387
|
+
// had switched off stays off: a dropped connection is no reason to
|
|
1388
|
+
// switch it back on.
|
|
1389
|
+
for (const track of stream.getTracks()) {
|
|
1390
|
+
if (ended?.getTracks().some((t) => t.kind === track.kind && !t.enabled)) track.enabled = false;
|
|
1391
|
+
}
|
|
765
1392
|
this.localStream = stream;
|
|
766
1393
|
this.emit('localStreamChanged', stream);
|
|
767
1394
|
}
|
|
@@ -770,6 +1397,150 @@ export class StreamClient {
|
|
|
770
1397
|
await this.publish(stream);
|
|
771
1398
|
}
|
|
772
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
|
+
|
|
773
1544
|
// ------------------------------------------------------------------ music
|
|
774
1545
|
|
|
775
1546
|
/**
|
|
@@ -783,10 +1554,15 @@ export class StreamClient {
|
|
|
783
1554
|
* the user has not interacted with.
|
|
784
1555
|
*/
|
|
785
1556
|
async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
|
|
1557
|
+
const generation = this.publishGeneration;
|
|
786
1558
|
if (this.music) await this.stopMusic();
|
|
787
1559
|
const music = new MusicSource(input, options);
|
|
788
1560
|
try {
|
|
789
|
-
await this.produceMusic(music);
|
|
1561
|
+
await this.produceMusic(music, generation);
|
|
1562
|
+
// Checked again here: an unpublish() after the producer was kept has
|
|
1563
|
+
// already closed it, and the music must not be remembered for a
|
|
1564
|
+
// reconnect to publish again.
|
|
1565
|
+
if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('music'));
|
|
790
1566
|
} catch (err) {
|
|
791
1567
|
music.close();
|
|
792
1568
|
throw err;
|
|
@@ -796,8 +1572,9 @@ export class StreamClient {
|
|
|
796
1572
|
this.emitLocalMedia();
|
|
797
1573
|
}
|
|
798
1574
|
|
|
799
|
-
private async produceMusic(music: MusicSource): Promise<void> {
|
|
1575
|
+
private async produceMusic(music: MusicSource, generation: number): Promise<void> {
|
|
800
1576
|
const transport = await this.ensureSendTransport();
|
|
1577
|
+
if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('music'));
|
|
801
1578
|
const producer = await transport.produce({
|
|
802
1579
|
track: music.track,
|
|
803
1580
|
stopTracks: false,
|
|
@@ -806,8 +1583,10 @@ export class StreamClient {
|
|
|
806
1583
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
807
1584
|
appData: { source: 'music' },
|
|
808
1585
|
});
|
|
809
|
-
this.
|
|
1586
|
+
await this.keepProducer(producer, generation, 'music');
|
|
810
1587
|
this.musicProducerId = producer.id;
|
|
1588
|
+
// Music switched off before a reconnect comes back paused here.
|
|
1589
|
+
await this.pauseOnServer(producer);
|
|
811
1590
|
}
|
|
812
1591
|
|
|
813
1592
|
/** True while music is published, whether or not it is switched on. */
|
|
@@ -873,6 +1652,20 @@ export class StreamClient {
|
|
|
873
1652
|
|
|
874
1653
|
const consumer = await transport.consume(params);
|
|
875
1654
|
this.consumers.set(remote.producerId, consumer);
|
|
1655
|
+
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
|
|
1656
|
+
|
|
1657
|
+
// Layers asked for before a reconnect: the new consumer gets them before
|
|
1658
|
+
// its first frame instead of starting at the full picture. Not getting
|
|
1659
|
+
// them costs data, not the track, so a refusal is only reported.
|
|
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
|
+
}
|
|
1666
|
+
if (layers) {
|
|
1667
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
1668
|
+
}
|
|
876
1669
|
|
|
877
1670
|
// The server created it paused. Resume only now that our end exists,
|
|
878
1671
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
@@ -909,7 +1702,7 @@ export class StreamClient {
|
|
|
909
1702
|
|
|
910
1703
|
private trackEnabled(kind: 'audio' | 'video'): boolean {
|
|
911
1704
|
for (const p of this.producers.values()) {
|
|
912
|
-
if (p.kind === kind && p.
|
|
1705
|
+
if (p.kind === kind && p.appData.source === defaultSource(kind)) return !p.paused;
|
|
913
1706
|
}
|
|
914
1707
|
return false;
|
|
915
1708
|
}
|
|
@@ -927,33 +1720,61 @@ export class StreamClient {
|
|
|
927
1720
|
await this.setKindEnabled('video', enabled);
|
|
928
1721
|
}
|
|
929
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
|
+
|
|
930
1735
|
private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
|
|
931
1736
|
for (const producer of this.producers.values()) {
|
|
932
1737
|
// The music has its own switch. Muting the microphone must not stop the song.
|
|
933
|
-
if (producer.kind !== kind || producer.
|
|
934
|
-
if (enabled) producer.
|
|
935
|
-
else producer.pause();
|
|
1738
|
+
if (producer.kind !== kind || producer.appData.source !== defaultSource(kind)) continue;
|
|
1739
|
+
if (!enabled) producer.pause();
|
|
936
1740
|
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
1741
|
+
if (enabled) producer.resume();
|
|
937
1742
|
}
|
|
938
1743
|
this.emitLocalMedia();
|
|
939
1744
|
}
|
|
940
1745
|
|
|
941
1746
|
/** Stop publishing entirely and release the local devices. */
|
|
942
1747
|
async unpublish(): Promise<void> {
|
|
1748
|
+
// All of it takes effect now; only telling the server is waited for. So
|
|
1749
|
+
// publish() and unpublish() act in the order they are called: a publish
|
|
1750
|
+
// called while this still waits is not undone by it, and one already
|
|
1751
|
+
// under way stops at its next step (keepProducer).
|
|
1752
|
+
this.publishGeneration += 1;
|
|
1753
|
+
const screenWasStarted = this.screenStarted;
|
|
1754
|
+
this.releaseScreen();
|
|
1755
|
+
if (screenWasStarted) this.emit('screenShareEnded', { reason: 'stopped' });
|
|
943
1756
|
const ids = [...this.producers.keys()];
|
|
944
1757
|
for (const producer of this.producers.values()) producer.close();
|
|
945
1758
|
this.producers.clear();
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1759
|
+
this.cameraEncodings = null;
|
|
1760
|
+
// Start over for the next publish, and clear a limitation still showing:
|
|
1761
|
+
// nothing is being held back once nothing is being sent.
|
|
1762
|
+
const cleared = this.limitWatch.reset();
|
|
1763
|
+
if (cleared) this.emit('videoLimited', cleared);
|
|
949
1764
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
950
1765
|
this.localStream = null;
|
|
1766
|
+
// Forgotten with the stream: kept, a reconnect would ask for the camera
|
|
1767
|
+
// and microphone again and publish them, after the app unpublished them.
|
|
1768
|
+
this.publishConstraints = null;
|
|
951
1769
|
this.music?.close();
|
|
952
1770
|
this.music = null;
|
|
953
1771
|
this.musicProducerId = null;
|
|
954
1772
|
this.wasPublishing = false;
|
|
955
1773
|
this.emit('localStreamChanged', null);
|
|
956
1774
|
this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
|
|
1775
|
+
for (const id of ids) {
|
|
1776
|
+
await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
|
|
1777
|
+
}
|
|
957
1778
|
}
|
|
958
1779
|
|
|
959
1780
|
get local(): MediaStream | null {
|
|
@@ -962,8 +1783,8 @@ export class StreamClient {
|
|
|
962
1783
|
|
|
963
1784
|
// ------------------------------------------------------------- moderation
|
|
964
1785
|
|
|
965
|
-
raiseHand(): Promise<unknown> { return this.rpc('raiseHand'); }
|
|
966
|
-
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 } : {}); }
|
|
967
1788
|
muteParticipant(identity: string): Promise<unknown> { return this.rpc('requestMute', { identity }); }
|
|
968
1789
|
/**
|
|
969
1790
|
* Moderating roles: switch someone's microphone (`audio`), camera (`video`)
|