@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
@@ -4,9 +4,8 @@ import {
4
4
  VideoTrackType,
5
5
  VisibilityState,
6
6
  } from '../types';
7
- import { TrackType, VideoDimension } from '../gen/video/sfu/models/models';
7
+ import { VideoDimension } from '../gen/video/sfu/models/models';
8
8
  import {
9
- BehaviorSubject,
10
9
  combineLatest,
11
10
  distinctUntilChanged,
12
11
  distinctUntilKeyChanged,
@@ -14,163 +13,55 @@ import {
14
13
  shareReplay,
15
14
  takeWhile,
16
15
  } from 'rxjs';
17
- import { ViewportTracker } from './ViewportTracker';
18
- import { AudioBindingsWatchdog } from './AudioBindingsWatchdog';
16
+ import type { BlockedAudioTracker } from './BlockedAudioTracker';
17
+ import { MediaPlaybackWatchdog } from './MediaPlaybackWatchdog';
18
+ import type { TrackSubscriptionManager } from './TrackSubscriptionManager';
19
19
  import { isFirefox, isSafari } from './browsers';
20
- import { isReactNative } from './platforms';
21
- import {
22
- hasScreenShare,
23
- hasScreenShareAudio,
24
- hasVideo,
25
- } from './participantUtils';
26
- import type { TrackSubscriptionDetails } from '../gen/video/sfu/signal_rpc/signal';
20
+ import { hasScreenShare, hasVideo } from './participantUtils';
27
21
  import { CallState } from '../store';
28
- import type { StreamSfuClient } from '../StreamSfuClient';
29
22
  import { SpeakerManager } from '../devices';
30
- import { getCurrentValue, setCurrentValue } from '../store/rxUtils';
31
23
  import { videoLoggerSystem } from '../logger';
32
24
  import { Tracer } from '../stats';
33
-
34
- const DEFAULT_VIEWPORT_VISIBILITY_STATE: Record<
35
- VideoTrackType,
36
- VisibilityState
37
- > = {
38
- videoTrack: VisibilityState.UNKNOWN,
39
- screenShareTrack: VisibilityState.UNKNOWN,
40
- } as const;
41
-
42
- type VideoTrackSubscriptionOverride =
43
- | {
44
- enabled: true;
45
- dimension: VideoDimension;
46
- }
47
- | { enabled: false };
48
-
49
- const globalOverrideKey = Symbol('globalOverrideKey');
50
-
51
- interface VideoTrackSubscriptionOverrides {
52
- [sessionId: string]: VideoTrackSubscriptionOverride | undefined;
53
- [globalOverrideKey]?: VideoTrackSubscriptionOverride;
54
- }
25
+ import { timeboxed } from '../coordinator/connection/utils';
55
26
 
56
27
  /**
57
28
  * A manager class that handles dynascale related tasks like:
58
29
  *
59
30
  * - binding video elements to session ids
60
31
  * - binding audio elements to session ids
61
- * - tracking element visibility
62
- * - updating subscriptions based on viewport visibility
63
- * - updating subscriptions based on video element dimensions
64
- * - updating subscriptions based on published tracks
65
32
  */
66
33
  export class DynascaleManager {
67
- /**
68
- * The viewport tracker instance.
69
- */
70
- readonly viewportTracker = new ViewportTracker();
71
-
72
34
  private logger = videoLoggerSystem.getLogger('DynascaleManager');
73
35
  private callState: CallState;
74
36
  private speaker: SpeakerManager;
75
- private tracer: Tracer;
37
+ private readonly tracer: Tracer;
76
38
  private useWebAudio = false;
77
39
  private audioContext: AudioContext | undefined;
78
- private sfuClient: StreamSfuClient | undefined;
79
- private pendingSubscriptionsUpdate: NodeJS.Timeout | null = null;
80
- readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
81
40
 
82
- /**
83
- * Audio elements that were blocked by the browser's autoplay policy.
84
- * These can be retried by calling `resumeAudio()` from a user gesture.
85
- */
86
- private blockedAudioElementsSubject = new BehaviorSubject<
87
- Set<HTMLAudioElement>
88
- >(new Set());
89
-
90
- /**
91
- * Whether the browser's autoplay policy is blocking audio playback.
92
- * Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
93
- * Use `resumeAudio()` within a user gesture to unblock.
94
- */
95
- autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(
96
- map((elements) => elements.size > 0),
97
- distinctUntilChanged(),
98
- );
99
-
100
- private addBlockedAudioElement = (audioElement: HTMLAudioElement) => {
101
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
102
- const next = new Set(elements);
103
- next.add(audioElement);
104
- return next;
105
- });
106
- };
107
-
108
- private removeBlockedAudioElement = (audioElement: HTMLAudioElement) => {
109
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
110
- const nextElements = new Set(elements);
111
- nextElements.delete(audioElement);
112
- return nextElements;
113
- });
114
- };
115
-
116
- private videoTrackSubscriptionOverridesSubject =
117
- new BehaviorSubject<VideoTrackSubscriptionOverrides>({});
118
-
119
- videoTrackSubscriptionOverrides$ =
120
- this.videoTrackSubscriptionOverridesSubject.asObservable();
121
-
122
- incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(
123
- map((overrides) => {
124
- const { [globalOverrideKey]: globalSettings, ...participants } =
125
- overrides;
126
- return {
127
- enabled: globalSettings?.enabled !== false,
128
- preferredResolution: globalSettings?.enabled
129
- ? globalSettings.dimension
130
- : undefined,
131
- participants: Object.fromEntries(
132
- Object.entries(participants).map(
133
- ([sessionId, participantOverride]) => [
134
- sessionId,
135
- {
136
- enabled: participantOverride?.enabled !== false,
137
- preferredResolution: participantOverride?.enabled
138
- ? participantOverride.dimension
139
- : undefined,
140
- },
141
- ],
142
- ),
143
- ),
144
- isParticipantVideoEnabled: (sessionId: string) =>
145
- overrides[sessionId]?.enabled ??
146
- overrides[globalOverrideKey]?.enabled ??
147
- true,
148
- };
149
- }),
150
- shareReplay(1),
151
- );
41
+ private trackSubscriptionManager: TrackSubscriptionManager;
42
+ private blockedAudioTracker: BlockedAudioTracker;
152
43
 
153
44
  /**
154
45
  * Creates a new DynascaleManager instance.
155
46
  */
156
- constructor(callState: CallState, speaker: SpeakerManager, tracer: Tracer) {
47
+ constructor(
48
+ callState: CallState,
49
+ speaker: SpeakerManager,
50
+ tracer: Tracer,
51
+ trackSubscriptionManager: TrackSubscriptionManager,
52
+ blockedAudioTracker: BlockedAudioTracker,
53
+ ) {
157
54
  this.callState = callState;
158
55
  this.speaker = speaker;
159
56
  this.tracer = tracer;
160
- if (!isReactNative()) {
161
- this.audioBindingsWatchdog = new AudioBindingsWatchdog(callState, tracer);
162
- }
57
+ this.trackSubscriptionManager = trackSubscriptionManager;
58
+ this.blockedAudioTracker = blockedAudioTracker;
163
59
  }
164
60
 
165
61
  /**
166
- * Disposes the allocated resources and closes the audio context if it was created.
62
+ * Closes the audio context if it was created.
167
63
  */
168
64
  dispose = async () => {
169
- if (this.pendingSubscriptionsUpdate) {
170
- clearTimeout(this.pendingSubscriptionsUpdate);
171
- }
172
- this.audioBindingsWatchdog?.dispose();
173
- setCurrentValue(this.blockedAudioElementsSubject, new Set());
174
65
  const context = this.audioContext;
175
66
  if (context && context.state !== 'closed') {
176
67
  document.removeEventListener('click', this.resumeAudioContext);
@@ -179,174 +70,6 @@ export class DynascaleManager {
179
70
  }
180
71
  };
181
72
 
182
- setSfuClient(sfuClient: StreamSfuClient | undefined) {
183
- this.sfuClient = sfuClient;
184
- }
185
-
186
- get trackSubscriptions() {
187
- const subscriptions: TrackSubscriptionDetails[] = [];
188
- // Use getParticipantsSnapshot() to bypass the observable pipeline
189
- // and avoid stale data caused by shareReplay with no active subscribers
190
- const participants = this.callState.getParticipantsSnapshot();
191
- const videoTrackSubscriptionOverrides =
192
- this.videoTrackSubscriptionOverridesSubject.getValue();
193
- for (const p of participants) {
194
- if (p.isLocalParticipant) continue;
195
- // NOTE: audio tracks don't have to be requested explicitly
196
- // as the SFU will implicitly subscribe us to all of them,
197
- // once they become available.
198
- if (p.videoDimension && hasVideo(p)) {
199
- const override =
200
- videoTrackSubscriptionOverrides[p.sessionId] ??
201
- videoTrackSubscriptionOverrides[globalOverrideKey];
202
-
203
- if (override?.enabled !== false) {
204
- subscriptions.push({
205
- userId: p.userId,
206
- sessionId: p.sessionId,
207
- trackType: TrackType.VIDEO,
208
- dimension: override?.dimension ?? p.videoDimension,
209
- });
210
- }
211
- }
212
- if (p.screenShareDimension && hasScreenShare(p)) {
213
- subscriptions.push({
214
- userId: p.userId,
215
- sessionId: p.sessionId,
216
- trackType: TrackType.SCREEN_SHARE,
217
- dimension: p.screenShareDimension,
218
- });
219
- }
220
- if (hasScreenShareAudio(p)) {
221
- subscriptions.push({
222
- userId: p.userId,
223
- sessionId: p.sessionId,
224
- trackType: TrackType.SCREEN_SHARE_AUDIO,
225
- });
226
- }
227
- }
228
- return subscriptions;
229
- }
230
-
231
- get videoTrackSubscriptionOverrides() {
232
- return getCurrentValue(this.videoTrackSubscriptionOverrides$);
233
- }
234
-
235
- setVideoTrackSubscriptionOverrides = (
236
- override: VideoTrackSubscriptionOverride | undefined,
237
- sessionIds?: string[],
238
- ) => {
239
- this.tracer.trace('setVideoTrackSubscriptionOverrides', [
240
- override,
241
- sessionIds,
242
- ]);
243
- if (!sessionIds) {
244
- return setCurrentValue(
245
- this.videoTrackSubscriptionOverridesSubject,
246
- override ? { [globalOverrideKey]: override } : {},
247
- );
248
- }
249
-
250
- return setCurrentValue(
251
- this.videoTrackSubscriptionOverridesSubject,
252
- (overrides) => ({
253
- ...overrides,
254
- ...Object.fromEntries(sessionIds.map((id) => [id, override])),
255
- }),
256
- );
257
- };
258
-
259
- applyTrackSubscriptions = (
260
- debounceType: DebounceType = DebounceType.SLOW,
261
- ) => {
262
- if (this.pendingSubscriptionsUpdate) {
263
- clearTimeout(this.pendingSubscriptionsUpdate);
264
- }
265
-
266
- const updateSubscriptions = () => {
267
- this.pendingSubscriptionsUpdate = null;
268
- this.sfuClient
269
- ?.updateSubscriptions(this.trackSubscriptions)
270
- .catch((err: unknown) => {
271
- this.logger.debug(`Failed to update track subscriptions`, err);
272
- });
273
- };
274
-
275
- if (debounceType) {
276
- this.pendingSubscriptionsUpdate = setTimeout(
277
- updateSubscriptions,
278
- debounceType,
279
- );
280
- } else {
281
- updateSubscriptions();
282
- }
283
- };
284
-
285
- /**
286
- * Will begin tracking the given element for visibility changes within the
287
- * configured viewport element (`call.setViewport`).
288
- *
289
- * @param element the element to track.
290
- * @param sessionId the session id.
291
- * @param trackType the kind of video.
292
- * @returns Untrack.
293
- */
294
- trackElementVisibility = <T extends HTMLElement>(
295
- element: T,
296
- sessionId: string,
297
- trackType: VideoTrackType,
298
- ) => {
299
- const cleanup = this.viewportTracker.observe(element, (entry) => {
300
- this.callState.updateParticipant(sessionId, (participant) => {
301
- const previousVisibilityState =
302
- participant.viewportVisibilityState ??
303
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
304
-
305
- // observer triggers when the element is "moved" to be a fullscreen element
306
- // keep it VISIBLE if that happens to prevent fullscreen with placeholder
307
- const isVisible =
308
- entry.isIntersecting || document.fullscreenElement === element
309
- ? VisibilityState.VISIBLE
310
- : VisibilityState.INVISIBLE;
311
- return {
312
- ...participant,
313
- viewportVisibilityState: {
314
- ...previousVisibilityState,
315
- [trackType]: isVisible,
316
- },
317
- };
318
- });
319
- });
320
-
321
- return () => {
322
- cleanup();
323
- // reset visibility state to UNKNOWN upon cleanup
324
- // so that the layouts that are not actively observed
325
- // can still function normally (runtime layout switching)
326
- this.callState.updateParticipant(sessionId, (participant) => {
327
- const previousVisibilityState =
328
- participant.viewportVisibilityState ??
329
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
330
- return {
331
- ...participant,
332
- viewportVisibilityState: {
333
- ...previousVisibilityState,
334
- [trackType]: VisibilityState.UNKNOWN,
335
- },
336
- };
337
- });
338
- };
339
- };
340
-
341
- /**
342
- * Sets the viewport element to track bound video elements for visibility.
343
- *
344
- * @param element the viewport element.
345
- */
346
- setViewport = <T extends HTMLElement>(element: T) => {
347
- return this.viewportTracker.setViewport(element);
348
- };
349
-
350
73
  /**
351
74
  * Sets whether to use WebAudio API for audio playback.
352
75
  * Must be set before joining the call.
@@ -399,7 +122,7 @@ export class DynascaleManager {
399
122
  this.callState.updateParticipantTracks(trackType, {
400
123
  [sessionId]: { dimension },
401
124
  });
402
- this.applyTrackSubscriptions(debounceType);
125
+ this.trackSubscriptionManager.apply(debounceType);
403
126
  };
404
127
 
405
128
  const participant$ = this.callState.participants$.pipe(
@@ -521,6 +244,12 @@ export class DynascaleManager {
521
244
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
522
245
  videoElement.muted = true;
523
246
 
247
+ const playbackWatchdog = new MediaPlaybackWatchdog({
248
+ element: videoElement,
249
+ kind: 'video',
250
+ tracer: this.tracer,
251
+ });
252
+
524
253
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
525
254
  const streamSubscription = participant$
526
255
  .pipe(distinctUntilKeyChanged(trackKey))
@@ -529,14 +258,13 @@ export class DynascaleManager {
529
258
  if (videoElement.srcObject === source) return;
530
259
  videoElement.srcObject = source ?? null;
531
260
  if (isSafari() || isFirefox()) {
532
- setTimeout(() => {
261
+ setTimeout(async () => {
533
262
  videoElement.srcObject = source ?? null;
534
- videoElement.play().catch((e) => {
263
+ try {
264
+ await timeboxed([videoElement.play()], 2000);
265
+ } catch (e) {
535
266
  this.logger.warn(`Failed to play stream`, e);
536
- });
537
- // we add extra delay until we attempt to force-play
538
- // the participant's media stream in Firefox and Safari,
539
- // as they seem to have some timing issues
267
+ }
540
268
  }, 25);
541
269
  }
542
270
  });
@@ -547,6 +275,7 @@ export class DynascaleManager {
547
275
  publishedTracksSubscription?.unsubscribe();
548
276
  streamSubscription.unsubscribe();
549
277
  resizeObserver?.disconnect();
278
+ playbackWatchdog.dispose();
550
279
  };
551
280
  };
552
281
 
@@ -569,8 +298,6 @@ export class DynascaleManager {
569
298
  const participant = this.callState.findParticipantBySessionId(sessionId);
570
299
  if (!participant || participant.isLocalParticipant) return;
571
300
 
572
- this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
573
-
574
301
  const participant$ = this.callState.participants$.pipe(
575
302
  map((ps) => ps.find((p) => p.sessionId === sessionId)),
576
303
  takeWhile((p) => !!p),
@@ -599,6 +326,7 @@ export class DynascaleManager {
599
326
 
600
327
  let sourceNode: MediaStreamAudioSourceNode | undefined = undefined;
601
328
  let gainNode: GainNode | undefined = undefined;
329
+ let audioWatchdog: MediaPlaybackWatchdog | undefined = undefined;
602
330
 
603
331
  const isAudioTrack = trackType === 'audioTrack';
604
332
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
@@ -610,8 +338,10 @@ export class DynascaleManager {
610
338
 
611
339
  setTimeout(() => {
612
340
  audioElement.srcObject = source ?? null;
341
+ audioWatchdog?.dispose();
342
+ audioWatchdog = undefined;
613
343
  if (!source) {
614
- this.removeBlockedAudioElement(audioElement);
344
+ this.blockedAudioTracker.markBlocked(audioElement, false);
615
345
  return;
616
346
  }
617
347
 
@@ -639,10 +369,16 @@ export class DynascaleManager {
639
369
  this.tracer.trace('audioPlaybackError', e.message);
640
370
  if (e.name === 'NotAllowedError') {
641
371
  this.tracer.trace('audioPlaybackBlocked', null);
642
- this.addBlockedAudioElement(audioElement);
372
+ this.blockedAudioTracker.markBlocked(audioElement, true);
643
373
  }
644
374
  this.logger.warn(`Failed to play audio stream`, e);
645
375
  });
376
+ audioWatchdog = new MediaPlaybackWatchdog({
377
+ element: audioElement,
378
+ kind: 'audio',
379
+ tracer: this.tracer,
380
+ isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
381
+ });
646
382
  }
647
383
 
648
384
  const { selectedDevice } = this.speaker.state;
@@ -669,45 +405,18 @@ export class DynascaleManager {
669
405
  audioElement.autoplay = true;
670
406
 
671
407
  return () => {
672
- this.audioBindingsWatchdog?.unregister(sessionId, trackType);
673
- this.removeBlockedAudioElement(audioElement);
408
+ this.blockedAudioTracker.markBlocked(audioElement, false);
674
409
  sinkIdSubscription?.unsubscribe();
675
410
  volumeSubscription.unsubscribe();
676
411
  updateMediaStreamSubscription.unsubscribe();
677
412
  audioElement.srcObject = null;
678
413
  sourceNode?.disconnect();
679
414
  gainNode?.disconnect();
415
+ audioWatchdog?.dispose();
416
+ audioWatchdog = undefined;
680
417
  };
681
418
  };
682
419
 
683
- /**
684
- * Plays all audio elements blocked by the browser's autoplay policy.
685
- * Must be called from within a user gesture (e.g., click handler).
686
- *
687
- * @returns a promise that resolves when all blocked elements have been retried.
688
- */
689
- resumeAudio = async () => {
690
- this.tracer.trace('resumeAudio', null);
691
- const blocked = new Set<HTMLAudioElement>();
692
- await Promise.all(
693
- Array.from(
694
- getCurrentValue(this.blockedAudioElementsSubject),
695
- async (el) => {
696
- try {
697
- if (el.srcObject) {
698
- await el.play();
699
- }
700
- } catch {
701
- this.logger.warn(`Can't resume audio for element: `, el);
702
- blocked.add(el);
703
- }
704
- },
705
- ),
706
- );
707
-
708
- setCurrentValue(this.blockedAudioElementsSubject, blocked);
709
- };
710
-
711
420
  private getOrCreateAudioContext = (): AudioContext | undefined => {
712
421
  if (!this.useWebAudio) return;
713
422
  if (this.audioContext) return this.audioContext;
@@ -0,0 +1,121 @@
1
+ import { videoLoggerSystem } from '../logger';
2
+ import { Tracer } from '../stats';
3
+ import { retryInterval, timeboxed } from '../coordinator/connection/utils';
4
+
5
+ type MediaKind = 'audio' | 'video';
6
+
7
+ export type MediaPlaybackWatchdogOptions = {
8
+ element: HTMLMediaElement;
9
+ kind: MediaKind;
10
+ tracer: Tracer;
11
+ isBlocked?: () => boolean;
12
+ };
13
+
14
+ /**
15
+ * Watches a single audio or video element and attempts to recover playback
16
+ * after the element transitions to a paused or suspended state unexpectedly.
17
+ */
18
+ export class MediaPlaybackWatchdog {
19
+ private logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
20
+ private readonly kind: MediaKind;
21
+ private readonly isBlocked: () => boolean;
22
+ private element: HTMLMediaElement;
23
+ private tracer: Tracer;
24
+ private controller = new AbortController();
25
+ private pendingTimer: ReturnType<typeof setTimeout> | undefined;
26
+ private attempt = 0;
27
+ private disposed = false;
28
+
29
+ constructor(opts: MediaPlaybackWatchdogOptions) {
30
+ this.element = opts.element;
31
+ this.kind = opts.kind;
32
+ this.tracer = opts.tracer;
33
+ this.isBlocked = opts.isBlocked ?? (() => false);
34
+ this.attach();
35
+ }
36
+
37
+ private attach = () => {
38
+ if (this.disposed) return;
39
+ const { signal } = this.controller;
40
+ this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
41
+ this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
42
+ this.element.addEventListener('playing', this.onPlaying, { signal });
43
+ };
44
+
45
+ dispose = () => {
46
+ if (this.disposed) return;
47
+ this.disposed = true;
48
+ this.controller.abort();
49
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
50
+ this.pendingTimer = undefined;
51
+ };
52
+
53
+ private onPlaying = () => {
54
+ if (this.attempt > 0) {
55
+ this.tracer.trace('mediaPlayback.recover.success', {
56
+ kind: this.kind,
57
+ attempts: this.attempt,
58
+ });
59
+ }
60
+ this.attempt = 0;
61
+ if (this.pendingTimer) clearTimeout(this.pendingTimer);
62
+ this.pendingTimer = undefined;
63
+ };
64
+
65
+ private onPauseOrSuspend = (event: Event) => {
66
+ if (this.disposed) return;
67
+ this.tracer.trace('mediaPlayback.paused', {
68
+ kind: this.kind,
69
+ reason: event.type,
70
+ });
71
+ this.scheduleRecovery();
72
+ };
73
+
74
+ private scheduleRecovery = () => {
75
+ if (this.disposed || this.pendingTimer) return;
76
+ const skipReason = this.computeSkipReason();
77
+ if (skipReason) {
78
+ this.tracer.trace('mediaPlayback.recover.skipped', {
79
+ kind: this.kind,
80
+ reason: skipReason,
81
+ });
82
+ return;
83
+ }
84
+ const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
85
+ this.pendingTimer = setTimeout(this.attemptPlay, delay);
86
+ };
87
+
88
+ private computeSkipReason = (): string | undefined => {
89
+ if (this.disposed) return 'disposed';
90
+ if (!this.element.srcObject) return 'noSrc';
91
+ if (this.element.ended) return 'ended';
92
+ if (this.isBlocked()) return 'blocked';
93
+ const HAVE_CURRENT_DATA = 2;
94
+ if (this.element.readyState < HAVE_CURRENT_DATA) return 'notReady';
95
+ if (!this.element.paused) return 'notPaused';
96
+ };
97
+
98
+ private attemptPlay = async () => {
99
+ this.pendingTimer = undefined;
100
+ if (this.disposed) return;
101
+ this.attempt += 1;
102
+ this.tracer.trace('mediaPlayback.recover.attempt', {
103
+ kind: this.kind,
104
+ attempt: this.attempt,
105
+ });
106
+ try {
107
+ await timeboxed([this.element.play()], 2000);
108
+ } catch (err) {
109
+ if (this.disposed) return;
110
+ this.logger.warn(`Failed to recover ${this.kind} playback`, err);
111
+ if (this.attempt >= 10) {
112
+ this.tracer.trace('mediaPlayback.recover.giveUp', {
113
+ kind: this.kind,
114
+ attempts: this.attempt,
115
+ });
116
+ return;
117
+ }
118
+ this.scheduleRecovery();
119
+ }
120
+ };
121
+ }