@stream-io/video-client 1.61.1 → 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 (44) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/index.browser.es.js +281 -152
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +281 -152
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +281 -152
  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 +1 -1
  37. package/src/ringing/RingStatePoller.ts +1 -1
  38. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  39. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  40. package/src/ringing/reconcileRingState.ts +18 -10
  41. package/src/stats/SfuStatsReporter.ts +19 -93
  42. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  43. package/src/stats/rtc/StatsTracer.ts +6 -6
  44. 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
 
@@ -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 = (
@@ -202,6 +202,39 @@ describe('reconcileRingState', () => {
202
202
  message: 'ring: call ended',
203
203
  });
204
204
  });
205
+
206
+ describe('as the callee', () => {
207
+ const ended = { reason: 'ended', message: 'ring: call ended' };
208
+
209
+ it('leaves when the session has ended', async () => {
210
+ const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
211
+ setSession(call, { ended_at: timestamp() });
212
+
213
+ expect(await reconcile(call)).toBe(true);
214
+ expect(call.leave).toHaveBeenCalledWith(ended);
215
+ });
216
+
217
+ it('leaves when the call itself has ended', async () => {
218
+ const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
219
+ setSession(call, {});
220
+ call.state.setEndedAt(new Date());
221
+
222
+ expect(await reconcile(call)).toBe(true);
223
+ expect(call.leave).toHaveBeenCalledWith(ended);
224
+ });
225
+
226
+ it('leaves once with the ended message when the creator also rejected', async () => {
227
+ const call = ringingCall({ currentUserId: 'm2', createdById: 'm1' });
228
+ setSession(call, {
229
+ ended_at: timestamp(),
230
+ rejected_by: { m1: timestamp() },
231
+ });
232
+
233
+ expect(await reconcile(call)).toBe(true);
234
+ expect(call.leave).toHaveBeenCalledTimes(1);
235
+ expect(call.leave).toHaveBeenCalledWith(ended);
236
+ });
237
+ });
205
238
  });
206
239
 
207
240
  it('is terminal once the call is no longer ringing', async () => {