@voltkit-rtc/client 0.7.2 → 0.8.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 +89 -0
- package/README.md +255 -4
- package/dist/cjs/adaptive.d.ts +104 -0
- package/dist/cjs/adaptive.d.ts.map +1 -0
- package/dist/cjs/adaptive.js +263 -0
- package/dist/cjs/adaptive.js.map +1 -0
- package/dist/cjs/devices.d.ts +42 -0
- package/dist/cjs/devices.d.ts.map +1 -0
- package/dist/cjs/devices.js +56 -0
- package/dist/cjs/devices.js.map +1 -0
- package/dist/cjs/diagnostics.d.ts +65 -0
- package/dist/cjs/diagnostics.d.ts.map +1 -0
- package/dist/cjs/diagnostics.js +69 -0
- package/dist/cjs/diagnostics.js.map +1 -0
- package/dist/cjs/fallback.d.ts +81 -0
- package/dist/cjs/fallback.d.ts.map +1 -0
- package/dist/cjs/fallback.js +148 -0
- package/dist/cjs/fallback.js.map +1 -0
- package/dist/cjs/health.d.ts +70 -0
- package/dist/cjs/health.d.ts.map +1 -0
- package/dist/cjs/health.js +116 -0
- package/dist/cjs/health.js.map +1 -0
- package/dist/cjs/index.d.ts +350 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1201 -36
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/link.d.ts +99 -0
- package/dist/cjs/link.d.ts.map +1 -0
- package/dist/cjs/link.js +219 -0
- package/dist/cjs/link.js.map +1 -0
- package/dist/cjs/preflight.d.ts +79 -0
- package/dist/cjs/preflight.d.ts.map +1 -0
- package/dist/cjs/preflight.js +159 -0
- package/dist/cjs/preflight.js.map +1 -0
- package/dist/cjs/recording.d.ts +14 -0
- package/dist/cjs/recording.d.ts.map +1 -1
- package/dist/cjs/recording.js +51 -5
- package/dist/cjs/recording.js.map +1 -1
- package/dist/cjs/speakers.d.ts +34 -0
- package/dist/cjs/speakers.d.ts.map +1 -0
- package/dist/cjs/speakers.js +71 -0
- package/dist/cjs/speakers.js.map +1 -0
- package/dist/esm/adaptive.d.ts +104 -0
- package/dist/esm/adaptive.d.ts.map +1 -0
- package/dist/esm/adaptive.js +256 -0
- package/dist/esm/adaptive.js.map +1 -0
- package/dist/esm/devices.d.ts +42 -0
- package/dist/esm/devices.d.ts.map +1 -0
- package/dist/esm/devices.js +50 -0
- package/dist/esm/devices.js.map +1 -0
- package/dist/esm/diagnostics.d.ts +65 -0
- package/dist/esm/diagnostics.d.ts.map +1 -0
- package/dist/esm/diagnostics.js +65 -0
- package/dist/esm/diagnostics.js.map +1 -0
- package/dist/esm/fallback.d.ts +81 -0
- package/dist/esm/fallback.d.ts.map +1 -0
- package/dist/esm/fallback.js +142 -0
- package/dist/esm/fallback.js.map +1 -0
- package/dist/esm/health.d.ts +70 -0
- package/dist/esm/health.d.ts.map +1 -0
- package/dist/esm/health.js +111 -0
- package/dist/esm/health.js.map +1 -0
- package/dist/esm/index.d.ts +350 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1199 -36
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/link.d.ts +99 -0
- package/dist/esm/link.d.ts.map +1 -0
- package/dist/esm/link.js +214 -0
- package/dist/esm/link.js.map +1 -0
- package/dist/esm/preflight.d.ts +79 -0
- package/dist/esm/preflight.d.ts.map +1 -0
- package/dist/esm/preflight.js +153 -0
- package/dist/esm/preflight.js.map +1 -0
- package/dist/esm/recording.d.ts +14 -0
- package/dist/esm/recording.d.ts.map +1 -1
- package/dist/esm/recording.js +51 -5
- package/dist/esm/recording.js.map +1 -1
- package/dist/esm/speakers.d.ts +34 -0
- package/dist/esm/speakers.d.ts.map +1 -0
- package/dist/esm/speakers.js +67 -0
- package/dist/esm/speakers.js.map +1 -0
- package/package.json +1 -1
- package/src/adaptive.ts +295 -0
- package/src/devices.ts +70 -0
- package/src/diagnostics.ts +94 -0
- package/src/fallback.ts +169 -0
- package/src/health.ts +127 -0
- package/src/index.ts +1192 -36
- package/src/link.ts +255 -0
- package/src/preflight.ts +191 -0
- package/src/recording.ts +59 -21
- package/src/speakers.ts +66 -0
package/src/index.ts
CHANGED
|
@@ -30,7 +30,15 @@ import {
|
|
|
30
30
|
type JoinMedia,
|
|
31
31
|
type MediaPermissions,
|
|
32
32
|
} from './media-permissions.js';
|
|
33
|
-
import { NetworkQualityWatch, type NetworkQuality } from './network.js';
|
|
33
|
+
import { NetworkQualityWatch, networkQualityOf, readTransportStats, type NetworkQuality } from './network.js';
|
|
34
|
+
import { MediaPathWatch, SocketLiveness, type RestartOutcome } from './link.js';
|
|
35
|
+
import { AdaptiveVideos, pageIsVisible, type AdaptiveWant } from './adaptive.js';
|
|
36
|
+
import { AudioProtection, PROTECT_SAMPLE_MS, readInboundAudio, readOutboundAudio } from './fallback.js';
|
|
37
|
+
import { ActiveSpeakers, AUDIO_LEVEL_INTERVAL_MS, SPEAKING_LEVEL } from './speakers.js';
|
|
38
|
+
import { connectionPath, runPreflight, syntheticMedia, type PreflightOptions, type PreflightReport } from './preflight.js';
|
|
39
|
+
import { DiagnosticsLog, SDK_VERSION, type Diagnostics, type DiagnosticsTransport } from './diagnostics.js';
|
|
40
|
+
import { HEALTH_SAMPLE_MS, HealthDecisions, INTERRUPTED_AFTER_MS, readMediaSource, type MediaWarning } from './health.js';
|
|
41
|
+
import { constraintsFor, deviceIdOf, diffDevices, enumerate, type DeviceKind, type DevicesChangedEvent, type MediaDevice } from './devices.js';
|
|
34
42
|
import { socketUrl, TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
35
43
|
import {
|
|
36
44
|
chooseCodec,
|
|
@@ -89,6 +97,12 @@ export { TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
|
89
97
|
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
90
98
|
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
|
|
91
99
|
export type { CodecPreference } from './device.js';
|
|
100
|
+
export type { DeviceKind, DevicesChangedEvent, MediaDevice } from './devices.js';
|
|
101
|
+
export { SPEAKING_LEVEL } from './speakers.js';
|
|
102
|
+
export type { MediaWarning, MediaWarningCode } from './health.js';
|
|
103
|
+
export type { PreflightOptions, PreflightReport, PreflightStep, PreflightStepName } from './preflight.js';
|
|
104
|
+
export { SDK_VERSION } from './diagnostics.js';
|
|
105
|
+
export type { Diagnostics, DiagnosticsEntry, DiagnosticsTransport } from './diagnostics.js';
|
|
92
106
|
export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
|
|
93
107
|
export type {
|
|
94
108
|
ChatClientEvents,
|
|
@@ -261,6 +275,37 @@ export interface ClientOptions {
|
|
|
261
275
|
* reserved for future native hosts, which have no user agent to detect.
|
|
262
276
|
*/
|
|
263
277
|
handlerName?: DeviceHandlerName;
|
|
278
|
+
/**
|
|
279
|
+
* Audio-only fallback (0.8.0, on unless `false`): when this viewer's connection is so poor that the received sound
|
|
280
|
+
* breaks up, remote camera videos are paused so the audio keeps going, and resumed once it recovers. See
|
|
281
|
+
* `setAudioOnlyFallback` and the `streamFallback` event.
|
|
282
|
+
*/
|
|
283
|
+
audioOnlyFallback?: boolean;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** One participant's sound now (Agora's volume-indicator, Tencent TRTC's AUDIO_VOLUME). */
|
|
287
|
+
export interface AudioLevel {
|
|
288
|
+
identity: string;
|
|
289
|
+
producerId: string;
|
|
290
|
+
source: TrackSource;
|
|
291
|
+
/** WebRTC's audioLevel: linear, 0 = silence, 1 = 0 dBov. */
|
|
292
|
+
level: number;
|
|
293
|
+
/** Above `SPEAKING_LEVEL` (-35 dBov, LiveKit's threshold). */
|
|
294
|
+
speaking: boolean;
|
|
295
|
+
/** This client's own microphone. */
|
|
296
|
+
local: boolean;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Why a remote video is held back: the SFU cut it for this viewer's bandwidth, or the SDK paused it to save the audio. */
|
|
300
|
+
export type StreamFallbackReason = 'bandwidth' | 'audio-protection';
|
|
301
|
+
|
|
302
|
+
export interface StreamFallbackEvent {
|
|
303
|
+
producerId: string;
|
|
304
|
+
identity: string;
|
|
305
|
+
source: TrackSource;
|
|
306
|
+
/** true: the video is held back (show the person's name or avatar); false: it is coming back. */
|
|
307
|
+
fallback: boolean;
|
|
308
|
+
reason: StreamFallbackReason;
|
|
264
309
|
}
|
|
265
310
|
|
|
266
311
|
export interface SlotClaim {
|
|
@@ -295,7 +340,26 @@ export interface RemoteProducer {
|
|
|
295
340
|
|
|
296
341
|
const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio' ? 'mic' : 'camera');
|
|
297
342
|
|
|
343
|
+
/** A receiver's most recent audio level (0..1), from its synchronization sources: cheap, made for this. */
|
|
344
|
+
function receiverLevel(receiver: RTCRtpReceiver | undefined): number {
|
|
345
|
+
const sources = receiver?.getSynchronizationSources?.() ?? [];
|
|
346
|
+
let level = 0;
|
|
347
|
+
for (const s of sources) if (typeof s.audioLevel === 'number') level = Math.max(level, s.audioLevel);
|
|
348
|
+
return level;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A microphone producer's level now (0..1), from its media-source statistics. */
|
|
352
|
+
async function senderLevel(producer: Producer): Promise<number> {
|
|
353
|
+
const stats = await producer.getStats();
|
|
354
|
+
for (const s of stats.values() as Iterable<Record<string, unknown>>) {
|
|
355
|
+
if (s.type === 'media-source' && typeof s.audioLevel === 'number') return s.audioLevel;
|
|
356
|
+
}
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
298
360
|
export interface ClientEvents extends ChatEvents {
|
|
361
|
+
/** A transport is recovering its media path; the room stays joined. */
|
|
362
|
+
mediaReconnecting: (e: { transportId: string; reconnecting: boolean }) => void;
|
|
299
363
|
connected: () => void;
|
|
300
364
|
disconnected: (reason: string) => void;
|
|
301
365
|
/** idle → connecting → connected ⇄ reconnecting → closed. Read it any time as `connectionState`. */
|
|
@@ -390,6 +454,66 @@ export interface ClientEvents extends ChatEvents {
|
|
|
390
454
|
* every 2 s, only while publishing a camera and someone listens.
|
|
391
455
|
*/
|
|
392
456
|
videoLimited: (e: VideoLimitedEvent) => void;
|
|
457
|
+
/**
|
|
458
|
+
* This device is too busy to encode its camera at full quality (WebRTC's
|
|
459
|
+
* `qualityLimitationReason: "cpu"`, held for about 4 s), or no longer is.
|
|
460
|
+
* The moment to lighten its load, as LiveKit's and Agora's guides advise:
|
|
461
|
+
* `prioritizePerformance()`, video effects off, remote videos smaller (or
|
|
462
|
+
* attach them with `attachVideo`). Checked every 2 s while a camera is
|
|
463
|
+
* published and someone listens.
|
|
464
|
+
*/
|
|
465
|
+
cpuConstrained: (e: { constrained: boolean }) => void;
|
|
466
|
+
/**
|
|
467
|
+
* A remote video is held back because this viewer's connection cannot carry it (`fallback: true`), or is coming
|
|
468
|
+
* back (`false`) -- Agora's `stream-fallback`. `reason: 'bandwidth'`: the media server cut it, the audio still
|
|
469
|
+
* flows. `'audio-protection'`: the received sound was breaking up, so the SDK paused remote cameras to save it
|
|
470
|
+
* (screen shares stay). Show the person's name or avatar meanwhile.
|
|
471
|
+
*/
|
|
472
|
+
streamFallback: (e: StreamFallbackEvent) => void;
|
|
473
|
+
/**
|
|
474
|
+
* The publisher-side audio-only fallback (`setPublishFallback(true)`, off by default) paused this client's camera
|
|
475
|
+
* because its upload could not carry the sound (`fallback: true`), or brought it back (`false`). Agora's
|
|
476
|
+
* onLocalPublishFallbackToAudioOnly. Viewers see the camera paused meanwhile.
|
|
477
|
+
*/
|
|
478
|
+
publishFallback: (e: { fallback: boolean }) => void;
|
|
479
|
+
/**
|
|
480
|
+
* A camera, microphone or speaker was plugged in or removed (Tencent TRTC's DEVICE_CHANGED, Chime's
|
|
481
|
+
* audioInputsChanged / videoInputsChanged). Watched only while someone listens.
|
|
482
|
+
*/
|
|
483
|
+
devicesChanged: (e: DevicesChangedEvent) => void;
|
|
484
|
+
/**
|
|
485
|
+
* The camera or microphone this client publishes stopped from outside: unplugged, a Bluetooth headset switched off,
|
|
486
|
+
* the permission revoked. A microphone is captured again from the system default and keeps publishing
|
|
487
|
+
* (`recovered: true`), or is switched off if there is none; a camera is switched off (`recovered: false`) and never
|
|
488
|
+
* swapped for another one on its own -- offer `listDevices('videoinput')` and `switchDevice`, then
|
|
489
|
+
* `setCamEnabled(true)`.
|
|
490
|
+
*/
|
|
491
|
+
localTrackEnded: (e: { kind: 'audio' | 'video'; deviceId: string | null; recovered: boolean }) => void;
|
|
492
|
+
/**
|
|
493
|
+
* Every participant's sound about four times a second while listened to (Agora's volume-indicator, TRTC's
|
|
494
|
+
* AUDIO_VOLUME): each remote microphone, music and shared audio this client receives, and its own microphone while
|
|
495
|
+
* on. For a "speaking" ring use `speaking`.
|
|
496
|
+
*/
|
|
497
|
+
audioLevels: (levels: AudioLevel[]) => void;
|
|
498
|
+
/**
|
|
499
|
+
* Who is speaking, most active first, when that changes (Amazon Chime's active speaker ranking): for a speaker
|
|
500
|
+
* view or a highlighted tile. Microphones only; music does not count. This client is in it while its own
|
|
501
|
+
* microphone is on.
|
|
502
|
+
*/
|
|
503
|
+
activeSpeakers: (e: { speakers: string[] }) => void;
|
|
504
|
+
/**
|
|
505
|
+
* A problem with this client's own camera or microphone starts (`active: true`) or clears (`false`), like Agora's
|
|
506
|
+
* exception events: `mic-silent` (on, but exact silence: a mute switch or a broken device), `camera-fps-low` (far
|
|
507
|
+
* fewer frames than asked: usually a dark room), `camera-interrupted` / `mic-interrupted` (the system took the
|
|
508
|
+
* device: a call, another app). Checked every 2 s while listened to.
|
|
509
|
+
*/
|
|
510
|
+
mediaWarning: (e: MediaWarning) => void;
|
|
511
|
+
/**
|
|
512
|
+
* The browser refused to play an element handed to `play()` until the viewer interacts with the page (Agora's
|
|
513
|
+
* autoplay-failed, Tencent TRTC's AUTOPLAY_FAILED). Show "Tap to unmute" and call `resume()` from that tap; the SDK
|
|
514
|
+
* also retries every refused element on the next tap or key press by itself.
|
|
515
|
+
*/
|
|
516
|
+
autoplayFailed: (e: { element: HTMLMediaElement; resume: () => Promise<void> }) => void;
|
|
393
517
|
}
|
|
394
518
|
|
|
395
519
|
type Handler = (...args: never[]) => void;
|
|
@@ -398,12 +522,14 @@ type Handler = (...args: never[]) => void;
|
|
|
398
522
|
// event without listing it here (or listing one that does not exist) fails to
|
|
399
523
|
// compile; the contract check compares this list with docs/sdk-contract.json.
|
|
400
524
|
const EVENT_NAMES: { readonly [K in keyof ClientEvents]-?: true } = {
|
|
525
|
+
mediaReconnecting: true,
|
|
401
526
|
connected: true, disconnected: true, connectionStateChanged: true, joined: true,
|
|
402
527
|
track: true, trackEnded: true, trackMuted: true,
|
|
403
528
|
participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
|
|
404
529
|
roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
|
|
405
530
|
mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
|
|
406
|
-
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
|
|
531
|
+
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true, cpuConstrained: true,
|
|
532
|
+
streamFallback: true, publishFallback: true, devicesChanged: true, localTrackEnded: true, audioLevels: true, activeSpeakers: true, mediaWarning: true, autoplayFailed: true,
|
|
407
533
|
screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
|
|
408
534
|
message: true, messageDeleted: true, chatMuted: true, typing: true,
|
|
409
535
|
};
|
|
@@ -426,8 +552,6 @@ const CLOSE_REPLACED = 4001;
|
|
|
426
552
|
/** The server ended the session on purpose and said why first: do not reconnect. */
|
|
427
553
|
const CLOSE_ENDED = 4003;
|
|
428
554
|
|
|
429
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
430
|
-
|
|
431
555
|
/** What setPreferredLayers sends; no temporal layer means the full frame rate. */
|
|
432
556
|
interface PreferredLayers {
|
|
433
557
|
spatialLayer: number;
|
|
@@ -446,6 +570,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
|
|
|
446
570
|
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
447
571
|
|
|
448
572
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
573
|
+
/**
|
|
574
|
+
* The constraints that capture again what two publishes captured: each kind
|
|
575
|
+
* as the later publish asked for it, or as the earlier one did if the later
|
|
576
|
+
* one did not ask for it.
|
|
577
|
+
*/
|
|
578
|
+
function mergeConstraints(earlier: MediaStreamConstraints | null, later: MediaStreamConstraints): MediaStreamConstraints {
|
|
579
|
+
if (!earlier) return later;
|
|
580
|
+
return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
|
|
581
|
+
}
|
|
582
|
+
|
|
449
583
|
const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
450
584
|
|
|
451
585
|
/**
|
|
@@ -468,6 +602,72 @@ function declareAudioQuality(rtpParameters: ms.RtpParameters, appData: Record<st
|
|
|
468
602
|
}
|
|
469
603
|
|
|
470
604
|
export class StreamClient {
|
|
605
|
+
private backoffWake: (() => void) | null = null;
|
|
606
|
+
private onlineTarget: Window | null = null;
|
|
607
|
+
private readonly online = () => {
|
|
608
|
+
if (this.closedByUs) return;
|
|
609
|
+
this.backoffWake?.();
|
|
610
|
+
this.socketLiveness.checkNow();
|
|
611
|
+
};
|
|
612
|
+
private readonly socketLiveness = new SocketLiveness({
|
|
613
|
+
active: () => !this.closedByUs && this.state === 'connected',
|
|
614
|
+
ping: () => { void this.rpc('ping').catch(() => undefined); },
|
|
615
|
+
dead: () => this.recoverConnection('socket-timeout'),
|
|
616
|
+
});
|
|
617
|
+
private readonly mediaPaths = new MediaPathWatch({
|
|
618
|
+
active: () => !this.closedByUs && this.state === 'connected',
|
|
619
|
+
restartIce: id => this.restartMediaIce(id),
|
|
620
|
+
reconnect: reason => this.recoverConnection(reason),
|
|
621
|
+
onPathChange: (transportId, reconnecting) => {
|
|
622
|
+
if (!this.closedByUs) this.emit('mediaReconnecting', { transportId, reconnecting });
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
private recoverConnection(reason: string): void {
|
|
627
|
+
if (this.closedByUs || !this.joinedOnce || this.state !== 'connected') return;
|
|
628
|
+
this.setState('reconnecting');
|
|
629
|
+
if (this.closedByUs) return; // a state listener may close synchronously
|
|
630
|
+
this.mediaPaths.reset();
|
|
631
|
+
if (this.closedByUs) return;
|
|
632
|
+
this.detachSocket(reason);
|
|
633
|
+
this.networkWatch.set(false);
|
|
634
|
+
this.emit('disconnected', reason);
|
|
635
|
+
void this.scheduleReconnect();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private async restartMediaIce(id: string): Promise<RestartOutcome> {
|
|
639
|
+
const transport = [this.sendTransport, this.recvTransport].find(t => t?.id === id);
|
|
640
|
+
const socket = this.ws;
|
|
641
|
+
if (!transport || transport.closed || this.closedByUs || this.state !== 'connected') return 'skipped';
|
|
642
|
+
const { iceParameters } = await this.rpc('restartIce', { transportId: id }) as { iceParameters: ms.IceParameters };
|
|
643
|
+
if (this.ws !== socket || transport.closed || this.closedByUs ||
|
|
644
|
+
(transport !== this.sendTransport && transport !== this.recvTransport)) return 'skipped';
|
|
645
|
+
await transport.restartIce({ iceParameters });
|
|
646
|
+
if (this.ws === socket && !this.closedByUs && !transport.closed && transport.connectionState === 'connected') {
|
|
647
|
+
this.mediaPaths.update(id, 'connected');
|
|
648
|
+
}
|
|
649
|
+
return 'restarted';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private watchTransport(transport: Transport): void {
|
|
653
|
+
transport.on('connectionstatechange', state => {
|
|
654
|
+
if (transport !== this.sendTransport && transport !== this.recvTransport) return;
|
|
655
|
+
if (!this.closedByUs && this.state === 'connected') this.mediaPaths.update(transport.id, state);
|
|
656
|
+
});
|
|
657
|
+
transport.observer?.on('close', () => this.mediaPaths.forget(transport.id));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private waitBackoff(ms: number): Promise<void> {
|
|
661
|
+
return new Promise(resolve => {
|
|
662
|
+
const finish = () => {
|
|
663
|
+
clearTimeout(timer);
|
|
664
|
+
if (this.backoffWake === finish) this.backoffWake = null;
|
|
665
|
+
resolve();
|
|
666
|
+
};
|
|
667
|
+
const timer = setTimeout(finish, ms);
|
|
668
|
+
this.backoffWake = finish;
|
|
669
|
+
});
|
|
670
|
+
}
|
|
471
671
|
private ws: WebSocket | null = null;
|
|
472
672
|
private device: Device | null = null;
|
|
473
673
|
private sendTransport: Transport | null = null;
|
|
@@ -492,6 +692,15 @@ export class StreamClient {
|
|
|
492
692
|
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
493
693
|
// disconnected: the room looks healthy and shows nothing.
|
|
494
694
|
private wasPublishing = false;
|
|
695
|
+
/** The deadline of the socket being opened (openSocket). */
|
|
696
|
+
private openDeadline: ReturnType<typeof setTimeout> | undefined;
|
|
697
|
+
/**
|
|
698
|
+
* This client's own moderation per kind: whether a moderator holds it off,
|
|
699
|
+
* and a revision every decision bumps. A switch-on that was waiting for the
|
|
700
|
+
* server when a decision arrived does not act on its reply (V072-06).
|
|
701
|
+
*/
|
|
702
|
+
private forcedOff = { audio: false, video: false };
|
|
703
|
+
private moderationRevision = { audio: 0, video: 0 };
|
|
495
704
|
private publishConstraints: MediaStreamConstraints | null = null;
|
|
496
705
|
private publishOptions: PublishOptions = {};
|
|
497
706
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
@@ -531,6 +740,67 @@ export class StreamClient {
|
|
|
531
740
|
* the full picture.
|
|
532
741
|
*/
|
|
533
742
|
private readonly preferredLayers = new Map<string, PreferredLayers>();
|
|
743
|
+
/**
|
|
744
|
+
* Adaptive stream (attachVideo): each attached remote video at the size it is
|
|
745
|
+
* shown, and paused at the server while no attached element is visible.
|
|
746
|
+
*/
|
|
747
|
+
private readonly adaptive = new AdaptiveVideos({
|
|
748
|
+
layers: (id) => this.remoteVideoLayers.get(id) ?? [],
|
|
749
|
+
apply: (id, want) => { void this.applyAdaptive(id, this.withFallback(id, want)); },
|
|
750
|
+
pixelRatio: () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1),
|
|
751
|
+
pageVisible: pageIsVisible,
|
|
752
|
+
});
|
|
753
|
+
/** Audio-only fallback: whether it may happen, its decisions, and the sampling that feeds them. */
|
|
754
|
+
private audioOnlyEnabled = true;
|
|
755
|
+
private readonly protection = new AudioProtection((active) => this.onAudioProtection(active));
|
|
756
|
+
private protectionTimer: ReturnType<typeof setInterval> | null = null;
|
|
757
|
+
private protectionSampling = false;
|
|
758
|
+
/** Publisher-side fallback: off unless asked for; its decisions, timer, and whether it holds the camera now. */
|
|
759
|
+
private publishFallbackEnabled = false;
|
|
760
|
+
private readonly publishProtection = new AudioProtection((active) => { void this.onPublishProtection(active); });
|
|
761
|
+
private publishProtectionTimer: ReturnType<typeof setInterval> | null = null;
|
|
762
|
+
private publishProtectionSampling = false;
|
|
763
|
+
private cameraHeldByFallback = false;
|
|
764
|
+
/** Remote videos the media server reports it cut for this viewer's bandwidth. */
|
|
765
|
+
private readonly bandwidthCut = new Set<string>();
|
|
766
|
+
/** Remote cameras the audio-only fallback holds back now (reported with fallback: true). */
|
|
767
|
+
private readonly protectionHeld = new Set<string>();
|
|
768
|
+
/** audioLevels / activeSpeakers: read only while someone listens. */
|
|
769
|
+
private levelTimer: ReturnType<typeof setInterval> | null = null;
|
|
770
|
+
private levelSampling = false;
|
|
771
|
+
private readonly speakers = new ActiveSpeakers();
|
|
772
|
+
/** mediaWarning: decisions, the sampling timer, and the system interruptions being timed. */
|
|
773
|
+
private readonly health = new HealthDecisions((warning) => this.emit('mediaWarning', warning));
|
|
774
|
+
private healthTimer: ReturnType<typeof setInterval> | null = null;
|
|
775
|
+
private healthSampling = false;
|
|
776
|
+
private readonly interruptions = new Map<'camera' | 'mic', ReturnType<typeof setTimeout>>();
|
|
777
|
+
/** getDiagnostics(): the SDK's recent events, for a support ticket. */
|
|
778
|
+
private readonly diagnosticsLog = new DiagnosticsLog();
|
|
779
|
+
/** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
|
|
780
|
+
private readonly autoplayBlocked = new Set<HTMLMediaElement>();
|
|
781
|
+
private gestureWatchOn = false;
|
|
782
|
+
private readonly onGesture = (): void => { void this.retryAutoplay(); };
|
|
783
|
+
/** setPlayoutDelay: the receive buffer asked for every remote track, in ms (null: the browser's own). */
|
|
784
|
+
private playoutDelayMs: number | null = null;
|
|
785
|
+
/** devicesChanged: the device list last seen, kept while someone listens. */
|
|
786
|
+
private knownDevices: MediaDevice[] = [];
|
|
787
|
+
private deviceWatchOn = false;
|
|
788
|
+
private readonly onDeviceChange = (): void => { void this.refreshDevices(); };
|
|
789
|
+
/** Videos adaptive stream has paused at the server, by producer id. */
|
|
790
|
+
private readonly adaptivePaused = new Set<string>();
|
|
791
|
+
/** The server has no pauseConsumer (an older controller): a hidden video drops to its smallest layer instead. */
|
|
792
|
+
private pauseUnsupported = false;
|
|
793
|
+
/** The last cpuConstrained said. */
|
|
794
|
+
private cpuConstrainedNow = false;
|
|
795
|
+
/** prioritizePerformance(): the camera's largest simulcast layer is not encoded while true. */
|
|
796
|
+
private performanceFirst = false;
|
|
797
|
+
/**
|
|
798
|
+
* Dynacast (the server's `producerLayers`): the highest layer any viewer needs, by producer id (-1 = nobody
|
|
799
|
+
* watches, the smallest layer stays on). No entry: every layer, as before the server said anything.
|
|
800
|
+
*/
|
|
801
|
+
private readonly layersNeeded = new Map<string, number>();
|
|
802
|
+
/** The highest camera layer this client set active, and on which producer. */
|
|
803
|
+
private cameraMaxLayer: { producerId: string; layer: number } | null = null;
|
|
534
804
|
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
535
805
|
private readonly endRoomWaiters = new Set<() => void>();
|
|
536
806
|
private closedByUs = false;
|
|
@@ -559,7 +829,14 @@ export class StreamClient {
|
|
|
559
829
|
private readonly recvStatsBaseline: StatsBaseline = new Map();
|
|
560
830
|
private readonly limitWatch = new VideoLimitWatch(
|
|
561
831
|
(baseline, isCurrent) => this.sampleCamera(baseline, isCurrent),
|
|
562
|
-
(event) =>
|
|
832
|
+
(event) => {
|
|
833
|
+
this.emit('videoLimited', event);
|
|
834
|
+
const constrained = event.reason === 'cpu';
|
|
835
|
+
if (constrained !== this.cpuConstrainedNow) {
|
|
836
|
+
this.cpuConstrainedNow = constrained;
|
|
837
|
+
this.emit('cpuConstrained', { constrained });
|
|
838
|
+
}
|
|
839
|
+
},
|
|
563
840
|
undefined,
|
|
564
841
|
() => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; },
|
|
565
842
|
);
|
|
@@ -598,9 +875,53 @@ export class StreamClient {
|
|
|
598
875
|
(quality) => this.emit('networkQuality', quality),
|
|
599
876
|
);
|
|
600
877
|
|
|
878
|
+
/**
|
|
879
|
+
* A pre-call check (Twilio's runPreflight, LiveKit's ConnectionCheck, Agora's last-mile probe): joins the room the
|
|
880
|
+
* token names, publishes `options.media` (cloned, your tracks keep running) or a tone and a moving picture it makes,
|
|
881
|
+
* measures for `durationMs` (5 s) and leaves. Mint a host token for a test room nobody else is in: the session is
|
|
882
|
+
* real, and billed like any other for its few seconds. Resolves with each step, how media travelled (UDP is best;
|
|
883
|
+
* TCP or a relay means a firewall in the way), the round trip, loss and upload, and any camera or microphone warning
|
|
884
|
+
* seen. Never rejects: a failure is a step with `ok: false` and its error.
|
|
885
|
+
*/
|
|
886
|
+
static async preflight(wsUrl: string, token: string, options: PreflightOptions = {}): Promise<PreflightReport> {
|
|
887
|
+
const client = new StreamClient(wsUrl, token, { audioOnlyFallback: false });
|
|
888
|
+
const synthetic = options.media ? null : syntheticMedia();
|
|
889
|
+
const stream = options.media ? new MediaStream(options.media.getTracks().map((t) => t.clone())) : synthetic!.stream;
|
|
890
|
+
try {
|
|
891
|
+
return await runPreflight({
|
|
892
|
+
connect: () => client.connect(),
|
|
893
|
+
publish: (media) => client.publish(media),
|
|
894
|
+
sendState: () => client.sendTransport?.connectionState ?? null,
|
|
895
|
+
sendStats: async () => (client.sendTransport ? ((await client.sendTransport.getStats()).values() as Iterable<Record<string, unknown>>) : null),
|
|
896
|
+
onWarning: (listener) => client.on('mediaWarning', (w) => listener(w.code, w.active)),
|
|
897
|
+
close: () => client.close(),
|
|
898
|
+
}, stream, options);
|
|
899
|
+
} finally {
|
|
900
|
+
synthetic?.stop();
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Warm the connection before `connect()` (LiveKit's prepareConnection): a HEAD request to the server, so the
|
|
906
|
+
* name lookup and the TLS handshake are done by the time the socket opens. Call it as the user heads for the
|
|
907
|
+
* room (opening the room screen, say). Never rejects; it only saves time.
|
|
908
|
+
*/
|
|
909
|
+
static async prepareConnection(wsUrl: string): Promise<void> {
|
|
910
|
+
try {
|
|
911
|
+
const url = new URL(wsUrl);
|
|
912
|
+
url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
|
|
913
|
+
url.pathname = '/';
|
|
914
|
+
url.search = '';
|
|
915
|
+
await fetch(url.toString(), { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
|
|
916
|
+
} catch {
|
|
917
|
+
// Only a warm-up: connect() does the real work either way.
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
601
921
|
constructor(private readonly wsUrl: string, token: string, private readonly options: ClientOptions = {}) {
|
|
602
922
|
this.token = token;
|
|
603
923
|
this.tokenExpiry = tokenExpiresAt(token);
|
|
924
|
+
this.audioOnlyEnabled = options.audioOnlyFallback !== false;
|
|
604
925
|
}
|
|
605
926
|
|
|
606
927
|
on<K extends keyof ClientEvents>(event: K, fn: ClientEvents[K]): () => void {
|
|
@@ -612,12 +933,18 @@ export class StreamClient {
|
|
|
612
933
|
set.add(fn as Handler);
|
|
613
934
|
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
614
935
|
// they run only while heard.
|
|
615
|
-
if (event === 'videoLimited') this.syncLimitWatch();
|
|
936
|
+
if (event === 'videoLimited' || event === 'cpuConstrained') this.syncLimitWatch();
|
|
616
937
|
if (event === 'networkQuality') this.syncNetworkWatch();
|
|
938
|
+
if (event === 'devicesChanged') this.syncDeviceWatch();
|
|
939
|
+
if (event === 'audioLevels' || event === 'activeSpeakers') this.syncLevelWatch();
|
|
940
|
+
if (event === 'mediaWarning') this.syncHealthWatch();
|
|
617
941
|
return () => {
|
|
618
942
|
set!.delete(fn as Handler);
|
|
619
|
-
if (event === 'videoLimited') this.syncLimitWatch();
|
|
943
|
+
if (event === 'videoLimited' || event === 'cpuConstrained') this.syncLimitWatch();
|
|
620
944
|
if (event === 'networkQuality') this.syncNetworkWatch();
|
|
945
|
+
if (event === 'devicesChanged') this.syncDeviceWatch();
|
|
946
|
+
if (event === 'audioLevels' || event === 'activeSpeakers') this.syncLevelWatch();
|
|
947
|
+
if (event === 'mediaWarning') this.syncHealthWatch();
|
|
621
948
|
};
|
|
622
949
|
}
|
|
623
950
|
|
|
@@ -631,6 +958,10 @@ export class StreamClient {
|
|
|
631
958
|
const previous = this.state;
|
|
632
959
|
this.state = next;
|
|
633
960
|
if (next === 'closed') this.stopTimers();
|
|
961
|
+
else {
|
|
962
|
+
this.syncLevelWatch();
|
|
963
|
+
this.syncHealthWatch();
|
|
964
|
+
}
|
|
634
965
|
this.emit('connectionStateChanged', { state: next, previous });
|
|
635
966
|
}
|
|
636
967
|
|
|
@@ -655,6 +986,19 @@ export class StreamClient {
|
|
|
655
986
|
}
|
|
656
987
|
|
|
657
988
|
private stopTimers(): void {
|
|
989
|
+
if (this.publishProtectionTimer) clearInterval(this.publishProtectionTimer);
|
|
990
|
+
this.publishProtectionTimer = null;
|
|
991
|
+
this.autoplayBlocked.clear();
|
|
992
|
+
this.watchGesture(false);
|
|
993
|
+
this.stopProtection();
|
|
994
|
+
this.stopLevelWatch();
|
|
995
|
+
this.stopHealthWatch(false);
|
|
996
|
+
this.stopDeviceWatch();
|
|
997
|
+
this.socketLiveness.stop();
|
|
998
|
+
this.mediaPaths.reset();
|
|
999
|
+
this.backoffWake?.();
|
|
1000
|
+
this.onlineTarget?.removeEventListener('online', this.online);
|
|
1001
|
+
this.onlineTarget = null;
|
|
658
1002
|
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
659
1003
|
this.tokenTimer = null;
|
|
660
1004
|
this.networkWatch.set(false);
|
|
@@ -665,6 +1009,7 @@ export class StreamClient {
|
|
|
665
1009
|
}
|
|
666
1010
|
|
|
667
1011
|
private emit<K extends keyof ClientEvents>(event: K, ...args: Parameters<ClientEvents[K]>): void {
|
|
1012
|
+
this.diagnosticsLog.record(event, args);
|
|
668
1013
|
for (const fn of this.listeners.get(event) ?? []) {
|
|
669
1014
|
try {
|
|
670
1015
|
(fn as (...a: unknown[]) => void)(...args);
|
|
@@ -701,7 +1046,10 @@ export class StreamClient {
|
|
|
701
1046
|
// the socket would be closed by the server's join deadline instead, and
|
|
702
1047
|
// that close would start reconnecting a session that never began.
|
|
703
1048
|
const superseded = this.ws !== null && this.ws !== socket;
|
|
704
|
-
|
|
1049
|
+
// A reconnect attempt that failed (its join timed out, say) lets its
|
|
1050
|
+
// socket go now too, so nothing it says later reaches the session while
|
|
1051
|
+
// the next attempt waits its backoff (V072-05).
|
|
1052
|
+
if ((!this.joinedOnce || this.reconnecting) && !superseded) {
|
|
705
1053
|
this.detachSocket();
|
|
706
1054
|
if (this.state === 'connecting') this.setState('idle');
|
|
707
1055
|
}
|
|
@@ -715,6 +1063,11 @@ export class StreamClient {
|
|
|
715
1063
|
this.joinedOnce = true;
|
|
716
1064
|
this.skippedVideos.clear();
|
|
717
1065
|
this.setState('connected');
|
|
1066
|
+
this.socketLiveness.start();
|
|
1067
|
+
if (!this.onlineTarget && typeof window !== 'undefined' && window.addEventListener) {
|
|
1068
|
+
this.onlineTarget = window;
|
|
1069
|
+
window.addEventListener('online', this.online);
|
|
1070
|
+
}
|
|
718
1071
|
this.scheduleTokenExpiring();
|
|
719
1072
|
|
|
720
1073
|
// A role or mute can change while the socket is away. The fresh roster
|
|
@@ -724,6 +1077,8 @@ export class StreamClient {
|
|
|
724
1077
|
if (closed()) throw closedWhileConnecting();
|
|
725
1078
|
}
|
|
726
1079
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
1080
|
+
this.moderate('audio', Boolean(own?.muted), true);
|
|
1081
|
+
this.moderate('video', Boolean(own?.videoBlocked), true);
|
|
727
1082
|
if (own?.muted) this.applyLocalModeration('audio');
|
|
728
1083
|
if (own?.videoBlocked) this.applyLocalModeration('video');
|
|
729
1084
|
this.emit('joined', result);
|
|
@@ -743,10 +1098,11 @@ export class StreamClient {
|
|
|
743
1098
|
|
|
744
1099
|
// Subscribe to everything already publishing before we announce readiness.
|
|
745
1100
|
// A close() part-way stops it: what is left would only fail, after closed.
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1101
|
+
// All at once, as newProducer events already are (and the Flutter SDK does): each subscription reserves its slot
|
|
1102
|
+
// before its first await, in this order, and the receive transport is made once. A room with six publishers
|
|
1103
|
+
// starts six videos together instead of one after another, two round trips each.
|
|
1104
|
+
if (closed()) throw closedWhileConnecting();
|
|
1105
|
+
await Promise.all(result.producers.map((p) => this.consume(p).catch((err) => { if (!closed()) this.emit('error', err as Error); })));
|
|
750
1106
|
|
|
751
1107
|
// Restore our own broadcast. This is what makes an origin failover
|
|
752
1108
|
// survivable for the host: it reconnects onto the new origin and the
|
|
@@ -756,6 +1112,9 @@ export class StreamClient {
|
|
|
756
1112
|
}
|
|
757
1113
|
if (closed()) throw closedWhileConnecting();
|
|
758
1114
|
this.syncNetworkWatch();
|
|
1115
|
+
this.syncProtection();
|
|
1116
|
+
this.syncLevelWatch();
|
|
1117
|
+
this.syncPublishProtection();
|
|
759
1118
|
return result;
|
|
760
1119
|
}
|
|
761
1120
|
|
|
@@ -779,21 +1138,37 @@ export class StreamClient {
|
|
|
779
1138
|
const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
|
|
780
1139
|
this.ws = ws;
|
|
781
1140
|
|
|
1141
|
+
// Bounded like every request (V072-05): a socket that does not open in
|
|
1142
|
+
// time fails the attempt with `timeout` and is closed, instead of
|
|
1143
|
+
// holding connect() -- or a reconnect attempt -- indefinitely.
|
|
1144
|
+
this.openDeadline = setTimeout(() => {
|
|
1145
|
+
reject(new RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
|
|
1146
|
+
if (this.ws === ws) this.detachSocket('open timed out', 'the connection did not open in time');
|
|
1147
|
+
}, RPC_TIMEOUT_MS);
|
|
782
1148
|
const onOpen = () => {
|
|
1149
|
+
clearTimeout(this.openDeadline);
|
|
783
1150
|
this.emit('connected');
|
|
784
1151
|
resolve();
|
|
785
1152
|
};
|
|
786
|
-
const onError = () =>
|
|
1153
|
+
const onError = () => {
|
|
1154
|
+
clearTimeout(this.openDeadline);
|
|
1155
|
+
reject(new RpcError('connect-failed', 'websocket failed to open'));
|
|
1156
|
+
};
|
|
787
1157
|
|
|
788
1158
|
ws.addEventListener('open', onOpen, { once: true });
|
|
789
1159
|
ws.addEventListener('error', onError, { once: true });
|
|
790
1160
|
ws.addEventListener('message', (ev) => {
|
|
791
|
-
if (this.ws === ws)
|
|
1161
|
+
if (this.ws === ws) {
|
|
1162
|
+
this.socketLiveness.received();
|
|
1163
|
+
this.onMessage(ev.data as string);
|
|
1164
|
+
}
|
|
792
1165
|
});
|
|
793
1166
|
ws.addEventListener('close', (ev) => {
|
|
794
1167
|
ws.removeEventListener('error', onError);
|
|
795
1168
|
// Only the socket we currently consider ours may drive reconnection.
|
|
796
1169
|
if (this.ws !== ws) return;
|
|
1170
|
+
this.socketLiveness.stop();
|
|
1171
|
+
this.mediaPaths.reset();
|
|
797
1172
|
this.ws = null;
|
|
798
1173
|
this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
|
|
799
1174
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
@@ -826,6 +1201,8 @@ export class StreamClient {
|
|
|
826
1201
|
}
|
|
827
1202
|
|
|
828
1203
|
private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
|
|
1204
|
+
this.socketLiveness.stop();
|
|
1205
|
+
clearTimeout(this.openDeadline);
|
|
829
1206
|
const ws = this.ws;
|
|
830
1207
|
this.ws = null;
|
|
831
1208
|
if (!ws) return;
|
|
@@ -874,7 +1251,7 @@ export class StreamClient {
|
|
|
874
1251
|
return;
|
|
875
1252
|
}
|
|
876
1253
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
877
|
-
await
|
|
1254
|
+
await this.waitBackoff(backoff + Math.random() * 400);
|
|
878
1255
|
if (this.closedByUs) return;
|
|
879
1256
|
try {
|
|
880
1257
|
this.teardownMedia();
|
|
@@ -906,6 +1283,9 @@ export class StreamClient {
|
|
|
906
1283
|
|
|
907
1284
|
close(): void {
|
|
908
1285
|
void this.stopRecording().catch(() => undefined);
|
|
1286
|
+
// Adaptive stream stops watching elements: nothing is received any more.
|
|
1287
|
+
this.adaptive.dispose();
|
|
1288
|
+
this.adaptivePaused.clear();
|
|
909
1289
|
// Only an app closing a live session is 'left'; kicked, room-closed and the
|
|
910
1290
|
// others have already reported themselves and set closedByUs.
|
|
911
1291
|
const wasOpen = !this.closedByUs;
|
|
@@ -939,7 +1319,15 @@ export class StreamClient {
|
|
|
939
1319
|
}
|
|
940
1320
|
|
|
941
1321
|
private teardownMedia(): void {
|
|
1322
|
+
this.mediaPaths.reset();
|
|
1323
|
+
// The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
|
|
1324
|
+
// app's "poor connection" overlay does not outlive it.
|
|
1325
|
+
this.clearFallbacks();
|
|
942
1326
|
this.subscriptions.clear();
|
|
1327
|
+
// Producers made after this are new: the server says again what their viewers need.
|
|
1328
|
+
this.layersNeeded.clear();
|
|
1329
|
+
// New consumers start paused at the server; adaptive stream decides again for each.
|
|
1330
|
+
this.adaptivePaused.clear();
|
|
943
1331
|
for (const c of this.consumers.values()) c.close();
|
|
944
1332
|
this.consumers.clear();
|
|
945
1333
|
this.remoteInfo.clear();
|
|
@@ -1162,6 +1550,27 @@ export class StreamClient {
|
|
|
1162
1550
|
case 'newProducer':
|
|
1163
1551
|
await this.consume(data as RemoteProducer).catch((err) => this.emit('error', err as Error));
|
|
1164
1552
|
break;
|
|
1553
|
+
case 'streamFallback': {
|
|
1554
|
+
// The media server cut (or resumed) one of this viewer's videos for its bandwidth.
|
|
1555
|
+
const e = data as { producerId?: string; fallback?: boolean };
|
|
1556
|
+
if (typeof e.producerId !== 'string' || !this.consumers.has(e.producerId)) break;
|
|
1557
|
+
const cut = e.fallback === true;
|
|
1558
|
+
if (cut === this.bandwidthCut.has(e.producerId)) break;
|
|
1559
|
+
if (cut) this.bandwidthCut.add(e.producerId);
|
|
1560
|
+
else this.bandwidthCut.delete(e.producerId);
|
|
1561
|
+
this.emitFallback(e.producerId, cut, 'bandwidth');
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
case 'producerLayers': {
|
|
1565
|
+
// Dynacast: what this client's viewers need from one of its producers. Layers nobody receives are not
|
|
1566
|
+
// encoded (the smallest always is, so a new viewer starts at once); null means every layer.
|
|
1567
|
+
const e = data as { producerId?: string; spatialLayer?: number | null };
|
|
1568
|
+
if (typeof e.producerId !== 'string') break;
|
|
1569
|
+
if (typeof e.spatialLayer === 'number' && Number.isInteger(e.spatialLayer)) this.layersNeeded.set(e.producerId, e.spatialLayer);
|
|
1570
|
+
else this.layersNeeded.delete(e.producerId);
|
|
1571
|
+
if (this.cameraProducer()?.id === e.producerId) void this.cameraTask(() => this.syncCameraLayers());
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1165
1574
|
case 'producerClosed': {
|
|
1166
1575
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
1167
1576
|
this.subscriptions.delete(producerId);
|
|
@@ -1169,6 +1578,9 @@ export class StreamClient {
|
|
|
1169
1578
|
this.receiveQualities.delete(producerId);
|
|
1170
1579
|
this.remoteVideoLayers.delete(producerId);
|
|
1171
1580
|
this.skippedVideos.delete(producerId);
|
|
1581
|
+
this.adaptivePaused.delete(producerId);
|
|
1582
|
+
this.bandwidthCut.delete(producerId);
|
|
1583
|
+
this.protectionHeld.delete(producerId);
|
|
1172
1584
|
const consumer = this.consumers.get(producerId);
|
|
1173
1585
|
consumer?.close();
|
|
1174
1586
|
this.consumers.delete(producerId);
|
|
@@ -1203,6 +1615,7 @@ export class StreamClient {
|
|
|
1203
1615
|
case 'participantMuted': {
|
|
1204
1616
|
const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
|
|
1205
1617
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
1618
|
+
if (e.identity && e.identity === this.joinResult?.identity) this.moderate(e.kind, e.paused, true);
|
|
1206
1619
|
this.updateRoster((list) => list.map((p) =>
|
|
1207
1620
|
p.identity !== e.identity ? p : { ...p,
|
|
1208
1621
|
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
@@ -1221,6 +1634,8 @@ export class StreamClient {
|
|
|
1221
1634
|
const e = data as { identity: string; reason?: string };
|
|
1222
1635
|
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
1223
1636
|
this.emit('participantLeft', e);
|
|
1637
|
+
const speakers = this.speakers.remove(e.identity);
|
|
1638
|
+
if (speakers) this.emit('activeSpeakers', { speakers });
|
|
1224
1639
|
break;
|
|
1225
1640
|
}
|
|
1226
1641
|
case 'roleChanged': {
|
|
@@ -1249,6 +1664,7 @@ export class StreamClient {
|
|
|
1249
1664
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
1250
1665
|
// changes nothing here -- switching back on is the user's choice.
|
|
1251
1666
|
const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
|
|
1667
|
+
this.moderate(e.kind, Boolean(e.paused));
|
|
1252
1668
|
if (e.paused) {
|
|
1253
1669
|
this.applyLocalModeration(e.kind);
|
|
1254
1670
|
this.emitLocalMedia();
|
|
@@ -1362,17 +1778,19 @@ export class StreamClient {
|
|
|
1362
1778
|
private ensureSendTransport(): Promise<Transport> {
|
|
1363
1779
|
if (this.sendTransport) return Promise.resolve(this.sendTransport);
|
|
1364
1780
|
if (this.sendTransportPending) return this.sendTransportPending;
|
|
1365
|
-
|
|
1366
|
-
this.sendTransportPending = null;
|
|
1781
|
+
const pending = this.createSendTransport().finally(() => {
|
|
1782
|
+
if (this.sendTransportPending === pending) this.sendTransportPending = null;
|
|
1367
1783
|
});
|
|
1368
|
-
return this.sendTransportPending;
|
|
1784
|
+
return this.sendTransportPending = pending;
|
|
1369
1785
|
}
|
|
1370
1786
|
|
|
1371
1787
|
private async createSendTransport(): Promise<Transport> {
|
|
1372
1788
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1373
|
-
|
|
1789
|
+
const device = this.device;
|
|
1790
|
+
const socket = this.ws;
|
|
1374
1791
|
const params = (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
|
|
1375
|
-
|
|
1792
|
+
if (this.closedByUs || device !== this.device || socket !== this.ws) throw closedWhileConnecting();
|
|
1793
|
+
const transport = device.createSendTransport({
|
|
1376
1794
|
...params,
|
|
1377
1795
|
iceServers: this.joinResult?.iceServers,
|
|
1378
1796
|
});
|
|
@@ -1392,23 +1810,26 @@ export class StreamClient {
|
|
|
1392
1810
|
});
|
|
1393
1811
|
|
|
1394
1812
|
this.sendTransport = transport;
|
|
1813
|
+
this.watchTransport(transport);
|
|
1395
1814
|
return transport;
|
|
1396
1815
|
}
|
|
1397
1816
|
|
|
1398
1817
|
private ensureRecvTransport(): Promise<Transport> {
|
|
1399
1818
|
if (this.recvTransport) return Promise.resolve(this.recvTransport);
|
|
1400
1819
|
if (this.recvTransportPending) return this.recvTransportPending;
|
|
1401
|
-
|
|
1402
|
-
this.recvTransportPending = null;
|
|
1820
|
+
const pending = this.createRecvTransport().finally(() => {
|
|
1821
|
+
if (this.recvTransportPending === pending) this.recvTransportPending = null;
|
|
1403
1822
|
});
|
|
1404
|
-
return this.recvTransportPending;
|
|
1823
|
+
return this.recvTransportPending = pending;
|
|
1405
1824
|
}
|
|
1406
1825
|
|
|
1407
1826
|
private async createRecvTransport(): Promise<Transport> {
|
|
1408
1827
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1409
|
-
|
|
1828
|
+
const device = this.device;
|
|
1829
|
+
const socket = this.ws;
|
|
1410
1830
|
const params = (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
|
|
1411
|
-
|
|
1831
|
+
if (this.closedByUs || device !== this.device || socket !== this.ws) throw closedWhileConnecting();
|
|
1832
|
+
const transport = device.createRecvTransport({
|
|
1412
1833
|
...params,
|
|
1413
1834
|
iceServers: this.joinResult?.iceServers,
|
|
1414
1835
|
});
|
|
@@ -1419,6 +1840,7 @@ export class StreamClient {
|
|
|
1419
1840
|
});
|
|
1420
1841
|
|
|
1421
1842
|
this.recvTransport = transport;
|
|
1843
|
+
this.watchTransport(transport);
|
|
1422
1844
|
return transport;
|
|
1423
1845
|
}
|
|
1424
1846
|
|
|
@@ -1453,9 +1875,26 @@ export class StreamClient {
|
|
|
1453
1875
|
// unpublish() ran first: this publish is over before it began, and must
|
|
1454
1876
|
// leave nothing behind that a reconnect would publish again.
|
|
1455
1877
|
if (generation !== this.publishGeneration) throw unpublishedWhile('stream');
|
|
1456
|
-
|
|
1878
|
+
// A second publish adds to the first (the microphone, then the camera):
|
|
1879
|
+
// one local stream carries every live track, so unpublish() and close()
|
|
1880
|
+
// stop them all and a reconnect publishes each again. Replacing it
|
|
1881
|
+
// orphaned the first stream's tracks, still capturing (V072-01).
|
|
1882
|
+
const previous = this.localStream;
|
|
1883
|
+
const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
|
|
1884
|
+
if (merged) {
|
|
1885
|
+
const ids = new Set(stream.getTracks().map((t) => t.id));
|
|
1886
|
+
// The new stream's tracks first: of one kind, the newer track is the one
|
|
1887
|
+
// published (as before), and the older one is still stopped with it.
|
|
1888
|
+
this.localStream = new MediaStream([
|
|
1889
|
+
...stream.getTracks(),
|
|
1890
|
+
...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
|
|
1891
|
+
]);
|
|
1892
|
+
this.emit('localStreamChanged', this.localStream);
|
|
1893
|
+
} else {
|
|
1894
|
+
this.localStream = stream;
|
|
1895
|
+
}
|
|
1457
1896
|
this.wasPublishing = true;
|
|
1458
|
-
if (constraints) this.publishConstraints = constraints;
|
|
1897
|
+
if (constraints) this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
|
|
1459
1898
|
if (options) {
|
|
1460
1899
|
this.publishOptions = { ...options };
|
|
1461
1900
|
this.frameRateFallback = false;
|
|
@@ -1566,6 +2005,7 @@ export class StreamClient {
|
|
|
1566
2005
|
appData: { source: 'mic', audioQuality: quality },
|
|
1567
2006
|
});
|
|
1568
2007
|
await this.keepProducer(producer, generation, 'microphone');
|
|
2008
|
+
this.watchTrackEnded(producer);
|
|
1569
2009
|
this.micQuality = quality;
|
|
1570
2010
|
// A microphone handed over switched off, or muted before a reconnect.
|
|
1571
2011
|
await this.pauseOnServer(producer);
|
|
@@ -1713,6 +2153,7 @@ export class StreamClient {
|
|
|
1713
2153
|
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
1714
2154
|
previousSize?.width === width && previousSize?.height === height) {
|
|
1715
2155
|
await current.replaceTrack({ track });
|
|
2156
|
+
this.watchTrackEnded(current);
|
|
1716
2157
|
await this.applyLayerBitrates(current, next);
|
|
1717
2158
|
return;
|
|
1718
2159
|
}
|
|
@@ -1812,7 +2253,9 @@ export class StreamClient {
|
|
|
1812
2253
|
this.receiveQualities.delete(producerId);
|
|
1813
2254
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
1814
2255
|
this.preferredLayers.set(producerId, layers);
|
|
1815
|
-
|
|
2256
|
+
// Shown through attachVideo: the lower of this and the size it is shown at.
|
|
2257
|
+
const adaptive = this.adaptive.current(producerId);
|
|
2258
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
1816
2259
|
}
|
|
1817
2260
|
|
|
1818
2261
|
private async collectVideoStats(baseline: StatsBaseline): Promise<VideoSendStats | null> {
|
|
@@ -1843,7 +2286,8 @@ export class StreamClient {
|
|
|
1843
2286
|
}
|
|
1844
2287
|
this.receiveQualities.set(producerId, quality);
|
|
1845
2288
|
this.preferredLayers.set(producerId, layers);
|
|
1846
|
-
|
|
2289
|
+
const adaptive = this.adaptive.current(producerId);
|
|
2290
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
1847
2291
|
}
|
|
1848
2292
|
|
|
1849
2293
|
private cameraProducer(): Producer | undefined {
|
|
@@ -1941,15 +2385,63 @@ export class StreamClient {
|
|
|
1941
2385
|
// unpublish() may have run while this was being made (a publish, a
|
|
1942
2386
|
// quality change or a track swap under way).
|
|
1943
2387
|
await this.keepProducer(producer, generation, 'camera');
|
|
2388
|
+
this.watchTrackEnded(producer);
|
|
2389
|
+
this.syncPublishProtection();
|
|
1944
2390
|
this.cameraEncodings = encodings;
|
|
1945
2391
|
this.frameRateLowSamples = 0;
|
|
1946
2392
|
this.frameRateWarmupSamples = 2;
|
|
1947
2393
|
await this.pauseOnServer(producer);
|
|
1948
2394
|
await this.applyDegradationPreference(producer);
|
|
1949
2395
|
this.syncLimitWatch();
|
|
2396
|
+
// A new camera producer starts with every layer: put the performance and viewer limits back on it.
|
|
2397
|
+
await this.syncCameraLayers();
|
|
1950
2398
|
return producer;
|
|
1951
2399
|
}
|
|
1952
2400
|
|
|
2401
|
+
/**
|
|
2402
|
+
* Lighten this device's work while it is overloaded (LiveKit's
|
|
2403
|
+
* `prioritizePerformance`): the camera's largest simulcast layer stops being
|
|
2404
|
+
* encoded -- the most expensive part of publishing -- and viewers get the
|
|
2405
|
+
* next one. `false` brings it back. Call it on `cpuConstrained`, together
|
|
2406
|
+
* with your app's own savings (effects off, smaller remote videos). Kept for
|
|
2407
|
+
* later publishes and reconnects until changed. A camera sent as one layer
|
|
2408
|
+
* is left as it is.
|
|
2409
|
+
*/
|
|
2410
|
+
async prioritizePerformance(on = true): Promise<void> {
|
|
2411
|
+
this.performanceFirst = on;
|
|
2412
|
+
await this.cameraTask(() => this.syncCameraLayers());
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/** Whether `prioritizePerformance()` is in force. */
|
|
2416
|
+
get performancePrioritized(): boolean {
|
|
2417
|
+
return this.performanceFirst;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/**
|
|
2421
|
+
* The camera's active simulcast layers: every layer, except the top one while
|
|
2422
|
+
* performance comes first, and none above the highest any viewer needs
|
|
2423
|
+
* (dynacast; the smallest always stays on, so a new viewer starts at once).
|
|
2424
|
+
*/
|
|
2425
|
+
private async syncCameraLayers(): Promise<void> {
|
|
2426
|
+
const producer = this.cameraProducer();
|
|
2427
|
+
const count = this.cameraEncodings?.length ?? 0;
|
|
2428
|
+
if (!producer || producer.closed || count < 2) return;
|
|
2429
|
+
const top = count - 1;
|
|
2430
|
+
let max = top;
|
|
2431
|
+
if (this.performanceFirst) max = Math.min(max, top - 1);
|
|
2432
|
+
const needed = this.layersNeeded.get(producer.id);
|
|
2433
|
+
if (needed !== undefined) max = Math.min(max, Math.max(0, needed));
|
|
2434
|
+
// A producer just made sends every layer: nothing to ask of it until a limit applies.
|
|
2435
|
+
const applied = this.cameraMaxLayer?.producerId === producer.id ? this.cameraMaxLayer.layer : top;
|
|
2436
|
+
if (applied === max) return;
|
|
2437
|
+
try {
|
|
2438
|
+
await producer.setMaxSpatialLayer(max);
|
|
2439
|
+
this.cameraMaxLayer = { producerId: producer.id, layer: max };
|
|
2440
|
+
} catch (err) {
|
|
2441
|
+
if (!producer.closed) this.emit('error', err as Error);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
1953
2445
|
/**
|
|
1954
2446
|
* Keep a producer just made -- unless unpublish() ran while it was being
|
|
1955
2447
|
* made. Kept, it would publish again what was just unpublished, so it is
|
|
@@ -2027,7 +2519,7 @@ export class StreamClient {
|
|
|
2027
2519
|
|
|
2028
2520
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
2029
2521
|
private syncLimitWatch(): void {
|
|
2030
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
2522
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.listenerCount('cpuConstrained') > 0 || this.cameraFrameRate === 60));
|
|
2031
2523
|
}
|
|
2032
2524
|
|
|
2033
2525
|
/**
|
|
@@ -2460,6 +2952,7 @@ export class StreamClient {
|
|
|
2460
2952
|
const consumer = await transport.consume(params);
|
|
2461
2953
|
current(consumer);
|
|
2462
2954
|
this.consumers.set(remote.producerId, consumer);
|
|
2955
|
+
if (this.playoutDelayMs !== null) this.applyPlayoutDelay(consumer.rtpReceiver);
|
|
2463
2956
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
|
|
2464
2957
|
|
|
2465
2958
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
@@ -2471,6 +2964,12 @@ export class StreamClient {
|
|
|
2471
2964
|
try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) }; }
|
|
2472
2965
|
catch { layers = undefined; this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto')); }
|
|
2473
2966
|
}
|
|
2967
|
+
// Adaptive stream: a video the app shows through attachVideo starts at the
|
|
2968
|
+
// size it is shown, and one nobody can see stays paused.
|
|
2969
|
+
const adaptive = consumer.kind === 'video' ? this.adaptive.current(remote.producerId) : null;
|
|
2970
|
+
if (adaptive) layers = this.effectiveLayers(remote.producerId, adaptive);
|
|
2971
|
+
// A camera arriving while the audio-only fallback holds cameras back starts held back too.
|
|
2972
|
+
const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
|
|
2474
2973
|
if (layers) {
|
|
2475
2974
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
2476
2975
|
}
|
|
@@ -2478,7 +2977,12 @@ export class StreamClient {
|
|
|
2478
2977
|
|
|
2479
2978
|
// The server created it paused. Resume only now that our end exists,
|
|
2480
2979
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
2481
|
-
|
|
2980
|
+
this.adaptivePaused.delete(remote.producerId);
|
|
2981
|
+
if ((adaptive && !adaptive.visible) || (held && !this.pauseUnsupported)) this.adaptivePaused.add(remote.producerId);
|
|
2982
|
+
else await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2983
|
+
if (held && this.pauseUnsupported) {
|
|
2984
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 }).catch(() => undefined);
|
|
2985
|
+
}
|
|
2482
2986
|
current(consumer);
|
|
2483
2987
|
|
|
2484
2988
|
const kind = consumer.kind as 'audio' | 'video';
|
|
@@ -2492,6 +2996,620 @@ export class StreamClient {
|
|
|
2492
2996
|
producerId: remote.producerId,
|
|
2493
2997
|
source,
|
|
2494
2998
|
});
|
|
2999
|
+
if (kind === 'video') this.adaptive.consumed(remote.producerId);
|
|
3000
|
+
if (held) {
|
|
3001
|
+
this.protectionHeld.add(remote.producerId);
|
|
3002
|
+
this.emitFallback(remote.producerId, true, 'audio-protection');
|
|
3003
|
+
}
|
|
3004
|
+
this.syncProtection();
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
/**
|
|
3008
|
+
* Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
|
|
3009
|
+
* stream with receiveWhenViewVisible): tell the SDK which element shows a
|
|
3010
|
+
* remote video. It then receives the smallest layer that fills that element
|
|
3011
|
+
* sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
|
|
3012
|
+
* video at the server while the element is not visible (scrolled away,
|
|
3013
|
+
* `display: none`, or the tab in the background), so a grid of small tiles
|
|
3014
|
+
* stops decoding full-size video. Attach the same video to several elements
|
|
3015
|
+
* and the largest visible one decides. Returns the function that detaches
|
|
3016
|
+
* it; detach when the element goes away. A ceiling set with
|
|
3017
|
+
* `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
|
|
3018
|
+
* server; one without `pauseConsumer` gets the smallest layer instead of a
|
|
3019
|
+
* pause.
|
|
3020
|
+
*/
|
|
3021
|
+
attachVideo(producerId: string, element: Element): () => void {
|
|
3022
|
+
return this.adaptive.attach(producerId, element);
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
/** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
|
|
3026
|
+
private effectiveLayers(producerId: string, want: AdaptiveWant | null): PreferredLayers {
|
|
3027
|
+
let manual: PreferredLayers | undefined = this.preferredLayers.get(producerId);
|
|
3028
|
+
const quality = this.receiveQualities.get(producerId);
|
|
3029
|
+
if (quality && quality !== 'auto') {
|
|
3030
|
+
try { manual = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; } catch { manual = undefined; }
|
|
3031
|
+
}
|
|
3032
|
+
const adaptive = want?.visible ? want.spatialLayer : null;
|
|
3033
|
+
if (adaptive === null || adaptive === undefined) return manual ?? { spatialLayer: -1, temporalLayer: -1 };
|
|
3034
|
+
if (!manual || manual.spatialLayer < 0) return { spatialLayer: adaptive };
|
|
3035
|
+
return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
// ------------------------------------------------------- diagnostics
|
|
3039
|
+
|
|
3040
|
+
/**
|
|
3041
|
+
* A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
|
|
3042
|
+
* browser, the session (room, identity, role, state), how each media transport is connected (UDP, TCP or relayed),
|
|
3043
|
+
* the round trip and upload now, what is published and received, and the last 200 notable events (state changes,
|
|
3044
|
+
* errors, recoveries, fallbacks, warnings) with their times. Plain JSON: `JSON.stringify` it and attach it. Holds
|
|
3045
|
+
* no tokens and no media.
|
|
3046
|
+
*/
|
|
3047
|
+
async getDiagnostics(): Promise<Diagnostics> {
|
|
3048
|
+
const transport = async (t: Transport | null): Promise<DiagnosticsTransport | null> => {
|
|
3049
|
+
if (!t || t.closed) return null;
|
|
3050
|
+
let path: DiagnosticsTransport['path'] = null;
|
|
3051
|
+
try {
|
|
3052
|
+
path = connectionPath((await t.getStats()).values() as Iterable<Record<string, unknown>>);
|
|
3053
|
+
} catch {
|
|
3054
|
+
// Best effort.
|
|
3055
|
+
}
|
|
3056
|
+
return { state: t.connectionState, path };
|
|
3057
|
+
};
|
|
3058
|
+
let network: Diagnostics['network'] = null;
|
|
3059
|
+
try {
|
|
3060
|
+
const reports = await this.transportStats();
|
|
3061
|
+
const current = new Map([...reports].map(([key, report]) => [key, readTransportStats(report)] as const));
|
|
3062
|
+
if (current.size > 0) {
|
|
3063
|
+
const q = networkQualityOf(current, new Map(), this.producers.size > 0);
|
|
3064
|
+
network = { rttMs: q.rttMs, availableKbps: q.availableKbps };
|
|
3065
|
+
}
|
|
3066
|
+
} catch {
|
|
3067
|
+
// Best effort.
|
|
3068
|
+
}
|
|
3069
|
+
const join = this.joinResult;
|
|
3070
|
+
return {
|
|
3071
|
+
sdk: SDK_VERSION,
|
|
3072
|
+
at: Date.now(),
|
|
3073
|
+
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
|
3074
|
+
connectionState: this.state,
|
|
3075
|
+
room: join?.roomId ?? null,
|
|
3076
|
+
identity: join?.identity ?? null,
|
|
3077
|
+
role: join?.role ?? null,
|
|
3078
|
+
transports: { send: await transport(this.sendTransport), recv: await transport(this.recvTransport) },
|
|
3079
|
+
network,
|
|
3080
|
+
published: [...this.producers.values()].filter((p) => !p.closed)
|
|
3081
|
+
.map((p) => ({ source: String(p.appData.source ?? p.kind), kind: p.kind, paused: p.paused })),
|
|
3082
|
+
received: [...this.consumers.entries()].filter(([, c]) => !c.closed).map(([producerId, c]) => ({
|
|
3083
|
+
identity: this.remoteInfo.get(producerId)?.identity ?? '',
|
|
3084
|
+
source: this.remoteSources.get(producerId) ?? (c.kind === 'audio' ? 'mic' : 'camera'),
|
|
3085
|
+
kind: c.kind,
|
|
3086
|
+
paused: c.paused || this.adaptivePaused.has(producerId),
|
|
3087
|
+
})),
|
|
3088
|
+
log: this.diagnosticsLog.list(),
|
|
3089
|
+
};
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// ------------------------------------------------------- autoplay
|
|
3093
|
+
|
|
3094
|
+
/**
|
|
3095
|
+
* Play a `<video>` or `<audio>` showing a remote track, the way autoplay rules allow (Agora's and Tencent TRTC's
|
|
3096
|
+
* autoplay handling). Resolves true when it plays. When the browser refuses until the viewer interacts with the
|
|
3097
|
+
* page, resolves false, emits `autoplayFailed` with a `resume()` for a "Tap to unmute" button, and plays it -- with
|
|
3098
|
+
* every other refused element -- on the next tap, click or key press by itself.
|
|
3099
|
+
*/
|
|
3100
|
+
async play(element: HTMLMediaElement): Promise<boolean> {
|
|
3101
|
+
try {
|
|
3102
|
+
await element.play();
|
|
3103
|
+
this.autoplayBlocked.delete(element);
|
|
3104
|
+
return true;
|
|
3105
|
+
} catch (err) {
|
|
3106
|
+
if ((err as { name?: string })?.name !== 'NotAllowedError') throw err;
|
|
3107
|
+
this.autoplayBlocked.add(element);
|
|
3108
|
+
this.watchGesture(true);
|
|
3109
|
+
this.emit('autoplayFailed', { element, resume: async () => { await this.play(element); } });
|
|
3110
|
+
return false;
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
private watchGesture(on: boolean): void {
|
|
3115
|
+
if (typeof document === 'undefined' || on === this.gestureWatchOn) return;
|
|
3116
|
+
this.gestureWatchOn = on;
|
|
3117
|
+
for (const type of ['pointerdown', 'keydown', 'touchend'] as const) {
|
|
3118
|
+
if (on) document.addEventListener(type, this.onGesture, { capture: true });
|
|
3119
|
+
else document.removeEventListener(type, this.onGesture, { capture: true });
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
private async retryAutoplay(): Promise<void> {
|
|
3124
|
+
for (const element of [...this.autoplayBlocked]) {
|
|
3125
|
+
try {
|
|
3126
|
+
await element.play();
|
|
3127
|
+
this.autoplayBlocked.delete(element);
|
|
3128
|
+
} catch {
|
|
3129
|
+
// Still refused (or the element went away); the next gesture tries again.
|
|
3130
|
+
if (!element.isConnected) this.autoplayBlocked.delete(element);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
if (this.autoplayBlocked.size === 0) this.watchGesture(false);
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// ------------------------------------------------------- playout delay
|
|
3137
|
+
|
|
3138
|
+
/**
|
|
3139
|
+
* Trade a little delay for smoother playback (Tencent TRTC's playoutDelay): every remote track is buffered about
|
|
3140
|
+
* `ms` before it plays, which rides out the jitter of a mobile network without freezes. For an audience watching a
|
|
3141
|
+
* stream, 300-500 ms is a good value; people talking with each other want `null` (the browser's own, as small as
|
|
3142
|
+
* it can keep). Applies to what is received now and later. Browsers without the setting (before Chrome 114) keep
|
|
3143
|
+
* their own; 0-4000 ms.
|
|
3144
|
+
*/
|
|
3145
|
+
setPlayoutDelay(ms: number | null): void {
|
|
3146
|
+
this.playoutDelayMs = ms === null ? null : Math.min(4000, Math.max(0, Math.round(ms)));
|
|
3147
|
+
for (const consumer of this.consumers.values()) this.applyPlayoutDelay(consumer.rtpReceiver);
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
private applyPlayoutDelay(receiver: RTCRtpReceiver | undefined): void {
|
|
3151
|
+
if (!receiver || !('jitterBufferTarget' in receiver)) return;
|
|
3152
|
+
try {
|
|
3153
|
+
(receiver as RTCRtpReceiver & { jitterBufferTarget: number | null }).jitterBufferTarget = this.playoutDelayMs;
|
|
3154
|
+
} catch {
|
|
3155
|
+
// Out of the browser's range: it keeps its own.
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
// ------------------------------------------------------- audio levels
|
|
3160
|
+
|
|
3161
|
+
/** Levels are read only while someone listens and the session is connected. */
|
|
3162
|
+
private syncLevelWatch(): void {
|
|
3163
|
+
const wanted = !this.closedByUs && this.state === 'connected' &&
|
|
3164
|
+
(this.listenerCount('audioLevels') > 0 || this.listenerCount('activeSpeakers') > 0);
|
|
3165
|
+
if (wanted && !this.levelTimer) {
|
|
3166
|
+
this.levelTimer = setInterval(() => void this.sampleAudioLevels(), AUDIO_LEVEL_INTERVAL_MS);
|
|
3167
|
+
(this.levelTimer as { unref?: () => void }).unref?.();
|
|
3168
|
+
} else if (!wanted) {
|
|
3169
|
+
this.stopLevelWatch();
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
private stopLevelWatch(): void {
|
|
3174
|
+
if (this.levelTimer) clearInterval(this.levelTimer);
|
|
3175
|
+
this.levelTimer = null;
|
|
3176
|
+
this.speakers.reset();
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
/**
|
|
3180
|
+
* One reading: each remote audio from its receiver's synchronization sources (the most recent packet's level, no
|
|
3181
|
+
* statistics report needed), the own microphone from its sender's media-source statistics.
|
|
3182
|
+
*/
|
|
3183
|
+
private async sampleAudioLevels(): Promise<void> {
|
|
3184
|
+
if (this.levelSampling) return;
|
|
3185
|
+
this.levelSampling = true;
|
|
3186
|
+
try {
|
|
3187
|
+
const levels: AudioLevel[] = [];
|
|
3188
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3189
|
+
if (consumer.kind !== 'audio' || consumer.closed) continue;
|
|
3190
|
+
const info = this.remoteInfo.get(producerId);
|
|
3191
|
+
if (!info) continue;
|
|
3192
|
+
const level = info.paused ? 0 : receiverLevel(consumer.rtpReceiver);
|
|
3193
|
+
levels.push({ identity: info.identity, producerId, source: this.remoteSources.get(producerId) ?? 'mic', level, speaking: level > SPEAKING_LEVEL, local: false });
|
|
3194
|
+
}
|
|
3195
|
+
const mic = this.micProducer();
|
|
3196
|
+
const self = this.joinResult?.identity;
|
|
3197
|
+
if (mic && !mic.paused && self) {
|
|
3198
|
+
const level = await senderLevel(mic);
|
|
3199
|
+
levels.push({ identity: self, producerId: mic.id, source: 'mic', level, speaking: level > SPEAKING_LEVEL, local: true });
|
|
3200
|
+
}
|
|
3201
|
+
if (!this.levelTimer) return;
|
|
3202
|
+
if (this.listenerCount('audioLevels') > 0) this.emit('audioLevels', levels);
|
|
3203
|
+
const loudest = new Map<string, number>();
|
|
3204
|
+
for (const l of levels) if (l.source === 'mic') loudest.set(l.identity, Math.max(loudest.get(l.identity) ?? 0, l.level));
|
|
3205
|
+
const speakers = this.speakers.update(loudest);
|
|
3206
|
+
if (speakers) this.emit('activeSpeakers', { speakers });
|
|
3207
|
+
} catch {
|
|
3208
|
+
// Best effort; the next reading tries again.
|
|
3209
|
+
} finally {
|
|
3210
|
+
this.levelSampling = false;
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
// ------------------------------------------------------- media health
|
|
3215
|
+
|
|
3216
|
+
/** The published devices are sampled only while someone listens and the session is connected. */
|
|
3217
|
+
private syncHealthWatch(): void {
|
|
3218
|
+
const wanted = !this.closedByUs && this.state === 'connected' && this.listenerCount('mediaWarning') > 0;
|
|
3219
|
+
if (wanted && !this.healthTimer) {
|
|
3220
|
+
this.healthTimer = setInterval(() => void this.sampleHealth(), HEALTH_SAMPLE_MS);
|
|
3221
|
+
(this.healthTimer as { unref?: () => void }).unref?.();
|
|
3222
|
+
} else if (!wanted) {
|
|
3223
|
+
this.stopHealthWatch(this.listenerCount('mediaWarning') > 0 && !this.closedByUs);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/** Stop sampling; `report` says each active warning cleared (nothing is said after close). */
|
|
3228
|
+
private stopHealthWatch(report: boolean): void {
|
|
3229
|
+
if (this.healthTimer) clearInterval(this.healthTimer);
|
|
3230
|
+
this.healthTimer = null;
|
|
3231
|
+
for (const timer of this.interruptions.values()) clearTimeout(timer);
|
|
3232
|
+
this.interruptions.clear();
|
|
3233
|
+
if (report) this.health.clearAll();
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
private async sampleHealth(): Promise<void> {
|
|
3237
|
+
if (this.healthSampling) return;
|
|
3238
|
+
this.healthSampling = true;
|
|
3239
|
+
try {
|
|
3240
|
+
const mic = this.micProducer();
|
|
3241
|
+
const camera = this.cameraProducer();
|
|
3242
|
+
const micLevel = mic && !mic.paused ? readMediaSource((await mic.getStats()).values() as Iterable<Record<string, unknown>>).level : null;
|
|
3243
|
+
const cameraFps = camera && !camera.paused ? readMediaSource((await camera.getStats()).values() as Iterable<Record<string, unknown>>).fps : null;
|
|
3244
|
+
if (!this.healthTimer) return;
|
|
3245
|
+
this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
|
|
3246
|
+
// An interrupted device that is no longer published never says it came back: that is over too.
|
|
3247
|
+
if (!mic) this.health.interrupted('mic-interrupted', false);
|
|
3248
|
+
if (!camera) this.health.interrupted('camera-interrupted', false);
|
|
3249
|
+
} catch {
|
|
3250
|
+
// Best effort; the next sample tries again.
|
|
3251
|
+
} finally {
|
|
3252
|
+
this.healthSampling = false;
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
// ---------------------------------------------------------------- devices
|
|
3257
|
+
|
|
3258
|
+
/**
|
|
3259
|
+
* The cameras, microphones and speakers the browser offers, optionally of one kind. Labels are empty until the
|
|
3260
|
+
* page has been granted camera or microphone access.
|
|
3261
|
+
*/
|
|
3262
|
+
listDevices(kind?: DeviceKind): Promise<MediaDevice[]> {
|
|
3263
|
+
return enumerate(kind);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
/**
|
|
3267
|
+
* Publish from another camera or microphone (LiveKit's switchActiveDevice, Agora's setDevice). The device is
|
|
3268
|
+
* captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
|
|
3269
|
+
* published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
|
|
3270
|
+
* replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
|
|
3271
|
+
* reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
|
|
3272
|
+
*
|
|
3273
|
+
* For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
|
|
3274
|
+
* processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
|
|
3275
|
+
* Rejects when that kind is not being published.
|
|
3276
|
+
*/
|
|
3277
|
+
async switchDevice(kind: 'audioinput' | 'videoinput', deviceId: string): Promise<void> {
|
|
3278
|
+
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
3279
|
+
if (!md?.getUserMedia) throw new Error('switchDevice: this browser cannot capture media');
|
|
3280
|
+
if (kind === 'videoinput') {
|
|
3281
|
+
const current = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
3282
|
+
if (!current) throw new Error('switchDevice: no camera is being published');
|
|
3283
|
+
if (!deviceIdOf(current)) {
|
|
3284
|
+
throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
|
|
3285
|
+
}
|
|
3286
|
+
const video = constraintsFor(this.publishConstraints?.video, deviceId);
|
|
3287
|
+
const track = (await md.getUserMedia({ video })).getVideoTracks()[0];
|
|
3288
|
+
if (!track) throw new Error('switchDevice: the camera gave no video');
|
|
3289
|
+
try {
|
|
3290
|
+
await this.replaceVideoTrack(track);
|
|
3291
|
+
} catch (err) {
|
|
3292
|
+
track.stop();
|
|
3293
|
+
throw err;
|
|
3294
|
+
}
|
|
3295
|
+
if (current !== track) current.stop();
|
|
3296
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), video };
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
await this.micTask(async () => {
|
|
3300
|
+
const producer = this.micProducer();
|
|
3301
|
+
if (!producer) throw new Error('switchDevice: no microphone is being published');
|
|
3302
|
+
const audio = constraintsFor(this.publishConstraints?.audio, deviceId);
|
|
3303
|
+
const track = (await md.getUserMedia({ audio })).getAudioTracks()[0];
|
|
3304
|
+
if (!track) throw new Error('switchDevice: the microphone gave no audio');
|
|
3305
|
+
if (producer.closed || this.micProducer() !== producer) {
|
|
3306
|
+
track.stop();
|
|
3307
|
+
throw new Error('switchDevice: the microphone was unpublished meanwhile');
|
|
3308
|
+
}
|
|
3309
|
+
await this.adoptAudioTrack(producer, track);
|
|
3310
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
/** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
|
|
3315
|
+
private async adoptAudioTrack(producer: Producer, track: MediaStreamTrack): Promise<void> {
|
|
3316
|
+
const previous = producer.track;
|
|
3317
|
+
await producer.replaceTrack({ track });
|
|
3318
|
+
const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
|
|
3319
|
+
this.localStream = new MediaStream([track, ...video]);
|
|
3320
|
+
this.emit('localStreamChanged', this.localStream);
|
|
3321
|
+
if (previous && previous !== track) previous.stop();
|
|
3322
|
+
this.watchTrackEnded(producer);
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
/** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
|
|
3326
|
+
private watchTrackEnded(producer: Producer): void {
|
|
3327
|
+
const track = producer.track;
|
|
3328
|
+
if (!track || typeof track.addEventListener !== 'function') return;
|
|
3329
|
+
track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
|
|
3330
|
+
// The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
|
|
3331
|
+
const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
|
|
3332
|
+
if (!which) return;
|
|
3333
|
+
const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
|
|
3334
|
+
const current = () => !this.closedByUs && !producer.closed && producer.track === track;
|
|
3335
|
+
track.addEventListener('mute', () => {
|
|
3336
|
+
if (!current() || this.interruptions.has(which)) return;
|
|
3337
|
+
this.interruptions.set(which, setTimeout(() => {
|
|
3338
|
+
this.interruptions.delete(which);
|
|
3339
|
+
if (current() && track.muted) this.health.interrupted(code, true);
|
|
3340
|
+
}, INTERRUPTED_AFTER_MS));
|
|
3341
|
+
});
|
|
3342
|
+
track.addEventListener('unmute', () => {
|
|
3343
|
+
const timer = this.interruptions.get(which);
|
|
3344
|
+
if (timer) clearTimeout(timer);
|
|
3345
|
+
this.interruptions.delete(which);
|
|
3346
|
+
if (current()) this.health.interrupted(code, false);
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
/**
|
|
3351
|
+
* A published camera or microphone stopped from outside. The microphone comes back from the system default (Chime
|
|
3352
|
+
* keeps audio flowing, LiveKit moves to the first available device); a camera is switched off and reported, never
|
|
3353
|
+
* swapped for another camera on its own (Chime stops it the same way).
|
|
3354
|
+
*/
|
|
3355
|
+
private async onTrackEnded(producer: Producer, track: MediaStreamTrack): Promise<void> {
|
|
3356
|
+
const live = () => !this.closedByUs && !producer.closed && producer.track === track && this.producers.get(producer.id) === producer;
|
|
3357
|
+
if (!live()) return;
|
|
3358
|
+
const deviceId = deviceIdOf(track);
|
|
3359
|
+
if (producer.appData.source === 'mic') {
|
|
3360
|
+
await this.micTask(async () => {
|
|
3361
|
+
if (!live()) return;
|
|
3362
|
+
try {
|
|
3363
|
+
const md = navigator.mediaDevices;
|
|
3364
|
+
const audio = constraintsFor(this.publishConstraints?.audio, null);
|
|
3365
|
+
const next = (await md.getUserMedia({ audio })).getAudioTracks()[0];
|
|
3366
|
+
if (!next) throw new Error('no microphone');
|
|
3367
|
+
if (!live()) { next.stop(); return; }
|
|
3368
|
+
await this.adoptAudioTrack(producer, next);
|
|
3369
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
3370
|
+
this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: true });
|
|
3371
|
+
} catch {
|
|
3372
|
+
if (!live()) return;
|
|
3373
|
+
await this.setMicEnabled(false).catch(() => undefined);
|
|
3374
|
+
this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: false });
|
|
3375
|
+
}
|
|
3376
|
+
});
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
if (producer.appData.source === 'camera') {
|
|
3380
|
+
await this.setCamEnabled(false).catch(() => undefined);
|
|
3381
|
+
if (!this.closedByUs) this.emit('localTrackEnded', { kind: 'video', deviceId, recovered: false });
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
/** devicesChanged is watched only while someone listens. */
|
|
3386
|
+
private syncDeviceWatch(): void {
|
|
3387
|
+
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
3388
|
+
const wanted = !this.closedByUs && this.listenerCount('devicesChanged') > 0 && typeof md?.addEventListener === 'function';
|
|
3389
|
+
if (wanted && !this.deviceWatchOn) {
|
|
3390
|
+
this.deviceWatchOn = true;
|
|
3391
|
+
md!.addEventListener('devicechange', this.onDeviceChange);
|
|
3392
|
+
void enumerate().then((list) => { if (this.deviceWatchOn) this.knownDevices = list; }).catch(() => undefined);
|
|
3393
|
+
} else if (!wanted) {
|
|
3394
|
+
this.stopDeviceWatch();
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
private stopDeviceWatch(): void {
|
|
3399
|
+
if (!this.deviceWatchOn) return;
|
|
3400
|
+
this.deviceWatchOn = false;
|
|
3401
|
+
navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
private async refreshDevices(): Promise<void> {
|
|
3405
|
+
let now: MediaDevice[];
|
|
3406
|
+
try {
|
|
3407
|
+
now = await enumerate();
|
|
3408
|
+
} catch {
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
if (!this.deviceWatchOn) return;
|
|
3412
|
+
const { added, removed } = diffDevices(this.knownDevices, now);
|
|
3413
|
+
this.knownDevices = now;
|
|
3414
|
+
if (added.length > 0 || removed.length > 0) this.emit('devicesChanged', { devices: now, added, removed });
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
/** Adaptive stream's decision for a video, with the audio-only fallback holding remote cameras back. */
|
|
3418
|
+
private withFallback(producerId: string, want: AdaptiveWant): AdaptiveWant {
|
|
3419
|
+
if (this.protection.active && this.remoteSources.get(producerId) === 'camera') return { visible: false, spatialLayer: null };
|
|
3420
|
+
return want;
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
/**
|
|
3424
|
+
* Audio-only fallback (like Agora's AUDIO_ONLY stream fallback): on unless `audioOnlyFallback: false` was given.
|
|
3425
|
+
* While this viewer's received sound breaks up badly -- a quarter of it lost over about 15 s -- remote camera
|
|
3426
|
+
* videos are paused at the server (screen shares stay) so what bandwidth there is goes to the audio; they come
|
|
3427
|
+
* back once it has recovered for a while, holding longer if it falls back again soon. Each held video is reported
|
|
3428
|
+
* by `streamFallback`. `false` brings any held video back now and stops watching.
|
|
3429
|
+
*/
|
|
3430
|
+
setAudioOnlyFallback(enabled: boolean): void {
|
|
3431
|
+
this.audioOnlyEnabled = enabled;
|
|
3432
|
+
if (!enabled) this.protection.release();
|
|
3433
|
+
this.syncProtection();
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
/**
|
|
3437
|
+
* The publisher side of the audio-only fallback (Agora's setLocalPublishFallbackOption(AUDIO_ONLY)), off by default:
|
|
3438
|
+
* while this client's own sound breaks up on the way to the server -- a quarter of it lost over about 15 s -- its
|
|
3439
|
+
* camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers, holding
|
|
3440
|
+
* longer after a relapse. Reported by `publishFallback`. A camera you or a moderator switch off meanwhile stays off.
|
|
3441
|
+
* `false` brings a held camera back now.
|
|
3442
|
+
*/
|
|
3443
|
+
setPublishFallback(enabled: boolean): void {
|
|
3444
|
+
this.publishFallbackEnabled = enabled;
|
|
3445
|
+
if (!enabled) this.publishProtection.release();
|
|
3446
|
+
this.syncPublishProtection();
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
private syncPublishProtection(): void {
|
|
3450
|
+
const wanted = this.publishFallbackEnabled && !this.closedByUs && this.state === 'connected' &&
|
|
3451
|
+
this.micProducer() !== undefined && (this.cameraProducer() !== undefined || this.publishProtection.active);
|
|
3452
|
+
if (wanted && !this.publishProtectionTimer) {
|
|
3453
|
+
this.publishProtectionTimer = setInterval(() => void this.samplePublishProtection(), PROTECT_SAMPLE_MS);
|
|
3454
|
+
(this.publishProtectionTimer as { unref?: () => void }).unref?.();
|
|
3455
|
+
} else if (!wanted && this.publishProtectionTimer) {
|
|
3456
|
+
clearInterval(this.publishProtectionTimer);
|
|
3457
|
+
this.publishProtectionTimer = null;
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
private async samplePublishProtection(): Promise<void> {
|
|
3462
|
+
const transport = this.sendTransport;
|
|
3463
|
+
if (this.publishProtectionSampling || !transport || transport.closed) return;
|
|
3464
|
+
this.publishProtectionSampling = true;
|
|
3465
|
+
try {
|
|
3466
|
+
const report = await transport.getStats();
|
|
3467
|
+
if (transport !== this.sendTransport || !this.publishProtectionTimer) return;
|
|
3468
|
+
this.publishProtection.feed(readOutboundAudio(report.values() as Iterable<Record<string, unknown>>));
|
|
3469
|
+
} catch {
|
|
3470
|
+
// Best effort.
|
|
3471
|
+
} finally {
|
|
3472
|
+
this.publishProtectionSampling = false;
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
/** The publisher-side fallback decided: hold the camera, or bring it back if it is still ours to bring back. */
|
|
3477
|
+
private async onPublishProtection(active: boolean): Promise<void> {
|
|
3478
|
+
const camera = this.cameraProducer();
|
|
3479
|
+
if (active) {
|
|
3480
|
+
if (!camera || camera.paused) { this.syncPublishProtection(); return; }
|
|
3481
|
+
this.cameraHeldByFallback = true;
|
|
3482
|
+
camera.pause();
|
|
3483
|
+
this.emit('publishFallback', { fallback: true });
|
|
3484
|
+
await this.rpc('pauseProducer', { producerId: camera.id, paused: true }).catch(() => undefined);
|
|
3485
|
+
this.emitLocalMedia();
|
|
3486
|
+
} else if (this.cameraHeldByFallback) {
|
|
3487
|
+
this.cameraHeldByFallback = false;
|
|
3488
|
+
if (camera && camera.paused && !this.forcedOff.video) {
|
|
3489
|
+
camera.resume();
|
|
3490
|
+
await this.rpc('pauseProducer', { producerId: camera.id, paused: false }).catch(() => undefined);
|
|
3491
|
+
this.emitLocalMedia();
|
|
3492
|
+
}
|
|
3493
|
+
this.emit('publishFallback', { fallback: false });
|
|
3494
|
+
}
|
|
3495
|
+
this.syncPublishProtection();
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
/** Whether the audio-only fallback is holding remote cameras back now. */
|
|
3499
|
+
get audioOnlyFallbackActive(): boolean {
|
|
3500
|
+
return this.protection.active;
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
/** The audio-only fallback decided: hold remote cameras back, or let them come back. */
|
|
3504
|
+
private onAudioProtection(active: boolean): void {
|
|
3505
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3506
|
+
if (consumer.kind !== 'video' || this.remoteSources.get(producerId) !== 'camera') continue;
|
|
3507
|
+
const want = this.withFallback(producerId, this.adaptive.current(producerId) ?? { visible: true, spatialLayer: null });
|
|
3508
|
+
void this.applyAdaptive(producerId, want);
|
|
3509
|
+
if (active && !this.protectionHeld.has(producerId)) {
|
|
3510
|
+
this.protectionHeld.add(producerId);
|
|
3511
|
+
this.emitFallback(producerId, true, 'audio-protection');
|
|
3512
|
+
} else if (!active && this.protectionHeld.delete(producerId)) {
|
|
3513
|
+
this.emitFallback(producerId, false, 'audio-protection');
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
this.syncProtection();
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
private emitFallback(producerId: string, fallback: boolean, reason: StreamFallbackEvent['reason']): void {
|
|
3520
|
+
const info = this.remoteInfo.get(producerId);
|
|
3521
|
+
this.emit('streamFallback', {
|
|
3522
|
+
producerId,
|
|
3523
|
+
identity: info?.identity ?? '',
|
|
3524
|
+
source: this.remoteSources.get(producerId) ?? 'camera',
|
|
3525
|
+
fallback,
|
|
3526
|
+
reason,
|
|
3527
|
+
});
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
/** Withdraw every held-back report (teardown; nothing is said after close) and forget the decisions. */
|
|
3531
|
+
private clearFallbacks(): void {
|
|
3532
|
+
if (!this.closedByUs) {
|
|
3533
|
+
for (const producerId of this.bandwidthCut) this.emitFallback(producerId, false, 'bandwidth');
|
|
3534
|
+
for (const producerId of this.protectionHeld) this.emitFallback(producerId, false, 'audio-protection');
|
|
3535
|
+
}
|
|
3536
|
+
this.bandwidthCut.clear();
|
|
3537
|
+
this.protectionHeld.clear();
|
|
3538
|
+
this.protection.reset();
|
|
3539
|
+
this.stopProtection();
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
/**
|
|
3543
|
+
* The received audio is sampled only while the fallback could matter: enabled, connected, with remote audio to
|
|
3544
|
+
* judge by and a remote camera to hold back (or one held back already).
|
|
3545
|
+
*/
|
|
3546
|
+
private syncProtection(): void {
|
|
3547
|
+
let audio = false;
|
|
3548
|
+
let camera = false;
|
|
3549
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3550
|
+
if (consumer.closed) continue;
|
|
3551
|
+
if (consumer.kind === 'audio') audio = true;
|
|
3552
|
+
else if (this.remoteSources.get(producerId) === 'camera') camera = true;
|
|
3553
|
+
}
|
|
3554
|
+
const wanted = this.audioOnlyEnabled && !this.closedByUs && this.state === 'connected' && audio && (camera || this.protection.active);
|
|
3555
|
+
if (wanted && !this.protectionTimer) {
|
|
3556
|
+
this.protectionTimer = setInterval(() => void this.sampleProtection(), PROTECT_SAMPLE_MS);
|
|
3557
|
+
// Background sampling never keeps a server-side or test process alive (Node); browsers have no unref.
|
|
3558
|
+
(this.protectionTimer as { unref?: () => void }).unref?.();
|
|
3559
|
+
} else if (!wanted) {
|
|
3560
|
+
this.stopProtection();
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
private stopProtection(): void {
|
|
3565
|
+
if (this.protectionTimer) clearInterval(this.protectionTimer);
|
|
3566
|
+
this.protectionTimer = null;
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
private async sampleProtection(): Promise<void> {
|
|
3570
|
+
const transport = this.recvTransport;
|
|
3571
|
+
if (this.protectionSampling || !transport || transport.closed) return;
|
|
3572
|
+
this.protectionSampling = true;
|
|
3573
|
+
try {
|
|
3574
|
+
const report = await transport.getStats();
|
|
3575
|
+
if (transport !== this.recvTransport || !this.protectionTimer) return;
|
|
3576
|
+
this.protection.feed(readInboundAudio(report.values() as Iterable<Record<string, unknown>>));
|
|
3577
|
+
} catch {
|
|
3578
|
+
// Statistics are best effort; the next sample tries again.
|
|
3579
|
+
} finally {
|
|
3580
|
+
this.protectionSampling = false;
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
/** Apply adaptive stream's decision for one video: pause it, or resume it at its layer. */
|
|
3585
|
+
private async applyAdaptive(producerId: string, want: AdaptiveWant): Promise<void> {
|
|
3586
|
+
const consumer = this.consumers.get(producerId);
|
|
3587
|
+
// Not received (yet): a consumer made later starts from the decision in force.
|
|
3588
|
+
if (!consumer || consumer.kind !== 'video' || consumer.closed || this.closedByUs) return;
|
|
3589
|
+
try {
|
|
3590
|
+
if (!want.visible) {
|
|
3591
|
+
if (this.pauseUnsupported) {
|
|
3592
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 });
|
|
3593
|
+
return;
|
|
3594
|
+
}
|
|
3595
|
+
if (this.adaptivePaused.has(producerId)) return;
|
|
3596
|
+
this.adaptivePaused.add(producerId);
|
|
3597
|
+
await this.rpc('pauseConsumer', { consumerId: consumer.id });
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3600
|
+
if (this.adaptivePaused.delete(producerId)) await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
3601
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...this.effectiveLayers(producerId, want) });
|
|
3602
|
+
} catch (err) {
|
|
3603
|
+
if (this.consumers.get(producerId) !== consumer || consumer.closed || this.closedByUs) return;
|
|
3604
|
+
if (!want.visible && err instanceof RpcError && err.code === 'unknown-method') {
|
|
3605
|
+
// An older server: drop to the smallest layer instead of pausing.
|
|
3606
|
+
this.adaptivePaused.delete(producerId);
|
|
3607
|
+
this.pauseUnsupported = true;
|
|
3608
|
+
await this.applyAdaptive(producerId, want);
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
this.emit('error', err as Error);
|
|
3612
|
+
}
|
|
2495
3613
|
}
|
|
2496
3614
|
|
|
2497
3615
|
// ------------------------------------------------------------- moderation
|
|
@@ -2544,19 +3662,57 @@ export class StreamClient {
|
|
|
2544
3662
|
}
|
|
2545
3663
|
|
|
2546
3664
|
private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
3665
|
+
// Your own choice about the camera ends the publisher-side fallback's hold on it: it never undoes your choice.
|
|
3666
|
+
if (kind === 'video' && this.cameraHeldByFallback) {
|
|
3667
|
+
this.cameraHeldByFallback = false;
|
|
3668
|
+
this.publishProtection.reset();
|
|
3669
|
+
this.emit('publishFallback', { fallback: false });
|
|
3670
|
+
}
|
|
3671
|
+
const generation = this.publishGeneration;
|
|
3672
|
+
// Unpublished or closed while waiting: nothing is left to switch, and the
|
|
3673
|
+
// call resolves, as it does with nothing published (V072-03).
|
|
3674
|
+
const ended = () => generation !== this.publishGeneration || this.closedByUs;
|
|
3675
|
+
// The music has its own switch. Muting the microphone must not stop the song.
|
|
3676
|
+
const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
|
|
3677
|
+
for (const producer of targets) {
|
|
3678
|
+
if (ended()) return;
|
|
3679
|
+
if (this.producers.get(producer.id) !== producer || producer.closed) continue;
|
|
3680
|
+
const revision = this.moderationRevision[kind];
|
|
2550
3681
|
if (!enabled) producer.pause();
|
|
2551
|
-
|
|
2552
|
-
|
|
3682
|
+
try {
|
|
3683
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
3684
|
+
} catch (err) {
|
|
3685
|
+
if (ended()) return;
|
|
3686
|
+
throw err;
|
|
3687
|
+
}
|
|
3688
|
+
if (ended()) return;
|
|
3689
|
+
if (!enabled || this.producers.get(producer.id) !== producer || producer.closed) continue;
|
|
3690
|
+
if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
|
|
3691
|
+
// A moderator decided while this waited: the older request does not
|
|
3692
|
+
// undo it. It stays off here, and the server is told so too, since
|
|
3693
|
+
// which of the two it applied last cannot be known from here.
|
|
3694
|
+
void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
|
|
3695
|
+
this.emitLocalMedia();
|
|
3696
|
+
throw new RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
|
|
3697
|
+
}
|
|
3698
|
+
producer.resume();
|
|
2553
3699
|
}
|
|
2554
3700
|
this.emitLocalMedia();
|
|
2555
3701
|
}
|
|
2556
3702
|
|
|
3703
|
+
/** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
|
|
3704
|
+
private moderate(kind: 'audio' | 'video' | '' | undefined, paused: boolean, snapshot = false): void {
|
|
3705
|
+
for (const k of kind ? [kind] : (['audio', 'video'] as const)) {
|
|
3706
|
+
if (snapshot && this.forcedOff[k] === paused) continue;
|
|
3707
|
+
this.forcedOff[k] = paused;
|
|
3708
|
+
this.moderationRevision[k] += 1;
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
|
|
2557
3712
|
/** Stop publishing entirely and release the local devices. */
|
|
2558
3713
|
async unpublish(): Promise<void> {
|
|
2559
3714
|
void this.stopRecording().catch(error => this.emit('error', error as Error));
|
|
3715
|
+
this.layersNeeded.clear();
|
|
2560
3716
|
// All of it takes effect now; only telling the server is waited for. So
|
|
2561
3717
|
// publish() and unpublish() act in the order they are called: a publish
|
|
2562
3718
|
// called while this still waits is not undone by it, and one already
|