@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.
- package/CHANGELOG.md +21 -0
- package/dist/index.browser.es.js +525 -178
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +525 -178
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +525 -178
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +21 -1
- package/dist/src/coordinator/connection/types.d.ts +1 -1
- package/dist/src/devices/DeviceManager.d.ts +6 -0
- package/dist/src/devices/DeviceManagerState.d.ts +17 -0
- package/dist/src/devices/MicrophoneManager.d.ts +11 -0
- package/dist/src/devices/devices.d.ts +5 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
- package/dist/src/helpers/DynascaleManager.d.ts +15 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
- package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
- package/dist/src/ringing/reconcileRingState.d.ts +8 -7
- package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
- package/dist/src/rtc/types.d.ts +7 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
- package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
- package/dist/src/stats/rtc/types.d.ts +0 -11
- package/package.json +1 -1
- package/src/Call.ts +110 -9
- package/src/StreamVideoClient.ts +8 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
- package/src/coordinator/connection/types.ts +1 -1
- package/src/devices/DeviceManager.ts +26 -1
- package/src/devices/DeviceManagerState.ts +24 -0
- package/src/devices/MicrophoneManager.ts +79 -17
- package/src/devices/SpeakerManager.ts +1 -1
- package/src/devices/__tests__/CameraManager.test.ts +50 -1
- package/src/devices/__tests__/DeviceManager.test.ts +29 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
- package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
- package/src/devices/__tests__/devices.test.ts +10 -14
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +11 -6
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/devices/devices.ts +7 -1
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
- package/src/helpers/AudioBindingsWatchdog.ts +91 -35
- package/src/helpers/DynascaleManager.ts +86 -11
- package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +253 -13
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
- package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
- package/src/helpers/mediaInterruptions.ts +37 -0
- package/src/reporting/ClientEventReporter.ts +1 -1
- package/src/ringing/RingStatePoller.ts +1 -1
- package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
- package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
- package/src/ringing/reconcileRingState.ts +18 -10
- package/src/rtc/BasePeerConnection.ts +47 -9
- package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
- package/src/rtc/types.ts +8 -0
- package/src/stats/SfuStatsReporter.ts +19 -93
- package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
- package/src/stats/rtc/StatsTracer.ts +6 -6
- 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).
|
|
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
|
-
|
|
871
|
-
|
|
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
|
-
|
|
1138
|
+
play.mockClear();
|
|
896
1139
|
audioElement.dispatchEvent(new Event('pause'));
|
|
897
|
-
await vi.advanceTimersByTimeAsync(
|
|
1140
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
898
1141
|
|
|
899
|
-
expect(
|
|
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 ??
|
|
42
|
+
this.startAfterMs = options.startAfterMs ?? 9_000;
|
|
43
43
|
this.intervalMs = options.intervalMs ?? 5_000;
|
|
44
44
|
}
|
|
45
45
|
|