@stream-io/video-client 1.61.0 → 1.61.2

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 (47) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.browser.es.js +292 -154
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +292 -154
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +292 -154
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/coordinator/connection/types.d.ts +1 -1
  9. package/dist/src/devices/DeviceManager.d.ts +6 -0
  10. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
  11. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
  12. package/dist/src/helpers/DynascaleManager.d.ts +15 -0
  13. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
  14. package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
  15. package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
  16. package/dist/src/ringing/reconcileRingState.d.ts +8 -7
  17. package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
  18. package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
  19. package/dist/src/stats/rtc/types.d.ts +0 -11
  20. package/package.json +1 -1
  21. package/src/Call.ts +0 -3
  22. package/src/StreamVideoClient.ts +8 -0
  23. package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
  24. package/src/coordinator/connection/types.ts +1 -1
  25. package/src/devices/DeviceManager.ts +20 -1
  26. package/src/devices/__tests__/CameraManager.test.ts +50 -1
  27. package/src/devices/__tests__/mediaStreamTestHelpers.ts +10 -6
  28. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
  29. package/src/helpers/AudioBindingsWatchdog.ts +91 -35
  30. package/src/helpers/DynascaleManager.ts +84 -9
  31. package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
  32. package/src/helpers/__tests__/DynascaleManager.test.ts +247 -9
  33. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
  34. package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
  35. package/src/helpers/mediaInterruptions.ts +37 -0
  36. package/src/reporting/ClientEventReporter.ts +8 -3
  37. package/src/reporting/__tests__/ClientEventReporter.test.ts +2 -2
  38. package/src/ringing/RingStatePoller.ts +1 -1
  39. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  40. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  41. package/src/ringing/reconcileRingState.ts +18 -10
  42. package/src/rtc/BasePeerConnection.ts +6 -1
  43. package/src/rtc/__tests__/Publisher.test.ts +50 -0
  44. package/src/stats/SfuStatsReporter.ts +19 -93
  45. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  46. package/src/stats/rtc/StatsTracer.ts +6 -6
  47. package/src/stats/rtc/types.ts +0 -13
@@ -812,6 +812,247 @@ describe('DynascaleManager', () => {
812
812
  cleanup?.();
813
813
  });
814
814
 
815
+ describe('audio: playback recovery', () => {
816
+ const bindPausedAudioElement = (
817
+ opts: { blockedByAutoplay?: boolean } = {},
818
+ ) => {
819
+ const audioElement = document.createElement('audio');
820
+ Object.defineProperties(audioElement, {
821
+ srcObject: { writable: true },
822
+ paused: { writable: true, configurable: true },
823
+ readyState: { writable: true, configurable: true },
824
+ });
825
+ // @ts-expect-error simulate paused, ready-to-play element
826
+ audioElement.paused = true;
827
+ // @ts-expect-error simulate paused, ready-to-play element
828
+ audioElement.readyState = 4;
829
+ const play = vi.spyOn(audioElement, 'play');
830
+ if (opts.blockedByAutoplay) {
831
+ play.mockRejectedValue(new DOMException('', 'NotAllowedError'));
832
+ }
833
+
834
+ // @ts-expect-error incomplete data
835
+ call.state.updateOrAddParticipant('session-id', {
836
+ userId: 'user-id',
837
+ sessionId: 'session-id',
838
+ publishedTracks: [],
839
+ });
840
+ const cleanup = dynascaleManager.bindAudioElement(
841
+ audioElement,
842
+ 'session-id',
843
+ 'audioTrack',
844
+ );
845
+ call.state.updateParticipant('session-id', {
846
+ audioStream: new MediaStream(),
847
+ });
848
+ vi.runAllTimers();
849
+ return { audioElement, play, cleanup };
850
+ };
851
+
852
+ const setVisibility = (state: DocumentVisibilityState) => {
853
+ Object.defineProperty(document, 'visibilityState', {
854
+ configurable: true,
855
+ get: () => state,
856
+ });
857
+ document.dispatchEvent(new Event('visibilitychange'));
858
+ };
859
+
860
+ afterEach(() => {
861
+ // @ts-expect-error restore the prototype getter
862
+ delete document.visibilityState;
863
+ });
864
+
865
+ it('marks the element as blocked once recovery gives up', async () => {
866
+ vi.useFakeTimers();
867
+ setVisibility('visible');
868
+ const { audioElement, play, cleanup } = bindPausedAudioElement();
869
+ play.mockRejectedValue(new Error('playback failed'));
870
+ await vi.advanceTimersByTimeAsync(0);
871
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(false);
872
+
873
+ audioElement.dispatchEvent(new Event('pause'));
874
+ for (let i = 0; i < 20; i++) {
875
+ await vi.advanceTimersByTimeAsync(6000);
876
+ }
877
+
878
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
879
+ expect(
880
+ getCurrentValue(call.blockedAudioTracker.blockedSessionIds$),
881
+ ).toEqual(['session-id']);
882
+
883
+ cleanup?.();
884
+ });
885
+
886
+ it('keeps retrying when the first re-armed attempt after a give-up fails', async () => {
887
+ vi.useFakeTimers();
888
+ setVisibility('visible');
889
+ const { audioElement, play, cleanup } = bindPausedAudioElement();
890
+ play.mockRejectedValue(new Error('playback failed'));
891
+ await vi.advanceTimersByTimeAsync(0);
892
+ audioElement.dispatchEvent(new Event('pause'));
893
+ for (let i = 0; i < 20; i++) {
894
+ await vi.advanceTimersByTimeAsync(6000);
895
+ }
896
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
897
+ const callsBeforeRearm = play.mock.calls.length;
898
+
899
+ play.mockRejectedValueOnce(new Error('still interrupted'));
900
+ play.mockResolvedValue();
901
+ setVisibility('hidden');
902
+ setVisibility('visible');
903
+ await vi.advanceTimersByTimeAsync(6000);
904
+
905
+ expect(play.mock.calls.length).toBe(callsBeforeRearm + 2);
906
+ // the prompt stays up until audio actually plays
907
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
908
+ audioElement.dispatchEvent(new Event('playing'));
909
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(false);
910
+
911
+ cleanup?.();
912
+ });
913
+
914
+ it('tries an autoplay-blocked element only once per re-arm', async () => {
915
+ vi.useFakeTimers();
916
+ setVisibility('visible');
917
+ const { audioElement, play, cleanup } = bindPausedAudioElement({
918
+ blockedByAutoplay: true,
919
+ });
920
+ await vi.advanceTimersByTimeAsync(0);
921
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
922
+ const callsBeforeRearm = play.mock.calls.length;
923
+
924
+ setVisibility('hidden');
925
+ setVisibility('visible');
926
+ for (let i = 0; i < 20; i++) {
927
+ await vi.advanceTimersByTimeAsync(6000);
928
+ }
929
+
930
+ expect(play.mock.calls.length).toBe(callsBeforeRearm + 1);
931
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
932
+
933
+ cleanup?.();
934
+ });
935
+
936
+ it('re-plays a paused element when the page becomes visible again', async () => {
937
+ vi.useFakeTimers();
938
+ setVisibility('visible');
939
+ const { play, cleanup } = bindPausedAudioElement();
940
+ play.mockResolvedValue();
941
+ await vi.advanceTimersByTimeAsync(0);
942
+
943
+ setVisibility('hidden');
944
+ await vi.advanceTimersByTimeAsync(0);
945
+ const callsWhileHidden = play.mock.calls.length;
946
+
947
+ setVisibility('visible');
948
+ await vi.advanceTimersByTimeAsync(0);
949
+
950
+ expect(play.mock.calls.length).toBe(callsWhileHidden + 1);
951
+
952
+ cleanup?.();
953
+ });
954
+
955
+ it('does not apply a queued source change after the element is unbound', async () => {
956
+ vi.useFakeTimers();
957
+ setVisibility('visible');
958
+ const audioElement = document.createElement('audio');
959
+ Object.defineProperties(audioElement, {
960
+ srcObject: { writable: true },
961
+ paused: { writable: true, configurable: true },
962
+ readyState: { writable: true, configurable: true },
963
+ });
964
+ // @ts-expect-error simulate paused, ready-to-play element
965
+ audioElement.paused = true;
966
+ // @ts-expect-error simulate paused, ready-to-play element
967
+ audioElement.readyState = 4;
968
+ const play = vi.spyOn(audioElement, 'play').mockResolvedValue();
969
+
970
+ // @ts-expect-error incomplete data
971
+ call.state.updateOrAddParticipant('session-id', {
972
+ userId: 'user-id',
973
+ sessionId: 'session-id',
974
+ publishedTracks: [],
975
+ });
976
+ const cleanup = dynascaleManager.bindAudioElement(
977
+ audioElement,
978
+ 'session-id',
979
+ 'audioTrack',
980
+ );
981
+ call.state.updateParticipant('session-id', {
982
+ audioStream: new MediaStream(),
983
+ });
984
+ cleanup?.();
985
+ await vi.runAllTimersAsync();
986
+
987
+ expect(audioElement.srcObject).toBeNull();
988
+ expect(play).not.toHaveBeenCalled();
989
+
990
+ setVisibility('hidden');
991
+ setVisibility('visible');
992
+ await vi.advanceTimersByTimeAsync(0);
993
+ expect(play).not.toHaveBeenCalled();
994
+ });
995
+
996
+ it('stops re-arming after the element is unbound', async () => {
997
+ vi.useFakeTimers();
998
+ setVisibility('visible');
999
+ const { play, cleanup } = bindPausedAudioElement();
1000
+ play.mockResolvedValue();
1001
+ await vi.advanceTimersByTimeAsync(0);
1002
+ cleanup?.();
1003
+ const callsAfterCleanup = play.mock.calls.length;
1004
+
1005
+ setVisibility('hidden');
1006
+ setVisibility('visible');
1007
+ await vi.advanceTimersByTimeAsync(0);
1008
+
1009
+ expect(play.mock.calls.length).toBe(callsAfterCleanup);
1010
+ });
1011
+ });
1012
+
1013
+ it('audio: registers one audioSession listener and removes it on dispose', async () => {
1014
+ vi.useFakeTimers();
1015
+ const session = new EventTarget();
1016
+ Object.defineProperty(session, 'state', { value: 'active' });
1017
+ Object.defineProperty(navigator, 'audioSession', {
1018
+ configurable: true,
1019
+ value: session,
1020
+ });
1021
+ const addListener = vi.spyOn(session, 'addEventListener');
1022
+ const removeListener = vi.spyOn(session, 'removeEventListener');
1023
+
1024
+ try {
1025
+ // @ts-expect-error incomplete data
1026
+ call.state.updateOrAddParticipant('session-id', {
1027
+ userId: 'user-id',
1028
+ sessionId: 'session-id',
1029
+ publishedTracks: [],
1030
+ });
1031
+ const cleanups = [
1032
+ dynascaleManager.bindAudioElement(
1033
+ document.createElement('audio'),
1034
+ 'session-id',
1035
+ 'audioTrack',
1036
+ ),
1037
+ dynascaleManager.bindAudioElement(
1038
+ document.createElement('audio'),
1039
+ 'session-id',
1040
+ 'screenShareAudioTrack',
1041
+ ),
1042
+ ];
1043
+ expect(addListener).toHaveBeenCalledTimes(1);
1044
+ const [, listener] = addListener.mock.calls[0];
1045
+
1046
+ await dynascaleManager.dispose();
1047
+ expect(removeListener).toHaveBeenCalledWith('statechange', listener);
1048
+
1049
+ cleanups.forEach((cleanup) => cleanup?.());
1050
+ } finally {
1051
+ // @ts-expect-error remove the test double
1052
+ delete navigator.audioSession;
1053
+ }
1054
+ });
1055
+
815
1056
  it('audio: no watchdog attached when useWebAudio is true', async () => {
816
1057
  globalThis._isSafari = true;
817
1058
  dynascaleManager.setUseWebAudio(true);
@@ -867,9 +1108,9 @@ describe('DynascaleManager', () => {
867
1108
  audioElement.paused = true;
868
1109
  // @ts-expect-error simulate paused, ready-to-play element
869
1110
  audioElement.readyState = 4;
870
- vi.spyOn(audioElement, 'play').mockRejectedValue(
871
- new DOMException('', 'NotAllowedError'),
872
- );
1111
+ const play = vi
1112
+ .spyOn(audioElement, 'play')
1113
+ .mockRejectedValue(new DOMException('', 'NotAllowedError'));
873
1114
 
874
1115
  // @ts-expect-error incomplete data
875
1116
  call.state.updateOrAddParticipant('session-id', {
@@ -892,14 +1133,11 @@ describe('DynascaleManager', () => {
892
1133
 
893
1134
  expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
894
1135
 
895
- const traceSpy = vi.spyOn(call.tracer, 'trace');
1136
+ play.mockClear();
896
1137
  audioElement.dispatchEvent(new Event('pause'));
897
- await vi.advanceTimersByTimeAsync(0);
1138
+ await vi.advanceTimersByTimeAsync(60_000);
898
1139
 
899
- expect(traceSpy).toHaveBeenCalledWith('mediaPlayback.recover.skipped', {
900
- kind: 'audio',
901
- reason: 'blocked',
902
- });
1140
+ expect(play).not.toHaveBeenCalled();
903
1141
 
904
1142
  cleanup?.();
905
1143
  });
@@ -59,6 +59,7 @@ type SetupOpts = {
59
59
  kind?: 'audio' | 'video';
60
60
  state?: FakeMediaState;
61
61
  isBlocked?: () => boolean;
62
+ onGiveUp?: () => void;
62
63
  };
63
64
 
64
65
  describe('MediaPlaybackWatchdog', () => {
@@ -77,6 +78,7 @@ describe('MediaPlaybackWatchdog', () => {
77
78
  kind,
78
79
  tracer,
79
80
  isBlocked: opts.isBlocked,
81
+ onGiveUp: opts.onGiveUp,
80
82
  });
81
83
  };
82
84
 
@@ -164,6 +166,24 @@ describe('MediaPlaybackWatchdog', () => {
164
166
  expect(play).toHaveBeenCalledTimes(2);
165
167
  });
166
168
 
169
+ it('keeps the settle window running through a routine suspend', async () => {
170
+ el.dispatchEvent(new Event('pause'));
171
+ await vi.advanceTimersByTimeAsync(0);
172
+ expect(play).toHaveBeenCalledTimes(1);
173
+
174
+ setPaused(el, false);
175
+ el.dispatchEvent(new Event('playing'));
176
+ await vi.advanceTimersByTimeAsync(500);
177
+ el.dispatchEvent(new Event('suspend'));
178
+ await vi.advanceTimersByTimeAsync(500);
179
+
180
+ // settled, so the next pause recovers immediately instead of backing off
181
+ setPaused(el, true);
182
+ el.dispatchEvent(new Event('pause'));
183
+ await vi.advanceTimersByTimeAsync(0);
184
+ expect(play).toHaveBeenCalledTimes(2);
185
+ });
186
+
167
187
  it('stops fighting an element that re-pauses after every successful play', async () => {
168
188
  setup({ kind: 'video' });
169
189
  // Safari re-pauses the element right after play() resolves. This used to
@@ -297,6 +317,126 @@ describe('MediaPlaybackWatchdog', () => {
297
317
  expect(play).not.toHaveBeenCalled();
298
318
  });
299
319
 
320
+ it('calls onGiveUp once when the attempts are exhausted', async () => {
321
+ const onGiveUp = vi.fn();
322
+ setup({ onGiveUp });
323
+ play.mockRejectedValue(new Error('nope'));
324
+
325
+ el.dispatchEvent(new Event('pause'));
326
+ for (let i = 0; i < 20; i++) {
327
+ await vi.advanceTimersByTimeAsync(6000);
328
+ }
329
+ el.dispatchEvent(new Event('pause'));
330
+ await vi.advanceTimersByTimeAsync(6000);
331
+
332
+ expect(play).toHaveBeenCalledTimes(10);
333
+ expect(onGiveUp).toHaveBeenCalledTimes(1);
334
+ });
335
+
336
+ it('does not call onGiveUp when playback recovers', async () => {
337
+ const onGiveUp = vi.fn();
338
+ setup({ onGiveUp });
339
+
340
+ el.dispatchEvent(new Event('pause'));
341
+ for (let i = 0; i < 20; i++) {
342
+ await vi.advanceTimersByTimeAsync(6000);
343
+ }
344
+
345
+ expect(play).toHaveBeenCalledTimes(1);
346
+ expect(onGiveUp).not.toHaveBeenCalled();
347
+ });
348
+
349
+ describe('rearm', () => {
350
+ it('restarts recovery with a fresh budget after giving up', async () => {
351
+ play.mockRejectedValue(new Error('nope'));
352
+ el.dispatchEvent(new Event('pause'));
353
+ for (let i = 0; i < 20; i++) {
354
+ await vi.advanceTimersByTimeAsync(6000);
355
+ }
356
+ expect(play).toHaveBeenCalledTimes(10);
357
+
358
+ watchdog.rearm();
359
+ await vi.advanceTimersByTimeAsync(0);
360
+ expect(play).toHaveBeenCalledTimes(11);
361
+
362
+ for (let i = 0; i < 20; i++) {
363
+ await vi.advanceTimersByTimeAsync(6000);
364
+ }
365
+ expect(play).toHaveBeenCalledTimes(20);
366
+ });
367
+
368
+ it('tries an element that regular recovery skips as blocked', async () => {
369
+ setup({ isBlocked: () => true });
370
+
371
+ el.dispatchEvent(new Event('pause'));
372
+ await vi.advanceTimersByTimeAsync(6000);
373
+ expect(play).not.toHaveBeenCalled();
374
+
375
+ watchdog.rearm();
376
+ await vi.advanceTimersByTimeAsync(0);
377
+ expect(play).toHaveBeenCalledTimes(1);
378
+ });
379
+
380
+ it('tries an element that regular recovery skips as not ready', async () => {
381
+ setup({ state: { readyState: 0 } });
382
+
383
+ watchdog.rearm();
384
+ await vi.advanceTimersByTimeAsync(0);
385
+
386
+ expect(play).toHaveBeenCalledTimes(1);
387
+ });
388
+
389
+ it('replaces a pending backoff retry with an immediate attempt', async () => {
390
+ play.mockRejectedValueOnce(new Error('fail-1'));
391
+ el.dispatchEvent(new Event('pause'));
392
+ await vi.advanceTimersByTimeAsync(0);
393
+ expect(play).toHaveBeenCalledTimes(1);
394
+
395
+ watchdog.rearm();
396
+ await vi.advanceTimersByTimeAsync(0);
397
+ expect(play).toHaveBeenCalledTimes(2);
398
+
399
+ await vi.advanceTimersByTimeAsync(6000);
400
+ expect(play).toHaveBeenCalledTimes(2);
401
+ });
402
+
403
+ it('does nothing when the element is playing', async () => {
404
+ setup({ state: { paused: false } });
405
+
406
+ watchdog.rearm();
407
+ await vi.advanceTimersByTimeAsync(6000);
408
+
409
+ expect(play).not.toHaveBeenCalled();
410
+ });
411
+
412
+ it('does nothing when the element has no source', async () => {
413
+ setup({ state: { srcObject: null } });
414
+
415
+ watchdog.rearm();
416
+ await vi.advanceTimersByTimeAsync(6000);
417
+
418
+ expect(play).not.toHaveBeenCalled();
419
+ });
420
+
421
+ it('does nothing when the element has ended', async () => {
422
+ setup({ state: { ended: true } });
423
+
424
+ watchdog.rearm();
425
+ await vi.advanceTimersByTimeAsync(6000);
426
+
427
+ expect(play).not.toHaveBeenCalled();
428
+ });
429
+
430
+ it('does nothing after dispose', async () => {
431
+ watchdog.dispose();
432
+
433
+ watchdog.rearm();
434
+ await vi.advanceTimersByTimeAsync(6000);
435
+
436
+ expect(play).not.toHaveBeenCalled();
437
+ });
438
+ });
439
+
300
440
  it('does not stack timers when pause fires multiple times before the first attempt', async () => {
301
441
  el.dispatchEvent(new Event('pause'));
302
442
  el.dispatchEvent(new Event('pause'));
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import {
7
+ isMediaInterrupted,
8
+ watchMediaInterruptions,
9
+ } from '../mediaInterruptions';
10
+ import type { AudioSessionState } from '../types';
11
+
12
+ const setVisibility = (state: DocumentVisibilityState) => {
13
+ Object.defineProperty(document, 'visibilityState', {
14
+ configurable: true,
15
+ get: () => state,
16
+ });
17
+ document.dispatchEvent(new Event('visibilitychange'));
18
+ };
19
+
20
+ const installAudioSession = (initial: AudioSessionState) => {
21
+ const session = Object.assign(new EventTarget(), {
22
+ type: 'auto',
23
+ state: initial,
24
+ });
25
+ Object.defineProperty(navigator, 'audioSession', {
26
+ configurable: true,
27
+ value: session,
28
+ });
29
+ return {
30
+ setState: (state: AudioSessionState) => {
31
+ session.state = state;
32
+ session.dispatchEvent(new Event('statechange'));
33
+ },
34
+ };
35
+ };
36
+
37
+ describe('mediaInterruptions', () => {
38
+ afterEach(() => {
39
+ // @ts-expect-error restore the prototype getter
40
+ delete document.visibilityState;
41
+ // @ts-expect-error remove the test double
42
+ delete navigator.audioSession;
43
+ });
44
+
45
+ describe('isMediaInterrupted', () => {
46
+ it('is false while visible without an interrupted audio session', () => {
47
+ setVisibility('visible');
48
+ expect(isMediaInterrupted()).toBe(false);
49
+
50
+ installAudioSession('active');
51
+ expect(isMediaInterrupted()).toBe(false);
52
+ });
53
+
54
+ it('is true while the page is hidden', () => {
55
+ setVisibility('hidden');
56
+ expect(isMediaInterrupted()).toBe(true);
57
+ });
58
+
59
+ it('is true while the audio session is interrupted', () => {
60
+ setVisibility('visible');
61
+ installAudioSession('interrupted');
62
+ expect(isMediaInterrupted()).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe('watchMediaInterruptions', () => {
67
+ it('fires when the page becomes visible again', () => {
68
+ setVisibility('visible');
69
+ const onResumed = vi.fn();
70
+ const stop = watchMediaInterruptions(onResumed);
71
+
72
+ setVisibility('hidden');
73
+ expect(onResumed).not.toHaveBeenCalled();
74
+ setVisibility('visible');
75
+ expect(onResumed).toHaveBeenCalledTimes(1);
76
+
77
+ stop();
78
+ });
79
+
80
+ it('does not fire on events that do not end an interruption', () => {
81
+ setVisibility('visible');
82
+ const onResumed = vi.fn();
83
+ const stop = watchMediaInterruptions(onResumed);
84
+
85
+ setVisibility('visible');
86
+ setVisibility('visible');
87
+
88
+ expect(onResumed).not.toHaveBeenCalled();
89
+ stop();
90
+ });
91
+
92
+ it('fires when the audio session leaves the interrupted state', () => {
93
+ setVisibility('visible');
94
+ const session = installAudioSession('active');
95
+ const onResumed = vi.fn();
96
+ const stop = watchMediaInterruptions(onResumed);
97
+
98
+ session.setState('interrupted');
99
+ expect(onResumed).not.toHaveBeenCalled();
100
+ session.setState('active');
101
+ expect(onResumed).toHaveBeenCalledTimes(1);
102
+
103
+ stop();
104
+ });
105
+
106
+ it('waits until both the page is visible and the session is no longer interrupted', () => {
107
+ setVisibility('hidden');
108
+ const session = installAudioSession('interrupted');
109
+ const onResumed = vi.fn();
110
+ const stop = watchMediaInterruptions(onResumed);
111
+
112
+ session.setState('active');
113
+ expect(onResumed).not.toHaveBeenCalled();
114
+ setVisibility('visible');
115
+ expect(onResumed).toHaveBeenCalledTimes(1);
116
+
117
+ stop();
118
+ });
119
+
120
+ it('stops firing once the returned cleanup is called', () => {
121
+ setVisibility('hidden');
122
+ const onResumed = vi.fn();
123
+ const stop = watchMediaInterruptions(onResumed);
124
+
125
+ stop();
126
+ setVisibility('visible');
127
+
128
+ expect(onResumed).not.toHaveBeenCalled();
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Checks whether media playback and capture are likely interrupted: the page
3
+ * is hidden, or WebKit reports the audio session as interrupted (a phone
4
+ * call, Siri, or another app taking over audio).
5
+ */
6
+ export const isMediaInterrupted = (): boolean => {
7
+ if (typeof document === 'undefined') return false;
8
+ return (
9
+ document.visibilityState === 'hidden' ||
10
+ navigator.audioSession?.state === 'interrupted'
11
+ );
12
+ };
13
+
14
+ /**
15
+ * Invokes `onResumed` every time the page leaves a media interruption,
16
+ * as defined by {@link isMediaInterrupted}.
17
+ *
18
+ * @param onResumed the callback to invoke.
19
+ * @returns a function that stops watching.
20
+ */
21
+ export const watchMediaInterruptions = (
22
+ onResumed: () => void,
23
+ ): (() => void) => {
24
+ if (typeof document === 'undefined') return () => {};
25
+ let interrupted = isMediaInterrupted();
26
+ const check = () => {
27
+ const wasInterrupted = interrupted;
28
+ interrupted = isMediaInterrupted();
29
+ if (wasInterrupted && !interrupted) onResumed();
30
+ };
31
+
32
+ const controller = new AbortController();
33
+ const { signal } = controller;
34
+ document.addEventListener('visibilitychange', check, { signal });
35
+ navigator.audioSession?.addEventListener('statechange', check, { signal });
36
+ return () => controller.abort();
37
+ };
@@ -48,7 +48,7 @@ export type JoinReason =
48
48
  /**
49
49
  * What triggered an automatic join, reported as `source` on the call's
50
50
  * CoordinatorJoin events. `ring-ws` is a ring WebSocket event, `ring-poll-api`
51
- * is the ring state poller.
51
+ * is the ring state poller, or the call refresh after a WS reconnect.
52
52
  */
53
53
  export type JoinSource = 'ring-ws' | 'ring-poll-api';
54
54
 
@@ -597,11 +597,16 @@ export class ClientEventReporter {
597
597
  }
598
598
 
599
599
  if (event.state === 'failed') {
600
+ const iceConnected =
601
+ event.iceConnectionState === 'connected' ||
602
+ event.iceConnectionState === 'completed';
600
603
  this.emitPeerConnectionFailure(
601
604
  cid,
602
605
  role,
603
- 'DTLS_CONNECTIVITY_FAILED',
604
- 'DTLS connectivity checks failed',
606
+ iceConnected ? 'DTLS_CONNECTIVITY_FAILED' : 'ICE_CONNECTIVITY_FAILED',
607
+ iceConnected
608
+ ? 'DTLS connectivity checks failed'
609
+ : 'ICE connectivity checks failed',
605
610
  );
606
611
  return;
607
612
  }
@@ -755,7 +755,7 @@ describe('ClientEventReporter', () => {
755
755
  });
756
756
  });
757
757
 
758
- it('reports a DTLS failure with the real ICE state (never connected)', async () => {
758
+ it('reports an ICE failure when the peer connection fails before ICE connected', async () => {
759
759
  reporter.startCorrelation(cid, 'first-attempt');
760
760
  reportPeerConnectionState(
761
761
  PeerType.PUBLISHER_UNSPECIFIED,
@@ -779,7 +779,7 @@ describe('ClientEventReporter', () => {
779
779
  expect(completed[0]).toMatchObject({
780
780
  outcome: 'failure',
781
781
  peer_connection: 'publish',
782
- retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
782
+ retry_failure_code: 'ICE_CONNECTIVITY_FAILED',
783
783
  ice_state: 'NOT_CONNECTED',
784
784
  });
785
785
  });
@@ -39,7 +39,7 @@ export class RingStatePoller {
39
39
 
40
40
  constructor(call: Call, options: RingStatePollingOptions = {}) {
41
41
  this.call = call;
42
- this.startAfterMs = options.startAfterMs ?? 15_000;
42
+ this.startAfterMs = options.startAfterMs ?? 9_000;
43
43
  this.intervalMs = options.intervalMs ?? 5_000;
44
44
  }
45
45
 
@@ -15,7 +15,7 @@ import {
15
15
  import type { GetCallRingStateResponse } from '../../gen/coordinator';
16
16
 
17
17
  const SESSION_ID = 'session-1';
18
- const START_AFTER_MS = 15_000;
18
+ const START_AFTER_MS = 9_000;
19
19
  const INTERVAL_MS = 5_000;
20
20
 
21
21
  const ringState = (