@stream-io/video-client 1.48.0 → 1.50.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 (86) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.browser.es.js +1497 -677
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1497 -677
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1497 -677
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +77 -4
  9. package/dist/src/StreamSfuClient.d.ts +8 -1
  10. package/dist/src/coordinator/connection/client.d.ts +1 -1
  11. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  12. package/dist/src/coordinator/connection/types.d.ts +14 -0
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +3 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +13 -1
  16. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  17. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  18. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  19. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  20. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  21. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  22. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  23. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  24. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  25. package/dist/src/helpers/browsers.d.ts +13 -0
  26. package/dist/src/helpers/concurrency.d.ts +6 -4
  27. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  28. package/dist/src/rtc/Publisher.d.ts +17 -0
  29. package/dist/src/rtc/Subscriber.d.ts +1 -0
  30. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/types.d.ts +33 -1
  33. package/dist/src/stats/rtc/types.d.ts +1 -1
  34. package/dist/src/store/rxUtils.d.ts +9 -0
  35. package/dist/src/types.d.ts +18 -0
  36. package/package.json +2 -2
  37. package/src/Call.ts +268 -40
  38. package/src/StreamSfuClient.ts +75 -12
  39. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  40. package/src/__tests__/Call.publishing.test.ts +103 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  42. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  43. package/src/coordinator/connection/client.ts +1 -1
  44. package/src/coordinator/connection/connection.ts +149 -96
  45. package/src/coordinator/connection/types.ts +15 -0
  46. package/src/coordinator/connection/utils.ts +15 -0
  47. package/src/devices/DeviceManager.ts +92 -32
  48. package/src/devices/DeviceManagerState.ts +20 -1
  49. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  50. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  51. package/src/devices/__tests__/mocks.ts +2 -0
  52. package/src/devices/devices.ts +2 -1
  53. package/src/gen/video/sfu/event/events.ts +15 -0
  54. package/src/gen/video/sfu/models/models.ts +44 -0
  55. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  56. package/src/helpers/BlockedAudioTracker.ts +74 -0
  57. package/src/helpers/DynascaleManager.ts +46 -337
  58. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  59. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  60. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  61. package/src/helpers/ViewportTracker.ts +74 -19
  62. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  63. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  64. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  65. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rpc/retryable.ts +0 -1
  72. package/src/rtc/BasePeerConnection.ts +96 -6
  73. package/src/rtc/Publisher.ts +49 -2
  74. package/src/rtc/Subscriber.ts +42 -14
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  76. package/src/rtc/__tests__/Publisher.test.ts +332 -10
  77. package/src/rtc/__tests__/Subscriber.test.ts +202 -1
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  80. package/src/rtc/helpers/degradationPreference.ts +22 -0
  81. package/src/rtc/index.ts +1 -0
  82. package/src/rtc/types.ts +38 -1
  83. package/src/stats/rtc/types.ts +1 -0
  84. package/src/store/__tests__/rxUtils.test.ts +276 -0
  85. package/src/store/rxUtils.ts +19 -0
  86. package/src/types.ts +19 -0
@@ -19,6 +19,9 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
19
19
  protected mediaStreamSubject = new BehaviorSubject<MediaStream | undefined>(
20
20
  undefined,
21
21
  );
22
+ protected rootMediaStreamSubject = new BehaviorSubject<
23
+ MediaStream | undefined
24
+ >(undefined);
22
25
  protected selectedDeviceSubject = new BehaviorSubject<string | undefined>(
23
26
  undefined,
24
27
  );
@@ -33,10 +36,16 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
33
36
 
34
37
  /**
35
38
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
36
- *
37
39
  */
38
40
  mediaStream$ = this.mediaStreamSubject.asObservable();
39
41
 
42
+ /**
43
+ * An Observable that emits the raw device media stream (before any filters are applied),
44
+ * or `undefined` if the device is currently disabled. When no filters are active, this
45
+ * emits the same stream as `mediaStream$`.
46
+ */
47
+ rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
48
+
40
49
  /**
41
50
  * An Observable that emits the currently selected device
42
51
  */
@@ -134,6 +143,15 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
134
143
  return RxUtils.getCurrentValue(this.mediaStream$);
135
144
  }
136
145
 
146
+ /**
147
+ * The raw device media stream (before any filters are applied), or `undefined`
148
+ * if the device is currently disabled. When no filters are active, this is the
149
+ * same as `mediaStream`.
150
+ */
151
+ get rootMediaStream() {
152
+ return RxUtils.getCurrentValue(this.rootMediaStream$);
153
+ }
154
+
137
155
  /**
138
156
  * @internal
139
157
  * @param status
@@ -163,6 +181,7 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
163
181
  rootStream: MediaStream | undefined,
164
182
  ) {
165
183
  RxUtils.setCurrentValue(this.mediaStreamSubject, stream);
184
+ RxUtils.setCurrentValue(this.rootMediaStreamSubject, rootStream);
166
185
  if (rootStream) {
167
186
  this.setDevice(this.getDeviceIdFromStream(rootStream));
168
187
  }
@@ -395,6 +395,289 @@ describe('Device Manager', () => {
395
395
  vi.useRealTimers();
396
396
  });
397
397
 
398
+ describe('interruptedTracks (hardware mute/unmute events)', () => {
399
+ const localSessionId = 'local-session-id';
400
+
401
+ beforeEach(() => {
402
+ manager['call'].state.setParticipants([
403
+ {
404
+ sessionId: localSessionId,
405
+ userId: 'local-user',
406
+ isLocalParticipant: true,
407
+ publishedTracks: [],
408
+ } as any,
409
+ ]);
410
+ });
411
+
412
+ const fireOn = async (track: MockTrack, event: 'mute' | 'unmute') => {
413
+ const handler = track.eventHandlers[event] as Function;
414
+ await handler();
415
+ };
416
+
417
+ const currentTrack = () =>
418
+ manager.state.mediaStream?.getTracks()[0] as MockTrack;
419
+
420
+ const isInterrupted = () =>
421
+ !!manager['call'].state.localParticipant?.interruptedTracks?.includes(
422
+ TrackType.VIDEO,
423
+ );
424
+
425
+ it('adds the track type on a mute event without touching status', async () => {
426
+ await manager.enable();
427
+ expect(manager.state.status).toBe('enabled');
428
+ expect(isInterrupted()).toBe(false);
429
+
430
+ await fireOn(currentTrack(), 'mute');
431
+
432
+ expect(isInterrupted()).toBe(true);
433
+ expect(manager.state.status).toBe('enabled');
434
+ expect(manager.state.optimisticStatus).toBe('enabled');
435
+ });
436
+
437
+ it('removes the track type on the matching unmute event', async () => {
438
+ await manager.enable();
439
+ const track = currentTrack();
440
+ await fireOn(track, 'mute');
441
+ expect(isInterrupted()).toBe(true);
442
+
443
+ await fireOn(track, 'unmute');
444
+
445
+ expect(isInterrupted()).toBe(false);
446
+ expect(manager.state.status).toBe('enabled');
447
+ });
448
+
449
+ it('notifies the SFU for video track mute/unmute events', async () => {
450
+ await manager.enable();
451
+ const track = currentTrack();
452
+
453
+ await fireOn(track, 'mute');
454
+ expect(manager['call'].notifyTrackMuteState).toHaveBeenCalledWith(
455
+ true,
456
+ TrackType.VIDEO,
457
+ );
458
+
459
+ await fireOn(track, 'unmute');
460
+ expect(manager['call'].notifyTrackMuteState).toHaveBeenCalledWith(
461
+ false,
462
+ TrackType.VIDEO,
463
+ );
464
+ });
465
+
466
+ it('emits localParticipant$ transitions to subscribers', async () => {
467
+ const observed: boolean[] = [];
468
+ const subscription = manager['call'].state.localParticipant$.subscribe(
469
+ (p) => observed.push(!!p?.interruptedTracks?.includes(TrackType.VIDEO)),
470
+ );
471
+
472
+ await manager.enable();
473
+ const track = currentTrack();
474
+ await fireOn(track, 'mute');
475
+ await fireOn(track, 'unmute');
476
+
477
+ expect(observed).toContain(true);
478
+ expect(observed[observed.length - 1]).toBe(false);
479
+ subscription.unsubscribe();
480
+ });
481
+
482
+ it('reacquires a fresh stream when the device is replaced mid-interruption', async () => {
483
+ vi.useFakeTimers();
484
+ emitDeviceIds(mockVideoDevices);
485
+
486
+ await manager.enable();
487
+ const device = mockVideoDevices[0];
488
+ await manager.select(device.deviceId);
489
+ await fireOn(currentTrack(), 'mute');
490
+ expect(isInterrupted()).toBe(true);
491
+ expect(manager.state.status).toBe('enabled');
492
+
493
+ manager.getStream.mockClear();
494
+
495
+ emitDeviceIds([
496
+ { ...device, groupId: device.groupId + 'new' },
497
+ ...mockVideoDevices.slice(1),
498
+ ]);
499
+
500
+ await vi.runAllTimersAsync();
501
+
502
+ // Status stays 'enabled' so the replacement flows through
503
+ // applySettingsToStream, which forces a fresh getStream call.
504
+ expect(manager.getStream).toHaveBeenCalled();
505
+ expect(manager.state.status).toBe('enabled');
506
+ vi.useRealTimers();
507
+ });
508
+
509
+ it('leaves manager.enabled === true so capability cleanup can still disable it', async () => {
510
+ await manager.enable();
511
+ await fireOn(currentTrack(), 'mute');
512
+
513
+ // `enabled` continues to reflect requested-publishing intent. Code
514
+ // that revokes SEND_AUDIO / SEND_VIDEO at the Call layer iterates
515
+ // managers whose `enabled` is true; if system-muted hid that bit,
516
+ // the cleanup would skip a still-published track.
517
+ expect(manager.enabled).toBe(true);
518
+ });
519
+
520
+ it('clears interruptedTracks when the user toggles the device off and back on', async () => {
521
+ await manager.enable();
522
+ await fireOn(currentTrack(), 'mute');
523
+ expect(isInterrupted()).toBe(true);
524
+
525
+ await manager.disable();
526
+ // Stream is cleared on disable; the prior hardware-mute signal
527
+ // belonged to the now-gone track.
528
+ expect(isInterrupted()).toBe(false);
529
+
530
+ await manager.enable();
531
+ // Re-acquired stream is fresh; the stale flag must not carry over.
532
+ expect(isInterrupted()).toBe(false);
533
+ });
534
+
535
+ it('clears interruptedTracks when select() swaps to a different device', async () => {
536
+ await manager.enable();
537
+ await fireOn(currentTrack(), 'mute');
538
+ expect(isInterrupted()).toBe(true);
539
+
540
+ await manager.select(mockVideoDevices[1].deviceId);
541
+
542
+ expect(isInterrupted()).toBe(false);
543
+ });
544
+
545
+ it('removes mute/unmute listeners from the prior track when select() swaps the stream', async () => {
546
+ await manager.enable();
547
+ const oldTrack = currentTrack();
548
+ expect(oldTrack.eventHandlers['mute']).toBeDefined();
549
+ expect(oldTrack.eventHandlers['unmute']).toBeDefined();
550
+
551
+ await manager.select(mockVideoDevices[1].deviceId);
552
+
553
+ // Listeners on the prior track are torn down so a delayed
554
+ // mute/unmute event cannot clobber the fresh stream's state.
555
+ expect(oldTrack.eventHandlers['mute']).toBeUndefined();
556
+ expect(oldTrack.eventHandlers['unmute']).toBeUndefined();
557
+ expect(oldTrack.eventHandlers['ended']).toBeUndefined();
558
+ });
559
+
560
+ it('removes mute/unmute listeners when the stream is cleared on disable', async () => {
561
+ await manager.enable();
562
+ const oldTrack = currentTrack();
563
+ expect(oldTrack.eventHandlers['mute']).toBeDefined();
564
+
565
+ await manager.disable();
566
+
567
+ expect(oldTrack.eventHandlers['mute']).toBeUndefined();
568
+ expect(oldTrack.eventHandlers['unmute']).toBeUndefined();
569
+ expect(oldTrack.eventHandlers['ended']).toBeUndefined();
570
+ });
571
+
572
+ describe('WebKit refreshTrack on unmute (encoder stall workaround)', () => {
573
+ const SAFARI_UA =
574
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
575
+ const IOS_WKWEBVIEW_UA =
576
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148';
577
+ const CHROME_UA =
578
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
579
+
580
+ const originalUserAgentDescriptor = Object.getOwnPropertyDescriptor(
581
+ window.navigator,
582
+ 'userAgent',
583
+ );
584
+
585
+ const setUserAgent = (ua: string) => {
586
+ Object.defineProperty(window.navigator, 'userAgent', {
587
+ configurable: true,
588
+ get: () => ua,
589
+ });
590
+ };
591
+
592
+ afterEach(() => {
593
+ if (originalUserAgentDescriptor) {
594
+ Object.defineProperty(
595
+ window.navigator,
596
+ 'userAgent',
597
+ originalUserAgentDescriptor,
598
+ );
599
+ }
600
+ });
601
+
602
+ it('calls refreshPublishedTrack on Safari unmute', async () => {
603
+ setUserAgent(SAFARI_UA);
604
+ await manager.enable();
605
+ const track = currentTrack();
606
+ await fireOn(track, 'mute');
607
+
608
+ await fireOn(track, 'unmute');
609
+
610
+ expect(manager['call'].refreshPublishedTrack).toHaveBeenCalledWith(
611
+ TrackType.VIDEO,
612
+ );
613
+ });
614
+
615
+ it('calls refreshPublishedTrack on a bare iOS WKWebView (no Safari token)', async () => {
616
+ setUserAgent(IOS_WKWEBVIEW_UA);
617
+ await manager.enable();
618
+ const track = currentTrack();
619
+ await fireOn(track, 'mute');
620
+
621
+ await fireOn(track, 'unmute');
622
+
623
+ expect(manager['call'].refreshPublishedTrack).toHaveBeenCalledWith(
624
+ TrackType.VIDEO,
625
+ );
626
+ });
627
+
628
+ it('does not call refreshPublishedTrack on Chrome unmute', async () => {
629
+ setUserAgent(CHROME_UA);
630
+ await manager.enable();
631
+ const track = currentTrack();
632
+ await fireOn(track, 'mute');
633
+
634
+ await fireOn(track, 'unmute');
635
+
636
+ expect(manager['call'].refreshPublishedTrack).not.toHaveBeenCalled();
637
+ });
638
+
639
+ it('does not call refreshPublishedTrack on the mute leg', async () => {
640
+ setUserAgent(SAFARI_UA);
641
+ await manager.enable();
642
+ const track = currentTrack();
643
+
644
+ await fireOn(track, 'mute');
645
+
646
+ expect(manager['call'].refreshPublishedTrack).not.toHaveBeenCalled();
647
+ });
648
+
649
+ it('skips refreshPublishedTrack while the page is hidden', async () => {
650
+ setUserAgent(SAFARI_UA);
651
+ const visibilityDescriptor = Object.getOwnPropertyDescriptor(
652
+ document,
653
+ 'visibilityState',
654
+ );
655
+ Object.defineProperty(document, 'visibilityState', {
656
+ configurable: true,
657
+ get: () => 'hidden',
658
+ });
659
+
660
+ try {
661
+ await manager.enable();
662
+ const track = currentTrack();
663
+ await fireOn(track, 'mute');
664
+
665
+ await fireOn(track, 'unmute');
666
+
667
+ expect(manager['call'].refreshPublishedTrack).not.toHaveBeenCalled();
668
+ } finally {
669
+ if (visibilityDescriptor) {
670
+ Object.defineProperty(
671
+ document,
672
+ 'visibilityState',
673
+ visibilityDescriptor,
674
+ );
675
+ }
676
+ }
677
+ });
678
+ });
679
+ });
680
+
398
681
  describe('persistPreference', () => {
399
682
  it('stores selected device and muted state', () => {
400
683
  const persistenceEnabledManager = new TestInputMediaDeviceManager(
@@ -34,7 +34,6 @@ describe('ScreenShareManager', () => {
34
34
  let manager: ScreenShareManager;
35
35
 
36
36
  beforeEach(() => {
37
- const devicePersistence = { enabled: false, storageKey: '' };
38
37
  manager = new ScreenShareManager(
39
38
  new Call({
40
39
  id: '',
@@ -42,7 +41,6 @@ describe('ScreenShareManager', () => {
42
41
  streamClient: new StreamClient('abc123'),
43
42
  clientStore: new StreamVideoWriteableStateStore(),
44
43
  }),
45
- devicePersistence,
46
44
  );
47
45
  });
48
46
 
@@ -104,6 +104,8 @@ export const mockCall = (): Partial<Call> => {
104
104
  }),
105
105
  notifyNoiseCancellationStarting: vi.fn().mockResolvedValue(undefined),
106
106
  notifyNoiseCancellationStopped: vi.fn().mockResolvedValue(undefined),
107
+ notifyTrackMuteState: vi.fn().mockResolvedValue(undefined),
108
+ refreshPublishedTrack: vi.fn().mockResolvedValue(undefined),
107
109
  tracer: new Tracer('tests'),
108
110
  };
109
111
  };
@@ -340,7 +340,6 @@ export const getScreenShareStream = async (
340
340
  const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
341
341
  try {
342
342
  const constraints: DisplayMediaStreamOptions = {
343
- // @ts-expect-error - not present in types yet
344
343
  systemAudio: 'include',
345
344
  ...options,
346
345
  video:
@@ -357,6 +356,8 @@ export const getScreenShareStream = async (
357
356
  ? options.audio
358
357
  : {
359
358
  channelCount: { ideal: 2 },
359
+ // @ts-expect-error not yet present in the types
360
+ restrictOwnAudio: true,
360
361
  echoCancellation: false,
361
362
  autoGainControl: false,
362
363
  noiseSuppression: false,
@@ -10,6 +10,7 @@ import {
10
10
  ClientDetails,
11
11
  Codec,
12
12
  ConnectionQuality,
13
+ DegradationPreference,
13
14
  Error as Error$,
14
15
  GoAwayReason,
15
16
  ICETrickle as ICETrickle$,
@@ -836,6 +837,10 @@ export interface VideoSender {
836
837
  * @generated from protobuf field: int32 publish_option_id = 5;
837
838
  */
838
839
  publishOptionId: number;
840
+ /**
841
+ * @generated from protobuf field: stream.video.sfu.models.DegradationPreference degradation_preference = 6;
842
+ */
843
+ degradationPreference: DegradationPreference;
839
844
  }
840
845
  /**
841
846
  * sent to users when they need to change the quality of their video
@@ -1796,6 +1801,16 @@ class VideoSender$Type extends MessageType<VideoSender> {
1796
1801
  kind: 'scalar',
1797
1802
  T: 5 /*ScalarType.INT32*/,
1798
1803
  },
1804
+ {
1805
+ no: 6,
1806
+ name: 'degradation_preference',
1807
+ kind: 'enum',
1808
+ T: () => [
1809
+ 'stream.video.sfu.models.DegradationPreference',
1810
+ DegradationPreference,
1811
+ 'DEGRADATION_PREFERENCE_',
1812
+ ],
1813
+ },
1799
1814
  ]);
1800
1815
  }
1801
1816
  }
@@ -307,6 +307,12 @@ export interface PublishOption {
307
307
  * @generated from protobuf field: repeated stream.video.sfu.models.AudioBitrate audio_bitrate_profiles = 10;
308
308
  */
309
309
  audioBitrateProfiles: AudioBitrate[];
310
+ /**
311
+ * The degradation preference for video encoding.
312
+ *
313
+ * @generated from protobuf field: stream.video.sfu.models.DegradationPreference degradation_preference = 11;
314
+ */
315
+ degradationPreference: DegradationPreference;
310
316
  }
311
317
  /**
312
318
  * @generated from protobuf message stream.video.sfu.models.Codec
@@ -1172,6 +1178,34 @@ export enum ClientCapability {
1172
1178
  */
1173
1179
  SUBSCRIBER_VIDEO_PAUSE = 1,
1174
1180
  }
1181
+ /**
1182
+ * DegradationPreference represents the RTCDegradationPreference from WebRTC.
1183
+ * See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
1184
+ *
1185
+ * @generated from protobuf enum stream.video.sfu.models.DegradationPreference
1186
+ */
1187
+ export enum DegradationPreference {
1188
+ /**
1189
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
1190
+ */
1191
+ UNSPECIFIED = 0,
1192
+ /**
1193
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
1194
+ */
1195
+ BALANCED = 1,
1196
+ /**
1197
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
1198
+ */
1199
+ MAINTAIN_FRAMERATE = 2,
1200
+ /**
1201
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
1202
+ */
1203
+ MAINTAIN_RESOLUTION = 3,
1204
+ /**
1205
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
1206
+ */
1207
+ MAINTAIN_FRAMERATE_AND_RESOLUTION = 4,
1208
+ }
1175
1209
  // @generated message type with reflection information, may provide speed optimized methods
1176
1210
  class CallState$Type extends MessageType<CallState> {
1177
1211
  constructor() {
@@ -1441,6 +1475,16 @@ class PublishOption$Type extends MessageType<PublishOption> {
1441
1475
  repeat: 2 /*RepeatType.UNPACKED*/,
1442
1476
  T: () => AudioBitrate,
1443
1477
  },
1478
+ {
1479
+ no: 11,
1480
+ name: 'degradation_preference',
1481
+ kind: 'enum',
1482
+ T: () => [
1483
+ 'stream.video.sfu.models.DegradationPreference',
1484
+ DegradationPreference,
1485
+ 'DEGRADATION_PREFERENCE_',
1486
+ ],
1487
+ },
1444
1488
  ]);
1445
1489
  }
1446
1490
  }
@@ -21,10 +21,12 @@ export class AudioBindingsWatchdog {
21
21
  private readonly unsubscribeCallingState: () => void;
22
22
  private logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
23
23
 
24
- constructor(
25
- private state: CallState,
26
- private tracer: Tracer,
27
- ) {
24
+ private readonly state: CallState;
25
+ private readonly tracer: Tracer;
26
+
27
+ constructor(state: CallState, tracer: Tracer) {
28
+ this.tracer = tracer;
29
+ this.state = state;
28
30
  this.unsubscribeCallingState = createSubscription(
29
31
  state.callingState$,
30
32
  (callingState) => {
@@ -43,19 +45,19 @@ export class AudioBindingsWatchdog {
43
45
  * Warns if a different element is already bound to the same key.
44
46
  */
45
47
  register = (
46
- audioElement: HTMLAudioElement,
48
+ element: HTMLAudioElement,
47
49
  sessionId: string,
48
50
  trackType: AudioTrackType,
49
51
  ) => {
50
52
  const key = toBindingKey(sessionId, trackType);
51
53
  const existing = this.bindings.get(key);
52
- if (existing && existing !== audioElement) {
54
+ if (existing && existing !== element) {
53
55
  this.logger.warn(
54
56
  `Audio element already bound to ${sessionId} and ${trackType}`,
55
57
  );
56
58
  this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
57
59
  }
58
- this.bindings.set(key, audioElement);
60
+ this.bindings.set(key, element);
59
61
  };
60
62
 
61
63
  /**
@@ -83,6 +85,7 @@ export class AudioBindingsWatchdog {
83
85
  */
84
86
  dispose = () => {
85
87
  this.stop();
88
+ this.bindings.clear();
86
89
  this.unsubscribeCallingState();
87
90
  };
88
91
 
@@ -0,0 +1,74 @@
1
+ import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
2
+ import { videoLoggerSystem } from '../logger';
3
+ import { Tracer } from '../stats';
4
+ import { setCurrentValue, setCurrentValueAsync } from '../store/rxUtils';
5
+ import { timeboxed } from '../coordinator/connection/utils';
6
+
7
+ /**
8
+ * Tracks audio elements that the browser's autoplay policy has blocked.
9
+ */
10
+ export class BlockedAudioTracker {
11
+ private logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
12
+ private tracer: Tracer;
13
+
14
+ private blockedElementsSubject = new BehaviorSubject(
15
+ new Set<HTMLAudioElement>(),
16
+ );
17
+
18
+ /**
19
+ * Whether the browser's autoplay policy is blocking audio playback.
20
+ * Will be `true` when at least one audio element is currently blocked.
21
+ * Use {@link resumeAudio} within a user gesture to unblock.
22
+ */
23
+ autoplayBlocked$ = this.blockedElementsSubject.pipe(
24
+ map((elements) => elements.size > 0),
25
+ distinctUntilChanged(),
26
+ );
27
+
28
+ constructor(tracer: Tracer) {
29
+ this.tracer = tracer;
30
+ }
31
+
32
+ /**
33
+ * Registers an audio element as blocked by the browser's autoplay policy.
34
+ */
35
+ markBlocked = (audioElement: HTMLAudioElement, blocked: boolean) => {
36
+ setCurrentValue(this.blockedElementsSubject, (elements) => {
37
+ if (blocked) elements.add(audioElement);
38
+ else elements.delete(audioElement);
39
+ return elements;
40
+ });
41
+ };
42
+
43
+ /**
44
+ * Returns whether the given audio element is currently flagged as blocked
45
+ * by the browser's autoplay policy.
46
+ */
47
+ isBlocked = (audioElement: HTMLAudioElement): boolean => {
48
+ return this.blockedElementsSubject.getValue().has(audioElement);
49
+ };
50
+
51
+ /**
52
+ * Plays all audio elements blocked by the browser's autoplay policy.
53
+ * Must be called from within a user gesture (e.g., click handler).
54
+ */
55
+ resumeAudio = async () => {
56
+ this.tracer.trace('resumeAudio', null);
57
+ await setCurrentValueAsync(
58
+ this.blockedElementsSubject,
59
+ async (elements) => {
60
+ await Promise.all(
61
+ Array.from(elements, async (element) => {
62
+ try {
63
+ if (element.srcObject) await timeboxed([element.play()], 2000);
64
+ elements.delete(element);
65
+ } catch (err) {
66
+ this.logger.warn(`Can't resume audio for element`, element, err);
67
+ }
68
+ }),
69
+ );
70
+ return elements;
71
+ },
72
+ );
73
+ };
74
+ }