@stream-io/video-client 1.49.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 (69) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +1086 -594
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1086 -594
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1086 -594
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +42 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/DeviceManager.d.ts +3 -0
  14. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  15. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  16. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  17. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  18. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  19. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  21. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  22. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  23. package/dist/src/helpers/browsers.d.ts +13 -0
  24. package/dist/src/helpers/concurrency.d.ts +6 -4
  25. package/dist/src/rtc/Publisher.d.ts +17 -0
  26. package/dist/src/rtc/Subscriber.d.ts +1 -0
  27. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  28. package/dist/src/stats/rtc/types.d.ts +1 -1
  29. package/dist/src/store/rxUtils.d.ts +9 -0
  30. package/dist/src/types.d.ts +18 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +89 -22
  33. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  34. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  35. package/src/coordinator/connection/client.ts +1 -1
  36. package/src/coordinator/connection/connection.ts +149 -96
  37. package/src/coordinator/connection/types.ts +15 -0
  38. package/src/coordinator/connection/utils.ts +15 -0
  39. package/src/devices/DeviceManager.ts +92 -32
  40. package/src/devices/DeviceManagerState.ts +0 -1
  41. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  42. package/src/devices/__tests__/mocks.ts +2 -0
  43. package/src/gen/video/sfu/event/events.ts +15 -0
  44. package/src/gen/video/sfu/models/models.ts +44 -0
  45. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  46. package/src/helpers/BlockedAudioTracker.ts +74 -0
  47. package/src/helpers/DynascaleManager.ts +46 -337
  48. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  49. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  50. package/src/helpers/ViewportTracker.ts +74 -19
  51. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  52. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  53. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  54. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  55. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  56. package/src/helpers/__tests__/browsers.test.ts +85 -1
  57. package/src/helpers/browsers.ts +24 -0
  58. package/src/helpers/concurrency.ts +9 -10
  59. package/src/rtc/Publisher.ts +47 -1
  60. package/src/rtc/Subscriber.ts +42 -14
  61. package/src/rtc/__tests__/Publisher.test.ts +122 -10
  62. package/src/rtc/__tests__/Subscriber.test.ts +146 -1
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  64. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  65. package/src/rtc/helpers/degradationPreference.ts +22 -0
  66. package/src/stats/rtc/types.ts +1 -0
  67. package/src/store/__tests__/rxUtils.test.ts +276 -0
  68. package/src/store/rxUtils.ts +19 -0
  69. package/src/types.ts +19 -0
@@ -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(
@@ -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
  };
@@ -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
+ }