@stream-io/video-client 1.61.1 → 1.62.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 (62) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.browser.es.js +525 -178
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +525 -178
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +525 -178
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +21 -1
  9. package/dist/src/coordinator/connection/types.d.ts +1 -1
  10. package/dist/src/devices/DeviceManager.d.ts +6 -0
  11. package/dist/src/devices/DeviceManagerState.d.ts +17 -0
  12. package/dist/src/devices/MicrophoneManager.d.ts +11 -0
  13. package/dist/src/devices/devices.d.ts +5 -0
  14. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
  15. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
  16. package/dist/src/helpers/DynascaleManager.d.ts +15 -0
  17. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
  18. package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
  19. package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
  20. package/dist/src/ringing/reconcileRingState.d.ts +8 -7
  21. package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
  22. package/dist/src/rtc/types.d.ts +7 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
  25. package/dist/src/stats/rtc/types.d.ts +0 -11
  26. package/package.json +1 -1
  27. package/src/Call.ts +110 -9
  28. package/src/StreamVideoClient.ts +8 -0
  29. package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
  30. package/src/coordinator/connection/types.ts +1 -1
  31. package/src/devices/DeviceManager.ts +26 -1
  32. package/src/devices/DeviceManagerState.ts +24 -0
  33. package/src/devices/MicrophoneManager.ts +79 -17
  34. package/src/devices/SpeakerManager.ts +1 -1
  35. package/src/devices/__tests__/CameraManager.test.ts +50 -1
  36. package/src/devices/__tests__/DeviceManager.test.ts +29 -0
  37. package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
  38. package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
  39. package/src/devices/__tests__/devices.test.ts +10 -14
  40. package/src/devices/__tests__/mediaStreamTestHelpers.ts +11 -6
  41. package/src/devices/__tests__/mocks.ts +1 -0
  42. package/src/devices/devices.ts +7 -1
  43. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
  44. package/src/helpers/AudioBindingsWatchdog.ts +91 -35
  45. package/src/helpers/DynascaleManager.ts +86 -11
  46. package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
  47. package/src/helpers/__tests__/DynascaleManager.test.ts +253 -13
  48. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
  49. package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
  50. package/src/helpers/mediaInterruptions.ts +37 -0
  51. package/src/reporting/ClientEventReporter.ts +1 -1
  52. package/src/ringing/RingStatePoller.ts +1 -1
  53. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  54. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  55. package/src/ringing/reconcileRingState.ts +18 -10
  56. package/src/rtc/BasePeerConnection.ts +47 -9
  57. package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
  58. package/src/rtc/types.ts +8 -0
  59. package/src/stats/SfuStatsReporter.ts +19 -93
  60. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  61. package/src/stats/rtc/StatsTracer.ts +6 -6
  62. package/src/stats/rtc/types.ts +0 -13
@@ -10,12 +10,43 @@ const toBindingKey = (
10
10
  trackType: AudioTrackType = 'audioTrack',
11
11
  ) => `${sessionId}/${trackType}`;
12
12
 
13
+ type AudioElementState = {
14
+ paused: boolean;
15
+ muted: boolean;
16
+ volume: number;
17
+ readyState: number;
18
+ sinkId?: string;
19
+ track: { enabled: boolean; muted: boolean; readyState: string } | null;
20
+ };
21
+
22
+ const toElementState = (element: HTMLAudioElement): AudioElementState => {
23
+ const stream = element.srcObject as MediaStream | null;
24
+ const [track] = stream?.getAudioTracks?.() ?? [];
25
+ return {
26
+ paused: element.paused,
27
+ muted: element.muted,
28
+ volume: Math.round(element.volume * 100) / 100,
29
+ readyState: element.readyState,
30
+ sinkId: element.sinkId,
31
+ track: track
32
+ ? {
33
+ enabled: track.enabled,
34
+ muted: track.muted,
35
+ readyState: track.readyState,
36
+ }
37
+ : null,
38
+ };
39
+ };
40
+
13
41
  /**
14
42
  * Tracks audio element bindings and periodically warns about
15
43
  * remote participants whose audio streams have no bound element.
44
+ * On the same tick, it traces the playback state of the bound elements,
45
+ * limited to the bindings whose state changed since the previous trace.
16
46
  */
17
47
  export class AudioBindingsWatchdog {
18
48
  private bindings = new Map<string, HTMLAudioElement>();
49
+ private tracedStates = new Map<string, string>();
19
50
  private enabled = true;
20
51
  private watchdogInterval?: NodeJS.Timeout;
21
52
  private readonly unsubscribeCallingState: () => void;
@@ -30,7 +61,6 @@ export class AudioBindingsWatchdog {
30
61
  this.unsubscribeCallingState = createSubscription(
31
62
  state.callingState$,
32
63
  (callingState) => {
33
- if (!this.enabled) return;
34
64
  if (callingState !== CallingState.JOINED) {
35
65
  this.stop();
36
66
  } else {
@@ -68,14 +98,14 @@ export class AudioBindingsWatchdog {
68
98
  };
69
99
 
70
100
  /**
71
- * Enables or disables the watchdog.
72
- * When disabled, the periodic check stops but bindings are still tracked.
101
+ * Enables or disables the dangling binding warnings.
102
+ * Bindings are still tracked and their playback state is still traced.
73
103
  */
74
104
  setEnabled = (enabled: boolean) => {
75
105
  this.enabled = enabled;
76
106
  if (enabled) {
77
107
  this.start();
78
- } else {
108
+ } else if (this.state.callingState !== CallingState.JOINED) {
79
109
  this.stop();
80
110
  }
81
111
  };
@@ -86,45 +116,71 @@ export class AudioBindingsWatchdog {
86
116
  dispose = () => {
87
117
  this.stop();
88
118
  this.bindings.clear();
119
+ this.tracedStates.clear();
89
120
  this.unsubscribeCallingState();
90
121
  };
91
122
 
92
123
  private start = () => {
93
124
  clearInterval(this.watchdogInterval);
94
125
  this.watchdogInterval = setInterval(() => {
95
- const danglingUserIds: string[] = [];
96
- for (const p of this.state.participants) {
97
- if (p.isLocalParticipant) continue;
98
- const {
99
- audioStream,
100
- screenShareAudioStream,
101
- sessionId,
102
- userId,
103
- publishedTracks,
104
- } = p;
105
- if (
106
- audioStream &&
107
- publishedTracks.includes(TrackType.AUDIO) &&
108
- !this.bindings.has(toBindingKey(sessionId))
109
- ) {
110
- danglingUserIds.push(userId);
111
- }
112
- if (
113
- screenShareAudioStream &&
114
- publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
115
- !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))
116
- ) {
117
- danglingUserIds.push(userId);
118
- }
126
+ if (this.enabled) this.warnAboutDanglingBindings();
127
+ this.traceBindingStates();
128
+ }, 3000);
129
+ };
130
+
131
+ private warnAboutDanglingBindings = () => {
132
+ const danglingUserIds: string[] = [];
133
+ for (const p of this.state.participants) {
134
+ if (p.isLocalParticipant) continue;
135
+ const {
136
+ audioStream,
137
+ screenShareAudioStream,
138
+ sessionId,
139
+ userId,
140
+ publishedTracks,
141
+ } = p;
142
+ if (
143
+ audioStream &&
144
+ publishedTracks.includes(TrackType.AUDIO) &&
145
+ !this.bindings.has(toBindingKey(sessionId))
146
+ ) {
147
+ danglingUserIds.push(userId);
119
148
  }
120
- if (danglingUserIds.length > 0) {
121
- const key = 'audioBinding.danglingWarning';
122
- this.tracer.traceOnce(key, key, danglingUserIds);
123
- this.logger.warn(
124
- `Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`,
125
- );
149
+ if (
150
+ screenShareAudioStream &&
151
+ publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
152
+ !this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))
153
+ ) {
154
+ danglingUserIds.push(userId);
126
155
  }
127
- }, 3000);
156
+ }
157
+ if (danglingUserIds.length > 0) {
158
+ const key = 'audioBinding.danglingWarning';
159
+ this.tracer.traceOnce(key, key, danglingUserIds);
160
+ this.logger.warn(
161
+ `Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`,
162
+ );
163
+ }
164
+ };
165
+
166
+ private traceBindingStates = () => {
167
+ const changes: Record<string, AudioElementState | null> = {};
168
+ let hasChanges = false;
169
+ for (const [key, element] of this.bindings) {
170
+ const state = toElementState(element);
171
+ const serialized = JSON.stringify(state);
172
+ if (this.tracedStates.get(key) === serialized) continue;
173
+ this.tracedStates.set(key, serialized);
174
+ changes[key] = state;
175
+ hasChanges = true;
176
+ }
177
+ for (const key of this.tracedStates.keys()) {
178
+ if (this.bindings.has(key)) continue;
179
+ this.tracedStates.delete(key);
180
+ changes[key] = null;
181
+ hasChanges = true;
182
+ }
183
+ if (hasChanges) this.tracer.trace('audioBinding.state', changes);
128
184
  };
129
185
 
130
186
  private stop = () => {
@@ -14,7 +14,11 @@ import {
14
14
  takeWhile,
15
15
  } from 'rxjs';
16
16
  import type { BlockedAudioTracker } from './BlockedAudioTracker';
17
- import { MediaPlaybackWatchdog } from './MediaPlaybackWatchdog';
17
+ import {
18
+ MediaPlaybackWatchdog,
19
+ type MediaPlaybackWatchdogOptions,
20
+ } from './MediaPlaybackWatchdog';
21
+ import { watchMediaInterruptions } from './mediaInterruptions';
18
22
  import type { TrackSubscriptionManager } from './TrackSubscriptionManager';
19
23
  import { isFirefox, isSafari } from './browsers';
20
24
  import { hasScreenShare, hasVideo } from './participantUtils';
@@ -37,6 +41,9 @@ export class DynascaleManager {
37
41
  private readonly tracer: Tracer;
38
42
  private useWebAudio = false;
39
43
  private audioContext: AudioContext | undefined;
44
+ private playbackWatchdogs = new Set<MediaPlaybackWatchdog>();
45
+ private stopWatchingInterruptions: (() => void) | undefined;
46
+ private stopTracingAudioSession: (() => void) | undefined;
40
47
 
41
48
  private trackSubscriptionManager: TrackSubscriptionManager;
42
49
  private blockedAudioTracker: BlockedAudioTracker;
@@ -62,6 +69,10 @@ export class DynascaleManager {
62
69
  * Closes the audio context if it was created.
63
70
  */
64
71
  dispose = async () => {
72
+ this.stopWatchingInterruptions?.();
73
+ this.stopWatchingInterruptions = undefined;
74
+ this.stopTracingAudioSession?.();
75
+ this.stopTracingAudioSession = undefined;
65
76
  const context = this.audioContext;
66
77
  if (context && context.state !== 'closed') {
67
78
  document.removeEventListener('click', this.resumeAudioContext);
@@ -244,10 +255,12 @@ export class DynascaleManager {
244
255
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
245
256
  videoElement.muted = true;
246
257
 
247
- const playbackWatchdog = new MediaPlaybackWatchdog({
258
+ const playbackWatchdog = this.createPlaybackWatchdog({
248
259
  element: videoElement,
249
260
  kind: 'video',
250
261
  tracer: this.tracer,
262
+ sessionId,
263
+ trackType,
251
264
  });
252
265
 
253
266
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
@@ -275,7 +288,7 @@ export class DynascaleManager {
275
288
  publishedTracksSubscription?.unsubscribe();
276
289
  streamSubscription.unsubscribe();
277
290
  resizeObserver?.disconnect();
278
- playbackWatchdog.dispose();
291
+ this.disposePlaybackWatchdog(playbackWatchdog);
279
292
  };
280
293
  };
281
294
 
@@ -297,6 +310,7 @@ export class DynascaleManager {
297
310
  ) => {
298
311
  const participant = this.callState.findParticipantBySessionId(sessionId);
299
312
  if (!participant || participant.isLocalParticipant) return;
313
+ this.traceAudioSessionState();
300
314
 
301
315
  const participant$ = this.callState.participants$.pipe(
302
316
  map((ps) => ps.find((p) => p.sessionId === sessionId)),
@@ -309,7 +323,7 @@ export class DynascaleManager {
309
323
  deviceId: string,
310
324
  audioContext: AudioContext | undefined,
311
325
  ) => {
312
- if (!deviceId) return;
326
+ if (!deviceId || !this.speaker.state.isDeviceSelectionSupported) return;
313
327
  if ('setSinkId' in audioElement) {
314
328
  audioElement.setSinkId(deviceId).catch((e) => {
315
329
  this.logger.warn(`Can't to set AudioElement sinkId`, e);
@@ -327,8 +341,10 @@ export class DynascaleManager {
327
341
  let sourceNode: MediaStreamAudioSourceNode | undefined = undefined;
328
342
  let gainNode: GainNode | undefined = undefined;
329
343
  let audioWatchdog: MediaPlaybackWatchdog | undefined = undefined;
344
+ let blockedByGiveUp = false;
330
345
 
331
346
  const clearBlockedAudio = () => {
347
+ blockedByGiveUp = false;
332
348
  if (!this.blockedAudioTracker.isBlocked(audioElement)) return;
333
349
  this.blockedAudioTracker.markBlocked(audioElement, false);
334
350
  };
@@ -336,15 +352,18 @@ export class DynascaleManager {
336
352
 
337
353
  const isAudioTrack = trackType === 'audioTrack';
338
354
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
355
+ let sourceChangeTimer: ReturnType<typeof setTimeout> | undefined;
339
356
  const updateMediaStreamSubscription = participant$
340
357
  .pipe(distinctUntilKeyChanged(trackKey))
341
358
  .subscribe((p) => {
342
359
  const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
343
360
  if (audioElement.srcObject === source) return;
344
361
 
345
- setTimeout(() => {
362
+ clearTimeout(sourceChangeTimer);
363
+ sourceChangeTimer = setTimeout(() => {
364
+ sourceChangeTimer = undefined;
346
365
  audioElement.srcObject = source ?? null;
347
- audioWatchdog?.dispose();
366
+ this.disposePlaybackWatchdog(audioWatchdog);
348
367
  audioWatchdog = undefined;
349
368
  if (!source) {
350
369
  clearBlockedAudio();
@@ -375,6 +394,7 @@ export class DynascaleManager {
375
394
  this.tracer.trace('audioPlaybackError', e.message);
376
395
  if (e.name === 'NotAllowedError') {
377
396
  this.tracer.trace('audioPlaybackBlocked', null);
397
+ blockedByGiveUp = false;
378
398
  this.blockedAudioTracker.markBlocked(
379
399
  audioElement,
380
400
  true,
@@ -383,11 +403,25 @@ export class DynascaleManager {
383
403
  }
384
404
  this.logger.warn(`Failed to play audio stream`, e);
385
405
  });
386
- audioWatchdog = new MediaPlaybackWatchdog({
406
+ audioWatchdog = this.createPlaybackWatchdog({
387
407
  element: audioElement,
388
408
  kind: 'audio',
389
409
  tracer: this.tracer,
390
- isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
410
+ sessionId,
411
+ trackType,
412
+ // only an autoplay-policy block needs a user gesture; a give-up
413
+ // block is for the UI and must not stop a re-armed cycle
414
+ isBlocked: () =>
415
+ !blockedByGiveUp &&
416
+ this.blockedAudioTracker.isBlocked(audioElement),
417
+ onGiveUp: () => {
418
+ blockedByGiveUp = true;
419
+ this.blockedAudioTracker.markBlocked(
420
+ audioElement,
421
+ true,
422
+ sessionId,
423
+ );
424
+ },
391
425
  });
392
426
  }
393
427
 
@@ -396,7 +430,7 @@ export class DynascaleManager {
396
430
  });
397
431
  });
398
432
 
399
- const sinkIdSubscription = !('setSinkId' in audioElement)
433
+ const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
400
434
  ? null
401
435
  : this.speaker.state.selectedDevice$.subscribe((deviceId) => {
402
436
  const audioContext = this.getOrCreateAudioContext();
@@ -420,14 +454,56 @@ export class DynascaleManager {
420
454
  sinkIdSubscription?.unsubscribe();
421
455
  volumeSubscription.unsubscribe();
422
456
  updateMediaStreamSubscription.unsubscribe();
457
+ clearTimeout(sourceChangeTimer);
423
458
  audioElement.srcObject = null;
424
459
  sourceNode?.disconnect();
425
460
  gainNode?.disconnect();
426
- audioWatchdog?.dispose();
461
+ this.disposePlaybackWatchdog(audioWatchdog);
427
462
  audioWatchdog = undefined;
428
463
  };
429
464
  };
430
465
 
466
+ /**
467
+ * Creates a playback watchdog that is re-armed whenever a media
468
+ * interruption (backgrounding, phone call, Siri) ends.
469
+ */
470
+ private createPlaybackWatchdog = (opts: MediaPlaybackWatchdogOptions) => {
471
+ const watchdog = new MediaPlaybackWatchdog(opts);
472
+ this.playbackWatchdogs.add(watchdog);
473
+ this.stopWatchingInterruptions ??= watchMediaInterruptions(
474
+ this.rearmPlayback,
475
+ );
476
+ return watchdog;
477
+ };
478
+
479
+ private disposePlaybackWatchdog = (watchdog?: MediaPlaybackWatchdog) => {
480
+ if (!watchdog) return;
481
+ watchdog.dispose();
482
+ this.playbackWatchdogs.delete(watchdog);
483
+ };
484
+
485
+ private rearmPlayback = () => {
486
+ this.tracer.trace('mediaPlayback.rearm', this.playbackWatchdogs.size);
487
+ this.playbackWatchdogs.forEach((watchdog) => watchdog.rearm());
488
+ };
489
+
490
+ /**
491
+ * Traces the WebKit audio session state (initial value and changes) in
492
+ * both playback modes, as it explains most iOS playback interruptions.
493
+ */
494
+ private traceAudioSessionState = () => {
495
+ const audioSession = navigator.audioSession;
496
+ if (!audioSession || this.stopTracingAudioSession) return;
497
+ const trace = () => {
498
+ this.tracer.trace('audioSession.state', audioSession.state);
499
+ };
500
+ trace();
501
+ audioSession.addEventListener('statechange', trace);
502
+ this.stopTracingAudioSession = () => {
503
+ audioSession.removeEventListener('statechange', trace);
504
+ };
505
+ };
506
+
431
507
  private getOrCreateAudioContext = (): AudioContext | undefined => {
432
508
  if (!this.useWebAudio) return;
433
509
  if (this.audioContext) return this.audioContext;
@@ -450,7 +526,6 @@ export class DynascaleManager {
450
526
 
451
527
  let isSessionInterrupted = false;
452
528
  audioSession.addEventListener('statechange', () => {
453
- this.tracer.trace('audioSession.state', audioSession.state);
454
529
  if (audioSession.state === 'interrupted') {
455
530
  isSessionInterrupted = true;
456
531
  } else if (isSessionInterrupted) {
@@ -8,7 +8,10 @@ export type MediaPlaybackWatchdogOptions = {
8
8
  element: HTMLMediaElement;
9
9
  kind: MediaKind;
10
10
  tracer: Tracer;
11
+ sessionId?: string;
12
+ trackType?: string;
11
13
  isBlocked?: () => boolean;
14
+ onGiveUp?: () => void;
12
15
  };
13
16
 
14
17
  /**
@@ -18,7 +21,13 @@ export type MediaPlaybackWatchdogOptions = {
18
21
  export class MediaPlaybackWatchdog {
19
22
  private logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
20
23
  private readonly kind: MediaKind;
24
+ private readonly traceInfo: {
25
+ kind: MediaKind;
26
+ sessionId?: string;
27
+ trackType?: string;
28
+ };
21
29
  private readonly isBlocked: () => boolean;
30
+ private readonly onGiveUp: () => void;
22
31
  private element: HTMLMediaElement;
23
32
  private tracer: Tracer;
24
33
  private controller = new AbortController();
@@ -31,8 +40,14 @@ export class MediaPlaybackWatchdog {
31
40
  constructor(opts: MediaPlaybackWatchdogOptions) {
32
41
  this.element = opts.element;
33
42
  this.kind = opts.kind;
43
+ this.traceInfo = {
44
+ kind: opts.kind,
45
+ sessionId: opts.sessionId,
46
+ trackType: opts.trackType,
47
+ };
34
48
  this.tracer = opts.tracer;
35
49
  this.isBlocked = opts.isBlocked ?? (() => false);
50
+ this.onGiveUp = opts.onGiveUp ?? (() => {});
36
51
  this.attach();
37
52
  }
38
53
 
@@ -51,6 +66,22 @@ export class MediaPlaybackWatchdog {
51
66
  this.clearTimers();
52
67
  };
53
68
 
69
+ /**
70
+ * Starts a fresh recovery cycle, e.g. after a media interruption ended.
71
+ * Unlike a regular pause-triggered recovery, it also tries elements that
72
+ * were skipped as blocked or not ready, since those conditions may have
73
+ * cleared together with the interruption.
74
+ */
75
+ rearm = () => {
76
+ if (this.disposed) return;
77
+ this.clearTimers();
78
+ this.attempt = 0;
79
+ this.stopped = false;
80
+ const { paused, srcObject, ended } = this.element;
81
+ if (!paused || !srcObject || ended) return;
82
+ this.pendingTimer = setTimeout(this.attemptPlay, 0);
83
+ };
84
+
54
85
  private clearTimers = () => {
55
86
  if (this.pendingTimer) clearTimeout(this.pendingTimer);
56
87
  this.pendingTimer = undefined;
@@ -69,17 +100,34 @@ export class MediaPlaybackWatchdog {
69
100
  this.settleTimer = undefined;
70
101
  if (this.element.paused) return;
71
102
  this.tracer.trace('mediaPlayback.recover.success', {
72
- kind: this.kind,
103
+ ...this.traceInfo,
73
104
  attempts: this.attempt,
74
105
  });
75
106
  this.attempt = 0;
76
107
  this.stopped = false;
77
108
  };
78
109
 
110
+ /**
111
+ * Media elements backed by a MediaStream fire `suspend` routinely (after
112
+ * the initial load, on source swaps, on layer switches), so a `suspend`
113
+ * that needs no recovery is ignored without a trace. An explicit `pause`
114
+ * is rare and always traced, along with the reason recovery was skipped.
115
+ */
79
116
  private onPauseOrSuspend = (event: Event) => {
80
- if (this.disposed || this.stopped) return;
117
+ if (this.disposed || this.stopped || this.pendingTimer) return;
118
+ const skipReason = this.computeSkipReason();
119
+ if (skipReason) {
120
+ if (event.type === 'pause') {
121
+ this.tracer.trace('mediaPlayback.paused', {
122
+ ...this.traceInfo,
123
+ reason: event.type,
124
+ skipped: skipReason,
125
+ });
126
+ }
127
+ return;
128
+ }
81
129
  this.tracer.trace('mediaPlayback.paused', {
82
- kind: this.kind,
130
+ ...this.traceInfo,
83
131
  reason: event.type,
84
132
  });
85
133
  if (this.settleTimer) clearTimeout(this.settleTimer);
@@ -92,7 +140,7 @@ export class MediaPlaybackWatchdog {
92
140
  const skipReason = this.computeSkipReason();
93
141
  if (skipReason) {
94
142
  this.tracer.trace('mediaPlayback.recover.skipped', {
95
- kind: this.kind,
143
+ ...this.traceInfo,
96
144
  reason: skipReason,
97
145
  });
98
146
  return;
@@ -102,9 +150,10 @@ export class MediaPlaybackWatchdog {
102
150
  this.stopped = true;
103
151
  this.clearTimers();
104
152
  this.tracer.trace('mediaPlayback.recover.giveUp', {
105
- kind: this.kind,
153
+ ...this.traceInfo,
106
154
  attempts: this.attempt,
107
155
  });
156
+ this.onGiveUp();
108
157
  return;
109
158
  }
110
159
  const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
@@ -127,7 +176,7 @@ export class MediaPlaybackWatchdog {
127
176
  if (this.disposed) return;
128
177
  this.attempt += 1;
129
178
  this.tracer.trace('mediaPlayback.recover.attempt', {
130
- kind: this.kind,
179
+ ...this.traceInfo,
131
180
  attempt: this.attempt,
132
181
  });
133
182
  try {