@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
@@ -29,6 +29,7 @@ vi.mock(import('../browsers'), async (importOriginal) => {
29
29
  return {
30
30
  ...module,
31
31
  isSafari: () => globalThis._isSafari ?? false,
32
+ isWebKit: () => false,
32
33
  };
33
34
  });
34
35
 
@@ -191,6 +192,9 @@ describe('DynascaleManager', () => {
191
192
  it('audio: Safari should use AudioContext for audio playback', () => {
192
193
  globalThis._isSafari = true;
193
194
  dynascaleManager.setUseWebAudio(true); // enabled by default on Safari
195
+ Object.defineProperty(call.speaker.state, 'isDeviceSelectionSupported', {
196
+ value: false,
197
+ });
194
198
 
195
199
  vi.useFakeTimers();
196
200
  const audioElement = document.createElement('audio');
@@ -257,11 +261,9 @@ describe('DynascaleManager', () => {
257
261
  expect(gainNode.connect).toHaveBeenCalledWith(audioContext.destination);
258
262
 
259
263
  call.speaker.select('different-device-id');
260
- expect(audioElement.setSinkId).toHaveBeenCalledWith(
261
- 'different-device-id',
262
- );
264
+ expect(audioElement.setSinkId).not.toHaveBeenCalled();
263
265
  // @ts-expect-error sinkId isn't available in the TS definition
264
- expect(audioContext.sinkId).toBe('different-device-id');
266
+ expect(audioContext.sinkId).not.toBe('different-device-id');
265
267
 
266
268
  call.speaker.setVolume(0.5);
267
269
  expect(audioElement.volume).toBe(0.5);
@@ -812,6 +814,247 @@ describe('DynascaleManager', () => {
812
814
  cleanup?.();
813
815
  });
814
816
 
817
+ describe('audio: playback recovery', () => {
818
+ const bindPausedAudioElement = (
819
+ opts: { blockedByAutoplay?: boolean } = {},
820
+ ) => {
821
+ const audioElement = document.createElement('audio');
822
+ Object.defineProperties(audioElement, {
823
+ srcObject: { writable: true },
824
+ paused: { writable: true, configurable: true },
825
+ readyState: { writable: true, configurable: true },
826
+ });
827
+ // @ts-expect-error simulate paused, ready-to-play element
828
+ audioElement.paused = true;
829
+ // @ts-expect-error simulate paused, ready-to-play element
830
+ audioElement.readyState = 4;
831
+ const play = vi.spyOn(audioElement, 'play');
832
+ if (opts.blockedByAutoplay) {
833
+ play.mockRejectedValue(new DOMException('', 'NotAllowedError'));
834
+ }
835
+
836
+ // @ts-expect-error incomplete data
837
+ call.state.updateOrAddParticipant('session-id', {
838
+ userId: 'user-id',
839
+ sessionId: 'session-id',
840
+ publishedTracks: [],
841
+ });
842
+ const cleanup = dynascaleManager.bindAudioElement(
843
+ audioElement,
844
+ 'session-id',
845
+ 'audioTrack',
846
+ );
847
+ call.state.updateParticipant('session-id', {
848
+ audioStream: new MediaStream(),
849
+ });
850
+ vi.runAllTimers();
851
+ return { audioElement, play, cleanup };
852
+ };
853
+
854
+ const setVisibility = (state: DocumentVisibilityState) => {
855
+ Object.defineProperty(document, 'visibilityState', {
856
+ configurable: true,
857
+ get: () => state,
858
+ });
859
+ document.dispatchEvent(new Event('visibilitychange'));
860
+ };
861
+
862
+ afterEach(() => {
863
+ // @ts-expect-error restore the prototype getter
864
+ delete document.visibilityState;
865
+ });
866
+
867
+ it('marks the element as blocked once recovery gives up', async () => {
868
+ vi.useFakeTimers();
869
+ setVisibility('visible');
870
+ const { audioElement, play, cleanup } = bindPausedAudioElement();
871
+ play.mockRejectedValue(new Error('playback failed'));
872
+ await vi.advanceTimersByTimeAsync(0);
873
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(false);
874
+
875
+ audioElement.dispatchEvent(new Event('pause'));
876
+ for (let i = 0; i < 20; i++) {
877
+ await vi.advanceTimersByTimeAsync(6000);
878
+ }
879
+
880
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
881
+ expect(
882
+ getCurrentValue(call.blockedAudioTracker.blockedSessionIds$),
883
+ ).toEqual(['session-id']);
884
+
885
+ cleanup?.();
886
+ });
887
+
888
+ it('keeps retrying when the first re-armed attempt after a give-up fails', async () => {
889
+ vi.useFakeTimers();
890
+ setVisibility('visible');
891
+ const { audioElement, play, cleanup } = bindPausedAudioElement();
892
+ play.mockRejectedValue(new Error('playback failed'));
893
+ await vi.advanceTimersByTimeAsync(0);
894
+ audioElement.dispatchEvent(new Event('pause'));
895
+ for (let i = 0; i < 20; i++) {
896
+ await vi.advanceTimersByTimeAsync(6000);
897
+ }
898
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
899
+ const callsBeforeRearm = play.mock.calls.length;
900
+
901
+ play.mockRejectedValueOnce(new Error('still interrupted'));
902
+ play.mockResolvedValue();
903
+ setVisibility('hidden');
904
+ setVisibility('visible');
905
+ await vi.advanceTimersByTimeAsync(6000);
906
+
907
+ expect(play.mock.calls.length).toBe(callsBeforeRearm + 2);
908
+ // the prompt stays up until audio actually plays
909
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
910
+ audioElement.dispatchEvent(new Event('playing'));
911
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(false);
912
+
913
+ cleanup?.();
914
+ });
915
+
916
+ it('tries an autoplay-blocked element only once per re-arm', async () => {
917
+ vi.useFakeTimers();
918
+ setVisibility('visible');
919
+ const { audioElement, play, cleanup } = bindPausedAudioElement({
920
+ blockedByAutoplay: true,
921
+ });
922
+ await vi.advanceTimersByTimeAsync(0);
923
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
924
+ const callsBeforeRearm = play.mock.calls.length;
925
+
926
+ setVisibility('hidden');
927
+ setVisibility('visible');
928
+ for (let i = 0; i < 20; i++) {
929
+ await vi.advanceTimersByTimeAsync(6000);
930
+ }
931
+
932
+ expect(play.mock.calls.length).toBe(callsBeforeRearm + 1);
933
+ expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
934
+
935
+ cleanup?.();
936
+ });
937
+
938
+ it('re-plays a paused element when the page becomes visible again', async () => {
939
+ vi.useFakeTimers();
940
+ setVisibility('visible');
941
+ const { play, cleanup } = bindPausedAudioElement();
942
+ play.mockResolvedValue();
943
+ await vi.advanceTimersByTimeAsync(0);
944
+
945
+ setVisibility('hidden');
946
+ await vi.advanceTimersByTimeAsync(0);
947
+ const callsWhileHidden = play.mock.calls.length;
948
+
949
+ setVisibility('visible');
950
+ await vi.advanceTimersByTimeAsync(0);
951
+
952
+ expect(play.mock.calls.length).toBe(callsWhileHidden + 1);
953
+
954
+ cleanup?.();
955
+ });
956
+
957
+ it('does not apply a queued source change after the element is unbound', async () => {
958
+ vi.useFakeTimers();
959
+ setVisibility('visible');
960
+ const audioElement = document.createElement('audio');
961
+ Object.defineProperties(audioElement, {
962
+ srcObject: { writable: true },
963
+ paused: { writable: true, configurable: true },
964
+ readyState: { writable: true, configurable: true },
965
+ });
966
+ // @ts-expect-error simulate paused, ready-to-play element
967
+ audioElement.paused = true;
968
+ // @ts-expect-error simulate paused, ready-to-play element
969
+ audioElement.readyState = 4;
970
+ const play = vi.spyOn(audioElement, 'play').mockResolvedValue();
971
+
972
+ // @ts-expect-error incomplete data
973
+ call.state.updateOrAddParticipant('session-id', {
974
+ userId: 'user-id',
975
+ sessionId: 'session-id',
976
+ publishedTracks: [],
977
+ });
978
+ const cleanup = dynascaleManager.bindAudioElement(
979
+ audioElement,
980
+ 'session-id',
981
+ 'audioTrack',
982
+ );
983
+ call.state.updateParticipant('session-id', {
984
+ audioStream: new MediaStream(),
985
+ });
986
+ cleanup?.();
987
+ await vi.runAllTimersAsync();
988
+
989
+ expect(audioElement.srcObject).toBeNull();
990
+ expect(play).not.toHaveBeenCalled();
991
+
992
+ setVisibility('hidden');
993
+ setVisibility('visible');
994
+ await vi.advanceTimersByTimeAsync(0);
995
+ expect(play).not.toHaveBeenCalled();
996
+ });
997
+
998
+ it('stops re-arming after the element is unbound', async () => {
999
+ vi.useFakeTimers();
1000
+ setVisibility('visible');
1001
+ const { play, cleanup } = bindPausedAudioElement();
1002
+ play.mockResolvedValue();
1003
+ await vi.advanceTimersByTimeAsync(0);
1004
+ cleanup?.();
1005
+ const callsAfterCleanup = play.mock.calls.length;
1006
+
1007
+ setVisibility('hidden');
1008
+ setVisibility('visible');
1009
+ await vi.advanceTimersByTimeAsync(0);
1010
+
1011
+ expect(play.mock.calls.length).toBe(callsAfterCleanup);
1012
+ });
1013
+ });
1014
+
1015
+ it('audio: registers one audioSession listener and removes it on dispose', async () => {
1016
+ vi.useFakeTimers();
1017
+ const session = new EventTarget();
1018
+ Object.defineProperty(session, 'state', { value: 'active' });
1019
+ Object.defineProperty(navigator, 'audioSession', {
1020
+ configurable: true,
1021
+ value: session,
1022
+ });
1023
+ const addListener = vi.spyOn(session, 'addEventListener');
1024
+ const removeListener = vi.spyOn(session, 'removeEventListener');
1025
+
1026
+ try {
1027
+ // @ts-expect-error incomplete data
1028
+ call.state.updateOrAddParticipant('session-id', {
1029
+ userId: 'user-id',
1030
+ sessionId: 'session-id',
1031
+ publishedTracks: [],
1032
+ });
1033
+ const cleanups = [
1034
+ dynascaleManager.bindAudioElement(
1035
+ document.createElement('audio'),
1036
+ 'session-id',
1037
+ 'audioTrack',
1038
+ ),
1039
+ dynascaleManager.bindAudioElement(
1040
+ document.createElement('audio'),
1041
+ 'session-id',
1042
+ 'screenShareAudioTrack',
1043
+ ),
1044
+ ];
1045
+ expect(addListener).toHaveBeenCalledTimes(1);
1046
+ const [, listener] = addListener.mock.calls[0];
1047
+
1048
+ await dynascaleManager.dispose();
1049
+ expect(removeListener).toHaveBeenCalledWith('statechange', listener);
1050
+
1051
+ cleanups.forEach((cleanup) => cleanup?.());
1052
+ } finally {
1053
+ // @ts-expect-error remove the test double
1054
+ delete navigator.audioSession;
1055
+ }
1056
+ });
1057
+
815
1058
  it('audio: no watchdog attached when useWebAudio is true', async () => {
816
1059
  globalThis._isSafari = true;
817
1060
  dynascaleManager.setUseWebAudio(true);
@@ -867,9 +1110,9 @@ describe('DynascaleManager', () => {
867
1110
  audioElement.paused = true;
868
1111
  // @ts-expect-error simulate paused, ready-to-play element
869
1112
  audioElement.readyState = 4;
870
- vi.spyOn(audioElement, 'play').mockRejectedValue(
871
- new DOMException('', 'NotAllowedError'),
872
- );
1113
+ const play = vi
1114
+ .spyOn(audioElement, 'play')
1115
+ .mockRejectedValue(new DOMException('', 'NotAllowedError'));
873
1116
 
874
1117
  // @ts-expect-error incomplete data
875
1118
  call.state.updateOrAddParticipant('session-id', {
@@ -892,14 +1135,11 @@ describe('DynascaleManager', () => {
892
1135
 
893
1136
  expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
894
1137
 
895
- const traceSpy = vi.spyOn(call.tracer, 'trace');
1138
+ play.mockClear();
896
1139
  audioElement.dispatchEvent(new Event('pause'));
897
- await vi.advanceTimersByTimeAsync(0);
1140
+ await vi.advanceTimersByTimeAsync(60_000);
898
1141
 
899
- expect(traceSpy).toHaveBeenCalledWith('mediaPlayback.recover.skipped', {
900
- kind: 'audio',
901
- reason: 'blocked',
902
- });
1142
+ expect(play).not.toHaveBeenCalled();
903
1143
 
904
1144
  cleanup?.();
905
1145
  });
@@ -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 = (