@voltkit-rtc/client 0.7.3 → 0.9.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 +86 -0
- package/README.md +346 -2
- 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/effects-core.d.ts +95 -0
- package/dist/cjs/effects-core.d.ts.map +1 -0
- package/dist/cjs/effects-core.js +154 -0
- package/dist/cjs/effects-core.js.map +1 -0
- package/dist/cjs/effects-gl.d.ts +79 -0
- package/dist/cjs/effects-gl.d.ts.map +1 -0
- package/dist/cjs/effects-gl.js +351 -0
- package/dist/cjs/effects-gl.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 +396 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1367 -81
- 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/noise-suppression.d.ts +44 -0
- package/dist/cjs/noise-suppression.d.ts.map +1 -0
- package/dist/cjs/noise-suppression.js +103 -0
- package/dist/cjs/noise-suppression.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/processors.d.ts +34 -0
- package/dist/cjs/processors.d.ts.map +1 -0
- package/dist/cjs/processors.js +10 -0
- package/dist/cjs/processors.js.map +1 -0
- 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/cjs/video-effects.d.ts +93 -0
- package/dist/cjs/video-effects.d.ts.map +1 -0
- package/dist/cjs/video-effects.js +493 -0
- package/dist/cjs/video-effects.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/effects-core.d.ts +95 -0
- package/dist/esm/effects-core.d.ts.map +1 -0
- package/dist/esm/effects-core.js +142 -0
- package/dist/esm/effects-core.js.map +1 -0
- package/dist/esm/effects-gl.d.ts +79 -0
- package/dist/esm/effects-gl.d.ts.map +1 -0
- package/dist/esm/effects-gl.js +347 -0
- package/dist/esm/effects-gl.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 +396 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1365 -81
- 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/noise-suppression.d.ts +44 -0
- package/dist/esm/noise-suppression.d.ts.map +1 -0
- package/dist/esm/noise-suppression.js +99 -0
- package/dist/esm/noise-suppression.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/processors.d.ts +34 -0
- package/dist/esm/processors.d.ts.map +1 -0
- package/dist/esm/processors.js +9 -0
- package/dist/esm/processors.js.map +1 -0
- 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/dist/esm/video-effects.d.ts +93 -0
- package/dist/esm/video-effects.d.ts.map +1 -0
- package/dist/esm/video-effects.js +488 -0
- package/dist/esm/video-effects.js.map +1 -0
- package/package.json +31 -1
- package/src/adaptive.ts +295 -0
- package/src/devices.ts +70 -0
- package/src/diagnostics.ts +94 -0
- package/src/effects-core.ts +177 -0
- package/src/effects-gl.ts +384 -0
- package/src/fallback.ts +169 -0
- package/src/health.ts +127 -0
- package/src/index.ts +1364 -81
- package/src/link.ts +255 -0
- package/src/noise-suppression.ts +135 -0
- package/src/preflight.ts +191 -0
- package/src/processors.ts +35 -0
- package/src/speakers.ts +66 -0
- package/src/video-effects.ts +552 -0
package/src/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ type RtpCapabilities = ms.RtpCapabilities;
|
|
|
17
17
|
type RtpCodecCapability = ms.RtpCodecCapability;
|
|
18
18
|
type Transport = ms.Transport;
|
|
19
19
|
type TransportOptions = ms.TransportOptions;
|
|
20
|
+
/** The transports a join made when asked (`join {transports}`, P3-6): exactly what createTransport returns. */
|
|
21
|
+
type PreparedTransports = { send?: TransportOptions; recv?: TransportOptions };
|
|
20
22
|
|
|
21
23
|
import type { ChatEvents, ChatMessage, ChatState, SendMessageOptions } from './chat.js';
|
|
22
24
|
import { isTerminalError, RpcError } from './errors.js';
|
|
@@ -30,7 +32,16 @@ import {
|
|
|
30
32
|
type JoinMedia,
|
|
31
33
|
type MediaPermissions,
|
|
32
34
|
} from './media-permissions.js';
|
|
33
|
-
import { NetworkQualityWatch, type NetworkQuality } from './network.js';
|
|
35
|
+
import { NetworkQualityWatch, networkQualityOf, readTransportStats, type NetworkQuality } from './network.js';
|
|
36
|
+
import { MediaPathWatch, SocketLiveness, type RestartOutcome } from './link.js';
|
|
37
|
+
import { AdaptiveVideos, pageIsVisible, type AdaptiveWant } from './adaptive.js';
|
|
38
|
+
import { AudioProtection, PROTECT_SAMPLE_MS, readInboundAudio, readOutboundAudio } from './fallback.js';
|
|
39
|
+
import { ActiveSpeakers, AUDIO_LEVEL_INTERVAL_MS, SPEAKING_LEVEL } from './speakers.js';
|
|
40
|
+
import { connectionPath, runPreflight, syntheticMedia, type PreflightOptions, type PreflightReport } from './preflight.js';
|
|
41
|
+
import type { AudioTrackProcessor, VideoTrackProcessor } from './processors.js';
|
|
42
|
+
import { DiagnosticsLog, SDK_VERSION, type Diagnostics, type DiagnosticsTransport } from './diagnostics.js';
|
|
43
|
+
import { HEALTH_SAMPLE_MS, HealthDecisions, INTERRUPTED_AFTER_MS, readMediaSource, type MediaWarning } from './health.js';
|
|
44
|
+
import { constraintsFor, deviceIdOf, diffDevices, enumerate, type DeviceKind, type DevicesChangedEvent, type MediaDevice } from './devices.js';
|
|
34
45
|
import { socketUrl, TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
35
46
|
import {
|
|
36
47
|
chooseCodec,
|
|
@@ -89,6 +100,13 @@ export { TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
|
89
100
|
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
90
101
|
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
|
|
91
102
|
export type { CodecPreference } from './device.js';
|
|
103
|
+
export type { DeviceKind, DevicesChangedEvent, MediaDevice } from './devices.js';
|
|
104
|
+
export { SPEAKING_LEVEL } from './speakers.js';
|
|
105
|
+
export type { MediaWarning, MediaWarningCode } from './health.js';
|
|
106
|
+
export type { PreflightOptions, PreflightReport, PreflightStep, PreflightStepName } from './preflight.js';
|
|
107
|
+
export { SDK_VERSION } from './diagnostics.js';
|
|
108
|
+
export type { AudioTrackProcessor, VideoTrackProcessor } from './processors.js';
|
|
109
|
+
export type { Diagnostics, DiagnosticsEntry, DiagnosticsTransport } from './diagnostics.js';
|
|
92
110
|
export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
|
|
93
111
|
export type {
|
|
94
112
|
ChatClientEvents,
|
|
@@ -261,6 +279,37 @@ export interface ClientOptions {
|
|
|
261
279
|
* reserved for future native hosts, which have no user agent to detect.
|
|
262
280
|
*/
|
|
263
281
|
handlerName?: DeviceHandlerName;
|
|
282
|
+
/**
|
|
283
|
+
* Audio-only fallback (0.8.0, on unless `false`): when this viewer's connection is so poor that the received sound
|
|
284
|
+
* breaks up, remote camera videos are paused so the audio keeps going, and resumed once it recovers. See
|
|
285
|
+
* `setAudioOnlyFallback` and the `streamFallback` event.
|
|
286
|
+
*/
|
|
287
|
+
audioOnlyFallback?: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** One participant's sound now (Agora's volume-indicator, Tencent TRTC's AUDIO_VOLUME). */
|
|
291
|
+
export interface AudioLevel {
|
|
292
|
+
identity: string;
|
|
293
|
+
producerId: string;
|
|
294
|
+
source: TrackSource;
|
|
295
|
+
/** WebRTC's audioLevel: linear, 0 = silence, 1 = 0 dBov. */
|
|
296
|
+
level: number;
|
|
297
|
+
/** Above `SPEAKING_LEVEL` (-35 dBov, LiveKit's threshold). */
|
|
298
|
+
speaking: boolean;
|
|
299
|
+
/** This client's own microphone. */
|
|
300
|
+
local: boolean;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 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. */
|
|
304
|
+
export type StreamFallbackReason = 'bandwidth' | 'audio-protection';
|
|
305
|
+
|
|
306
|
+
export interface StreamFallbackEvent {
|
|
307
|
+
producerId: string;
|
|
308
|
+
identity: string;
|
|
309
|
+
source: TrackSource;
|
|
310
|
+
/** true: the video is held back (show the person's name or avatar); false: it is coming back. */
|
|
311
|
+
fallback: boolean;
|
|
312
|
+
reason: StreamFallbackReason;
|
|
264
313
|
}
|
|
265
314
|
|
|
266
315
|
export interface SlotClaim {
|
|
@@ -295,7 +344,26 @@ export interface RemoteProducer {
|
|
|
295
344
|
|
|
296
345
|
const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio' ? 'mic' : 'camera');
|
|
297
346
|
|
|
347
|
+
/** A receiver's most recent audio level (0..1), from its synchronization sources: cheap, made for this. */
|
|
348
|
+
function receiverLevel(receiver: RTCRtpReceiver | undefined): number {
|
|
349
|
+
const sources = receiver?.getSynchronizationSources?.() ?? [];
|
|
350
|
+
let level = 0;
|
|
351
|
+
for (const s of sources) if (typeof s.audioLevel === 'number') level = Math.max(level, s.audioLevel);
|
|
352
|
+
return level;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A microphone producer's level now (0..1), from its media-source statistics. */
|
|
356
|
+
async function senderLevel(producer: Producer): Promise<number> {
|
|
357
|
+
const stats = await producer.getStats();
|
|
358
|
+
for (const s of stats.values() as Iterable<Record<string, unknown>>) {
|
|
359
|
+
if (s.type === 'media-source' && typeof s.audioLevel === 'number') return s.audioLevel;
|
|
360
|
+
}
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
298
364
|
export interface ClientEvents extends ChatEvents {
|
|
365
|
+
/** A transport is recovering its media path; the room stays joined. */
|
|
366
|
+
mediaReconnecting: (e: { transportId: string; reconnecting: boolean }) => void;
|
|
299
367
|
connected: () => void;
|
|
300
368
|
disconnected: (reason: string) => void;
|
|
301
369
|
/** idle → connecting → connected ⇄ reconnecting → closed. Read it any time as `connectionState`. */
|
|
@@ -390,6 +458,66 @@ export interface ClientEvents extends ChatEvents {
|
|
|
390
458
|
* every 2 s, only while publishing a camera and someone listens.
|
|
391
459
|
*/
|
|
392
460
|
videoLimited: (e: VideoLimitedEvent) => void;
|
|
461
|
+
/**
|
|
462
|
+
* This device is too busy to encode its camera at full quality (WebRTC's
|
|
463
|
+
* `qualityLimitationReason: "cpu"`, held for about 4 s), or no longer is.
|
|
464
|
+
* The moment to lighten its load, as LiveKit's and Agora's guides advise:
|
|
465
|
+
* `prioritizePerformance()`, video effects off, remote videos smaller (or
|
|
466
|
+
* attach them with `attachVideo`). Checked every 2 s while a camera is
|
|
467
|
+
* published and someone listens.
|
|
468
|
+
*/
|
|
469
|
+
cpuConstrained: (e: { constrained: boolean }) => void;
|
|
470
|
+
/**
|
|
471
|
+
* A remote video is held back because this viewer's connection cannot carry it (`fallback: true`), or is coming
|
|
472
|
+
* back (`false`) -- Agora's `stream-fallback`. `reason: 'bandwidth'`: the media server cut it, the audio still
|
|
473
|
+
* flows. `'audio-protection'`: the received sound was breaking up, so the SDK paused remote cameras to save it
|
|
474
|
+
* (screen shares stay). Show the person's name or avatar meanwhile.
|
|
475
|
+
*/
|
|
476
|
+
streamFallback: (e: StreamFallbackEvent) => void;
|
|
477
|
+
/**
|
|
478
|
+
* The publisher-side audio-only fallback (`setPublishFallback(true)`, off by default) paused this client's camera
|
|
479
|
+
* because its upload could not carry the sound (`fallback: true`), or brought it back (`false`). Agora's
|
|
480
|
+
* onLocalPublishFallbackToAudioOnly. Viewers see the camera paused meanwhile.
|
|
481
|
+
*/
|
|
482
|
+
publishFallback: (e: { fallback: boolean }) => void;
|
|
483
|
+
/**
|
|
484
|
+
* A camera, microphone or speaker was plugged in or removed (Tencent TRTC's DEVICE_CHANGED, Chime's
|
|
485
|
+
* audioInputsChanged / videoInputsChanged). Watched only while someone listens.
|
|
486
|
+
*/
|
|
487
|
+
devicesChanged: (e: DevicesChangedEvent) => void;
|
|
488
|
+
/**
|
|
489
|
+
* The camera or microphone this client publishes stopped from outside: unplugged, a Bluetooth headset switched off,
|
|
490
|
+
* the permission revoked. A microphone is captured again from the system default and keeps publishing
|
|
491
|
+
* (`recovered: true`), or is switched off if there is none; a camera is switched off (`recovered: false`) and never
|
|
492
|
+
* swapped for another one on its own -- offer `listDevices('videoinput')` and `switchDevice`, then
|
|
493
|
+
* `setCamEnabled(true)`.
|
|
494
|
+
*/
|
|
495
|
+
localTrackEnded: (e: { kind: 'audio' | 'video'; deviceId: string | null; recovered: boolean }) => void;
|
|
496
|
+
/**
|
|
497
|
+
* Every participant's sound about four times a second while listened to (Agora's volume-indicator, TRTC's
|
|
498
|
+
* AUDIO_VOLUME): each remote microphone, music and shared audio this client receives, and its own microphone while
|
|
499
|
+
* on. For a "speaking" ring use `speaking`.
|
|
500
|
+
*/
|
|
501
|
+
audioLevels: (levels: AudioLevel[]) => void;
|
|
502
|
+
/**
|
|
503
|
+
* Who is speaking, most active first, when that changes (Amazon Chime's active speaker ranking): for a speaker
|
|
504
|
+
* view or a highlighted tile. Microphones only; music does not count. This client is in it while its own
|
|
505
|
+
* microphone is on.
|
|
506
|
+
*/
|
|
507
|
+
activeSpeakers: (e: { speakers: string[] }) => void;
|
|
508
|
+
/**
|
|
509
|
+
* A problem with this client's own camera or microphone starts (`active: true`) or clears (`false`), like Agora's
|
|
510
|
+
* exception events: `mic-silent` (on, but exact silence: a mute switch or a broken device), `camera-fps-low` (far
|
|
511
|
+
* fewer frames than asked: usually a dark room), `camera-interrupted` / `mic-interrupted` (the system took the
|
|
512
|
+
* device: a call, another app). Checked every 2 s while listened to.
|
|
513
|
+
*/
|
|
514
|
+
mediaWarning: (e: MediaWarning) => void;
|
|
515
|
+
/**
|
|
516
|
+
* The browser refused to play an element handed to `play()` until the viewer interacts with the page (Agora's
|
|
517
|
+
* autoplay-failed, Tencent TRTC's AUTOPLAY_FAILED). Show "Tap to unmute" and call `resume()` from that tap; the SDK
|
|
518
|
+
* also retries every refused element on the next tap or key press by itself.
|
|
519
|
+
*/
|
|
520
|
+
autoplayFailed: (e: { element: HTMLMediaElement; resume: () => Promise<void> }) => void;
|
|
393
521
|
}
|
|
394
522
|
|
|
395
523
|
type Handler = (...args: never[]) => void;
|
|
@@ -398,12 +526,14 @@ type Handler = (...args: never[]) => void;
|
|
|
398
526
|
// event without listing it here (or listing one that does not exist) fails to
|
|
399
527
|
// compile; the contract check compares this list with docs/sdk-contract.json.
|
|
400
528
|
const EVENT_NAMES: { readonly [K in keyof ClientEvents]-?: true } = {
|
|
529
|
+
mediaReconnecting: true,
|
|
401
530
|
connected: true, disconnected: true, connectionStateChanged: true, joined: true,
|
|
402
531
|
track: true, trackEnded: true, trackMuted: true,
|
|
403
532
|
participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
|
|
404
533
|
roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
|
|
405
534
|
mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
|
|
406
|
-
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
|
|
535
|
+
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true, cpuConstrained: true,
|
|
536
|
+
streamFallback: true, publishFallback: true, devicesChanged: true, localTrackEnded: true, audioLevels: true, activeSpeakers: true, mediaWarning: true, autoplayFailed: true,
|
|
407
537
|
screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
|
|
408
538
|
message: true, messageDeleted: true, chatMuted: true, typing: true,
|
|
409
539
|
};
|
|
@@ -426,8 +556,6 @@ const CLOSE_REPLACED = 4001;
|
|
|
426
556
|
/** The server ended the session on purpose and said why first: do not reconnect. */
|
|
427
557
|
const CLOSE_ENDED = 4003;
|
|
428
558
|
|
|
429
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
430
|
-
|
|
431
559
|
/** What setPreferredLayers sends; no temporal layer means the full frame rate. */
|
|
432
560
|
interface PreferredLayers {
|
|
433
561
|
spatialLayer: number;
|
|
@@ -478,6 +606,72 @@ function declareAudioQuality(rtpParameters: ms.RtpParameters, appData: Record<st
|
|
|
478
606
|
}
|
|
479
607
|
|
|
480
608
|
export class StreamClient {
|
|
609
|
+
private backoffWake: (() => void) | null = null;
|
|
610
|
+
private onlineTarget: Window | null = null;
|
|
611
|
+
private readonly online = () => {
|
|
612
|
+
if (this.closedByUs) return;
|
|
613
|
+
this.backoffWake?.();
|
|
614
|
+
this.socketLiveness.checkNow();
|
|
615
|
+
};
|
|
616
|
+
private readonly socketLiveness = new SocketLiveness({
|
|
617
|
+
active: () => !this.closedByUs && this.state === 'connected',
|
|
618
|
+
ping: () => { void this.rpc('ping').catch(() => undefined); },
|
|
619
|
+
dead: () => this.recoverConnection('socket-timeout'),
|
|
620
|
+
});
|
|
621
|
+
private readonly mediaPaths = new MediaPathWatch({
|
|
622
|
+
active: () => !this.closedByUs && this.state === 'connected',
|
|
623
|
+
restartIce: id => this.restartMediaIce(id),
|
|
624
|
+
reconnect: reason => this.recoverConnection(reason),
|
|
625
|
+
onPathChange: (transportId, reconnecting) => {
|
|
626
|
+
if (!this.closedByUs) this.emit('mediaReconnecting', { transportId, reconnecting });
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
private recoverConnection(reason: string): void {
|
|
631
|
+
if (this.closedByUs || !this.joinedOnce || this.state !== 'connected') return;
|
|
632
|
+
this.setState('reconnecting');
|
|
633
|
+
if (this.closedByUs) return; // a state listener may close synchronously
|
|
634
|
+
this.mediaPaths.reset();
|
|
635
|
+
if (this.closedByUs) return;
|
|
636
|
+
this.detachSocket(reason);
|
|
637
|
+
this.networkWatch.set(false);
|
|
638
|
+
this.emit('disconnected', reason);
|
|
639
|
+
void this.scheduleReconnect();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private async restartMediaIce(id: string): Promise<RestartOutcome> {
|
|
643
|
+
const transport = [this.sendTransport, this.recvTransport].find(t => t?.id === id);
|
|
644
|
+
const socket = this.ws;
|
|
645
|
+
if (!transport || transport.closed || this.closedByUs || this.state !== 'connected') return 'skipped';
|
|
646
|
+
const { iceParameters } = await this.rpc('restartIce', { transportId: id }) as { iceParameters: ms.IceParameters };
|
|
647
|
+
if (this.ws !== socket || transport.closed || this.closedByUs ||
|
|
648
|
+
(transport !== this.sendTransport && transport !== this.recvTransport)) return 'skipped';
|
|
649
|
+
await transport.restartIce({ iceParameters });
|
|
650
|
+
if (this.ws === socket && !this.closedByUs && !transport.closed && transport.connectionState === 'connected') {
|
|
651
|
+
this.mediaPaths.update(id, 'connected');
|
|
652
|
+
}
|
|
653
|
+
return 'restarted';
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private watchTransport(transport: Transport): void {
|
|
657
|
+
transport.on('connectionstatechange', state => {
|
|
658
|
+
if (transport !== this.sendTransport && transport !== this.recvTransport) return;
|
|
659
|
+
if (!this.closedByUs && this.state === 'connected') this.mediaPaths.update(transport.id, state);
|
|
660
|
+
});
|
|
661
|
+
transport.observer?.on('close', () => this.mediaPaths.forget(transport.id));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private waitBackoff(ms: number): Promise<void> {
|
|
665
|
+
return new Promise(resolve => {
|
|
666
|
+
const finish = () => {
|
|
667
|
+
clearTimeout(timer);
|
|
668
|
+
if (this.backoffWake === finish) this.backoffWake = null;
|
|
669
|
+
resolve();
|
|
670
|
+
};
|
|
671
|
+
const timer = setTimeout(finish, ms);
|
|
672
|
+
this.backoffWake = finish;
|
|
673
|
+
});
|
|
674
|
+
}
|
|
481
675
|
private ws: WebSocket | null = null;
|
|
482
676
|
private device: Device | null = null;
|
|
483
677
|
private sendTransport: Transport | null = null;
|
|
@@ -488,6 +682,8 @@ export class StreamClient {
|
|
|
488
682
|
// server only remembers the last one, rejecting consumes on the first.
|
|
489
683
|
private sendTransportPending: Promise<Transport> | null = null;
|
|
490
684
|
private recvTransportPending: Promise<Transport> | null = null;
|
|
685
|
+
/** Transports the join made (P3-6), each used once by the first transport of its direction. */
|
|
686
|
+
private preparedTransports: PreparedTransports | null = null;
|
|
491
687
|
private readonly pending = new Map<number, Pending>();
|
|
492
688
|
private readonly listeners = new Map<string, Set<Handler>>();
|
|
493
689
|
private readonly consumers = new Map<string, Consumer>();
|
|
@@ -550,6 +746,74 @@ export class StreamClient {
|
|
|
550
746
|
* the full picture.
|
|
551
747
|
*/
|
|
552
748
|
private readonly preferredLayers = new Map<string, PreferredLayers>();
|
|
749
|
+
/**
|
|
750
|
+
* Adaptive stream (attachVideo): each attached remote video at the size it is
|
|
751
|
+
* shown, and paused at the server while no attached element is visible.
|
|
752
|
+
*/
|
|
753
|
+
private readonly adaptive = new AdaptiveVideos({
|
|
754
|
+
layers: (id) => this.remoteVideoLayers.get(id) ?? [],
|
|
755
|
+
apply: (id, want) => { void this.applyAdaptive(id, this.withFallback(id, want)); },
|
|
756
|
+
pixelRatio: () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1),
|
|
757
|
+
pageVisible: pageIsVisible,
|
|
758
|
+
});
|
|
759
|
+
/** Audio-only fallback: whether it may happen, its decisions, and the sampling that feeds them. */
|
|
760
|
+
private audioOnlyEnabled = true;
|
|
761
|
+
private readonly protection = new AudioProtection((active) => this.onAudioProtection(active));
|
|
762
|
+
private protectionTimer: ReturnType<typeof setInterval> | null = null;
|
|
763
|
+
private protectionSampling = false;
|
|
764
|
+
/** Publisher-side fallback: off unless asked for; its decisions, timer, and whether it holds the camera now. */
|
|
765
|
+
private publishFallbackEnabled = false;
|
|
766
|
+
private readonly publishProtection = new AudioProtection((active) => { void this.onPublishProtection(active); });
|
|
767
|
+
private publishProtectionTimer: ReturnType<typeof setInterval> | null = null;
|
|
768
|
+
private publishProtectionSampling = false;
|
|
769
|
+
private cameraHeldByFallback = false;
|
|
770
|
+
/** Remote videos the media server reports it cut for this viewer's bandwidth. */
|
|
771
|
+
private readonly bandwidthCut = new Set<string>();
|
|
772
|
+
/** Remote cameras the audio-only fallback holds back now (reported with fallback: true). */
|
|
773
|
+
private readonly protectionHeld = new Set<string>();
|
|
774
|
+
/** audioLevels / activeSpeakers: read only while someone listens. */
|
|
775
|
+
private levelTimer: ReturnType<typeof setInterval> | null = null;
|
|
776
|
+
private levelSampling = false;
|
|
777
|
+
private readonly speakers = new ActiveSpeakers();
|
|
778
|
+
/** mediaWarning: decisions, the sampling timer, and the system interruptions being timed. */
|
|
779
|
+
private readonly health = new HealthDecisions((warning) => this.emit('mediaWarning', warning));
|
|
780
|
+
private healthTimer: ReturnType<typeof setInterval> | null = null;
|
|
781
|
+
private healthSampling = false;
|
|
782
|
+
private readonly interruptions = new Map<'camera' | 'mic', ReturnType<typeof setTimeout>>();
|
|
783
|
+
/** setAudioProcessor: what the microphone passes through, and the device's own track while it does. */
|
|
784
|
+
private audioProcessor: AudioTrackProcessor | null = null;
|
|
785
|
+
private micRaw: MediaStreamTrack | null = null;
|
|
786
|
+
private videoProcessor: VideoTrackProcessor | null = null;
|
|
787
|
+
/** With a video processor: the camera (or the app's track) it draws from, and the picture it makes -- sent and shown. */
|
|
788
|
+
private camRaw: MediaStreamTrack | null = null;
|
|
789
|
+
private camOut: MediaStreamTrack | null = null;
|
|
790
|
+
/** getDiagnostics(): the SDK's recent events, for a support ticket. */
|
|
791
|
+
private readonly diagnosticsLog = new DiagnosticsLog();
|
|
792
|
+
/** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
|
|
793
|
+
private readonly autoplayBlocked = new Set<HTMLMediaElement>();
|
|
794
|
+
private gestureWatchOn = false;
|
|
795
|
+
private readonly onGesture = (): void => { void this.retryAutoplay(); };
|
|
796
|
+
/** setPlayoutDelay: the receive buffer asked for every remote track, in ms (null: the browser's own). */
|
|
797
|
+
private playoutDelayMs: number | null = null;
|
|
798
|
+
/** devicesChanged: the device list last seen, kept while someone listens. */
|
|
799
|
+
private knownDevices: MediaDevice[] = [];
|
|
800
|
+
private deviceWatchOn = false;
|
|
801
|
+
private readonly onDeviceChange = (): void => { void this.refreshDevices(); };
|
|
802
|
+
/** Videos adaptive stream has paused at the server, by producer id. */
|
|
803
|
+
private readonly adaptivePaused = new Set<string>();
|
|
804
|
+
/** The server has no pauseConsumer (an older controller): a hidden video drops to its smallest layer instead. */
|
|
805
|
+
private pauseUnsupported = false;
|
|
806
|
+
/** The last cpuConstrained said. */
|
|
807
|
+
private cpuConstrainedNow = false;
|
|
808
|
+
/** prioritizePerformance(): the camera's largest simulcast layer is not encoded while true. */
|
|
809
|
+
private performanceFirst = false;
|
|
810
|
+
/**
|
|
811
|
+
* Dynacast (the server's `producerLayers`): the highest layer any viewer needs, by producer id (-1 = nobody
|
|
812
|
+
* watches, the smallest layer stays on). No entry: every layer, as before the server said anything.
|
|
813
|
+
*/
|
|
814
|
+
private readonly layersNeeded = new Map<string, number>();
|
|
815
|
+
/** The highest camera layer this client set active, and on which producer. */
|
|
816
|
+
private cameraMaxLayer: { producerId: string; layer: number } | null = null;
|
|
553
817
|
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
554
818
|
private readonly endRoomWaiters = new Set<() => void>();
|
|
555
819
|
private closedByUs = false;
|
|
@@ -578,7 +842,14 @@ export class StreamClient {
|
|
|
578
842
|
private readonly recvStatsBaseline: StatsBaseline = new Map();
|
|
579
843
|
private readonly limitWatch = new VideoLimitWatch(
|
|
580
844
|
(baseline, isCurrent) => this.sampleCamera(baseline, isCurrent),
|
|
581
|
-
(event) =>
|
|
845
|
+
(event) => {
|
|
846
|
+
this.emit('videoLimited', event);
|
|
847
|
+
const constrained = event.reason === 'cpu';
|
|
848
|
+
if (constrained !== this.cpuConstrainedNow) {
|
|
849
|
+
this.cpuConstrainedNow = constrained;
|
|
850
|
+
this.emit('cpuConstrained', { constrained });
|
|
851
|
+
}
|
|
852
|
+
},
|
|
582
853
|
undefined,
|
|
583
854
|
() => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; },
|
|
584
855
|
);
|
|
@@ -617,9 +888,53 @@ export class StreamClient {
|
|
|
617
888
|
(quality) => this.emit('networkQuality', quality),
|
|
618
889
|
);
|
|
619
890
|
|
|
891
|
+
/**
|
|
892
|
+
* A pre-call check (Twilio's runPreflight, LiveKit's ConnectionCheck, Agora's last-mile probe): joins the room the
|
|
893
|
+
* token names, publishes `options.media` (cloned, your tracks keep running) or a tone and a moving picture it makes,
|
|
894
|
+
* measures for `durationMs` (5 s) and leaves. Mint a host token for a test room nobody else is in: the session is
|
|
895
|
+
* real, and billed like any other for its few seconds. Resolves with each step, how media travelled (UDP is best;
|
|
896
|
+
* TCP or a relay means a firewall in the way), the round trip, loss and upload, and any camera or microphone warning
|
|
897
|
+
* seen. Never rejects: a failure is a step with `ok: false` and its error.
|
|
898
|
+
*/
|
|
899
|
+
static async preflight(wsUrl: string, token: string, options: PreflightOptions = {}): Promise<PreflightReport> {
|
|
900
|
+
const client = new StreamClient(wsUrl, token, { audioOnlyFallback: false });
|
|
901
|
+
const synthetic = options.media ? null : syntheticMedia();
|
|
902
|
+
const stream = options.media ? new MediaStream(options.media.getTracks().map((t) => t.clone())) : synthetic!.stream;
|
|
903
|
+
try {
|
|
904
|
+
return await runPreflight({
|
|
905
|
+
connect: () => client.connect(),
|
|
906
|
+
publish: (media) => client.publish(media),
|
|
907
|
+
sendState: () => client.sendTransport?.connectionState ?? null,
|
|
908
|
+
sendStats: async () => (client.sendTransport ? ((await client.sendTransport.getStats()).values() as Iterable<Record<string, unknown>>) : null),
|
|
909
|
+
onWarning: (listener) => client.on('mediaWarning', (w) => listener(w.code, w.active)),
|
|
910
|
+
close: () => client.close(),
|
|
911
|
+
}, stream, options);
|
|
912
|
+
} finally {
|
|
913
|
+
synthetic?.stop();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Warm the connection before `connect()` (LiveKit's prepareConnection): a HEAD request to the server, so the
|
|
919
|
+
* name lookup and the TLS handshake are done by the time the socket opens. Call it as the user heads for the
|
|
920
|
+
* room (opening the room screen, say). Never rejects; it only saves time.
|
|
921
|
+
*/
|
|
922
|
+
static async prepareConnection(wsUrl: string): Promise<void> {
|
|
923
|
+
try {
|
|
924
|
+
const url = new URL(wsUrl);
|
|
925
|
+
url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
|
|
926
|
+
url.pathname = '/';
|
|
927
|
+
url.search = '';
|
|
928
|
+
await fetch(url.toString(), { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
|
|
929
|
+
} catch {
|
|
930
|
+
// Only a warm-up: connect() does the real work either way.
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
620
934
|
constructor(private readonly wsUrl: string, token: string, private readonly options: ClientOptions = {}) {
|
|
621
935
|
this.token = token;
|
|
622
936
|
this.tokenExpiry = tokenExpiresAt(token);
|
|
937
|
+
this.audioOnlyEnabled = options.audioOnlyFallback !== false;
|
|
623
938
|
}
|
|
624
939
|
|
|
625
940
|
on<K extends keyof ClientEvents>(event: K, fn: ClientEvents[K]): () => void {
|
|
@@ -631,12 +946,18 @@ export class StreamClient {
|
|
|
631
946
|
set.add(fn as Handler);
|
|
632
947
|
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
633
948
|
// they run only while heard.
|
|
634
|
-
if (event === 'videoLimited') this.syncLimitWatch();
|
|
949
|
+
if (event === 'videoLimited' || event === 'cpuConstrained') this.syncLimitWatch();
|
|
635
950
|
if (event === 'networkQuality') this.syncNetworkWatch();
|
|
951
|
+
if (event === 'devicesChanged') this.syncDeviceWatch();
|
|
952
|
+
if (event === 'audioLevels' || event === 'activeSpeakers') this.syncLevelWatch();
|
|
953
|
+
if (event === 'mediaWarning') this.syncHealthWatch();
|
|
636
954
|
return () => {
|
|
637
955
|
set!.delete(fn as Handler);
|
|
638
|
-
if (event === 'videoLimited') this.syncLimitWatch();
|
|
956
|
+
if (event === 'videoLimited' || event === 'cpuConstrained') this.syncLimitWatch();
|
|
639
957
|
if (event === 'networkQuality') this.syncNetworkWatch();
|
|
958
|
+
if (event === 'devicesChanged') this.syncDeviceWatch();
|
|
959
|
+
if (event === 'audioLevels' || event === 'activeSpeakers') this.syncLevelWatch();
|
|
960
|
+
if (event === 'mediaWarning') this.syncHealthWatch();
|
|
640
961
|
};
|
|
641
962
|
}
|
|
642
963
|
|
|
@@ -650,6 +971,10 @@ export class StreamClient {
|
|
|
650
971
|
const previous = this.state;
|
|
651
972
|
this.state = next;
|
|
652
973
|
if (next === 'closed') this.stopTimers();
|
|
974
|
+
else {
|
|
975
|
+
this.syncLevelWatch();
|
|
976
|
+
this.syncHealthWatch();
|
|
977
|
+
}
|
|
653
978
|
this.emit('connectionStateChanged', { state: next, previous });
|
|
654
979
|
}
|
|
655
980
|
|
|
@@ -674,6 +999,19 @@ export class StreamClient {
|
|
|
674
999
|
}
|
|
675
1000
|
|
|
676
1001
|
private stopTimers(): void {
|
|
1002
|
+
if (this.publishProtectionTimer) clearInterval(this.publishProtectionTimer);
|
|
1003
|
+
this.publishProtectionTimer = null;
|
|
1004
|
+
this.autoplayBlocked.clear();
|
|
1005
|
+
this.watchGesture(false);
|
|
1006
|
+
this.stopProtection();
|
|
1007
|
+
this.stopLevelWatch();
|
|
1008
|
+
this.stopHealthWatch(false);
|
|
1009
|
+
this.stopDeviceWatch();
|
|
1010
|
+
this.socketLiveness.stop();
|
|
1011
|
+
this.mediaPaths.reset();
|
|
1012
|
+
this.backoffWake?.();
|
|
1013
|
+
this.onlineTarget?.removeEventListener('online', this.online);
|
|
1014
|
+
this.onlineTarget = null;
|
|
677
1015
|
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
678
1016
|
this.tokenTimer = null;
|
|
679
1017
|
this.networkWatch.set(false);
|
|
@@ -684,6 +1022,7 @@ export class StreamClient {
|
|
|
684
1022
|
}
|
|
685
1023
|
|
|
686
1024
|
private emit<K extends keyof ClientEvents>(event: K, ...args: Parameters<ClientEvents[K]>): void {
|
|
1025
|
+
this.diagnosticsLog.record(event, args);
|
|
687
1026
|
for (const fn of this.listeners.get(event) ?? []) {
|
|
688
1027
|
try {
|
|
689
1028
|
(fn as (...a: unknown[]) => void)(...args);
|
|
@@ -707,8 +1046,13 @@ export class StreamClient {
|
|
|
707
1046
|
const opening = this.openSocket();
|
|
708
1047
|
socket = this.ws;
|
|
709
1048
|
await opening;
|
|
710
|
-
|
|
1049
|
+
// The transports come with the join where the server can make them (P3-6): the first publish and the first
|
|
1050
|
+
// video skip a createTransport round trip. Older servers ignore the request and the client asks as before.
|
|
1051
|
+
const joined = (await this.rpc('join', { token: this.token, transports: ['send', 'recv'] })) as JoinResult & { transports?: PreparedTransports };
|
|
1052
|
+
const { transports, ...joinResult } = joined;
|
|
1053
|
+
result = joinResult;
|
|
711
1054
|
this.joinResult = result;
|
|
1055
|
+
this.preparedTransports = transports ?? null;
|
|
712
1056
|
|
|
713
1057
|
this.device = this.createDevice();
|
|
714
1058
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
@@ -737,6 +1081,11 @@ export class StreamClient {
|
|
|
737
1081
|
this.joinedOnce = true;
|
|
738
1082
|
this.skippedVideos.clear();
|
|
739
1083
|
this.setState('connected');
|
|
1084
|
+
this.socketLiveness.start();
|
|
1085
|
+
if (!this.onlineTarget && typeof window !== 'undefined' && window.addEventListener) {
|
|
1086
|
+
this.onlineTarget = window;
|
|
1087
|
+
window.addEventListener('online', this.online);
|
|
1088
|
+
}
|
|
740
1089
|
this.scheduleTokenExpiring();
|
|
741
1090
|
|
|
742
1091
|
// A role or mute can change while the socket is away. The fresh roster
|
|
@@ -767,10 +1116,11 @@ export class StreamClient {
|
|
|
767
1116
|
|
|
768
1117
|
// Subscribe to everything already publishing before we announce readiness.
|
|
769
1118
|
// A close() part-way stops it: what is left would only fail, after closed.
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1119
|
+
// All at once, as newProducer events already are (and the Flutter SDK does): each subscription reserves its slot
|
|
1120
|
+
// before its first await, in this order, and the receive transport is made once. A room with six publishers
|
|
1121
|
+
// starts six videos together instead of one after another, two round trips each.
|
|
1122
|
+
if (closed()) throw closedWhileConnecting();
|
|
1123
|
+
await Promise.all(result.producers.map((p) => this.consume(p).catch((err) => { if (!closed()) this.emit('error', err as Error); })));
|
|
774
1124
|
|
|
775
1125
|
// Restore our own broadcast. This is what makes an origin failover
|
|
776
1126
|
// survivable for the host: it reconnects onto the new origin and the
|
|
@@ -780,6 +1130,9 @@ export class StreamClient {
|
|
|
780
1130
|
}
|
|
781
1131
|
if (closed()) throw closedWhileConnecting();
|
|
782
1132
|
this.syncNetworkWatch();
|
|
1133
|
+
this.syncProtection();
|
|
1134
|
+
this.syncLevelWatch();
|
|
1135
|
+
this.syncPublishProtection();
|
|
783
1136
|
return result;
|
|
784
1137
|
}
|
|
785
1138
|
|
|
@@ -823,12 +1176,17 @@ export class StreamClient {
|
|
|
823
1176
|
ws.addEventListener('open', onOpen, { once: true });
|
|
824
1177
|
ws.addEventListener('error', onError, { once: true });
|
|
825
1178
|
ws.addEventListener('message', (ev) => {
|
|
826
|
-
if (this.ws === ws)
|
|
1179
|
+
if (this.ws === ws) {
|
|
1180
|
+
this.socketLiveness.received();
|
|
1181
|
+
this.onMessage(ev.data as string);
|
|
1182
|
+
}
|
|
827
1183
|
});
|
|
828
1184
|
ws.addEventListener('close', (ev) => {
|
|
829
1185
|
ws.removeEventListener('error', onError);
|
|
830
1186
|
// Only the socket we currently consider ours may drive reconnection.
|
|
831
1187
|
if (this.ws !== ws) return;
|
|
1188
|
+
this.socketLiveness.stop();
|
|
1189
|
+
this.mediaPaths.reset();
|
|
832
1190
|
this.ws = null;
|
|
833
1191
|
this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
|
|
834
1192
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
@@ -861,6 +1219,7 @@ export class StreamClient {
|
|
|
861
1219
|
}
|
|
862
1220
|
|
|
863
1221
|
private detachSocket(reason = 'superseded', why = 'the connection was replaced'): void {
|
|
1222
|
+
this.socketLiveness.stop();
|
|
864
1223
|
clearTimeout(this.openDeadline);
|
|
865
1224
|
const ws = this.ws;
|
|
866
1225
|
this.ws = null;
|
|
@@ -910,7 +1269,7 @@ export class StreamClient {
|
|
|
910
1269
|
return;
|
|
911
1270
|
}
|
|
912
1271
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
913
|
-
await
|
|
1272
|
+
await this.waitBackoff(backoff + Math.random() * 400);
|
|
914
1273
|
if (this.closedByUs) return;
|
|
915
1274
|
try {
|
|
916
1275
|
this.teardownMedia();
|
|
@@ -942,6 +1301,17 @@ export class StreamClient {
|
|
|
942
1301
|
|
|
943
1302
|
close(): void {
|
|
944
1303
|
void this.stopRecording().catch(() => undefined);
|
|
1304
|
+
// The processor's audio graph goes with the session.
|
|
1305
|
+
const processor = this.audioProcessor;
|
|
1306
|
+
this.audioProcessor = null;
|
|
1307
|
+
this.micRaw = null;
|
|
1308
|
+
void Promise.resolve(processor?.destroy()).catch(() => undefined);
|
|
1309
|
+
const videoProcessor = this.videoProcessor;
|
|
1310
|
+
this.videoProcessor = null;
|
|
1311
|
+
void Promise.resolve(videoProcessor?.destroy()).catch(() => undefined);
|
|
1312
|
+
// Adaptive stream stops watching elements: nothing is received any more.
|
|
1313
|
+
this.adaptive.dispose();
|
|
1314
|
+
this.adaptivePaused.clear();
|
|
945
1315
|
// Only an app closing a live session is 'left'; kicked, room-closed and the
|
|
946
1316
|
// others have already reported themselves and set closedByUs.
|
|
947
1317
|
const wasOpen = !this.closedByUs;
|
|
@@ -961,7 +1331,7 @@ export class StreamClient {
|
|
|
961
1331
|
this.skippedVideos.clear();
|
|
962
1332
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
963
1333
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
964
|
-
this.
|
|
1334
|
+
this.stopLocalTracks();
|
|
965
1335
|
this.localStream = null;
|
|
966
1336
|
const ws = this.ws;
|
|
967
1337
|
this.ws = null;
|
|
@@ -975,7 +1345,18 @@ export class StreamClient {
|
|
|
975
1345
|
}
|
|
976
1346
|
|
|
977
1347
|
private teardownMedia(): void {
|
|
1348
|
+
this.micRaw = null;
|
|
1349
|
+
// Made for the session that is going; a rejoin brings new ones.
|
|
1350
|
+
this.preparedTransports = null;
|
|
1351
|
+
this.mediaPaths.reset();
|
|
1352
|
+
// The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
|
|
1353
|
+
// app's "poor connection" overlay does not outlive it.
|
|
1354
|
+
this.clearFallbacks();
|
|
978
1355
|
this.subscriptions.clear();
|
|
1356
|
+
// Producers made after this are new: the server says again what their viewers need.
|
|
1357
|
+
this.layersNeeded.clear();
|
|
1358
|
+
// New consumers start paused at the server; adaptive stream decides again for each.
|
|
1359
|
+
this.adaptivePaused.clear();
|
|
979
1360
|
for (const c of this.consumers.values()) c.close();
|
|
980
1361
|
this.consumers.clear();
|
|
981
1362
|
this.remoteInfo.clear();
|
|
@@ -1198,6 +1579,27 @@ export class StreamClient {
|
|
|
1198
1579
|
case 'newProducer':
|
|
1199
1580
|
await this.consume(data as RemoteProducer).catch((err) => this.emit('error', err as Error));
|
|
1200
1581
|
break;
|
|
1582
|
+
case 'streamFallback': {
|
|
1583
|
+
// The media server cut (or resumed) one of this viewer's videos for its bandwidth.
|
|
1584
|
+
const e = data as { producerId?: string; fallback?: boolean };
|
|
1585
|
+
if (typeof e.producerId !== 'string' || !this.consumers.has(e.producerId)) break;
|
|
1586
|
+
const cut = e.fallback === true;
|
|
1587
|
+
if (cut === this.bandwidthCut.has(e.producerId)) break;
|
|
1588
|
+
if (cut) this.bandwidthCut.add(e.producerId);
|
|
1589
|
+
else this.bandwidthCut.delete(e.producerId);
|
|
1590
|
+
this.emitFallback(e.producerId, cut, 'bandwidth');
|
|
1591
|
+
break;
|
|
1592
|
+
}
|
|
1593
|
+
case 'producerLayers': {
|
|
1594
|
+
// Dynacast: what this client's viewers need from one of its producers. Layers nobody receives are not
|
|
1595
|
+
// encoded (the smallest always is, so a new viewer starts at once); null means every layer.
|
|
1596
|
+
const e = data as { producerId?: string; spatialLayer?: number | null };
|
|
1597
|
+
if (typeof e.producerId !== 'string') break;
|
|
1598
|
+
if (typeof e.spatialLayer === 'number' && Number.isInteger(e.spatialLayer)) this.layersNeeded.set(e.producerId, e.spatialLayer);
|
|
1599
|
+
else this.layersNeeded.delete(e.producerId);
|
|
1600
|
+
if (this.cameraProducer()?.id === e.producerId) void this.cameraTask(() => this.syncCameraLayers());
|
|
1601
|
+
break;
|
|
1602
|
+
}
|
|
1201
1603
|
case 'producerClosed': {
|
|
1202
1604
|
const { producerId, identity } = data as { producerId: string; identity: string };
|
|
1203
1605
|
this.subscriptions.delete(producerId);
|
|
@@ -1205,6 +1607,9 @@ export class StreamClient {
|
|
|
1205
1607
|
this.receiveQualities.delete(producerId);
|
|
1206
1608
|
this.remoteVideoLayers.delete(producerId);
|
|
1207
1609
|
this.skippedVideos.delete(producerId);
|
|
1610
|
+
this.adaptivePaused.delete(producerId);
|
|
1611
|
+
this.bandwidthCut.delete(producerId);
|
|
1612
|
+
this.protectionHeld.delete(producerId);
|
|
1208
1613
|
const consumer = this.consumers.get(producerId);
|
|
1209
1614
|
consumer?.close();
|
|
1210
1615
|
this.consumers.delete(producerId);
|
|
@@ -1258,6 +1663,8 @@ export class StreamClient {
|
|
|
1258
1663
|
const e = data as { identity: string; reason?: string };
|
|
1259
1664
|
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
1260
1665
|
this.emit('participantLeft', e);
|
|
1666
|
+
const speakers = this.speakers.remove(e.identity);
|
|
1667
|
+
if (speakers) this.emit('activeSpeakers', { speakers });
|
|
1261
1668
|
break;
|
|
1262
1669
|
}
|
|
1263
1670
|
case 'roleChanged': {
|
|
@@ -1400,26 +1807,24 @@ export class StreamClient {
|
|
|
1400
1807
|
private ensureSendTransport(): Promise<Transport> {
|
|
1401
1808
|
if (this.sendTransport) return Promise.resolve(this.sendTransport);
|
|
1402
1809
|
if (this.sendTransportPending) return this.sendTransportPending;
|
|
1403
|
-
|
|
1404
|
-
this.sendTransportPending = null;
|
|
1810
|
+
const pending = this.createSendTransport().finally(() => {
|
|
1811
|
+
if (this.sendTransportPending === pending) this.sendTransportPending = null;
|
|
1405
1812
|
});
|
|
1406
|
-
return this.sendTransportPending;
|
|
1813
|
+
return this.sendTransportPending = pending;
|
|
1407
1814
|
}
|
|
1408
1815
|
|
|
1409
1816
|
private async createSendTransport(): Promise<Transport> {
|
|
1410
1817
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1411
|
-
|
|
1412
|
-
const
|
|
1413
|
-
const
|
|
1818
|
+
const device = this.device;
|
|
1819
|
+
const socket = this.ws;
|
|
1820
|
+
const params = this.takePreparedTransport('send') ?? (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
|
|
1821
|
+
if (this.closedByUs || device !== this.device || socket !== this.ws) throw closedWhileConnecting();
|
|
1822
|
+
const transport = device.createSendTransport({
|
|
1414
1823
|
...params,
|
|
1415
1824
|
iceServers: this.joinResult?.iceServers,
|
|
1416
1825
|
});
|
|
1417
1826
|
|
|
1418
|
-
|
|
1419
|
-
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
1420
|
-
.then(() => ok())
|
|
1421
|
-
.catch(fail);
|
|
1422
|
-
});
|
|
1827
|
+
this.connectEarly(transport);
|
|
1423
1828
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
1424
1829
|
this.rpc('produce', {
|
|
1425
1830
|
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
@@ -1430,33 +1835,60 @@ export class StreamClient {
|
|
|
1430
1835
|
});
|
|
1431
1836
|
|
|
1432
1837
|
this.sendTransport = transport;
|
|
1838
|
+
this.watchTransport(transport);
|
|
1433
1839
|
return transport;
|
|
1434
1840
|
}
|
|
1435
1841
|
|
|
1842
|
+
/**
|
|
1843
|
+
* A faster first picture (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-6). mediasoup-client applies the
|
|
1844
|
+
* remote description -- which starts ICE -- only once this transport's DTLS parameters are with the server: a round
|
|
1845
|
+
* trip before any media path is even tried, for the first publish and the first video alike. mediasoup does not need
|
|
1846
|
+
* them first: it starts DTLS as soon as ICE connects and checks the fingerprint whenever it arrives (its `auto`
|
|
1847
|
+
* role; the browser is always the DTLS client), and the server handles a socket's requests in order. So they are
|
|
1848
|
+
* sent and the transport goes on at once: ICE starts a round trip sooner, and the first produce or resume follows
|
|
1849
|
+
* straight after. A refusal means the transport can never carry media: the session rejoins.
|
|
1850
|
+
*/
|
|
1851
|
+
private connectEarly(transport: Transport): void {
|
|
1852
|
+
transport.on('connect', ({ dtlsParameters }, ok) => {
|
|
1853
|
+
ok();
|
|
1854
|
+
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters }).catch((err) => {
|
|
1855
|
+
if (transport.closed || this.closedByUs || (transport !== this.sendTransport && transport !== this.recvTransport)) return;
|
|
1856
|
+
this.emit('error', err as Error);
|
|
1857
|
+
this.recoverConnection(`connectTransport failed: ${(err as Error)?.message ?? err}`);
|
|
1858
|
+
});
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** The transport the join made for this direction, once: a later one is created as before. */
|
|
1863
|
+
private takePreparedTransport(direction: 'send' | 'recv'): TransportOptions | null {
|
|
1864
|
+
const prepared = this.preparedTransports?.[direction] ?? null;
|
|
1865
|
+
if (this.preparedTransports) this.preparedTransports[direction] = undefined;
|
|
1866
|
+
return prepared;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1436
1869
|
private ensureRecvTransport(): Promise<Transport> {
|
|
1437
1870
|
if (this.recvTransport) return Promise.resolve(this.recvTransport);
|
|
1438
1871
|
if (this.recvTransportPending) return this.recvTransportPending;
|
|
1439
|
-
|
|
1440
|
-
this.recvTransportPending = null;
|
|
1872
|
+
const pending = this.createRecvTransport().finally(() => {
|
|
1873
|
+
if (this.recvTransportPending === pending) this.recvTransportPending = null;
|
|
1441
1874
|
});
|
|
1442
|
-
return this.recvTransportPending;
|
|
1875
|
+
return this.recvTransportPending = pending;
|
|
1443
1876
|
}
|
|
1444
1877
|
|
|
1445
1878
|
private async createRecvTransport(): Promise<Transport> {
|
|
1446
1879
|
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1447
|
-
|
|
1448
|
-
const
|
|
1449
|
-
const
|
|
1880
|
+
const device = this.device;
|
|
1881
|
+
const socket = this.ws;
|
|
1882
|
+
const params = this.takePreparedTransport('recv') ?? (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
|
|
1883
|
+
if (this.closedByUs || device !== this.device || socket !== this.ws) throw closedWhileConnecting();
|
|
1884
|
+
const transport = device.createRecvTransport({
|
|
1450
1885
|
...params,
|
|
1451
1886
|
iceServers: this.joinResult?.iceServers,
|
|
1452
1887
|
});
|
|
1453
|
-
|
|
1454
|
-
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
1455
|
-
.then(() => ok())
|
|
1456
|
-
.catch(fail);
|
|
1457
|
-
});
|
|
1888
|
+
this.connectEarly(transport);
|
|
1458
1889
|
|
|
1459
1890
|
this.recvTransport = transport;
|
|
1891
|
+
this.watchTransport(transport);
|
|
1460
1892
|
return transport;
|
|
1461
1893
|
}
|
|
1462
1894
|
|
|
@@ -1567,7 +1999,8 @@ export class StreamClient {
|
|
|
1567
1999
|
this.audioCapReported = false;
|
|
1568
2000
|
await this.micTask(async () => {
|
|
1569
2001
|
const current = this.micProducer();
|
|
1570
|
-
|
|
2002
|
+
// The device's track: the processed one is the old processor graph's, let go when it is processed again.
|
|
2003
|
+
const track = current ? this.deviceTrackOf(current) : null;
|
|
1571
2004
|
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality) return;
|
|
1572
2005
|
await this.reproduceMic(current, track);
|
|
1573
2006
|
});
|
|
@@ -1607,8 +2040,10 @@ export class StreamClient {
|
|
|
1607
2040
|
if (generation !== this.publishGeneration) throw unpublishedWhile('microphone');
|
|
1608
2041
|
const quality = this.audioQualityFor();
|
|
1609
2042
|
const settings = AUDIO_SETTINGS[quality];
|
|
2043
|
+
// Through the audio processor (noise suppression) when there is one; the device's track stays the one watched.
|
|
2044
|
+
const sent = await this.processMic(track);
|
|
1610
2045
|
const producer = await transport.produce({
|
|
1611
|
-
track,
|
|
2046
|
+
track: sent,
|
|
1612
2047
|
// Keep the track alive when the producer closes: a reconnect closes
|
|
1613
2048
|
// every producer, and a stopped track cannot be republished.
|
|
1614
2049
|
stopTracks: false,
|
|
@@ -1621,6 +2056,8 @@ export class StreamClient {
|
|
|
1621
2056
|
appData: { source: 'mic', audioQuality: quality },
|
|
1622
2057
|
});
|
|
1623
2058
|
await this.keepProducer(producer, generation, 'microphone');
|
|
2059
|
+
this.micRaw = sent !== track ? track : null;
|
|
2060
|
+
this.watchTrackEnded(producer);
|
|
1624
2061
|
this.micQuality = quality;
|
|
1625
2062
|
// A microphone handed over switched off, or muted before a reconnect.
|
|
1626
2063
|
await this.pauseOnServer(producer);
|
|
@@ -1695,7 +2132,8 @@ export class StreamClient {
|
|
|
1695
2132
|
const current = this.cameraProducer();
|
|
1696
2133
|
const track = current?.track;
|
|
1697
2134
|
if (!current || !track || track.readyState !== 'live') return;
|
|
1698
|
-
|
|
2135
|
+
const camera = this.cameraSource(track);
|
|
2136
|
+
try { await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } }); }
|
|
1699
2137
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
1700
2138
|
await this.reproduceCamera(current, track);
|
|
1701
2139
|
});
|
|
@@ -1745,39 +2183,51 @@ export class StreamClient {
|
|
|
1745
2183
|
// but the camera is still being published: the republish sends this.
|
|
1746
2184
|
const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
|
|
1747
2185
|
if (!current && !reconnecting) throw new Error('replaceVideoTrack: no camera is being published');
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
if (replaced && replaced !== track) {
|
|
1755
|
-
track.enabled = replaced.enabled;
|
|
1756
|
-
replaced.enabled = true;
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
this.adoptVideoTrack(track);
|
|
1760
|
-
if (!current || current.track === track) return;
|
|
1761
|
-
const previous = current.track;
|
|
1762
|
-
const paused = current.paused;
|
|
2186
|
+
// The picture behind what is sent now (a video processor's input), for its size; the new one goes through the
|
|
2187
|
+
// processor too, when there is one.
|
|
2188
|
+
const shown = current?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
2189
|
+
const previousSource = shown ? this.cameraSource(shown) : null;
|
|
2190
|
+
const before = this.camOut;
|
|
2191
|
+
const sent = await this.processCamera(track);
|
|
1763
2192
|
try {
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
2193
|
+
if (!current) {
|
|
2194
|
+
// The republish reads the camera's state off the track it sends, and
|
|
2195
|
+
// the track being replaced is switched off exactly when the camera is
|
|
2196
|
+
// paused. Hand that state on, or a camera that was off comes back on;
|
|
2197
|
+
// and the old track back switched on, as below.
|
|
2198
|
+
const replaced = this.localStream?.getVideoTracks()[0];
|
|
2199
|
+
if (replaced && replaced !== sent) {
|
|
2200
|
+
sent.enabled = replaced.enabled;
|
|
2201
|
+
replaced.enabled = true;
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
this.adoptVideoTrack(sent);
|
|
2205
|
+
if (!current || current.track === sent) return;
|
|
2206
|
+
const previous = current.track;
|
|
2207
|
+
const paused = current.paused;
|
|
2208
|
+
try {
|
|
2209
|
+
const { width = 0, height = 0 } = track.getSettings();
|
|
2210
|
+
const frameRate = this.frameRateFor(track);
|
|
2211
|
+
const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
2212
|
+
const previousSize = previousSource?.getSettings();
|
|
2213
|
+
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
2214
|
+
previousSize?.width === width && previousSize?.height === height) {
|
|
2215
|
+
await current.replaceTrack({ track: sent });
|
|
2216
|
+
this.watchTrackEnded(current);
|
|
2217
|
+
await this.applyLayerBitrates(current, next);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
await this.reproduceCamera(current, sent);
|
|
2221
|
+
} finally {
|
|
2222
|
+
// Pausing switched the old track off (mediasoup-client pauses that
|
|
2223
|
+
// way), and nothing switches it back on once it is replaced. It is the
|
|
2224
|
+
// app's again, often the camera its canvas draws from: handed back
|
|
2225
|
+
// off, that canvas stays black after the camera is resumed.
|
|
2226
|
+
if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
|
|
1773
2227
|
}
|
|
1774
|
-
await this.reproduceCamera(current, track);
|
|
1775
2228
|
} finally {
|
|
1776
|
-
//
|
|
1777
|
-
|
|
1778
|
-
// app's again, often the camera its canvas draws from: handed back
|
|
1779
|
-
// off, that canvas stays black after the camera is resumed.
|
|
1780
|
-
if (paused && previous && previous !== this.cameraProducer()?.track) previous.enabled = true;
|
|
2229
|
+
// The processor's previous picture is no longer sent or shown.
|
|
2230
|
+
if (before && before !== this.camOut) before.stop();
|
|
1781
2231
|
}
|
|
1782
2232
|
});
|
|
1783
2233
|
}
|
|
@@ -1867,7 +2317,9 @@ export class StreamClient {
|
|
|
1867
2317
|
this.receiveQualities.delete(producerId);
|
|
1868
2318
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
1869
2319
|
this.preferredLayers.set(producerId, layers);
|
|
1870
|
-
|
|
2320
|
+
// Shown through attachVideo: the lower of this and the size it is shown at.
|
|
2321
|
+
const adaptive = this.adaptive.current(producerId);
|
|
2322
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
1871
2323
|
}
|
|
1872
2324
|
|
|
1873
2325
|
private async collectVideoStats(baseline: StatsBaseline): Promise<VideoSendStats | null> {
|
|
@@ -1898,7 +2350,8 @@ export class StreamClient {
|
|
|
1898
2350
|
}
|
|
1899
2351
|
this.receiveQualities.set(producerId, quality);
|
|
1900
2352
|
this.preferredLayers.set(producerId, layers);
|
|
1901
|
-
|
|
2353
|
+
const adaptive = this.adaptive.current(producerId);
|
|
2354
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
1902
2355
|
}
|
|
1903
2356
|
|
|
1904
2357
|
private cameraProducer(): Producer | undefined {
|
|
@@ -1953,18 +2406,28 @@ export class StreamClient {
|
|
|
1953
2406
|
paused = !track.enabled,
|
|
1954
2407
|
): Promise<Producer> {
|
|
1955
2408
|
if (generation !== this.publishGeneration) throw unpublishedWhile('camera');
|
|
2409
|
+
// With a video processor its picture is sent (and shown); the camera behind it has the size and frame rate. The
|
|
2410
|
+
// picture already made from that camera is kept: a reconnect or a new quality does not start the processor again.
|
|
2411
|
+
const source = this.cameraSource(track);
|
|
2412
|
+
const before = this.camOut;
|
|
2413
|
+
const beforeSource = this.camRaw;
|
|
2414
|
+
const sent = await this.processCamera(source);
|
|
2415
|
+
if (generation !== this.publishGeneration) {
|
|
2416
|
+
if (sent !== source && sent !== before) sent.stop();
|
|
2417
|
+
throw unpublishedWhile('camera');
|
|
2418
|
+
}
|
|
1956
2419
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
1957
2420
|
// off. Setting the track to the camera's state first makes the producer
|
|
1958
2421
|
// start in it, and a camera that is off never sends a frame of the new
|
|
1959
2422
|
// track; the server is told below.
|
|
1960
|
-
|
|
2423
|
+
sent.enabled = !paused;
|
|
1961
2424
|
// Simulcast from day one: it is what lets one node serve far more viewers.
|
|
1962
2425
|
// The layers follow the picture's real size, the project's quality and
|
|
1963
2426
|
// the device, so the browser sends every layer asked for (see
|
|
1964
2427
|
// videoEncodings).
|
|
1965
|
-
const { width = 0, height = 0 } =
|
|
2428
|
+
const { width = 0, height = 0 } = source.getSettings();
|
|
1966
2429
|
// The frame rate first: the size the project allows depends on it.
|
|
1967
|
-
this.cameraFrameRate = this.frameRateFor(
|
|
2430
|
+
this.cameraFrameRate = this.frameRateFor(source);
|
|
1968
2431
|
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
1969
2432
|
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1970
2433
|
let codecs: RtpCodecCapability[] | undefined;
|
|
@@ -1976,7 +2439,7 @@ export class StreamClient {
|
|
|
1976
2439
|
const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
|
|
1977
2440
|
const produce = (codec: RtpCodecCapability | undefined) =>
|
|
1978
2441
|
transport.produce({
|
|
1979
|
-
track,
|
|
2442
|
+
track: sent,
|
|
1980
2443
|
stopTracks: false,
|
|
1981
2444
|
encodings,
|
|
1982
2445
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
@@ -1996,15 +2459,67 @@ export class StreamClient {
|
|
|
1996
2459
|
// unpublish() may have run while this was being made (a publish, a
|
|
1997
2460
|
// quality change or a track swap under way).
|
|
1998
2461
|
await this.keepProducer(producer, generation, 'camera');
|
|
2462
|
+
if (sent !== track) this.adoptVideoTrack(sent);
|
|
2463
|
+
if (before && before !== sent) before.stop();
|
|
2464
|
+
// A second publish brought another camera: the one the processor drew from before is no longer published.
|
|
2465
|
+
if (beforeSource && beforeSource !== source && beforeSource !== this.camRaw) beforeSource.stop();
|
|
2466
|
+
this.watchTrackEnded(producer);
|
|
2467
|
+
this.syncPublishProtection();
|
|
1999
2468
|
this.cameraEncodings = encodings;
|
|
2000
2469
|
this.frameRateLowSamples = 0;
|
|
2001
2470
|
this.frameRateWarmupSamples = 2;
|
|
2002
2471
|
await this.pauseOnServer(producer);
|
|
2003
2472
|
await this.applyDegradationPreference(producer);
|
|
2004
2473
|
this.syncLimitWatch();
|
|
2474
|
+
// A new camera producer starts with every layer: put the performance and viewer limits back on it.
|
|
2475
|
+
await this.syncCameraLayers();
|
|
2005
2476
|
return producer;
|
|
2006
2477
|
}
|
|
2007
2478
|
|
|
2479
|
+
/**
|
|
2480
|
+
* Lighten this device's work while it is overloaded (LiveKit's
|
|
2481
|
+
* `prioritizePerformance`): the camera's largest simulcast layer stops being
|
|
2482
|
+
* encoded -- the most expensive part of publishing -- and viewers get the
|
|
2483
|
+
* next one. `false` brings it back. Call it on `cpuConstrained`, together
|
|
2484
|
+
* with your app's own savings (effects off, smaller remote videos). Kept for
|
|
2485
|
+
* later publishes and reconnects until changed. A camera sent as one layer
|
|
2486
|
+
* is left as it is.
|
|
2487
|
+
*/
|
|
2488
|
+
async prioritizePerformance(on = true): Promise<void> {
|
|
2489
|
+
this.performanceFirst = on;
|
|
2490
|
+
await this.cameraTask(() => this.syncCameraLayers());
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/** Whether `prioritizePerformance()` is in force. */
|
|
2494
|
+
get performancePrioritized(): boolean {
|
|
2495
|
+
return this.performanceFirst;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* The camera's active simulcast layers: every layer, except the top one while
|
|
2500
|
+
* performance comes first, and none above the highest any viewer needs
|
|
2501
|
+
* (dynacast; the smallest always stays on, so a new viewer starts at once).
|
|
2502
|
+
*/
|
|
2503
|
+
private async syncCameraLayers(): Promise<void> {
|
|
2504
|
+
const producer = this.cameraProducer();
|
|
2505
|
+
const count = this.cameraEncodings?.length ?? 0;
|
|
2506
|
+
if (!producer || producer.closed || count < 2) return;
|
|
2507
|
+
const top = count - 1;
|
|
2508
|
+
let max = top;
|
|
2509
|
+
if (this.performanceFirst) max = Math.min(max, top - 1);
|
|
2510
|
+
const needed = this.layersNeeded.get(producer.id);
|
|
2511
|
+
if (needed !== undefined) max = Math.min(max, Math.max(0, needed));
|
|
2512
|
+
// A producer just made sends every layer: nothing to ask of it until a limit applies.
|
|
2513
|
+
const applied = this.cameraMaxLayer?.producerId === producer.id ? this.cameraMaxLayer.layer : top;
|
|
2514
|
+
if (applied === max) return;
|
|
2515
|
+
try {
|
|
2516
|
+
await producer.setMaxSpatialLayer(max);
|
|
2517
|
+
this.cameraMaxLayer = { producerId: producer.id, layer: max };
|
|
2518
|
+
} catch (err) {
|
|
2519
|
+
if (!producer.closed) this.emit('error', err as Error);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2008
2523
|
/**
|
|
2009
2524
|
* Keep a producer just made -- unless unpublish() ran while it was being
|
|
2010
2525
|
* made. Kept, it would publish again what was just unpublished, so it is
|
|
@@ -2082,7 +2597,7 @@ export class StreamClient {
|
|
|
2082
2597
|
|
|
2083
2598
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
2084
2599
|
private syncLimitWatch(): void {
|
|
2085
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
2600
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.listenerCount('cpuConstrained') > 0 || this.cameraFrameRate === 60));
|
|
2086
2601
|
}
|
|
2087
2602
|
|
|
2088
2603
|
/**
|
|
@@ -2295,7 +2810,8 @@ export class StreamClient {
|
|
|
2295
2810
|
const producer = this.cameraProducer();
|
|
2296
2811
|
if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed) return;
|
|
2297
2812
|
this.frameRateFallback = true;
|
|
2298
|
-
|
|
2813
|
+
const camera = this.cameraSource(producer.track);
|
|
2814
|
+
await camera.applyConstraints?.({ ...camera.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
2299
2815
|
await this.reproduceCamera(producer, producer.track);
|
|
2300
2816
|
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
2301
2817
|
}).catch((error) => this.emit('error', error as Error));
|
|
@@ -2515,6 +3031,7 @@ export class StreamClient {
|
|
|
2515
3031
|
const consumer = await transport.consume(params);
|
|
2516
3032
|
current(consumer);
|
|
2517
3033
|
this.consumers.set(remote.producerId, consumer);
|
|
3034
|
+
if (this.playoutDelayMs !== null) this.applyPlayoutDelay(consumer.rtpReceiver);
|
|
2518
3035
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
|
|
2519
3036
|
|
|
2520
3037
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
@@ -2526,14 +3043,27 @@ export class StreamClient {
|
|
|
2526
3043
|
try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) }; }
|
|
2527
3044
|
catch { layers = undefined; this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto')); }
|
|
2528
3045
|
}
|
|
3046
|
+
// Adaptive stream: a video the app shows through attachVideo starts at the
|
|
3047
|
+
// size it is shown, and one nobody can see stays paused.
|
|
3048
|
+
const adaptive = consumer.kind === 'video' ? this.adaptive.current(remote.producerId) : null;
|
|
3049
|
+
if (adaptive) layers = this.effectiveLayers(remote.producerId, adaptive);
|
|
3050
|
+
// A camera arriving while the audio-only fallback holds cameras back starts held back too.
|
|
3051
|
+
const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
|
|
3052
|
+
// Not waited for: the server applies them before the resume below (a socket's requests in order), so the first
|
|
3053
|
+
// frame is already the right size without a round trip in front of it (P3-6).
|
|
2529
3054
|
if (layers) {
|
|
2530
|
-
|
|
3055
|
+
void this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
|
|
2531
3056
|
}
|
|
2532
3057
|
current(consumer);
|
|
2533
3058
|
|
|
2534
3059
|
// The server created it paused. Resume only now that our end exists,
|
|
2535
3060
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
2536
|
-
|
|
3061
|
+
this.adaptivePaused.delete(remote.producerId);
|
|
3062
|
+
if ((adaptive && !adaptive.visible) || (held && !this.pauseUnsupported)) this.adaptivePaused.add(remote.producerId);
|
|
3063
|
+
else await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
3064
|
+
if (held && this.pauseUnsupported) {
|
|
3065
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 }).catch(() => undefined);
|
|
3066
|
+
}
|
|
2537
3067
|
current(consumer);
|
|
2538
3068
|
|
|
2539
3069
|
const kind = consumer.kind as 'audio' | 'video';
|
|
@@ -2547,6 +3077,752 @@ export class StreamClient {
|
|
|
2547
3077
|
producerId: remote.producerId,
|
|
2548
3078
|
source,
|
|
2549
3079
|
});
|
|
3080
|
+
if (kind === 'video') this.adaptive.consumed(remote.producerId);
|
|
3081
|
+
if (held) {
|
|
3082
|
+
this.protectionHeld.add(remote.producerId);
|
|
3083
|
+
this.emitFallback(remote.producerId, true, 'audio-protection');
|
|
3084
|
+
}
|
|
3085
|
+
this.syncProtection();
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
/**
|
|
3089
|
+
* Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
|
|
3090
|
+
* stream with receiveWhenViewVisible): tell the SDK which element shows a
|
|
3091
|
+
* remote video. It then receives the smallest layer that fills that element
|
|
3092
|
+
* sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
|
|
3093
|
+
* video at the server while the element is not visible (scrolled away,
|
|
3094
|
+
* `display: none`, or the tab in the background), so a grid of small tiles
|
|
3095
|
+
* stops decoding full-size video. Attach the same video to several elements
|
|
3096
|
+
* and the largest visible one decides. Returns the function that detaches
|
|
3097
|
+
* it; detach when the element goes away. A ceiling set with
|
|
3098
|
+
* `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
|
|
3099
|
+
* server; one without `pauseConsumer` gets the smallest layer instead of a
|
|
3100
|
+
* pause.
|
|
3101
|
+
*/
|
|
3102
|
+
attachVideo(producerId: string, element: Element): () => void {
|
|
3103
|
+
return this.adaptive.attach(producerId, element);
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
/** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
|
|
3107
|
+
private effectiveLayers(producerId: string, want: AdaptiveWant | null): PreferredLayers {
|
|
3108
|
+
let manual: PreferredLayers | undefined = this.preferredLayers.get(producerId);
|
|
3109
|
+
const quality = this.receiveQualities.get(producerId);
|
|
3110
|
+
if (quality && quality !== 'auto') {
|
|
3111
|
+
try { manual = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; } catch { manual = undefined; }
|
|
3112
|
+
}
|
|
3113
|
+
const adaptive = want?.visible ? want.spatialLayer : null;
|
|
3114
|
+
if (adaptive === null || adaptive === undefined) return manual ?? { spatialLayer: -1, temporalLayer: -1 };
|
|
3115
|
+
if (!manual || manual.spatialLayer < 0) return { spatialLayer: adaptive };
|
|
3116
|
+
return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// ------------------------------------------------------- audio processor
|
|
3120
|
+
|
|
3121
|
+
/**
|
|
3122
|
+
* Send the microphone through `processor` first (LiveKit's setProcessor, Agora's extensions): above all noise
|
|
3123
|
+
* suppression -- `createNoiseSuppressor()` from `@voltkit-rtc/client/noise-suppression`. Applies to the microphone
|
|
3124
|
+
* published now (swapped in place, no gap for listeners) and to every one after it: another device, a reconnect, a
|
|
3125
|
+
* new quality. `null` sends the microphone as it is again and lets the processor go. A processor that fails never
|
|
3126
|
+
* keeps the microphone off: it is sent as it is, and `error` says why.
|
|
3127
|
+
*/
|
|
3128
|
+
async setAudioProcessor(processor: AudioTrackProcessor | null): Promise<void> {
|
|
3129
|
+
await this.micTask(async () => {
|
|
3130
|
+
const previous = this.audioProcessor;
|
|
3131
|
+
this.audioProcessor = processor;
|
|
3132
|
+
const producer = this.micProducer();
|
|
3133
|
+
const device = producer ? this.deviceTrackOf(producer) : null;
|
|
3134
|
+
if (producer && device && !producer.closed) {
|
|
3135
|
+
const sent = await this.processMic(device);
|
|
3136
|
+
await producer.replaceTrack({ track: sent });
|
|
3137
|
+
this.micRaw = sent !== device ? device : null;
|
|
3138
|
+
}
|
|
3139
|
+
if (previous && previous !== processor) await Promise.resolve(previous.destroy()).catch(() => undefined);
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
/** The processor in use now, or null. */
|
|
3144
|
+
get audioProcessorInUse(): AudioTrackProcessor | null {
|
|
3145
|
+
return this.audioProcessor;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
/** The track to send for the microphone's device track `track`: processed when there is a processor that works. */
|
|
3149
|
+
private async processMic(track: MediaStreamTrack): Promise<MediaStreamTrack> {
|
|
3150
|
+
const processor = this.audioProcessor;
|
|
3151
|
+
if (!processor) return track;
|
|
3152
|
+
try {
|
|
3153
|
+
return await processor.process(track);
|
|
3154
|
+
} catch (err) {
|
|
3155
|
+
this.emit('error', new Error(`audio processor ${processor.name} failed, sending the microphone as it is: ${(err as Error)?.message ?? err}`));
|
|
3156
|
+
return track;
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
/** The device's own track behind a producer: for the microphone, the one before any processor. */
|
|
3161
|
+
private deviceTrackOf(producer: Producer): MediaStreamTrack | null {
|
|
3162
|
+
const source = producer.appData.source;
|
|
3163
|
+
if (source === 'mic') return this.micRaw ?? producer.track;
|
|
3164
|
+
return source === 'camera' && producer.track ? this.cameraSource(producer.track) : producer.track;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
/**
|
|
3168
|
+
* Send the camera through `processor` first (LiveKit's setProcessor, Agora's and TRTC's video extensions): a blurred
|
|
3169
|
+
* or replaced background, beauty -- `createVideoEffects()` from `@voltkit-rtc/client/video-effects`. The processed
|
|
3170
|
+
* picture is what viewers get and what `local` shows. Applies to the camera published now (swapped in place: the
|
|
3171
|
+
* same size, so no pause for viewers) and to every one after it: `switchDevice`, `replaceVideoTrack`, a reconnect, a
|
|
3172
|
+
* new quality. `null` sends the camera as it is again and lets the processor go. A processor that fails never keeps
|
|
3173
|
+
* the camera off: it is sent as it is, and `error` says why.
|
|
3174
|
+
*/
|
|
3175
|
+
async setVideoProcessor(processor: VideoTrackProcessor | null): Promise<void> {
|
|
3176
|
+
await this.cameraTask(async () => {
|
|
3177
|
+
const previous = this.videoProcessor;
|
|
3178
|
+
this.videoProcessor = processor;
|
|
3179
|
+
const producer = this.cameraProducer();
|
|
3180
|
+
const shown = producer?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
3181
|
+
if (shown && shown.readyState === 'live') {
|
|
3182
|
+
const source = this.cameraSource(shown);
|
|
3183
|
+
const before = this.camOut;
|
|
3184
|
+
const paused = producer ? producer.paused : !shown.enabled;
|
|
3185
|
+
// Made afresh, by the new processor.
|
|
3186
|
+
this.camRaw = null;
|
|
3187
|
+
this.camOut = null;
|
|
3188
|
+
const sent = await this.processCamera(source);
|
|
3189
|
+
sent.enabled = !paused;
|
|
3190
|
+
if (producer && !producer.closed && producer.track !== sent) await producer.replaceTrack({ track: sent });
|
|
3191
|
+
this.adoptVideoTrack(sent);
|
|
3192
|
+
if (before && before !== sent) before.stop();
|
|
3193
|
+
}
|
|
3194
|
+
if (previous && previous !== processor) await Promise.resolve(previous.destroy()).catch(() => undefined);
|
|
3195
|
+
});
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
/** The video processor in use now, or null. */
|
|
3199
|
+
get videoProcessorInUse(): VideoTrackProcessor | null {
|
|
3200
|
+
return this.videoProcessor;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
/** The camera picture behind `track`: the one a video processor draws from, when `track` is its picture. */
|
|
3204
|
+
private cameraSource(track: MediaStreamTrack): MediaStreamTrack {
|
|
3205
|
+
return track === this.camOut && this.camRaw ? this.camRaw : track;
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
/**
|
|
3209
|
+
* The picture to send for the camera picture `source`: a video processor's, when there is one that works. The one
|
|
3210
|
+
* already made from `source` is kept (a reconnect, a new quality); otherwise it is made afresh.
|
|
3211
|
+
*/
|
|
3212
|
+
private async processCamera(source: MediaStreamTrack): Promise<MediaStreamTrack> {
|
|
3213
|
+
const processor = this.videoProcessor;
|
|
3214
|
+
if (!processor) {
|
|
3215
|
+
this.camRaw = null;
|
|
3216
|
+
this.camOut = null;
|
|
3217
|
+
return source;
|
|
3218
|
+
}
|
|
3219
|
+
if (this.camRaw === source && this.camOut?.readyState === 'live') return this.camOut;
|
|
3220
|
+
try {
|
|
3221
|
+
const out = await processor.process(source);
|
|
3222
|
+
// The processor draws from the camera whether or not the camera is paused: its picture carries the pause.
|
|
3223
|
+
source.enabled = true;
|
|
3224
|
+
this.camRaw = source;
|
|
3225
|
+
this.camOut = out;
|
|
3226
|
+
return out;
|
|
3227
|
+
} catch (err) {
|
|
3228
|
+
this.emit('error', new Error(`video processor ${processor.name} failed, sending the camera as it is: ${(err as Error)?.message ?? err}`));
|
|
3229
|
+
this.camRaw = null;
|
|
3230
|
+
this.camOut = null;
|
|
3231
|
+
return source;
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
/** Stop the client's own tracks, and the camera behind a video processor's picture (not in the stream itself). */
|
|
3236
|
+
private stopLocalTracks(): void {
|
|
3237
|
+
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
3238
|
+
this.camRaw?.stop();
|
|
3239
|
+
this.camOut?.stop();
|
|
3240
|
+
this.camRaw = null;
|
|
3241
|
+
this.camOut = null;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
// ------------------------------------------------------- diagnostics
|
|
3245
|
+
|
|
3246
|
+
/**
|
|
3247
|
+
* A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
|
|
3248
|
+
* browser, the session (room, identity, role, state), how each media transport is connected (UDP, TCP or relayed),
|
|
3249
|
+
* the round trip and upload now, what is published and received, and the last 200 notable events (state changes,
|
|
3250
|
+
* errors, recoveries, fallbacks, warnings) with their times. Plain JSON: `JSON.stringify` it and attach it. Holds
|
|
3251
|
+
* no tokens and no media.
|
|
3252
|
+
*/
|
|
3253
|
+
async getDiagnostics(): Promise<Diagnostics> {
|
|
3254
|
+
const transport = async (t: Transport | null): Promise<DiagnosticsTransport | null> => {
|
|
3255
|
+
if (!t || t.closed) return null;
|
|
3256
|
+
let path: DiagnosticsTransport['path'] = null;
|
|
3257
|
+
try {
|
|
3258
|
+
path = connectionPath((await t.getStats()).values() as Iterable<Record<string, unknown>>);
|
|
3259
|
+
} catch {
|
|
3260
|
+
// Best effort.
|
|
3261
|
+
}
|
|
3262
|
+
return { state: t.connectionState, path };
|
|
3263
|
+
};
|
|
3264
|
+
let network: Diagnostics['network'] = null;
|
|
3265
|
+
try {
|
|
3266
|
+
const reports = await this.transportStats();
|
|
3267
|
+
const current = new Map([...reports].map(([key, report]) => [key, readTransportStats(report)] as const));
|
|
3268
|
+
if (current.size > 0) {
|
|
3269
|
+
const q = networkQualityOf(current, new Map(), this.producers.size > 0);
|
|
3270
|
+
network = { rttMs: q.rttMs, availableKbps: q.availableKbps };
|
|
3271
|
+
}
|
|
3272
|
+
} catch {
|
|
3273
|
+
// Best effort.
|
|
3274
|
+
}
|
|
3275
|
+
const join = this.joinResult;
|
|
3276
|
+
return {
|
|
3277
|
+
sdk: SDK_VERSION,
|
|
3278
|
+
at: Date.now(),
|
|
3279
|
+
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
|
3280
|
+
connectionState: this.state,
|
|
3281
|
+
room: join?.roomId ?? null,
|
|
3282
|
+
identity: join?.identity ?? null,
|
|
3283
|
+
role: join?.role ?? null,
|
|
3284
|
+
transports: { send: await transport(this.sendTransport), recv: await transport(this.recvTransport) },
|
|
3285
|
+
network,
|
|
3286
|
+
published: [...this.producers.values()].filter((p) => !p.closed)
|
|
3287
|
+
.map((p) => ({ source: String(p.appData.source ?? p.kind), kind: p.kind, paused: p.paused })),
|
|
3288
|
+
received: [...this.consumers.entries()].filter(([, c]) => !c.closed).map(([producerId, c]) => ({
|
|
3289
|
+
identity: this.remoteInfo.get(producerId)?.identity ?? '',
|
|
3290
|
+
source: this.remoteSources.get(producerId) ?? (c.kind === 'audio' ? 'mic' : 'camera'),
|
|
3291
|
+
kind: c.kind,
|
|
3292
|
+
paused: c.paused || this.adaptivePaused.has(producerId),
|
|
3293
|
+
})),
|
|
3294
|
+
log: this.diagnosticsLog.list(),
|
|
3295
|
+
};
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
// ------------------------------------------------------- autoplay
|
|
3299
|
+
|
|
3300
|
+
/**
|
|
3301
|
+
* Play a `<video>` or `<audio>` showing a remote track, the way autoplay rules allow (Agora's and Tencent TRTC's
|
|
3302
|
+
* autoplay handling). Resolves true when it plays. When the browser refuses until the viewer interacts with the
|
|
3303
|
+
* page, resolves false, emits `autoplayFailed` with a `resume()` for a "Tap to unmute" button, and plays it -- with
|
|
3304
|
+
* every other refused element -- on the next tap, click or key press by itself.
|
|
3305
|
+
*/
|
|
3306
|
+
async play(element: HTMLMediaElement): Promise<boolean> {
|
|
3307
|
+
try {
|
|
3308
|
+
await element.play();
|
|
3309
|
+
this.autoplayBlocked.delete(element);
|
|
3310
|
+
return true;
|
|
3311
|
+
} catch (err) {
|
|
3312
|
+
if ((err as { name?: string })?.name !== 'NotAllowedError') throw err;
|
|
3313
|
+
this.autoplayBlocked.add(element);
|
|
3314
|
+
this.watchGesture(true);
|
|
3315
|
+
this.emit('autoplayFailed', { element, resume: async () => { await this.play(element); } });
|
|
3316
|
+
return false;
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
private watchGesture(on: boolean): void {
|
|
3321
|
+
if (typeof document === 'undefined' || on === this.gestureWatchOn) return;
|
|
3322
|
+
this.gestureWatchOn = on;
|
|
3323
|
+
for (const type of ['pointerdown', 'keydown', 'touchend'] as const) {
|
|
3324
|
+
if (on) document.addEventListener(type, this.onGesture, { capture: true });
|
|
3325
|
+
else document.removeEventListener(type, this.onGesture, { capture: true });
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
private async retryAutoplay(): Promise<void> {
|
|
3330
|
+
for (const element of [...this.autoplayBlocked]) {
|
|
3331
|
+
try {
|
|
3332
|
+
await element.play();
|
|
3333
|
+
this.autoplayBlocked.delete(element);
|
|
3334
|
+
} catch {
|
|
3335
|
+
// Still refused (or the element went away); the next gesture tries again.
|
|
3336
|
+
if (!element.isConnected) this.autoplayBlocked.delete(element);
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
if (this.autoplayBlocked.size === 0) this.watchGesture(false);
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
// ------------------------------------------------------- playout delay
|
|
3343
|
+
|
|
3344
|
+
/**
|
|
3345
|
+
* Trade a little delay for smoother playback (Tencent TRTC's playoutDelay): every remote track is buffered about
|
|
3346
|
+
* `ms` before it plays, which rides out the jitter of a mobile network without freezes. For an audience watching a
|
|
3347
|
+
* stream, 300-500 ms is a good value; people talking with each other want `null` (the browser's own, as small as
|
|
3348
|
+
* it can keep). Applies to what is received now and later. Browsers without the setting (before Chrome 114) keep
|
|
3349
|
+
* their own; 0-4000 ms.
|
|
3350
|
+
*/
|
|
3351
|
+
setPlayoutDelay(ms: number | null): void {
|
|
3352
|
+
this.playoutDelayMs = ms === null ? null : Math.min(4000, Math.max(0, Math.round(ms)));
|
|
3353
|
+
for (const consumer of this.consumers.values()) this.applyPlayoutDelay(consumer.rtpReceiver);
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
private applyPlayoutDelay(receiver: RTCRtpReceiver | undefined): void {
|
|
3357
|
+
if (!receiver || !('jitterBufferTarget' in receiver)) return;
|
|
3358
|
+
try {
|
|
3359
|
+
(receiver as RTCRtpReceiver & { jitterBufferTarget: number | null }).jitterBufferTarget = this.playoutDelayMs;
|
|
3360
|
+
} catch {
|
|
3361
|
+
// Out of the browser's range: it keeps its own.
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
// ------------------------------------------------------- audio levels
|
|
3366
|
+
|
|
3367
|
+
/** Levels are read only while someone listens and the session is connected. */
|
|
3368
|
+
private syncLevelWatch(): void {
|
|
3369
|
+
const wanted = !this.closedByUs && this.state === 'connected' &&
|
|
3370
|
+
(this.listenerCount('audioLevels') > 0 || this.listenerCount('activeSpeakers') > 0);
|
|
3371
|
+
if (wanted && !this.levelTimer) {
|
|
3372
|
+
this.levelTimer = setInterval(() => void this.sampleAudioLevels(), AUDIO_LEVEL_INTERVAL_MS);
|
|
3373
|
+
(this.levelTimer as { unref?: () => void }).unref?.();
|
|
3374
|
+
} else if (!wanted) {
|
|
3375
|
+
this.stopLevelWatch();
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
private stopLevelWatch(): void {
|
|
3380
|
+
if (this.levelTimer) clearInterval(this.levelTimer);
|
|
3381
|
+
this.levelTimer = null;
|
|
3382
|
+
this.speakers.reset();
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
/**
|
|
3386
|
+
* One reading: each remote audio from its receiver's synchronization sources (the most recent packet's level, no
|
|
3387
|
+
* statistics report needed), the own microphone from its sender's media-source statistics.
|
|
3388
|
+
*/
|
|
3389
|
+
private async sampleAudioLevels(): Promise<void> {
|
|
3390
|
+
if (this.levelSampling) return;
|
|
3391
|
+
this.levelSampling = true;
|
|
3392
|
+
try {
|
|
3393
|
+
const levels: AudioLevel[] = [];
|
|
3394
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3395
|
+
if (consumer.kind !== 'audio' || consumer.closed) continue;
|
|
3396
|
+
const info = this.remoteInfo.get(producerId);
|
|
3397
|
+
if (!info) continue;
|
|
3398
|
+
const level = info.paused ? 0 : receiverLevel(consumer.rtpReceiver);
|
|
3399
|
+
levels.push({ identity: info.identity, producerId, source: this.remoteSources.get(producerId) ?? 'mic', level, speaking: level > SPEAKING_LEVEL, local: false });
|
|
3400
|
+
}
|
|
3401
|
+
const mic = this.micProducer();
|
|
3402
|
+
const self = this.joinResult?.identity;
|
|
3403
|
+
if (mic && !mic.paused && self) {
|
|
3404
|
+
const level = await senderLevel(mic);
|
|
3405
|
+
levels.push({ identity: self, producerId: mic.id, source: 'mic', level, speaking: level > SPEAKING_LEVEL, local: true });
|
|
3406
|
+
}
|
|
3407
|
+
if (!this.levelTimer) return;
|
|
3408
|
+
if (this.listenerCount('audioLevels') > 0) this.emit('audioLevels', levels);
|
|
3409
|
+
const loudest = new Map<string, number>();
|
|
3410
|
+
for (const l of levels) if (l.source === 'mic') loudest.set(l.identity, Math.max(loudest.get(l.identity) ?? 0, l.level));
|
|
3411
|
+
const speakers = this.speakers.update(loudest);
|
|
3412
|
+
if (speakers) this.emit('activeSpeakers', { speakers });
|
|
3413
|
+
} catch {
|
|
3414
|
+
// Best effort; the next reading tries again.
|
|
3415
|
+
} finally {
|
|
3416
|
+
this.levelSampling = false;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
// ------------------------------------------------------- media health
|
|
3421
|
+
|
|
3422
|
+
/** The published devices are sampled only while someone listens and the session is connected. */
|
|
3423
|
+
private syncHealthWatch(): void {
|
|
3424
|
+
const wanted = !this.closedByUs && this.state === 'connected' && this.listenerCount('mediaWarning') > 0;
|
|
3425
|
+
if (wanted && !this.healthTimer) {
|
|
3426
|
+
this.healthTimer = setInterval(() => void this.sampleHealth(), HEALTH_SAMPLE_MS);
|
|
3427
|
+
(this.healthTimer as { unref?: () => void }).unref?.();
|
|
3428
|
+
} else if (!wanted) {
|
|
3429
|
+
this.stopHealthWatch(this.listenerCount('mediaWarning') > 0 && !this.closedByUs);
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
/** Stop sampling; `report` says each active warning cleared (nothing is said after close). */
|
|
3434
|
+
private stopHealthWatch(report: boolean): void {
|
|
3435
|
+
if (this.healthTimer) clearInterval(this.healthTimer);
|
|
3436
|
+
this.healthTimer = null;
|
|
3437
|
+
for (const timer of this.interruptions.values()) clearTimeout(timer);
|
|
3438
|
+
this.interruptions.clear();
|
|
3439
|
+
if (report) this.health.clearAll();
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
private async sampleHealth(): Promise<void> {
|
|
3443
|
+
if (this.healthSampling) return;
|
|
3444
|
+
this.healthSampling = true;
|
|
3445
|
+
try {
|
|
3446
|
+
const mic = this.micProducer();
|
|
3447
|
+
const camera = this.cameraProducer();
|
|
3448
|
+
// A processed microphone (noise suppression) is near-silent between words by design: no mic-silent then.
|
|
3449
|
+
const micLevel = mic && !mic.paused && !this.audioProcessor ? readMediaSource((await mic.getStats()).values() as Iterable<Record<string, unknown>>).level : null;
|
|
3450
|
+
// A processed picture runs at the processor's pace, not the camera's: no camera-fps-low then.
|
|
3451
|
+
const cameraFps = camera && !camera.paused && !this.videoProcessor ? readMediaSource((await camera.getStats()).values() as Iterable<Record<string, unknown>>).fps : null;
|
|
3452
|
+
if (!this.healthTimer) return;
|
|
3453
|
+
this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
|
|
3454
|
+
// An interrupted device that is no longer published never says it came back: that is over too.
|
|
3455
|
+
if (!mic) this.health.interrupted('mic-interrupted', false);
|
|
3456
|
+
if (!camera) this.health.interrupted('camera-interrupted', false);
|
|
3457
|
+
} catch {
|
|
3458
|
+
// Best effort; the next sample tries again.
|
|
3459
|
+
} finally {
|
|
3460
|
+
this.healthSampling = false;
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
// ---------------------------------------------------------------- devices
|
|
3465
|
+
|
|
3466
|
+
/**
|
|
3467
|
+
* The cameras, microphones and speakers the browser offers, optionally of one kind. Labels are empty until the
|
|
3468
|
+
* page has been granted camera or microphone access.
|
|
3469
|
+
*/
|
|
3470
|
+
listDevices(kind?: DeviceKind): Promise<MediaDevice[]> {
|
|
3471
|
+
return enumerate(kind);
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
/**
|
|
3475
|
+
* Publish from another camera or microphone (LiveKit's switchActiveDevice, Agora's setDevice). The device is
|
|
3476
|
+
* captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
|
|
3477
|
+
* published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
|
|
3478
|
+
* replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
|
|
3479
|
+
* reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
|
|
3480
|
+
*
|
|
3481
|
+
* For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
|
|
3482
|
+
* processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
|
|
3483
|
+
* Rejects when that kind is not being published.
|
|
3484
|
+
*/
|
|
3485
|
+
async switchDevice(kind: 'audioinput' | 'videoinput', deviceId: string): Promise<void> {
|
|
3486
|
+
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
3487
|
+
if (!md?.getUserMedia) throw new Error('switchDevice: this browser cannot capture media');
|
|
3488
|
+
if (kind === 'videoinput') {
|
|
3489
|
+
const shown = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
3490
|
+
// With a video processor, the camera it draws from.
|
|
3491
|
+
const current = shown ? this.cameraSource(shown) : null;
|
|
3492
|
+
if (!current) throw new Error('switchDevice: no camera is being published');
|
|
3493
|
+
if (!deviceIdOf(current)) {
|
|
3494
|
+
throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
|
|
3495
|
+
}
|
|
3496
|
+
const video = constraintsFor(this.publishConstraints?.video, deviceId);
|
|
3497
|
+
const track = (await md.getUserMedia({ video })).getVideoTracks()[0];
|
|
3498
|
+
if (!track) throw new Error('switchDevice: the camera gave no video');
|
|
3499
|
+
try {
|
|
3500
|
+
await this.replaceVideoTrack(track);
|
|
3501
|
+
} catch (err) {
|
|
3502
|
+
track.stop();
|
|
3503
|
+
throw err;
|
|
3504
|
+
}
|
|
3505
|
+
if (current !== track) current.stop();
|
|
3506
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), video };
|
|
3507
|
+
return;
|
|
3508
|
+
}
|
|
3509
|
+
await this.micTask(async () => {
|
|
3510
|
+
const producer = this.micProducer();
|
|
3511
|
+
if (!producer) throw new Error('switchDevice: no microphone is being published');
|
|
3512
|
+
const audio = constraintsFor(this.publishConstraints?.audio, deviceId);
|
|
3513
|
+
const track = (await md.getUserMedia({ audio })).getAudioTracks()[0];
|
|
3514
|
+
if (!track) throw new Error('switchDevice: the microphone gave no audio');
|
|
3515
|
+
if (producer.closed || this.micProducer() !== producer) {
|
|
3516
|
+
track.stop();
|
|
3517
|
+
throw new Error('switchDevice: the microphone was unpublished meanwhile');
|
|
3518
|
+
}
|
|
3519
|
+
await this.adoptAudioTrack(producer, track);
|
|
3520
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
3521
|
+
});
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
/** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
|
|
3525
|
+
private async adoptAudioTrack(producer: Producer, track: MediaStreamTrack): Promise<void> {
|
|
3526
|
+
const previous = this.micRaw ?? producer.track;
|
|
3527
|
+
const sent = await this.processMic(track);
|
|
3528
|
+
await producer.replaceTrack({ track: sent });
|
|
3529
|
+
this.micRaw = sent !== track ? track : null;
|
|
3530
|
+
const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
|
|
3531
|
+
this.localStream = new MediaStream([track, ...video]);
|
|
3532
|
+
this.emit('localStreamChanged', this.localStream);
|
|
3533
|
+
if (previous && previous !== track) previous.stop();
|
|
3534
|
+
this.watchTrackEnded(producer);
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
/** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
|
|
3538
|
+
private watchTrackEnded(producer: Producer): void {
|
|
3539
|
+
// The device's track, not the processed one a processor sends: that is the one a device ends.
|
|
3540
|
+
const track = this.deviceTrackOf(producer);
|
|
3541
|
+
if (!track || typeof track.addEventListener !== 'function') return;
|
|
3542
|
+
track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
|
|
3543
|
+
// The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
|
|
3544
|
+
const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
|
|
3545
|
+
if (!which) return;
|
|
3546
|
+
const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
|
|
3547
|
+
const current = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track;
|
|
3548
|
+
track.addEventListener('mute', () => {
|
|
3549
|
+
if (!current() || this.interruptions.has(which)) return;
|
|
3550
|
+
this.interruptions.set(which, setTimeout(() => {
|
|
3551
|
+
this.interruptions.delete(which);
|
|
3552
|
+
if (current() && track.muted) this.health.interrupted(code, true);
|
|
3553
|
+
}, INTERRUPTED_AFTER_MS));
|
|
3554
|
+
});
|
|
3555
|
+
track.addEventListener('unmute', () => {
|
|
3556
|
+
const timer = this.interruptions.get(which);
|
|
3557
|
+
if (timer) clearTimeout(timer);
|
|
3558
|
+
this.interruptions.delete(which);
|
|
3559
|
+
if (current()) this.health.interrupted(code, false);
|
|
3560
|
+
});
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
/**
|
|
3564
|
+
* A published camera or microphone stopped from outside. The microphone comes back from the system default (Chime
|
|
3565
|
+
* keeps audio flowing, LiveKit moves to the first available device); a camera is switched off and reported, never
|
|
3566
|
+
* swapped for another camera on its own (Chime stops it the same way).
|
|
3567
|
+
*/
|
|
3568
|
+
private async onTrackEnded(producer: Producer, track: MediaStreamTrack): Promise<void> {
|
|
3569
|
+
const live = () => !this.closedByUs && !producer.closed && this.deviceTrackOf(producer) === track && this.producers.get(producer.id) === producer;
|
|
3570
|
+
if (!live()) return;
|
|
3571
|
+
const deviceId = deviceIdOf(track);
|
|
3572
|
+
if (producer.appData.source === 'mic') {
|
|
3573
|
+
await this.micTask(async () => {
|
|
3574
|
+
if (!live()) return;
|
|
3575
|
+
try {
|
|
3576
|
+
const md = navigator.mediaDevices;
|
|
3577
|
+
const audio = constraintsFor(this.publishConstraints?.audio, null);
|
|
3578
|
+
const next = (await md.getUserMedia({ audio })).getAudioTracks()[0];
|
|
3579
|
+
if (!next) throw new Error('no microphone');
|
|
3580
|
+
if (!live()) { next.stop(); return; }
|
|
3581
|
+
await this.adoptAudioTrack(producer, next);
|
|
3582
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
3583
|
+
this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: true });
|
|
3584
|
+
} catch {
|
|
3585
|
+
if (!live()) return;
|
|
3586
|
+
await this.setMicEnabled(false).catch(() => undefined);
|
|
3587
|
+
this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: false });
|
|
3588
|
+
}
|
|
3589
|
+
});
|
|
3590
|
+
return;
|
|
3591
|
+
}
|
|
3592
|
+
if (producer.appData.source === 'camera') {
|
|
3593
|
+
await this.setCamEnabled(false).catch(() => undefined);
|
|
3594
|
+
if (!this.closedByUs) this.emit('localTrackEnded', { kind: 'video', deviceId, recovered: false });
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
/** devicesChanged is watched only while someone listens. */
|
|
3599
|
+
private syncDeviceWatch(): void {
|
|
3600
|
+
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
3601
|
+
const wanted = !this.closedByUs && this.listenerCount('devicesChanged') > 0 && typeof md?.addEventListener === 'function';
|
|
3602
|
+
if (wanted && !this.deviceWatchOn) {
|
|
3603
|
+
this.deviceWatchOn = true;
|
|
3604
|
+
md!.addEventListener('devicechange', this.onDeviceChange);
|
|
3605
|
+
void enumerate().then((list) => { if (this.deviceWatchOn) this.knownDevices = list; }).catch(() => undefined);
|
|
3606
|
+
} else if (!wanted) {
|
|
3607
|
+
this.stopDeviceWatch();
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
private stopDeviceWatch(): void {
|
|
3612
|
+
if (!this.deviceWatchOn) return;
|
|
3613
|
+
this.deviceWatchOn = false;
|
|
3614
|
+
navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
private async refreshDevices(): Promise<void> {
|
|
3618
|
+
let now: MediaDevice[];
|
|
3619
|
+
try {
|
|
3620
|
+
now = await enumerate();
|
|
3621
|
+
} catch {
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
if (!this.deviceWatchOn) return;
|
|
3625
|
+
const { added, removed } = diffDevices(this.knownDevices, now);
|
|
3626
|
+
this.knownDevices = now;
|
|
3627
|
+
if (added.length > 0 || removed.length > 0) this.emit('devicesChanged', { devices: now, added, removed });
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
/** Adaptive stream's decision for a video, with the audio-only fallback holding remote cameras back. */
|
|
3631
|
+
private withFallback(producerId: string, want: AdaptiveWant): AdaptiveWant {
|
|
3632
|
+
if (this.protection.active && this.remoteSources.get(producerId) === 'camera') return { visible: false, spatialLayer: null };
|
|
3633
|
+
return want;
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
/**
|
|
3637
|
+
* Audio-only fallback (like Agora's AUDIO_ONLY stream fallback): on unless `audioOnlyFallback: false` was given.
|
|
3638
|
+
* While this viewer's received sound breaks up badly -- a quarter of it lost over about 15 s -- remote camera
|
|
3639
|
+
* videos are paused at the server (screen shares stay) so what bandwidth there is goes to the audio; they come
|
|
3640
|
+
* back once it has recovered for a while, holding longer if it falls back again soon. Each held video is reported
|
|
3641
|
+
* by `streamFallback`. `false` brings any held video back now and stops watching.
|
|
3642
|
+
*/
|
|
3643
|
+
setAudioOnlyFallback(enabled: boolean): void {
|
|
3644
|
+
this.audioOnlyEnabled = enabled;
|
|
3645
|
+
if (!enabled) this.protection.release();
|
|
3646
|
+
this.syncProtection();
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/**
|
|
3650
|
+
* The publisher side of the audio-only fallback (Agora's setLocalPublishFallbackOption(AUDIO_ONLY)), off by default:
|
|
3651
|
+
* while this client's own sound breaks up on the way to the server -- a quarter of it lost over about 15 s -- its
|
|
3652
|
+
* camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers, holding
|
|
3653
|
+
* longer after a relapse. Reported by `publishFallback`. A camera you or a moderator switch off meanwhile stays off.
|
|
3654
|
+
* `false` brings a held camera back now.
|
|
3655
|
+
*/
|
|
3656
|
+
setPublishFallback(enabled: boolean): void {
|
|
3657
|
+
this.publishFallbackEnabled = enabled;
|
|
3658
|
+
if (!enabled) this.publishProtection.release();
|
|
3659
|
+
this.syncPublishProtection();
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
private syncPublishProtection(): void {
|
|
3663
|
+
const wanted = this.publishFallbackEnabled && !this.closedByUs && this.state === 'connected' &&
|
|
3664
|
+
this.micProducer() !== undefined && (this.cameraProducer() !== undefined || this.publishProtection.active);
|
|
3665
|
+
if (wanted && !this.publishProtectionTimer) {
|
|
3666
|
+
this.publishProtectionTimer = setInterval(() => void this.samplePublishProtection(), PROTECT_SAMPLE_MS);
|
|
3667
|
+
(this.publishProtectionTimer as { unref?: () => void }).unref?.();
|
|
3668
|
+
} else if (!wanted && this.publishProtectionTimer) {
|
|
3669
|
+
clearInterval(this.publishProtectionTimer);
|
|
3670
|
+
this.publishProtectionTimer = null;
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
private async samplePublishProtection(): Promise<void> {
|
|
3675
|
+
const transport = this.sendTransport;
|
|
3676
|
+
if (this.publishProtectionSampling || !transport || transport.closed) return;
|
|
3677
|
+
this.publishProtectionSampling = true;
|
|
3678
|
+
try {
|
|
3679
|
+
const report = await transport.getStats();
|
|
3680
|
+
if (transport !== this.sendTransport || !this.publishProtectionTimer) return;
|
|
3681
|
+
this.publishProtection.feed(readOutboundAudio(report.values() as Iterable<Record<string, unknown>>));
|
|
3682
|
+
} catch {
|
|
3683
|
+
// Best effort.
|
|
3684
|
+
} finally {
|
|
3685
|
+
this.publishProtectionSampling = false;
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
/** The publisher-side fallback decided: hold the camera, or bring it back if it is still ours to bring back. */
|
|
3690
|
+
private async onPublishProtection(active: boolean): Promise<void> {
|
|
3691
|
+
const camera = this.cameraProducer();
|
|
3692
|
+
if (active) {
|
|
3693
|
+
if (!camera || camera.paused) { this.syncPublishProtection(); return; }
|
|
3694
|
+
this.cameraHeldByFallback = true;
|
|
3695
|
+
camera.pause();
|
|
3696
|
+
this.emit('publishFallback', { fallback: true });
|
|
3697
|
+
await this.rpc('pauseProducer', { producerId: camera.id, paused: true }).catch(() => undefined);
|
|
3698
|
+
this.emitLocalMedia();
|
|
3699
|
+
} else if (this.cameraHeldByFallback) {
|
|
3700
|
+
this.cameraHeldByFallback = false;
|
|
3701
|
+
if (camera && camera.paused && !this.forcedOff.video) {
|
|
3702
|
+
camera.resume();
|
|
3703
|
+
await this.rpc('pauseProducer', { producerId: camera.id, paused: false }).catch(() => undefined);
|
|
3704
|
+
this.emitLocalMedia();
|
|
3705
|
+
}
|
|
3706
|
+
this.emit('publishFallback', { fallback: false });
|
|
3707
|
+
}
|
|
3708
|
+
this.syncPublishProtection();
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
/** Whether the audio-only fallback is holding remote cameras back now. */
|
|
3712
|
+
get audioOnlyFallbackActive(): boolean {
|
|
3713
|
+
return this.protection.active;
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
/** The audio-only fallback decided: hold remote cameras back, or let them come back. */
|
|
3717
|
+
private onAudioProtection(active: boolean): void {
|
|
3718
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3719
|
+
if (consumer.kind !== 'video' || this.remoteSources.get(producerId) !== 'camera') continue;
|
|
3720
|
+
const want = this.withFallback(producerId, this.adaptive.current(producerId) ?? { visible: true, spatialLayer: null });
|
|
3721
|
+
void this.applyAdaptive(producerId, want);
|
|
3722
|
+
if (active && !this.protectionHeld.has(producerId)) {
|
|
3723
|
+
this.protectionHeld.add(producerId);
|
|
3724
|
+
this.emitFallback(producerId, true, 'audio-protection');
|
|
3725
|
+
} else if (!active && this.protectionHeld.delete(producerId)) {
|
|
3726
|
+
this.emitFallback(producerId, false, 'audio-protection');
|
|
3727
|
+
}
|
|
3728
|
+
}
|
|
3729
|
+
this.syncProtection();
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
private emitFallback(producerId: string, fallback: boolean, reason: StreamFallbackEvent['reason']): void {
|
|
3733
|
+
const info = this.remoteInfo.get(producerId);
|
|
3734
|
+
this.emit('streamFallback', {
|
|
3735
|
+
producerId,
|
|
3736
|
+
identity: info?.identity ?? '',
|
|
3737
|
+
source: this.remoteSources.get(producerId) ?? 'camera',
|
|
3738
|
+
fallback,
|
|
3739
|
+
reason,
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
/** Withdraw every held-back report (teardown; nothing is said after close) and forget the decisions. */
|
|
3744
|
+
private clearFallbacks(): void {
|
|
3745
|
+
if (!this.closedByUs) {
|
|
3746
|
+
for (const producerId of this.bandwidthCut) this.emitFallback(producerId, false, 'bandwidth');
|
|
3747
|
+
for (const producerId of this.protectionHeld) this.emitFallback(producerId, false, 'audio-protection');
|
|
3748
|
+
}
|
|
3749
|
+
this.bandwidthCut.clear();
|
|
3750
|
+
this.protectionHeld.clear();
|
|
3751
|
+
this.protection.reset();
|
|
3752
|
+
this.stopProtection();
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
/**
|
|
3756
|
+
* The received audio is sampled only while the fallback could matter: enabled, connected, with remote audio to
|
|
3757
|
+
* judge by and a remote camera to hold back (or one held back already).
|
|
3758
|
+
*/
|
|
3759
|
+
private syncProtection(): void {
|
|
3760
|
+
let audio = false;
|
|
3761
|
+
let camera = false;
|
|
3762
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3763
|
+
if (consumer.closed) continue;
|
|
3764
|
+
if (consumer.kind === 'audio') audio = true;
|
|
3765
|
+
else if (this.remoteSources.get(producerId) === 'camera') camera = true;
|
|
3766
|
+
}
|
|
3767
|
+
const wanted = this.audioOnlyEnabled && !this.closedByUs && this.state === 'connected' && audio && (camera || this.protection.active);
|
|
3768
|
+
if (wanted && !this.protectionTimer) {
|
|
3769
|
+
this.protectionTimer = setInterval(() => void this.sampleProtection(), PROTECT_SAMPLE_MS);
|
|
3770
|
+
// Background sampling never keeps a server-side or test process alive (Node); browsers have no unref.
|
|
3771
|
+
(this.protectionTimer as { unref?: () => void }).unref?.();
|
|
3772
|
+
} else if (!wanted) {
|
|
3773
|
+
this.stopProtection();
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
private stopProtection(): void {
|
|
3778
|
+
if (this.protectionTimer) clearInterval(this.protectionTimer);
|
|
3779
|
+
this.protectionTimer = null;
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
private async sampleProtection(): Promise<void> {
|
|
3783
|
+
const transport = this.recvTransport;
|
|
3784
|
+
if (this.protectionSampling || !transport || transport.closed) return;
|
|
3785
|
+
this.protectionSampling = true;
|
|
3786
|
+
try {
|
|
3787
|
+
const report = await transport.getStats();
|
|
3788
|
+
if (transport !== this.recvTransport || !this.protectionTimer) return;
|
|
3789
|
+
this.protection.feed(readInboundAudio(report.values() as Iterable<Record<string, unknown>>));
|
|
3790
|
+
} catch {
|
|
3791
|
+
// Statistics are best effort; the next sample tries again.
|
|
3792
|
+
} finally {
|
|
3793
|
+
this.protectionSampling = false;
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
/** Apply adaptive stream's decision for one video: pause it, or resume it at its layer. */
|
|
3798
|
+
private async applyAdaptive(producerId: string, want: AdaptiveWant): Promise<void> {
|
|
3799
|
+
const consumer = this.consumers.get(producerId);
|
|
3800
|
+
// Not received (yet): a consumer made later starts from the decision in force.
|
|
3801
|
+
if (!consumer || consumer.kind !== 'video' || consumer.closed || this.closedByUs) return;
|
|
3802
|
+
try {
|
|
3803
|
+
if (!want.visible) {
|
|
3804
|
+
if (this.pauseUnsupported) {
|
|
3805
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 });
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
if (this.adaptivePaused.has(producerId)) return;
|
|
3809
|
+
this.adaptivePaused.add(producerId);
|
|
3810
|
+
await this.rpc('pauseConsumer', { consumerId: consumer.id });
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
if (this.adaptivePaused.delete(producerId)) await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
3814
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...this.effectiveLayers(producerId, want) });
|
|
3815
|
+
} catch (err) {
|
|
3816
|
+
if (this.consumers.get(producerId) !== consumer || consumer.closed || this.closedByUs) return;
|
|
3817
|
+
if (!want.visible && err instanceof RpcError && err.code === 'unknown-method') {
|
|
3818
|
+
// An older server: drop to the smallest layer instead of pausing.
|
|
3819
|
+
this.adaptivePaused.delete(producerId);
|
|
3820
|
+
this.pauseUnsupported = true;
|
|
3821
|
+
await this.applyAdaptive(producerId, want);
|
|
3822
|
+
return;
|
|
3823
|
+
}
|
|
3824
|
+
this.emit('error', err as Error);
|
|
3825
|
+
}
|
|
2550
3826
|
}
|
|
2551
3827
|
|
|
2552
3828
|
// ------------------------------------------------------------- moderation
|
|
@@ -2599,6 +3875,12 @@ export class StreamClient {
|
|
|
2599
3875
|
}
|
|
2600
3876
|
|
|
2601
3877
|
private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
|
|
3878
|
+
// Your own choice about the camera ends the publisher-side fallback's hold on it: it never undoes your choice.
|
|
3879
|
+
if (kind === 'video' && this.cameraHeldByFallback) {
|
|
3880
|
+
this.cameraHeldByFallback = false;
|
|
3881
|
+
this.publishProtection.reset();
|
|
3882
|
+
this.emit('publishFallback', { fallback: false });
|
|
3883
|
+
}
|
|
2602
3884
|
const generation = this.publishGeneration;
|
|
2603
3885
|
// Unpublished or closed while waiting: nothing is left to switch, and the
|
|
2604
3886
|
// call resolves, as it does with nothing published (V072-03).
|
|
@@ -2643,6 +3925,7 @@ export class StreamClient {
|
|
|
2643
3925
|
/** Stop publishing entirely and release the local devices. */
|
|
2644
3926
|
async unpublish(): Promise<void> {
|
|
2645
3927
|
void this.stopRecording().catch(error => this.emit('error', error as Error));
|
|
3928
|
+
this.layersNeeded.clear();
|
|
2646
3929
|
// All of it takes effect now; only telling the server is waited for. So
|
|
2647
3930
|
// publish() and unpublish() act in the order they are called: a publish
|
|
2648
3931
|
// called while this still waits is not undone by it, and one already
|
|
@@ -2659,7 +3942,7 @@ export class StreamClient {
|
|
|
2659
3942
|
// nothing is being held back once nothing is being sent.
|
|
2660
3943
|
const cleared = this.limitWatch.reset();
|
|
2661
3944
|
if (cleared) this.emit('videoLimited', cleared);
|
|
2662
|
-
this.
|
|
3945
|
+
this.stopLocalTracks();
|
|
2663
3946
|
this.localStream = null;
|
|
2664
3947
|
// Forgotten with the stream: kept, a reconnect would ask for the camera
|
|
2665
3948
|
// and microphone again and publish them, after the app unpublished them.
|