@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.
- package/CHANGELOG.md +15 -0
- package/dist/index.browser.es.js +292 -154
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +292 -154
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +292 -154
- package/dist/index.es.js.map +1 -1
- package/dist/src/coordinator/connection/types.d.ts +1 -1
- package/dist/src/devices/DeviceManager.d.ts +6 -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/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 +0 -3
- 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 +20 -1
- package/src/devices/__tests__/CameraManager.test.ts +50 -1
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +10 -6
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
- package/src/helpers/AudioBindingsWatchdog.ts +91 -35
- package/src/helpers/DynascaleManager.ts +84 -9
- package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +247 -9
- 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 +8 -3
- package/src/reporting/__tests__/ClientEventReporter.test.ts +2 -2
- 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 +6 -1
- package/src/rtc/__tests__/Publisher.test.ts +50 -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
|
@@ -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
|
-
|
|
871
|
-
|
|
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
|
-
|
|
1136
|
+
play.mockClear();
|
|
896
1137
|
audioElement.dispatchEvent(new Event('pause'));
|
|
897
|
-
await vi.advanceTimersByTimeAsync(
|
|
1138
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
898
1139
|
|
|
899
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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 ??
|
|
42
|
+
this.startAfterMs = options.startAfterMs ?? 9_000;
|
|
43
43
|
this.intervalMs = options.intervalMs ?? 5_000;
|
|
44
44
|
}
|
|
45
45
|
|