@voltkit-rtc/client 0.7.3 → 0.8.0

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