@voltkit-rtc/client 0.2.0 → 0.4.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 +112 -0
- package/README.md +147 -3
- 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 +184 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +546 -32
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/quality.d.ts +74 -0
- package/dist/cjs/quality.d.ts.map +1 -0
- package/dist/cjs/quality.js +115 -0
- package/dist/cjs/quality.js.map +1 -0
- 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 +184 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +541 -31
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/quality.d.ts +74 -0
- package/dist/esm/quality.d.ts.map +1 -0
- package/dist/esm/quality.js +107 -0
- package/dist/esm/quality.js.map +1 -0
- 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 +3 -2
- package/src/chat.ts +21 -4
- package/src/device.ts +168 -0
- package/src/index.ts +627 -29
- package/src/quality.ts +126 -0
- package/src/stats.ts +328 -0
package/src/index.ts
CHANGED
|
@@ -13,17 +13,54 @@ 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';
|
|
33
|
+
import {
|
|
34
|
+
allowedQuality,
|
|
35
|
+
DEFAULT_VIDEO_POLICY,
|
|
36
|
+
sameLayout,
|
|
37
|
+
topLayerSize,
|
|
38
|
+
videoEncodings,
|
|
39
|
+
type LayerCount,
|
|
40
|
+
type VideoEncoding,
|
|
41
|
+
type VideoPolicy,
|
|
42
|
+
type VideoQuality,
|
|
43
|
+
} from './quality.js';
|
|
44
|
+
import {
|
|
45
|
+
parseReceiveStats,
|
|
46
|
+
parseSendStats,
|
|
47
|
+
pruneBaseline,
|
|
48
|
+
VideoLimitWatch,
|
|
49
|
+
type StatsBaseline,
|
|
50
|
+
type VideoLimitedEvent,
|
|
51
|
+
type VideoReceiveStats,
|
|
52
|
+
type VideoSendStats,
|
|
53
|
+
} from './stats.js';
|
|
21
54
|
|
|
22
55
|
export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
|
|
23
56
|
export { AudioMixer, MusicSource } from './music.js';
|
|
24
57
|
export type { MusicInput, MusicOptions, TrackSource } from './music.js';
|
|
25
58
|
export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
|
|
26
59
|
export { ChatClient, ChatRpcError } from './chat.js';
|
|
60
|
+
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
61
|
+
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality } from './quality.js';
|
|
62
|
+
export type { CodecPreference } from './device.js';
|
|
63
|
+
export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
|
|
27
64
|
export type {
|
|
28
65
|
ChatClientEvents,
|
|
29
66
|
ChatEvents,
|
|
@@ -59,6 +96,48 @@ export interface JoinResult {
|
|
|
59
96
|
claims?: SlotClaim[];
|
|
60
97
|
/** Slots a moderator has locked. */
|
|
61
98
|
blockedSlots?: string[];
|
|
99
|
+
/**
|
|
100
|
+
* The highest video quality this project may publish, and the server's cap
|
|
101
|
+
* on a publisher's incoming bitrate. Absent from older servers: 720p.
|
|
102
|
+
*/
|
|
103
|
+
video?: VideoPolicy;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface PublishOptions {
|
|
107
|
+
/**
|
|
108
|
+
* The video quality to send: 1080p, 720p or 480p. Defaults to the highest
|
|
109
|
+
* the project allows (`videoPolicy.maxQuality`), and is lowered to it if
|
|
110
|
+
* higher, and to the camera's own size if that is smaller.
|
|
111
|
+
*/
|
|
112
|
+
quality?: VideoQuality;
|
|
113
|
+
/**
|
|
114
|
+
* The most simulcast layers to send: 1, 2 or 3. Defaults to 2 on phones and
|
|
115
|
+
* tablets and 3 elsewhere. Each layer is a separate encode. When a phone is
|
|
116
|
+
* too busy, the browser shrinks the picture and then sends fewer layers,
|
|
117
|
+
* dropping the top one: from three, viewers were left with a quarter of the
|
|
118
|
+
* picture; from two, the full picture survives every step down to 480×270.
|
|
119
|
+
* Never more than the picture allows: 3 from 960×540 up, 2 from 480×270,
|
|
120
|
+
* else 1. Two layers are half and full size. One is full size only, and
|
|
121
|
+
* every viewer then gets the same stream, whatever their connection.
|
|
122
|
+
*/
|
|
123
|
+
layers?: LayerCount;
|
|
124
|
+
/**
|
|
125
|
+
* The video codec. `'auto'` (the default) sends H.264 on phones and tablets
|
|
126
|
+
* that encode it in hardware and VP8 everywhere else. `'h264'` and `'vp8'`
|
|
127
|
+
* force one; a forced H.264 the browser cannot send falls back to VP8 and
|
|
128
|
+
* fires `error`. A viewer whose browser cannot decode the codec cannot
|
|
129
|
+
* watch, so force VP8 if your audience uses such browsers.
|
|
130
|
+
*/
|
|
131
|
+
codec?: CodecPreference;
|
|
132
|
+
/**
|
|
133
|
+
* What the encoder gives up first when the device cannot keep up:
|
|
134
|
+
* `'maintain-resolution'` lowers the frame rate, `'maintain-framerate'` the
|
|
135
|
+
* picture size, `'balanced'` some of each. Unset leaves the browser's
|
|
136
|
+
* default. Chrome also takes it from the track's `contentHint`: `'detail'`
|
|
137
|
+
* keeps resolution, `'motion'` keeps frame rate. Browsers that do not
|
|
138
|
+
* support it ignore it.
|
|
139
|
+
*/
|
|
140
|
+
degradationPreference?: 'maintain-resolution' | 'maintain-framerate' | 'balanced';
|
|
62
141
|
}
|
|
63
142
|
|
|
64
143
|
export interface SlotClaim {
|
|
@@ -131,6 +210,15 @@ export interface ClientEvents extends ChatEvents {
|
|
|
131
210
|
* account is topped up first -- tell the user, and stop asking them to wait.
|
|
132
211
|
*/
|
|
133
212
|
usageLimit: (e: UsageLimitNotice) => void;
|
|
213
|
+
/**
|
|
214
|
+
* Your camera video is being held back by this device (`cpu`: too busy to
|
|
215
|
+
* encode it), by the upload (`bandwidth`), or by something else (`other`);
|
|
216
|
+
* or that has cleared (`none`). Fires when the reason changes and has held
|
|
217
|
+
* for about 4 s, so brief spikes stay quiet; never `none` first. Tell the
|
|
218
|
+
* publisher: "your device is busy" or "your connection is slow". Checked
|
|
219
|
+
* every 2 s, only while publishing a camera and someone listens.
|
|
220
|
+
*/
|
|
221
|
+
videoLimited: (e: VideoLimitedEvent) => void;
|
|
134
222
|
}
|
|
135
223
|
|
|
136
224
|
type Handler = (...args: never[]) => void;
|
|
@@ -178,6 +266,23 @@ const CLOSE_ENDED = 4003;
|
|
|
178
266
|
|
|
179
267
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
180
268
|
|
|
269
|
+
/** What setPreferredLayers sends; no temporal layer means the full frame rate. */
|
|
270
|
+
interface PreferredLayers {
|
|
271
|
+
spatialLayer: number;
|
|
272
|
+
temporalLayer?: number;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const isLayer = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
279
|
+
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
280
|
+
*/
|
|
281
|
+
const VIDEO_START_BITRATE_KBPS = 1500;
|
|
282
|
+
|
|
283
|
+
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
284
|
+
const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published`;
|
|
285
|
+
|
|
181
286
|
export class StreamClient {
|
|
182
287
|
private ws: WebSocket | null = null;
|
|
183
288
|
private device: Device | null = null;
|
|
@@ -203,14 +308,40 @@ export class StreamClient {
|
|
|
203
308
|
// disconnected: the room looks healthy and shows nothing.
|
|
204
309
|
private wasPublishing = false;
|
|
205
310
|
private publishConstraints: MediaStreamConstraints | null = null;
|
|
311
|
+
private publishOptions: PublishOptions = {};
|
|
206
312
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
207
313
|
private music: MusicSource | null = null;
|
|
208
314
|
private musicProducerId: string | null = null;
|
|
209
315
|
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
210
316
|
private readonly remoteSources = new Map<string, TrackSource>();
|
|
317
|
+
/**
|
|
318
|
+
* The layers asked for each remote video, by producerId. A reconnect
|
|
319
|
+
* consumes everything again, and the new consumer would otherwise start at
|
|
320
|
+
* the full picture.
|
|
321
|
+
*/
|
|
322
|
+
private readonly preferredLayers = new Map<string, PreferredLayers>();
|
|
211
323
|
private closedByUs = false;
|
|
212
324
|
private reconnecting = false;
|
|
213
325
|
private reconnectAttempt = 0;
|
|
326
|
+
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
327
|
+
private joinedOnce = false;
|
|
328
|
+
/** The camera producer's layers, to tell whether a new track fits them. */
|
|
329
|
+
private cameraEncodings: VideoEncoding[] | null = null;
|
|
330
|
+
// Changes to the camera producer run one at a time. Two overlapping swaps
|
|
331
|
+
// (an effect toggled on and off quickly) would otherwise both close the
|
|
332
|
+
// same producer and both produce a new one.
|
|
333
|
+
private cameraQueue: Promise<unknown> = Promise.resolve();
|
|
334
|
+
// Bumped by unpublish(). A producer still being made when it runs (camera,
|
|
335
|
+
// microphone or music) belongs to the publish it ended: it is closed, not
|
|
336
|
+
// kept.
|
|
337
|
+
private publishGeneration = 0;
|
|
338
|
+
private codecFallbackLogged = false;
|
|
339
|
+
private readonly sendStatsBaseline: StatsBaseline = new Map();
|
|
340
|
+
private readonly recvStatsBaseline: StatsBaseline = new Map();
|
|
341
|
+
private readonly limitWatch = new VideoLimitWatch(
|
|
342
|
+
(baseline) => this.collectVideoStats(baseline),
|
|
343
|
+
(event) => this.emit('videoLimited', event),
|
|
344
|
+
);
|
|
214
345
|
|
|
215
346
|
constructor(private readonly wsUrl: string, token: string) {
|
|
216
347
|
this.token = token;
|
|
@@ -223,7 +354,16 @@ export class StreamClient {
|
|
|
223
354
|
this.listeners.set(event, set);
|
|
224
355
|
}
|
|
225
356
|
set.add(fn as Handler);
|
|
226
|
-
|
|
357
|
+
// videoLimited costs a getStats every 2 s, so it runs only while heard.
|
|
358
|
+
if (event === 'videoLimited') this.syncLimitWatch();
|
|
359
|
+
return () => {
|
|
360
|
+
set!.delete(fn as Handler);
|
|
361
|
+
if (event === 'videoLimited') this.syncLimitWatch();
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private listenerCount(event: keyof ClientEvents): number {
|
|
366
|
+
return this.listeners.get(event)?.size ?? 0;
|
|
227
367
|
}
|
|
228
368
|
|
|
229
369
|
private emit<K extends keyof ClientEvents>(event: K, ...args: Parameters<ClientEvents[K]>): void {
|
|
@@ -239,15 +379,37 @@ export class StreamClient {
|
|
|
239
379
|
// ------------------------------------------------------------- connection
|
|
240
380
|
|
|
241
381
|
async connect(): Promise<JoinResult> {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
382
|
+
let result: JoinResult;
|
|
383
|
+
try {
|
|
384
|
+
await this.openSocket();
|
|
385
|
+
result = (await this.rpc('join', { token: this.token })) as JoinResult;
|
|
386
|
+
this.joinResult = result;
|
|
245
387
|
|
|
246
|
-
|
|
247
|
-
|
|
388
|
+
this.device = this.createDevice();
|
|
389
|
+
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
390
|
+
} catch (err) {
|
|
391
|
+
// A session that never got going: the app has the error. Left open,
|
|
392
|
+
// the socket would be closed by the server's join deadline instead, and
|
|
393
|
+
// that close would start reconnecting a session that never began.
|
|
394
|
+
if (!this.joinedOnce) this.detachSocket();
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
398
|
+
// Not when the socket opens -- a join refused with a code worth retrying
|
|
399
|
+
// (no-capacity) never used them up, and every client of a full cluster
|
|
400
|
+
// retried about twice a second, forever.
|
|
401
|
+
this.reconnectAttempt = 0;
|
|
402
|
+
this.joinedOnce = true;
|
|
248
403
|
|
|
249
404
|
this.emit('joined', result);
|
|
250
405
|
|
|
406
|
+
// A video that ended while this client was away sends it no
|
|
407
|
+
// producerClosed: forget the layers asked for it.
|
|
408
|
+
const present = new Set(result.producers.map((p) => p.producerId));
|
|
409
|
+
for (const producerId of this.preferredLayers.keys()) {
|
|
410
|
+
if (!present.has(producerId)) this.preferredLayers.delete(producerId);
|
|
411
|
+
}
|
|
412
|
+
|
|
251
413
|
// Subscribe to everything already publishing before we announce readiness.
|
|
252
414
|
for (const p of result.producers) {
|
|
253
415
|
await this.consume(p).catch((err) => this.emit('error', err as Error));
|
|
@@ -262,6 +424,11 @@ export class StreamClient {
|
|
|
262
424
|
return result;
|
|
263
425
|
}
|
|
264
426
|
|
|
427
|
+
/** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
|
|
428
|
+
private createDevice(): Device {
|
|
429
|
+
return new Device();
|
|
430
|
+
}
|
|
431
|
+
|
|
265
432
|
private openSocket(): Promise<void> {
|
|
266
433
|
// Abandon any previous socket first. Its close handler checks identity
|
|
267
434
|
// against `this.ws`, so once it is detached it can no longer trigger a
|
|
@@ -275,7 +442,6 @@ export class StreamClient {
|
|
|
275
442
|
this.ws = ws;
|
|
276
443
|
|
|
277
444
|
const onOpen = () => {
|
|
278
|
-
this.reconnectAttempt = 0;
|
|
279
445
|
this.emit('connected');
|
|
280
446
|
resolve();
|
|
281
447
|
};
|
|
@@ -300,6 +466,9 @@ export class StreamClient {
|
|
|
300
466
|
// displace it, and it would displace us right back, forever.
|
|
301
467
|
this.emit('error', new Error('this identity connected from somewhere else'));
|
|
302
468
|
this.closedByUs = true;
|
|
469
|
+
// The producers outlive this close until the app calls close(); the
|
|
470
|
+
// videoLimited sampling must not, or it reports after `closed`.
|
|
471
|
+
this.limitWatch.reset();
|
|
303
472
|
this.emit('closed', 'replaced');
|
|
304
473
|
return;
|
|
305
474
|
}
|
|
@@ -307,9 +476,13 @@ export class StreamClient {
|
|
|
307
476
|
// Normally roomClosed has already ended the session; this covers a
|
|
308
477
|
// close that arrived without it.
|
|
309
478
|
this.closedByUs = true;
|
|
479
|
+
this.limitWatch.reset();
|
|
310
480
|
this.emit('closed', 'room-closed');
|
|
311
481
|
return;
|
|
312
482
|
}
|
|
483
|
+
// Before a first successful join the app is still waiting on
|
|
484
|
+
// connect(), which reports the failure; there is nothing to restore.
|
|
485
|
+
if (!this.joinedOnce) return;
|
|
313
486
|
void this.scheduleReconnect();
|
|
314
487
|
});
|
|
315
488
|
});
|
|
@@ -352,8 +525,12 @@ export class StreamClient {
|
|
|
352
525
|
if (this.closedByUs) return;
|
|
353
526
|
try {
|
|
354
527
|
this.teardownMedia();
|
|
355
|
-
await this.connect();
|
|
356
|
-
|
|
528
|
+
await this.connect(); // which resets the attempt counter once joined
|
|
529
|
+
// The socket can drop again while the session is being restored,
|
|
530
|
+
// and its close handler then finds this loop still running and
|
|
531
|
+
// leaves it to us. Returning would leave the client disconnected
|
|
532
|
+
// for good, with no `closed`.
|
|
533
|
+
if (this.ws) return;
|
|
357
534
|
} catch (err) {
|
|
358
535
|
this.emit('error', err as Error);
|
|
359
536
|
// Stop on failures a retry cannot resolve. Hammering a room that has
|
|
@@ -378,6 +555,9 @@ export class StreamClient {
|
|
|
378
555
|
this.closedByUs = true;
|
|
379
556
|
this.wasPublishing = false;
|
|
380
557
|
this.teardownMedia();
|
|
558
|
+
this.preferredLayers.clear();
|
|
559
|
+
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
560
|
+
this.limitWatch.reset();
|
|
381
561
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
382
562
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
383
563
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -394,6 +574,10 @@ export class StreamClient {
|
|
|
394
574
|
for (const p of this.producers.values()) p.close();
|
|
395
575
|
this.producers.clear();
|
|
396
576
|
this.musicProducerId = null;
|
|
577
|
+
this.cameraEncodings = null;
|
|
578
|
+
// Paused, not reset: a reconnect's republish starts it again, and a
|
|
579
|
+
// limitation already reported is not reported twice.
|
|
580
|
+
this.syncLimitWatch();
|
|
397
581
|
this.sendTransport?.close();
|
|
398
582
|
this.recvTransport?.close();
|
|
399
583
|
this.sendTransport = null;
|
|
@@ -466,6 +650,7 @@ export class StreamClient {
|
|
|
466
650
|
break;
|
|
467
651
|
case 'producerClosed': {
|
|
468
652
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
653
|
+
this.preferredLayers.delete(producerId);
|
|
469
654
|
const consumer = this.consumers.get(producerId);
|
|
470
655
|
if (!consumer) break; // never consumed it, or already cleaned up
|
|
471
656
|
consumer.close();
|
|
@@ -643,12 +828,23 @@ export class StreamClient {
|
|
|
643
828
|
*
|
|
644
829
|
* Pass the constraints used to obtain the stream and they will be reused to
|
|
645
830
|
* reacquire the devices if a reconnect finds the tracks have ended.
|
|
831
|
+
*
|
|
832
|
+
* `options.quality` picks 1080p, 720p or 480p (see `videoPolicy` for what
|
|
833
|
+
* the project allows). Capture at least that size for the full picture.
|
|
834
|
+
* `options.layers`, `codec` and `degradationPreference` tune how the video
|
|
835
|
+
* is encoded; the defaults suit both phones and computers. The options are
|
|
836
|
+
* kept for a reconnect's republish.
|
|
646
837
|
*/
|
|
647
|
-
async publish(stream: MediaStream, constraints?: MediaStreamConstraints): Promise<void> {
|
|
838
|
+
async publish(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
|
|
839
|
+
const generation = this.publishGeneration;
|
|
648
840
|
const transport = await this.ensureSendTransport();
|
|
841
|
+
// unpublish() ran first: this publish is over before it began, and must
|
|
842
|
+
// leave nothing behind that a reconnect would publish again.
|
|
843
|
+
if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('stream'));
|
|
649
844
|
this.localStream = stream;
|
|
650
845
|
this.wasPublishing = true;
|
|
651
846
|
if (constraints) this.publishConstraints = constraints;
|
|
847
|
+
if (options) this.publishOptions = { ...options };
|
|
652
848
|
|
|
653
849
|
const audio = stream.getAudioTracks()[0];
|
|
654
850
|
if (audio) {
|
|
@@ -660,26 +856,384 @@ export class StreamClient {
|
|
|
660
856
|
codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
|
|
661
857
|
appData: { source: 'mic' },
|
|
662
858
|
});
|
|
663
|
-
this.
|
|
859
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
860
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
861
|
+
await this.pauseOnServer(producer);
|
|
664
862
|
}
|
|
665
863
|
|
|
666
864
|
const video = stream.getVideoTracks()[0];
|
|
667
865
|
if (video) {
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
866
|
+
// The track is read when the camera's turn comes: one handed to
|
|
867
|
+
// replaceVideoTrack while this publish was under way is the one to send.
|
|
868
|
+
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** What this project may publish. Known once joined; 720p before that. */
|
|
873
|
+
get videoPolicy(): VideoPolicy {
|
|
874
|
+
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** The quality video is being sent at, after the project's limit. */
|
|
878
|
+
get videoQuality(): VideoQuality {
|
|
879
|
+
return allowedQuality(this.publishOptions.quality, this.videoPolicy);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Change the video quality while publishing. The camera's producer is
|
|
884
|
+
* replaced, so viewers see a brief pause while the new one starts. Resolves
|
|
885
|
+
* to the quality actually used, after the project's limit. Capture at least
|
|
886
|
+
* that size (for example with `track.applyConstraints`) first.
|
|
887
|
+
*/
|
|
888
|
+
async setVideoQuality(quality: VideoQuality): Promise<VideoQuality> {
|
|
889
|
+
this.publishOptions = { ...this.publishOptions, quality };
|
|
890
|
+
await this.cameraTask(async () => {
|
|
891
|
+
const current = this.cameraProducer();
|
|
892
|
+
const track = current?.track;
|
|
893
|
+
if (!current || !track || track.readyState !== 'live') return;
|
|
894
|
+
await this.reproduceCamera(current, track);
|
|
895
|
+
});
|
|
896
|
+
return this.videoQuality;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Send a different video track from now on: a canvas with effects drawn on
|
|
901
|
+
* it, the raw camera again, another camera. When the new track needs the
|
|
902
|
+
* same layers as the current one (the same size, or another size the
|
|
903
|
+
* project's quality scales alike), viewers see no pause, and the layers
|
|
904
|
+
* take the new picture's bitrates. Otherwise the camera producer is
|
|
905
|
+
* replaced, as `setVideoQuality` does. Either way a paused camera stays
|
|
906
|
+
* paused.
|
|
907
|
+
*
|
|
908
|
+
* Your own stream is left as it is: the client keeps a stream of its own
|
|
909
|
+
* (`local`, announced by `localStreamChanged`) with the new track and your
|
|
910
|
+
* microphone, and a reconnect republishes that. The track you replaced is
|
|
911
|
+
* not stopped; it is yours again, switched back on if pausing the camera
|
|
912
|
+
* had switched it off. Rejects when no camera is being published.
|
|
913
|
+
*/
|
|
914
|
+
async replaceVideoTrack(track: MediaStreamTrack): Promise<void> {
|
|
915
|
+
if (!track || track.kind !== 'video') throw new TypeError('replaceVideoTrack needs a video track');
|
|
916
|
+
if (track.readyState === 'ended') throw new Error('replaceVideoTrack: that track has ended');
|
|
917
|
+
await this.cameraTask(async () => {
|
|
918
|
+
const current = this.cameraProducer();
|
|
919
|
+
// Between a dropped connection and the republish there is no producer,
|
|
920
|
+
// but the camera is still being published: the republish sends this.
|
|
921
|
+
const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
|
922
|
+
if (!current && !reconnecting) throw new Error('replaceVideoTrack: no camera is being published');
|
|
923
|
+
if (!current) {
|
|
924
|
+
// The republish reads the camera's state off the track it sends, and
|
|
925
|
+
// the track being replaced is switched off exactly when the camera is
|
|
926
|
+
// paused. Hand that state on, or a camera that was off comes back on;
|
|
927
|
+
// and the old track back switched on, as below.
|
|
928
|
+
const replaced = this.localStream?.getVideoTracks()[0];
|
|
929
|
+
if (replaced && replaced !== track) {
|
|
930
|
+
track.enabled = replaced.enabled;
|
|
931
|
+
replaced.enabled = true;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
this.adoptVideoTrack(track);
|
|
935
|
+
if (!current || current.track === track) return;
|
|
936
|
+
const previous = current.track;
|
|
937
|
+
const paused = current.paused;
|
|
938
|
+
try {
|
|
939
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
940
|
+
const next = videoEncodings(width, height, this.videoQuality, this.layerCount());
|
|
941
|
+
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next)) {
|
|
942
|
+
await current.replaceTrack({ track });
|
|
943
|
+
await this.applyLayerBitrates(current, next);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
await this.reproduceCamera(current, track);
|
|
947
|
+
} finally {
|
|
948
|
+
// Pausing switched the old track off (mediasoup-client pauses that
|
|
949
|
+
// way), and nothing switches it back on once it is replaced. It is the
|
|
950
|
+
// app's again, often the camera its canvas draws from: handed back
|
|
951
|
+
// off, that canvas stays black after the camera is resumed.
|
|
952
|
+
if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Give a camera producer whose track was swapped in place the bitrates of
|
|
959
|
+
* its new picture. The layout matched, but a bigger picture at the same
|
|
960
|
+
* scales (a 1280×720 canvas for a 960×540 camera) would otherwise stay
|
|
961
|
+
* capped at the smaller one's. Best effort: a browser that refuses keeps
|
|
962
|
+
* the old caps.
|
|
963
|
+
*/
|
|
964
|
+
private async applyLayerBitrates(producer: Producer, encodings: VideoEncoding[]): Promise<void> {
|
|
965
|
+
const before = this.cameraEncodings;
|
|
966
|
+
if (before && before.every((encoding, i) => encoding.maxBitrate === encodings[i]?.maxBitrate)) {
|
|
967
|
+
this.cameraEncodings = encodings;
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const sender = producer.rtpSender;
|
|
971
|
+
if (!sender) return;
|
|
972
|
+
try {
|
|
973
|
+
const parameters = sender.getParameters();
|
|
974
|
+
// One entry per layer, smallest first, as they were produced. A browser
|
|
975
|
+
// that reports them some other way is left with its own.
|
|
976
|
+
if (parameters.encodings.length !== encodings.length) return;
|
|
977
|
+
parameters.encodings.forEach((encoding, i) => {
|
|
978
|
+
encoding.maxBitrate = encodings[i].maxBitrate;
|
|
979
|
+
});
|
|
980
|
+
await sender.setParameters(parameters);
|
|
981
|
+
this.cameraEncodings = encodings;
|
|
982
|
+
} catch {
|
|
983
|
+
// Unsupported here; the layers keep the caps they were produced with.
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* What your camera video is sending now, layer by layer, or null when no
|
|
989
|
+
* camera is being published. The top-level fields describe the biggest
|
|
990
|
+
* layer going out. `qualityLimitation` says why it is less than asked:
|
|
991
|
+
* `cpu` is this device, `bandwidth` the upload, `none` neither (then check
|
|
992
|
+
* the camera and the light). Bitrates are averaged since the previous call,
|
|
993
|
+
* so call it every second or two; the first call reports 0.
|
|
994
|
+
*/
|
|
995
|
+
async getVideoStats(): Promise<VideoSendStats | null> {
|
|
996
|
+
return this.collectVideoStats(this.sendStatsBaseline);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* What this client receives of a remote video (by its `producerId`, from
|
|
1001
|
+
* the `track` event), or null when it is not receiving that video. Bitrates
|
|
1002
|
+
* are averaged since the previous call for it; the first call reports 0.
|
|
1003
|
+
*/
|
|
1004
|
+
async getReceiveStats(producerId: string): Promise<VideoReceiveStats | null> {
|
|
1005
|
+
const consumer = this.consumers.get(producerId);
|
|
1006
|
+
if (!consumer || consumer.kind !== 'video') return null;
|
|
1007
|
+
let report: RTCStatsReport;
|
|
1008
|
+
try {
|
|
1009
|
+
report = await consumer.getStats();
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
if (consumer.closed) return null;
|
|
1012
|
+
throw err;
|
|
1013
|
+
}
|
|
1014
|
+
const live = new Set([...this.consumers.values()].map((c) => c.id));
|
|
1015
|
+
pruneBaseline(this.recvStatsBaseline, (key) => live.has(key));
|
|
1016
|
+
return parseReceiveStats(report.values(), { producerId, baseline: this.recvStatsBaseline, key: consumer.id });
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Receive a smaller copy of someone's video (by its `producerId`, from the
|
|
1021
|
+
* `track` event), for a tile that does not need the full picture. Spatial
|
|
1022
|
+
* layer 0 is the smallest copy the publisher sends: a computer sends three
|
|
1023
|
+
* (at 720p 320×180, 640×360 and 1280×720), a phone two (640×360 and
|
|
1024
|
+
* 1280×720), and a layer above the biggest means the biggest. Leave
|
|
1025
|
+
* `temporalLayer` out to keep the full frame rate. It is the most this
|
|
1026
|
+
* client is sent: a connection that cannot carry it still gets less. The
|
|
1027
|
+
* choice is kept across reconnects and forgotten when the track ends.
|
|
1028
|
+
* Rejects for a producer whose video this client is not receiving.
|
|
1029
|
+
*/
|
|
1030
|
+
async setPreferredLayers(producerId: string, spatialLayer: number, temporalLayer?: number): Promise<void> {
|
|
1031
|
+
if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
|
|
1032
|
+
throw new RangeError('setPreferredLayers: layers are whole numbers from 0');
|
|
1033
|
+
}
|
|
1034
|
+
const consumer = this.consumers.get(producerId);
|
|
1035
|
+
if (!consumer || consumer.kind !== 'video') {
|
|
1036
|
+
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
1037
|
+
}
|
|
1038
|
+
const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
1039
|
+
// Kept first: a reconnect that interrupts the request still applies it.
|
|
1040
|
+
this.preferredLayers.set(producerId, layers);
|
|
1041
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private async collectVideoStats(baseline: StatsBaseline): Promise<VideoSendStats | null> {
|
|
1045
|
+
const producer = this.cameraProducer();
|
|
1046
|
+
if (!producer) return null;
|
|
1047
|
+
let report: RTCStatsReport;
|
|
1048
|
+
try {
|
|
1049
|
+
report = await producer.getStats();
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
if (producer.closed) return null;
|
|
1052
|
+
throw err;
|
|
1053
|
+
}
|
|
1054
|
+
const prefix = `${producer.id}:`;
|
|
1055
|
+
pruneBaseline(baseline, (key) => key.startsWith(prefix));
|
|
1056
|
+
return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
private cameraProducer(): Producer | undefined {
|
|
1060
|
+
for (const p of this.producers.values()) {
|
|
1061
|
+
if (p.kind === 'video' && (p.appData as { source?: string }).source === 'camera') return p;
|
|
1062
|
+
}
|
|
1063
|
+
return undefined;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private cameraTask<T>(task: () => Promise<T>): Promise<T> {
|
|
1067
|
+
const run = this.cameraQueue.then(task, task);
|
|
1068
|
+
this.cameraQueue = run.catch(() => undefined);
|
|
1069
|
+
return run;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Swap the video in the client's own stream, never the app's. */
|
|
1073
|
+
private adoptVideoTrack(track: MediaStreamTrack): void {
|
|
1074
|
+
const previous = this.localStream;
|
|
1075
|
+
if (previous && previous.getVideoTracks()[0] === track) return;
|
|
1076
|
+
const others = previous ? previous.getTracks().filter((t) => t.kind !== 'video') : [];
|
|
1077
|
+
this.localStream = new MediaStream([...others, track]);
|
|
1078
|
+
this.emit('localStreamChanged', this.localStream);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** Close the camera producer and produce `track` in its place, paused on both ends if it was. */
|
|
1082
|
+
private async reproduceCamera(current: Producer, track: MediaStreamTrack): Promise<Producer> {
|
|
1083
|
+
const generation = this.publishGeneration;
|
|
1084
|
+
const paused = current.paused;
|
|
1085
|
+
current.close();
|
|
1086
|
+
this.producers.delete(current.id);
|
|
1087
|
+
this.cameraEncodings = null;
|
|
1088
|
+
this.syncLimitWatch();
|
|
1089
|
+
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
1090
|
+
return this.produceCamera(await this.ensureSendTransport(), track, generation, paused);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** `options.layers` when valid, else 2 on phones and tablets and 3 elsewhere. */
|
|
1094
|
+
private layerCount(): LayerCount {
|
|
1095
|
+
const asked = this.publishOptions.layers;
|
|
1096
|
+
return asked === 1 || asked === 2 || asked === 3 ? asked : defaultLayerCount();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Produce the camera from `track`, paused on both ends when `paused`. By
|
|
1101
|
+
* default the track says: one handed over switched off is a camera that is
|
|
1102
|
+
* off, and so is one a pause switched off before a reconnect.
|
|
1103
|
+
*/
|
|
1104
|
+
private async produceCamera(
|
|
1105
|
+
transport: Transport,
|
|
1106
|
+
track: MediaStreamTrack,
|
|
1107
|
+
generation: number,
|
|
1108
|
+
paused = !track.enabled,
|
|
1109
|
+
): Promise<Producer> {
|
|
1110
|
+
if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('camera'));
|
|
1111
|
+
// mediasoup-client starts a producer paused exactly when its track is
|
|
1112
|
+
// off. Setting the track to the camera's state first makes the producer
|
|
1113
|
+
// start in it, and a camera that is off never sends a frame of the new
|
|
1114
|
+
// track; the server is told below.
|
|
1115
|
+
track.enabled = !paused;
|
|
1116
|
+
// Simulcast from day one: it is what lets one node serve far more viewers.
|
|
1117
|
+
// The layers follow the picture's real size, the project's quality and
|
|
1118
|
+
// the device, so the browser sends every layer asked for (see
|
|
1119
|
+
// videoEncodings).
|
|
1120
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
1121
|
+
const quality = this.videoQuality;
|
|
1122
|
+
const encodings = videoEncodings(width, height, quality, this.layerCount());
|
|
1123
|
+
let codecs: RtpCodecCapability[] | undefined;
|
|
1124
|
+
try {
|
|
1125
|
+
codecs = this.device?.sendRtpCapabilities.codecs;
|
|
1126
|
+
} catch {
|
|
1127
|
+
codecs = undefined;
|
|
1128
|
+
}
|
|
1129
|
+
const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
|
|
1130
|
+
const produce = (codec: RtpCodecCapability | undefined) =>
|
|
1131
|
+
transport.produce({
|
|
1132
|
+
track,
|
|
672
1133
|
stopTracks: false,
|
|
673
|
-
encodings
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
{ rid: 'f', scaleResolutionDownBy: 1, maxBitrate: 1_800_000, scalabilityMode: 'L1T3' },
|
|
677
|
-
],
|
|
678
|
-
codecOptions: { videoGoogleStartBitrate: 1000 },
|
|
1134
|
+
encodings,
|
|
1135
|
+
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1136
|
+
...(codec ? { codec } : {}),
|
|
679
1137
|
appData: { source: 'camera' },
|
|
680
1138
|
});
|
|
681
|
-
|
|
1139
|
+
let producer: Producer;
|
|
1140
|
+
try {
|
|
1141
|
+
producer = await produce(chosen.capability);
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
// Never fail a publish over the codec: H.264 refused is VP8 instead.
|
|
1144
|
+
// A closed transport (a reconnect under way) fails either way.
|
|
1145
|
+
if (chosen.codec !== 'h264' || transport.closed) throw err;
|
|
1146
|
+
this.reportCodecFallback(`H.264 was refused (${(err as Error).message}): publishing VP8 instead`);
|
|
1147
|
+
producer = await produce(findCodec(codecs, 'vp8'));
|
|
682
1148
|
}
|
|
1149
|
+
// unpublish() may have run while this was being made (a publish, a
|
|
1150
|
+
// quality change or a track swap under way).
|
|
1151
|
+
await this.keepProducer(producer, generation, 'camera');
|
|
1152
|
+
this.cameraEncodings = encodings;
|
|
1153
|
+
await this.pauseOnServer(producer);
|
|
1154
|
+
await this.applyDegradationPreference(producer);
|
|
1155
|
+
this.syncLimitWatch();
|
|
1156
|
+
return producer;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Keep a producer just made -- unless unpublish() ran while it was being
|
|
1161
|
+
* made. Kept, it would publish again what was just unpublished, so it is
|
|
1162
|
+
* closed instead, on both ends.
|
|
1163
|
+
*/
|
|
1164
|
+
private async keepProducer(producer: Producer, generation: number, what: string): Promise<void> {
|
|
1165
|
+
if (generation !== this.publishGeneration) {
|
|
1166
|
+
producer.close();
|
|
1167
|
+
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1168
|
+
throw new Error(unpublishedWhile(what));
|
|
1169
|
+
}
|
|
1170
|
+
this.producers.set(producer.id, producer);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* mediasoup-client makes a producer paused when its track is off, and the
|
|
1175
|
+
* server makes every producer live. Unless the server is told, viewers get
|
|
1176
|
+
* black video or silence from a camera or microphone they are told is on.
|
|
1177
|
+
*/
|
|
1178
|
+
private async pauseOnServer(producer: Producer): Promise<void> {
|
|
1179
|
+
if (producer.paused) await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* H.264 or VP8 for the camera (see chooseCodec), with the device's own
|
|
1184
|
+
* capability entry for it: produce() must only ever get one of those.
|
|
1185
|
+
*/
|
|
1186
|
+
private async cameraCodec(
|
|
1187
|
+
codecs: RtpCodecCapability[] | undefined,
|
|
1188
|
+
encodings: VideoEncoding[],
|
|
1189
|
+
width: number,
|
|
1190
|
+
height: number,
|
|
1191
|
+
quality: VideoQuality,
|
|
1192
|
+
): Promise<{ codec: 'vp8' | 'h264'; capability?: RtpCodecCapability }> {
|
|
1193
|
+
const requested = normalizeCodec(this.publishOptions.codec);
|
|
1194
|
+
const env = currentEnv();
|
|
1195
|
+
const phoneOrTablet = isPhoneOrTablet(env);
|
|
1196
|
+
const h264 = findCodec(codecs, 'h264');
|
|
1197
|
+
let h264Hardware: boolean | null = null;
|
|
1198
|
+
if (requested === 'auto' && phoneOrTablet && h264) {
|
|
1199
|
+
const top = topLayerSize(width, height, quality);
|
|
1200
|
+
h264Hardware = await h264HardwareEncode({ width: top.width, height: top.height, bitrate: encodings[encodings.length - 1].maxBitrate });
|
|
1201
|
+
}
|
|
1202
|
+
const choice = chooseCodec({ requested, phoneOrTablet, appleMobile: isAppleMobile(env), h264Available: Boolean(h264), h264Hardware });
|
|
1203
|
+
if (choice.warning) this.reportCodecFallback(choice.warning);
|
|
1204
|
+
else if (requested === 'auto' && phoneOrTablet && !h264) this.reportCodecFallback('this browser cannot send H.264 to this server: publishing VP8');
|
|
1205
|
+
if (choice.codec === 'h264' && h264) return { codec: 'h264', capability: h264 };
|
|
1206
|
+
return { codec: 'vp8', capability: findCodec(codecs, 'vp8') };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
1210
|
+
private reportCodecFallback(message: string): void {
|
|
1211
|
+
if (normalizeCodec(this.publishOptions.codec) === 'h264') {
|
|
1212
|
+
this.emit('error', new Error(message));
|
|
1213
|
+
} else if (!this.codecFallbackLogged) {
|
|
1214
|
+
this.codecFallbackLogged = true;
|
|
1215
|
+
console.warn(`[voltkit-rtc] ${message}`);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Best effort: a browser that ignores or refuses it keeps its own default. */
|
|
1220
|
+
private async applyDegradationPreference(producer: Producer): Promise<void> {
|
|
1221
|
+
const preference = this.publishOptions.degradationPreference;
|
|
1222
|
+
const sender = producer.rtpSender;
|
|
1223
|
+
if (!sender) return;
|
|
1224
|
+
if (preference !== 'maintain-resolution' && preference !== 'maintain-framerate' && preference !== 'balanced') return;
|
|
1225
|
+
try {
|
|
1226
|
+
const parameters = sender.getParameters();
|
|
1227
|
+
parameters.degradationPreference = preference;
|
|
1228
|
+
await sender.setParameters(parameters);
|
|
1229
|
+
} catch {
|
|
1230
|
+
// Unsupported here; the encoder keeps the browser's own trade-off.
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1235
|
+
private syncLimitWatch(): void {
|
|
1236
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
|
|
683
1237
|
}
|
|
684
1238
|
|
|
685
1239
|
/**
|
|
@@ -690,9 +1244,13 @@ export class StreamClient {
|
|
|
690
1244
|
* when the tracks have genuinely ended.
|
|
691
1245
|
*/
|
|
692
1246
|
private async republish(): Promise<void> {
|
|
1247
|
+
// unpublish() can run while this is under way, and wins: nothing it
|
|
1248
|
+
// stopped is published again.
|
|
1249
|
+
const generation = this.publishGeneration;
|
|
693
1250
|
// Music is not a device and cannot be "gone": publish it again whatever
|
|
694
1251
|
// happens to the camera and microphone below.
|
|
695
|
-
if (this.music) await this.produceMusic(this.music).catch((err) => this.emit('error', err as Error));
|
|
1252
|
+
if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
|
|
1253
|
+
if (generation !== this.publishGeneration) return;
|
|
696
1254
|
|
|
697
1255
|
let stream = this.localStream;
|
|
698
1256
|
if (!stream && !this.publishConstraints) return; // music was all that was being published
|
|
@@ -703,7 +1261,18 @@ export class StreamClient {
|
|
|
703
1261
|
this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
|
|
704
1262
|
return;
|
|
705
1263
|
}
|
|
1264
|
+
const ended = stream;
|
|
706
1265
|
stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
|
|
1266
|
+
if (generation !== this.publishGeneration) {
|
|
1267
|
+
for (const track of stream.getTracks()) track.stop();
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
// New devices come back switched on. A camera or microphone the user
|
|
1271
|
+
// had switched off stays off: a dropped connection is no reason to
|
|
1272
|
+
// switch it back on.
|
|
1273
|
+
for (const track of stream.getTracks()) {
|
|
1274
|
+
if (ended?.getTracks().some((t) => t.kind === track.kind && !t.enabled)) track.enabled = false;
|
|
1275
|
+
}
|
|
707
1276
|
this.localStream = stream;
|
|
708
1277
|
this.emit('localStreamChanged', stream);
|
|
709
1278
|
}
|
|
@@ -725,10 +1294,15 @@ export class StreamClient {
|
|
|
725
1294
|
* the user has not interacted with.
|
|
726
1295
|
*/
|
|
727
1296
|
async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
|
|
1297
|
+
const generation = this.publishGeneration;
|
|
728
1298
|
if (this.music) await this.stopMusic();
|
|
729
1299
|
const music = new MusicSource(input, options);
|
|
730
1300
|
try {
|
|
731
|
-
await this.produceMusic(music);
|
|
1301
|
+
await this.produceMusic(music, generation);
|
|
1302
|
+
// Checked again here: an unpublish() after the producer was kept has
|
|
1303
|
+
// already closed it, and the music must not be remembered for a
|
|
1304
|
+
// reconnect to publish again.
|
|
1305
|
+
if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('music'));
|
|
732
1306
|
} catch (err) {
|
|
733
1307
|
music.close();
|
|
734
1308
|
throw err;
|
|
@@ -738,8 +1312,9 @@ export class StreamClient {
|
|
|
738
1312
|
this.emitLocalMedia();
|
|
739
1313
|
}
|
|
740
1314
|
|
|
741
|
-
private async produceMusic(music: MusicSource): Promise<void> {
|
|
1315
|
+
private async produceMusic(music: MusicSource, generation: number): Promise<void> {
|
|
742
1316
|
const transport = await this.ensureSendTransport();
|
|
1317
|
+
if (generation !== this.publishGeneration) throw new Error(unpublishedWhile('music'));
|
|
743
1318
|
const producer = await transport.produce({
|
|
744
1319
|
track: music.track,
|
|
745
1320
|
stopTracks: false,
|
|
@@ -748,8 +1323,10 @@ export class StreamClient {
|
|
|
748
1323
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
749
1324
|
appData: { source: 'music' },
|
|
750
1325
|
});
|
|
751
|
-
this.
|
|
1326
|
+
await this.keepProducer(producer, generation, 'music');
|
|
752
1327
|
this.musicProducerId = producer.id;
|
|
1328
|
+
// Music switched off before a reconnect comes back paused here.
|
|
1329
|
+
await this.pauseOnServer(producer);
|
|
753
1330
|
}
|
|
754
1331
|
|
|
755
1332
|
/** True while music is published, whether or not it is switched on. */
|
|
@@ -816,6 +1393,14 @@ export class StreamClient {
|
|
|
816
1393
|
const consumer = await transport.consume(params);
|
|
817
1394
|
this.consumers.set(remote.producerId, consumer);
|
|
818
1395
|
|
|
1396
|
+
// Layers asked for before a reconnect: the new consumer gets them before
|
|
1397
|
+
// its first frame instead of starting at the full picture. Not getting
|
|
1398
|
+
// them costs data, not the track, so a refusal is only reported.
|
|
1399
|
+
const layers = this.preferredLayers.get(remote.producerId);
|
|
1400
|
+
if (layers) {
|
|
1401
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
819
1404
|
// The server created it paused. Resume only now that our end exists,
|
|
820
1405
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
821
1406
|
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
@@ -882,20 +1467,33 @@ export class StreamClient {
|
|
|
882
1467
|
|
|
883
1468
|
/** Stop publishing entirely and release the local devices. */
|
|
884
1469
|
async unpublish(): Promise<void> {
|
|
1470
|
+
// All of it takes effect now; only telling the server is waited for. So
|
|
1471
|
+
// publish() and unpublish() act in the order they are called: a publish
|
|
1472
|
+
// called while this still waits is not undone by it, and one already
|
|
1473
|
+
// under way stops at its next step (keepProducer).
|
|
1474
|
+
this.publishGeneration += 1;
|
|
885
1475
|
const ids = [...this.producers.keys()];
|
|
886
1476
|
for (const producer of this.producers.values()) producer.close();
|
|
887
1477
|
this.producers.clear();
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1478
|
+
this.cameraEncodings = null;
|
|
1479
|
+
// Start over for the next publish, and clear a limitation still showing:
|
|
1480
|
+
// nothing is being held back once nothing is being sent.
|
|
1481
|
+
const cleared = this.limitWatch.reset();
|
|
1482
|
+
if (cleared) this.emit('videoLimited', cleared);
|
|
891
1483
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
892
1484
|
this.localStream = null;
|
|
1485
|
+
// Forgotten with the stream: kept, a reconnect would ask for the camera
|
|
1486
|
+
// and microphone again and publish them, after the app unpublished them.
|
|
1487
|
+
this.publishConstraints = null;
|
|
893
1488
|
this.music?.close();
|
|
894
1489
|
this.music = null;
|
|
895
1490
|
this.musicProducerId = null;
|
|
896
1491
|
this.wasPublishing = false;
|
|
897
1492
|
this.emit('localStreamChanged', null);
|
|
898
1493
|
this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
|
|
1494
|
+
for (const id of ids) {
|
|
1495
|
+
await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
|
|
1496
|
+
}
|
|
899
1497
|
}
|
|
900
1498
|
|
|
901
1499
|
get local(): MediaStream | null {
|