@voltkit-rtc/client 0.9.1 → 0.9.3
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 +32 -0
- package/README.md +13 -6
- package/dist/cjs/adaptive.d.ts +9 -5
- package/dist/cjs/adaptive.d.ts.map +1 -1
- package/dist/cjs/adaptive.js +10 -6
- package/dist/cjs/adaptive.js.map +1 -1
- package/dist/cjs/diagnostics.d.ts +1 -1
- package/dist/cjs/diagnostics.js +1 -1
- package/dist/cjs/index.d.ts +54 -11
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +200 -30
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/adaptive.d.ts +9 -5
- package/dist/esm/adaptive.d.ts.map +1 -1
- package/dist/esm/adaptive.js +10 -6
- package/dist/esm/adaptive.js.map +1 -1
- package/dist/esm/diagnostics.d.ts +1 -1
- package/dist/esm/diagnostics.js +1 -1
- package/dist/esm/index.d.ts +54 -11
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +200 -30
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
- package/src/adaptive.ts +10 -6
- package/src/diagnostics.ts +1 -1
- package/src/index.ts +193 -30
package/package.json
CHANGED
package/src/adaptive.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The app attaches the element that shows a video (`attachVideo`). From that
|
|
6
6
|
* element's size on screen (CSS pixels × devicePixelRatio, at most 2) the
|
|
7
|
-
* smallest simulcast layer that fills it
|
|
7
|
+
* smallest simulcast layer that fills it without being stretched is chosen; while no attached
|
|
8
8
|
* element is visible (scrolled away, `display: none`, the tab in the
|
|
9
9
|
* background) the video is paused at the media server, so it costs no
|
|
10
10
|
* bandwidth and no decoding. This is what LiveKit calls adaptiveStream and
|
|
@@ -27,12 +27,16 @@ export interface ViewBox {
|
|
|
27
27
|
|
|
28
28
|
/** Never ask for more than twice the CSS size: a 3× phone screen gains nothing visible from it. */
|
|
29
29
|
export const MAX_PIXEL_RATIO = 2;
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* A layer is never stretched on screen: a box bigger than a layer gets the next one up. Up to 0.9.1 a layer could be
|
|
32
|
+
* shown 15% bigger than it is, which looks soft on a face (owner, 2026-10-20: "quality should look good"). The 2% is
|
|
33
|
+
* for rounding only, so a box a pixel or two bigger than a layer keeps it.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_UPSCALE = 1.02;
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
* The smallest layer that fills `box`
|
|
35
|
-
* not known (an older publisher): then the server chooses, as before.
|
|
38
|
+
* The smallest layer that fills `box` without being stretched, or null when
|
|
39
|
+
* the layers are not known (an older publisher): then the server chooses, as before.
|
|
36
40
|
*
|
|
37
41
|
* With `cover` the picture is scaled until it fills the box (a part is
|
|
38
42
|
* cropped), with `contain` until it fits. Either way the scale is measured
|
|
@@ -46,7 +50,7 @@ export function layerForBox(layers: readonly VideoLayer[], box: ViewBox, pixelRa
|
|
|
46
50
|
const h = box.height * ratio;
|
|
47
51
|
for (const layer of usable) {
|
|
48
52
|
const scale = box.fit === 'cover' ? Math.max(w / layer.width, h / layer.height) : Math.min(w / layer.width, h / layer.height);
|
|
49
|
-
// The layer is
|
|
53
|
+
// The layer is not stretched on screen (see MAX_UPSCALE).
|
|
50
54
|
if (scale <= MAX_UPSCALE) return layer.spatialLayer;
|
|
51
55
|
}
|
|
52
56
|
return usable[usable.length - 1]!.spatialLayer;
|
package/src/diagnostics.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -564,6 +564,12 @@ interface PreferredLayers {
|
|
|
564
564
|
|
|
565
565
|
const isLayer = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;
|
|
566
566
|
|
|
567
|
+
/** A viewer's choice for one video: a quality (setReceiveQuality, which also sets the layers) or a ceiling (setPreferredLayers). */
|
|
568
|
+
interface ReceiveChoice {
|
|
569
|
+
quality: ReceiveQuality | undefined;
|
|
570
|
+
layers: PreferredLayers | undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
567
573
|
/**
|
|
568
574
|
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
569
575
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
@@ -732,6 +738,14 @@ export class StreamClient {
|
|
|
732
738
|
private readonly screenProducerIds = new Set<string>();
|
|
733
739
|
private readonly remoteVideoLayers = new Map<string, VideoLayer[]>();
|
|
734
740
|
private readonly receiveQualities = new Map<string, ReceiveQuality>();
|
|
741
|
+
/**
|
|
742
|
+
* BR-09 (the Flutter SDK's V092-02): per video, the choice the server last accepted, and a revision every change
|
|
743
|
+
* bumps. A choice is stored before its request (what is asked for is read from it), so a request that fails goes
|
|
744
|
+
* back to the last accepted choice -- not merely the one before it, which may itself be waiting or refused -- and
|
|
745
|
+
* only the latest change may go back, so a late failure never undoes a newer choice. Replies come in request order.
|
|
746
|
+
*/
|
|
747
|
+
private readonly receiveAccepted = new Map<string, ReceiveChoice>();
|
|
748
|
+
private readonly receiveRevision = new Map<string, number>();
|
|
735
749
|
private cameraFrameRate: 30 | 60 = 30;
|
|
736
750
|
private frameRateFallback = false;
|
|
737
751
|
private frameRateLowSamples = 0;
|
|
@@ -1104,10 +1118,9 @@ export class StreamClient {
|
|
|
1104
1118
|
// A video that ended while this client was away sends it no
|
|
1105
1119
|
// producerClosed: forget the layers asked for it.
|
|
1106
1120
|
const present = new Set(result.producers.map((p) => p.producerId));
|
|
1107
|
-
for (const producerId of this.preferredLayers.keys()) {
|
|
1121
|
+
for (const producerId of new Set([...this.preferredLayers.keys(), ...this.receiveAccepted.keys()])) {
|
|
1108
1122
|
if (!present.has(producerId)) {
|
|
1109
|
-
this.
|
|
1110
|
-
this.receiveQualities.delete(producerId);
|
|
1123
|
+
this.forgetReceiveChoice(producerId);
|
|
1111
1124
|
this.remoteVideoLayers.delete(producerId);
|
|
1112
1125
|
}
|
|
1113
1126
|
}
|
|
@@ -1322,6 +1335,8 @@ export class StreamClient {
|
|
|
1322
1335
|
this.teardownMedia();
|
|
1323
1336
|
this.preferredLayers.clear();
|
|
1324
1337
|
this.receiveQualities.clear();
|
|
1338
|
+
this.receiveAccepted.clear();
|
|
1339
|
+
this.receiveRevision.clear();
|
|
1325
1340
|
this.remoteVideoLayers.clear();
|
|
1326
1341
|
this.releaseScreen();
|
|
1327
1342
|
this.music?.close();
|
|
@@ -1603,8 +1618,7 @@ export class StreamClient {
|
|
|
1603
1618
|
case 'producerClosed': {
|
|
1604
1619
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
1605
1620
|
this.subscriptions.delete(producerId);
|
|
1606
|
-
this.
|
|
1607
|
-
this.receiveQualities.delete(producerId);
|
|
1621
|
+
this.forgetReceiveChoice(producerId);
|
|
1608
1622
|
this.remoteVideoLayers.delete(producerId);
|
|
1609
1623
|
this.skippedVideos.delete(producerId);
|
|
1610
1624
|
this.adaptivePaused.delete(producerId);
|
|
@@ -2188,7 +2202,10 @@ export class StreamClient {
|
|
|
2188
2202
|
const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
2189
2203
|
const previousSource = shown ? this.cameraSource(shown) : null;
|
|
2190
2204
|
const before = this.camOut;
|
|
2205
|
+
const beforeRaw = this.camRaw;
|
|
2206
|
+
const wasPaused = current?.paused ?? false;
|
|
2191
2207
|
const sent = await this.processCamera(track);
|
|
2208
|
+
let swapped = false;
|
|
2192
2209
|
try {
|
|
2193
2210
|
if (!current) {
|
|
2194
2211
|
// The republish reads the camera's state off the track it sends, and
|
|
@@ -2202,7 +2219,10 @@ export class StreamClient {
|
|
|
2202
2219
|
}
|
|
2203
2220
|
}
|
|
2204
2221
|
this.adoptVideoTrack(sent);
|
|
2205
|
-
if (!current || current.track === sent)
|
|
2222
|
+
if (!current || current.track === sent) {
|
|
2223
|
+
swapped = true;
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2206
2226
|
const previous = current.track;
|
|
2207
2227
|
const paused = current.paused;
|
|
2208
2228
|
try {
|
|
@@ -2213,11 +2233,13 @@ export class StreamClient {
|
|
|
2213
2233
|
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
2214
2234
|
previousSize?.width === width && previousSize?.height === height) {
|
|
2215
2235
|
await current.replaceTrack({ track: sent });
|
|
2236
|
+
swapped = true;
|
|
2216
2237
|
this.watchTrackEnded(current);
|
|
2217
2238
|
await this.applyLayerBitrates(current, next);
|
|
2218
2239
|
return;
|
|
2219
2240
|
}
|
|
2220
2241
|
await this.reproduceCamera(current, sent);
|
|
2242
|
+
swapped = true;
|
|
2221
2243
|
} finally {
|
|
2222
2244
|
// Pausing switched the old track off (mediasoup-client pauses that
|
|
2223
2245
|
// way), and nothing switches it back on once it is replaced. It is the
|
|
@@ -2225,13 +2247,55 @@ export class StreamClient {
|
|
|
2225
2247
|
// off, that canvas stays black after the camera is resumed.
|
|
2226
2248
|
if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
|
|
2227
2249
|
}
|
|
2250
|
+
} catch (err) {
|
|
2251
|
+
if (!swapped) {
|
|
2252
|
+
await this.undoCameraSwap({ producer: current ?? null, given: track, made: sent, shown, source: previousSource, raw: beforeRaw, out: before, paused: wasPaused });
|
|
2253
|
+
}
|
|
2254
|
+
throw err;
|
|
2228
2255
|
} finally {
|
|
2229
|
-
// The processor's previous picture is no longer sent or shown.
|
|
2230
|
-
if (before && before !== this.camOut) before.stop();
|
|
2256
|
+
// The processor's previous picture is no longer sent or shown -- once the new one is.
|
|
2257
|
+
if (swapped && before && before !== this.camOut) before.stop();
|
|
2231
2258
|
}
|
|
2232
2259
|
});
|
|
2233
2260
|
}
|
|
2234
2261
|
|
|
2262
|
+
/**
|
|
2263
|
+
* A camera swap that failed (BR-12; the Flutter SDK's V090-02). What is sent is still the old picture -- or nothing,
|
|
2264
|
+
* when the producer was replaced and the new one failed. The client's stream goes back to the old picture, so the
|
|
2265
|
+
* app's preview and a reconnect never use a track that did not go on air. With a processor, the old camera is put
|
|
2266
|
+
* back through it if it dropped the old picture when given the new track. The picture made for the new track is
|
|
2267
|
+
* released; the new track itself is the caller's.
|
|
2268
|
+
*/
|
|
2269
|
+
private async undoCameraSwap(undo: {
|
|
2270
|
+
producer: Producer | null; given: MediaStreamTrack; made: MediaStreamTrack; shown: MediaStreamTrack | null;
|
|
2271
|
+
source: MediaStreamTrack | null; raw: MediaStreamTrack | null; out: MediaStreamTrack | null; paused: boolean;
|
|
2272
|
+
}): Promise<void> {
|
|
2273
|
+
const { producer, given, made, shown, source, raw, out, paused } = undo;
|
|
2274
|
+
if (made !== given) made.stop();
|
|
2275
|
+
const sending = () => producer !== null && !producer.closed && this.cameraProducer() === producer;
|
|
2276
|
+
let picture = shown;
|
|
2277
|
+
if (shown && shown.readyState === 'ended' && source && source !== shown && source.readyState !== 'ended') {
|
|
2278
|
+
// The processor dropped the picture on air: make it again from the old camera, and send that.
|
|
2279
|
+
picture = await this.processCamera(source);
|
|
2280
|
+
if (sending() && producer && producer.track !== picture) {
|
|
2281
|
+
try {
|
|
2282
|
+
await producer.replaceTrack({ track: picture });
|
|
2283
|
+
this.watchTrackEnded(producer);
|
|
2284
|
+
} catch (err) {
|
|
2285
|
+
this.emit('error', new Error(`the camera could not be put back after a failed switch: ${(err as Error)?.message ?? err}`));
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
} else {
|
|
2289
|
+
// The picture on air is as it was, and so is what stands behind it.
|
|
2290
|
+
this.camRaw = raw;
|
|
2291
|
+
this.camOut = out;
|
|
2292
|
+
}
|
|
2293
|
+
if (!picture || picture.readyState === 'ended') return;
|
|
2294
|
+
// With nothing sending it, a republish reads the camera's state off the track: a paused camera stays paused.
|
|
2295
|
+
if (!sending()) picture.enabled = !paused;
|
|
2296
|
+
this.adoptVideoTrack(picture);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2235
2299
|
/**
|
|
2236
2300
|
* Give a camera producer whose track was swapped in place the bitrates of
|
|
2237
2301
|
* its new picture. The layout matched, but a bigger picture at the same
|
|
@@ -2302,8 +2366,10 @@ export class StreamClient {
|
|
|
2302
2366
|
* 1280×720), and a layer above the biggest means the biggest. Leave
|
|
2303
2367
|
* `temporalLayer` out to keep the full frame rate. It is the most this
|
|
2304
2368
|
* client is sent: a connection that cannot carry it still gets less. The
|
|
2305
|
-
* choice is kept across reconnects and forgotten when the track ends
|
|
2306
|
-
*
|
|
2369
|
+
* choice is kept across reconnects and forgotten when the track ends; a
|
|
2370
|
+
* call that fails (refused, or the connection lost before the answer)
|
|
2371
|
+
* leaves the previous choice in place. Rejects for a producer whose video
|
|
2372
|
+
* this client is not receiving.
|
|
2307
2373
|
*/
|
|
2308
2374
|
async setPreferredLayers(producerId: string, spatialLayer: number, temporalLayer?: number): Promise<void> {
|
|
2309
2375
|
if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
|
|
@@ -2314,12 +2380,18 @@ export class StreamClient {
|
|
|
2314
2380
|
throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
|
|
2315
2381
|
}
|
|
2316
2382
|
const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
|
|
2317
|
-
|
|
2318
|
-
//
|
|
2319
|
-
this.
|
|
2383
|
+
// Stored before the request, which reads it; a request that fails (refused, or the connection lost before the
|
|
2384
|
+
// answer) leaves the last accepted choice in place (BR-09).
|
|
2385
|
+
const change = this.changeReceive(producerId, { quality: undefined, layers });
|
|
2320
2386
|
// Shown through attachVideo: the lower of this and the size it is shown at.
|
|
2321
2387
|
const adaptive = this.adaptive.current(producerId);
|
|
2322
|
-
|
|
2388
|
+
try {
|
|
2389
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
change.failed();
|
|
2392
|
+
throw err;
|
|
2393
|
+
}
|
|
2394
|
+
change.accepted();
|
|
2323
2395
|
}
|
|
2324
2396
|
|
|
2325
2397
|
private async collectVideoStats(baseline: StatsBaseline): Promise<VideoSendStats | null> {
|
|
@@ -2337,7 +2409,14 @@ export class StreamClient {
|
|
|
2337
2409
|
return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
|
|
2338
2410
|
}
|
|
2339
2411
|
|
|
2340
|
-
/**
|
|
2412
|
+
/**
|
|
2413
|
+
* The quality this viewer gets of one remote video. '1080p', '720p' and '480p' pick the publisher's biggest layer
|
|
2414
|
+
* at or below that size (a 720p camera cannot give 1080p), and the viewer gets it while the video is shown, however
|
|
2415
|
+
* big it is on screen: someone who picks 1080p expects 1080p, not the smaller copy that fits the tile. A connection
|
|
2416
|
+
* that cannot carry it still gets less until it recovers, and a video shown through attachVideo is still paused
|
|
2417
|
+
* while nobody can see it. 'auto' (the default) goes back to the size it is shown at, or the server's choice.
|
|
2418
|
+
* Kept across reconnects; a call that fails leaves the previous choice in place.
|
|
2419
|
+
*/
|
|
2341
2420
|
async setReceiveQuality(producerId: string, quality: ReceiveQuality): Promise<void> {
|
|
2342
2421
|
if (!['auto', '1080p', '720p', '480p'].includes(quality)) throw new RangeError('setReceiveQuality: unsupported quality');
|
|
2343
2422
|
const consumer = this.consumers.get(producerId);
|
|
@@ -2348,10 +2427,48 @@ export class StreamClient {
|
|
|
2348
2427
|
try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; }
|
|
2349
2428
|
catch { throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto'); }
|
|
2350
2429
|
}
|
|
2351
|
-
this.
|
|
2352
|
-
this.preferredLayers.set(producerId, layers);
|
|
2430
|
+
const change = this.changeReceive(producerId, { quality, layers });
|
|
2353
2431
|
const adaptive = this.adaptive.current(producerId);
|
|
2354
|
-
|
|
2432
|
+
try {
|
|
2433
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
2434
|
+
} catch (err) {
|
|
2435
|
+
change.failed();
|
|
2436
|
+
throw err;
|
|
2437
|
+
}
|
|
2438
|
+
change.accepted();
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/** Store `choice` for one video now, before its request; the caller reports how the request ended (BR-09). */
|
|
2442
|
+
private changeReceive(producerId: string, choice: ReceiveChoice): { accepted(): void; failed(): void } {
|
|
2443
|
+
if (!this.receiveAccepted.has(producerId)) {
|
|
2444
|
+
this.receiveAccepted.set(producerId, { quality: this.receiveQualities.get(producerId), layers: this.preferredLayers.get(producerId) });
|
|
2445
|
+
}
|
|
2446
|
+
const revision = (this.receiveRevision.get(producerId) ?? 0) + 1;
|
|
2447
|
+
this.receiveRevision.set(producerId, revision);
|
|
2448
|
+
this.setReceiveChoice(producerId, choice);
|
|
2449
|
+
return {
|
|
2450
|
+
// Forgotten meanwhile (the video ended, the session closed): a late answer changes nothing.
|
|
2451
|
+
accepted: () => { if (this.receiveRevision.has(producerId)) this.receiveAccepted.set(producerId, choice); },
|
|
2452
|
+
failed: () => {
|
|
2453
|
+
const back = this.receiveAccepted.get(producerId);
|
|
2454
|
+
if (this.receiveRevision.get(producerId) === revision && back) this.setReceiveChoice(producerId, back);
|
|
2455
|
+
},
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
private setReceiveChoice(producerId: string, choice: ReceiveChoice): void {
|
|
2460
|
+
if (choice.quality === undefined) this.receiveQualities.delete(producerId);
|
|
2461
|
+
else this.receiveQualities.set(producerId, choice.quality);
|
|
2462
|
+
if (choice.layers === undefined) this.preferredLayers.delete(producerId);
|
|
2463
|
+
else this.preferredLayers.set(producerId, choice.layers);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
/** The video ended: its choices go with it. */
|
|
2467
|
+
private forgetReceiveChoice(producerId: string): void {
|
|
2468
|
+
this.preferredLayers.delete(producerId);
|
|
2469
|
+
this.receiveQualities.delete(producerId);
|
|
2470
|
+
this.receiveAccepted.delete(producerId);
|
|
2471
|
+
this.receiveRevision.delete(producerId);
|
|
2355
2472
|
}
|
|
2356
2473
|
|
|
2357
2474
|
private cameraProducer(): Producer | undefined {
|
|
@@ -3089,26 +3206,34 @@ export class StreamClient {
|
|
|
3089
3206
|
* Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
|
|
3090
3207
|
* stream with receiveWhenViewVisible): tell the SDK which element shows a
|
|
3091
3208
|
* remote video. It then receives the smallest layer that fills that element
|
|
3092
|
-
*
|
|
3209
|
+
* without stretching it (its size on screen × devicePixelRatio, at most 2) and pauses the
|
|
3093
3210
|
* video at the server while the element is not visible (scrolled away,
|
|
3094
3211
|
* `display: none`, or the tab in the background), so a grid of small tiles
|
|
3095
3212
|
* stops decoding full-size video. Attach the same video to several elements
|
|
3096
3213
|
* and the largest visible one decides. Returns the function that detaches
|
|
3097
|
-
* it; detach when the element goes away. A
|
|
3098
|
-
* `setReceiveQuality`
|
|
3099
|
-
*
|
|
3100
|
-
*
|
|
3214
|
+
* it; detach when the element goes away. A quality chosen with
|
|
3215
|
+
* `setReceiveQuality` replaces the size it is shown at (the video is still
|
|
3216
|
+
* paused while hidden), and a ceiling set with `setPreferredLayers` still
|
|
3217
|
+
* applies. Works with any server; one without `pauseConsumer` gets the
|
|
3218
|
+
* smallest layer instead of a pause.
|
|
3101
3219
|
*/
|
|
3102
3220
|
attachVideo(producerId: string, element: Element): () => void {
|
|
3103
3221
|
return this.adaptive.attach(producerId, element);
|
|
3104
3222
|
}
|
|
3105
3223
|
|
|
3106
|
-
/**
|
|
3224
|
+
/**
|
|
3225
|
+
* The layers to ask for. A quality chosen with setReceiveQuality is the layer, whatever the size on screen (owner,
|
|
3226
|
+
* 2026-10-20: viewers who picked 1080p got 540p, because it was only a ceiling). A ceiling set with
|
|
3227
|
+
* setPreferredLayers and adaptive stream's size: the lower of the two.
|
|
3228
|
+
*/
|
|
3107
3229
|
private effectiveLayers(producerId: string, want: AdaptiveWant | null): PreferredLayers {
|
|
3230
|
+
// Hidden: paused at the server, or held at the smallest layer where it cannot pause. A chosen quality or a ceiling
|
|
3231
|
+
// applies once the video is shown again, never while nobody can see it (BR-08; the Flutter SDK's V092-01).
|
|
3232
|
+
if (want && !want.visible) return { spatialLayer: 0, temporalLayer: 0 };
|
|
3108
3233
|
let manual: PreferredLayers | undefined = this.preferredLayers.get(producerId);
|
|
3109
3234
|
const quality = this.receiveQualities.get(producerId);
|
|
3110
3235
|
if (quality && quality !== 'auto') {
|
|
3111
|
-
try {
|
|
3236
|
+
try { return { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; } catch { manual = undefined; }
|
|
3112
3237
|
}
|
|
3113
3238
|
const adaptive = want?.visible ? want.spatialLayer : null;
|
|
3114
3239
|
if (adaptive === null || adaptive === undefined) return manual ?? { spatialLayer: -1, temporalLayer: -1 };
|
|
@@ -3476,7 +3601,9 @@ export class StreamClient {
|
|
|
3476
3601
|
* captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
|
|
3477
3602
|
* published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
|
|
3478
3603
|
* replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
|
|
3479
|
-
* reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
|
|
3604
|
+
* reconnect captures the chosen device again. A camera or microphone that is switched off stays off. A swap that
|
|
3605
|
+
* fails releases the new device and keeps the old one on air, put back through a noise suppressor or video effects
|
|
3606
|
+
* if one is set.
|
|
3480
3607
|
*
|
|
3481
3608
|
* For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
|
|
3482
3609
|
* processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
|
|
@@ -3516,16 +3643,35 @@ export class StreamClient {
|
|
|
3516
3643
|
track.stop();
|
|
3517
3644
|
throw new Error('switchDevice: the microphone was unpublished meanwhile');
|
|
3518
3645
|
}
|
|
3519
|
-
|
|
3646
|
+
try {
|
|
3647
|
+
// Releases the new microphone itself if the swap fails.
|
|
3648
|
+
await this.adoptAudioTrack(producer, track);
|
|
3649
|
+
} catch (err) {
|
|
3650
|
+
if (producer.closed || this.micProducer() !== producer) throw new Error('switchDevice: the microphone was unpublished meanwhile');
|
|
3651
|
+
throw err;
|
|
3652
|
+
}
|
|
3520
3653
|
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
3521
3654
|
});
|
|
3522
3655
|
}
|
|
3523
3656
|
|
|
3524
|
-
/**
|
|
3657
|
+
/**
|
|
3658
|
+
* Put `track` (one the SDK captured) on the microphone producer and in the client's own stream; the replaced track is
|
|
3659
|
+
* stopped. A swap that fails releases `track` and the sound made from it, and with a processor puts the microphone
|
|
3660
|
+
* still on air back through it: given the new track, the processor dropped that sound (BR-10, BR-11).
|
|
3661
|
+
*/
|
|
3525
3662
|
private async adoptAudioTrack(producer: Producer, track: MediaStreamTrack): Promise<void> {
|
|
3526
3663
|
const previous = this.micRaw ?? producer.track;
|
|
3527
|
-
const
|
|
3528
|
-
|
|
3664
|
+
const previousRaw = this.micRaw;
|
|
3665
|
+
let sent = track;
|
|
3666
|
+
try {
|
|
3667
|
+
sent = await this.processMic(track);
|
|
3668
|
+
await producer.replaceTrack({ track: sent });
|
|
3669
|
+
} catch (err) {
|
|
3670
|
+
if (sent !== track) sent.stop();
|
|
3671
|
+
track.stop();
|
|
3672
|
+
await this.restoreMic(producer, previousRaw);
|
|
3673
|
+
throw err;
|
|
3674
|
+
}
|
|
3529
3675
|
this.micRaw = sent !== track ? track : null;
|
|
3530
3676
|
const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
|
|
3531
3677
|
this.localStream = new MediaStream([track, ...video]);
|
|
@@ -3534,6 +3680,23 @@ export class StreamClient {
|
|
|
3534
3680
|
this.watchTrackEnded(producer);
|
|
3535
3681
|
}
|
|
3536
3682
|
|
|
3683
|
+
/** After a failed microphone swap with a processor: send the microphone on air through it again (it dropped that sound). */
|
|
3684
|
+
private async restoreMic(producer: Producer, raw: MediaStreamTrack | null): Promise<void> {
|
|
3685
|
+
const current = () => !producer.closed && this.micProducer() === producer;
|
|
3686
|
+
if (!this.audioProcessor || !raw || raw.readyState === 'ended' || !current()) return;
|
|
3687
|
+
try {
|
|
3688
|
+
const again = await this.processMic(raw);
|
|
3689
|
+
if (!current()) {
|
|
3690
|
+
if (again !== raw) again.stop();
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
await producer.replaceTrack({ track: again });
|
|
3694
|
+
this.micRaw = again !== raw ? raw : null;
|
|
3695
|
+
} catch (err) {
|
|
3696
|
+
this.emit('error', new Error(`the microphone could not be put back after a failed switch: ${(err as Error)?.message ?? err}`));
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3537
3700
|
/** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
|
|
3538
3701
|
private watchTrackEnded(producer: Producer): void {
|
|
3539
3702
|
// The device's track, not the processed one a processor sends: that is the one a device ends.
|