@stream-io/video-client 2.0.0-beta.0 → 2.0.0-beta.1

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 (90) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.browser.es.js +1126 -791
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1126 -791
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +1126 -791
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +37 -11
  10. package/dist/src/StreamVideoClient.d.ts +15 -21
  11. package/dist/src/coordinator/connection/types.d.ts +16 -0
  12. package/dist/src/events/call.d.ts +0 -11
  13. package/dist/src/gen/coordinator/index.d.ts +73 -0
  14. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  15. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  16. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  17. package/dist/src/ringing/index.d.ts +4 -0
  18. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  19. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  20. package/dist/src/rtc/Publisher.d.ts +18 -0
  21. package/dist/src/rtc/TransceiverCache.d.ts +10 -0
  22. package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
  23. package/dist/src/stats/utils.d.ts +6 -0
  24. package/dist/src/store/CallState.d.ts +118 -267
  25. package/dist/src/store/ClientState.d.ts +62 -0
  26. package/dist/src/store/index.d.ts +1 -1
  27. package/dist/src/store/subjects.d.ts +15 -0
  28. package/dist/src/store/types.d.ts +22 -0
  29. package/dist/src/types.d.ts +33 -4
  30. package/index.ts +1 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +285 -125
  33. package/src/StreamSfuClient.ts +6 -2
  34. package/src/StreamVideoClient.ts +40 -47
  35. package/src/__tests__/Call.autodrop.test.ts +118 -66
  36. package/src/__tests__/Call.getRingState.test.ts +87 -0
  37. package/src/__tests__/Call.lifecycle.test.ts +499 -6
  38. package/src/__tests__/Call.publishing.test.ts +2 -2
  39. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  40. package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  42. package/src/__tests__/StreamVideoClient.disconnect.test.ts +62 -0
  43. package/src/coordinator/connection/types.ts +19 -0
  44. package/src/devices/__tests__/CameraManager.test.ts +2 -2
  45. package/src/devices/__tests__/DeviceManager.test.ts +2 -2
  46. package/src/devices/__tests__/DeviceManagerFilters.test.ts +2 -2
  47. package/src/devices/__tests__/MicrophoneManager.test.ts +2 -2
  48. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -2
  49. package/src/devices/__tests__/ScreenShareManager.test.ts +3 -3
  50. package/src/devices/__tests__/SpeakerManager.test.ts +4 -4
  51. package/src/events/__tests__/call.test.ts +5 -220
  52. package/src/events/call.ts +1 -73
  53. package/src/events/callEventHandlers.ts +12 -23
  54. package/src/gen/coordinator/index.ts +67 -0
  55. package/src/helpers/__tests__/AudioBindingsWatchdog.test.ts +2 -2
  56. package/src/helpers/__tests__/DynascaleManager.test.ts +2 -2
  57. package/src/helpers/__tests__/ViewportTracker.test.ts +2 -2
  58. package/src/reporting/ClientEventReporter.ts +30 -4
  59. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  60. package/src/ringing/RingStatePoller.ts +177 -0
  61. package/src/ringing/RingTimeout.ts +90 -0
  62. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  63. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  64. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  65. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  66. package/src/ringing/index.ts +4 -0
  67. package/src/ringing/reconcileRingState.ts +108 -0
  68. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  69. package/src/rtc/Publisher.ts +66 -12
  70. package/src/rtc/TransceiverCache.ts +17 -0
  71. package/src/rtc/__tests__/Call.reconnect.test.ts +5 -5
  72. package/src/rtc/__tests__/Publisher.test.ts +209 -0
  73. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
  74. package/src/rtc/e2ee/E2EEManager.ts +10 -1
  75. package/src/rtc/e2ee/EncryptionManager.ts +1 -1
  76. package/src/rtc/e2ee/SPEC.md +1 -1
  77. package/src/stats/__tests__/utils.test.ts +33 -0
  78. package/src/stats/utils.ts +15 -0
  79. package/src/store/CallState.ts +305 -550
  80. package/src/store/ClientState.ts +101 -0
  81. package/src/store/__tests__/CallState.test.ts +60 -1
  82. package/src/store/__tests__/ClientState.test.ts +35 -0
  83. package/src/store/__tests__/rxUtils.test.ts +67 -1
  84. package/src/store/index.ts +1 -1
  85. package/src/store/rxUtils.ts +8 -4
  86. package/src/store/subjects.ts +30 -0
  87. package/src/store/types.ts +45 -0
  88. package/src/types.ts +40 -4
  89. package/dist/src/store/stateStore.d.ts +0 -84
  90. package/src/store/stateStore.ts +0 -156
@@ -39,6 +39,12 @@ const iceEvent = (
39
39
 
40
40
  describe('ClientEventReporter', () => {
41
41
  const cid = 'default:call-1';
42
+ const callContext: CallReportContext = {
43
+ callType: 'default',
44
+ callId: 'call-1',
45
+ getCallSessionId: () => 'session-1',
46
+ getSfuId: () => 'sfu-1',
47
+ };
42
48
  let doAxiosRequest: ReturnType<typeof vi.fn>;
43
49
  let reporter: ClientEventReporter;
44
50
  let connectId: string;
@@ -72,13 +78,7 @@ describe('ClientEventReporter', () => {
72
78
  reporter = new ClientEventReporter({ streamClient });
73
79
  connectId = reporter.startCoordinatorConnection('user-1');
74
80
 
75
- const ctx: CallReportContext = {
76
- callType: 'default',
77
- callId: 'call-1',
78
- getCallSessionId: () => 'session-1',
79
- getSfuId: () => 'sfu-1',
80
- };
81
- reporter.registerCall(cid, ctx);
81
+ reporter.registerCall(cid, callContext);
82
82
  });
83
83
 
84
84
  it('emits an initiated then a completed event on success', async () => {
@@ -117,6 +117,183 @@ describe('ClientEventReporter', () => {
117
117
  expect(events[1]).toMatchObject({ join_reason: 'migration' });
118
118
  });
119
119
 
120
+ it('carries the joinSource across the whole join lifecycle', async () => {
121
+ await reporter.withJoinLifecycle(
122
+ cid,
123
+ { joinReason: 'first-attempt', joinSource: 'ring-poll-api' },
124
+ () => reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
125
+ );
126
+ await flush();
127
+
128
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
129
+ expect(events).toHaveLength(2);
130
+ expect(events[0]).toMatchObject({
131
+ event_type: 'initiated',
132
+ join_reason: 'first-attempt',
133
+ source: 'ring-poll-api',
134
+ });
135
+ expect(events[1]).toMatchObject({
136
+ event_type: 'completed',
137
+ outcome: 'success',
138
+ source: 'ring-poll-api',
139
+ });
140
+ });
141
+
142
+ it('carries the joinSource on a failed join', async () => {
143
+ await expect(
144
+ reporter.withJoinLifecycle(
145
+ cid,
146
+ { joinReason: 'first-attempt', joinSource: 'ring-ws' },
147
+ () =>
148
+ reporter.track(cid, 'CoordinatorJoin', () =>
149
+ Promise.reject(new Error('boom')),
150
+ ),
151
+ ),
152
+ ).rejects.toThrow('boom');
153
+ await flush();
154
+
155
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
156
+ expect(events).toHaveLength(2);
157
+ expect(events[0]).toMatchObject({ source: 'ring-ws' });
158
+ expect(events[1]).toMatchObject({
159
+ event_type: 'completed',
160
+ outcome: 'failure',
161
+ source: 'ring-ws',
162
+ });
163
+ });
164
+
165
+ it('omits the source key entirely when the join has none', async () => {
166
+ await reporter.withJoinLifecycle(cid, { joinReason: 'first-attempt' }, () =>
167
+ reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
168
+ );
169
+ await flush();
170
+
171
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
172
+ expect(events).toHaveLength(2);
173
+ expect('source' in events[0]).toBe(false);
174
+ expect('source' in events[1]).toBe(false);
175
+ });
176
+
177
+ // the regression that matters: a reconnect must not inherit the ring source
178
+ // of the join that came before it
179
+ it('does not carry a ring source into a later reconnect', async () => {
180
+ await reporter.withJoinLifecycle(
181
+ cid,
182
+ { joinReason: 'first-attempt', joinSource: 'ring-ws' },
183
+ () => reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
184
+ );
185
+ doAxiosRequest.mockClear();
186
+
187
+ await reporter.withJoinLifecycle(cid, { joinReason: 'full-rejoin' }, () =>
188
+ reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
189
+ );
190
+ await flush();
191
+
192
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
193
+ expect(events).toHaveLength(2);
194
+ expect(events[0]).toMatchObject({ join_reason: 'full-rejoin' });
195
+ expect('source' in events[0]).toBe(false);
196
+ expect('source' in events[1]).toBe(false);
197
+ });
198
+
199
+ // `Call.join`'s retry loop restores the pre-join state between attempts, so a
200
+ // reconnect can open its own lifecycle while the join's is still on the
201
+ // stack. The inner one must not inherit the outer one's source.
202
+ it('does not leak the joinSource into a nested sourceless lifecycle', async () => {
203
+ await reporter.withJoinLifecycle(
204
+ cid,
205
+ { joinReason: 'first-attempt', joinSource: 'ring-ws' },
206
+ () =>
207
+ reporter.withJoinLifecycle(cid, { joinReason: 'full-rejoin' }, () =>
208
+ reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
209
+ ),
210
+ );
211
+ await flush();
212
+
213
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
214
+ expect(events).toHaveLength(2);
215
+ expect(events[0]).toMatchObject({ join_reason: 'full-rejoin' });
216
+ expect('source' in events[0]).toBe(false);
217
+ expect('source' in events[1]).toBe(false);
218
+ });
219
+
220
+ // a fast reconnect reports CoordinatorJoin without a join lifecycle, so the
221
+ // source has to be gone by the time the lifecycle ends
222
+ it('does not carry the joinSource into a join reported outside the lifecycle', async () => {
223
+ await reporter.withJoinLifecycle(
224
+ cid,
225
+ { joinReason: 'first-attempt', joinSource: 'ring-ws' },
226
+ () => reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok')),
227
+ );
228
+ doAxiosRequest.mockClear();
229
+
230
+ await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
231
+ await flush();
232
+
233
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
234
+ expect(events).toHaveLength(2);
235
+ expect('source' in events[0]).toBe(false);
236
+ expect('source' in events[1]).toBe(false);
237
+ });
238
+
239
+ // `Call.join` re-correlates mid-loop when it forces an SFU switch; those
240
+ // later attempts are still the same ring-caused join
241
+ it('keeps the joinSource across a mid-lifecycle re-correlation', async () => {
242
+ await reporter.withJoinLifecycle(
243
+ cid,
244
+ { joinReason: 'first-attempt', joinSource: 'ring-ws' },
245
+ async () => {
246
+ await expect(
247
+ reporter.track(cid, 'CoordinatorJoin', () =>
248
+ Promise.reject(new Error('boom')),
249
+ ),
250
+ ).rejects.toThrow('boom');
251
+ reporter.startCorrelation(cid, 'first-attempt');
252
+ await reporter.track(cid, 'CoordinatorJoin', () =>
253
+ Promise.resolve('ok'),
254
+ );
255
+ },
256
+ );
257
+ await flush();
258
+
259
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
260
+ expect(events).toHaveLength(4);
261
+ expect(events.every((e) => e.source === 'ring-ws')).toBe(true);
262
+ expect(events[0].join_attempt_id).not.toBe(events[3].join_attempt_id);
263
+ });
264
+
265
+ it('drops the joinSource when the call is unregistered', async () => {
266
+ await reporter.withJoinLifecycle(
267
+ cid,
268
+ { joinReason: 'first-attempt', joinSource: 'ring-ws' },
269
+ () => Promise.resolve('ok'),
270
+ );
271
+ reporter.unregisterCall(cid);
272
+ reporter.registerCall(cid, callContext);
273
+ doAxiosRequest.mockClear();
274
+
275
+ reporter.startCorrelation(cid, 'first-attempt');
276
+ await reporter.track(cid, 'CoordinatorJoin', () => Promise.resolve('ok'));
277
+ await flush();
278
+
279
+ const events = postedEvents().filter((e) => e.stage === 'CoordinatorJoin');
280
+ expect(events).toHaveLength(2);
281
+ expect('source' in events[0]).toBe(false);
282
+ });
283
+
284
+ it('reports the source only on CoordinatorJoin', async () => {
285
+ await reporter.withJoinLifecycle(
286
+ cid,
287
+ { joinReason: 'first-attempt', joinSource: 'ring-ws' },
288
+ () => reporter.track(cid, 'WSJoin', () => Promise.resolve('ok')),
289
+ );
290
+ await flush();
291
+
292
+ const others = postedEvents().filter((e) => e.stage !== 'CoordinatorJoin');
293
+ expect(others.length).toBeGreaterThan(0);
294
+ expect(others.some((e) => 'source' in e)).toBe(false);
295
+ });
296
+
120
297
  it('folds in-stage retries into a single pair', async () => {
121
298
  reporter.startCorrelation(cid, 'first-attempt');
122
299
  await expect(
@@ -0,0 +1,177 @@
1
+ import type { Call } from '../Call';
2
+ import { reconcileRingState } from './reconcileRingState';
3
+ import { CallingState } from '../store';
4
+ import { createSubscription } from '../store/rxUtils';
5
+ import { getTimers } from '../timers';
6
+ import {
7
+ ErrorFromResponse,
8
+ type RingStatePollingOptions,
9
+ } from '../coordinator/connection/types';
10
+ import { videoLoggerSystem } from '../logger';
11
+
12
+ // `doJoin` restores the previous calling state when a join fails, so JOINING can
13
+ // go back to RINGING. Only a state the ring cannot return from ends the poller.
14
+ const ringIsOver = (callingState: CallingState) =>
15
+ callingState !== CallingState.RINGING &&
16
+ callingState !== CallingState.JOINING;
17
+
18
+ /**
19
+ * Polls the coordinator for the outcome of a ring the current user started.
20
+ *
21
+ * `call.accepted`, `call.rejected` and `call.missed` are delivered best-effort,
22
+ * with no store-and-forward, so a caller that drops one is left on a ringing
23
+ * screen while the callee is already in the call. After a quiet period this
24
+ * reads the ring state until a terminal outcome or the end of the ring window.
25
+ */
26
+ export class RingStatePoller {
27
+ private readonly logger = videoLoggerSystem.getLogger('RingStatePoller');
28
+ private readonly call: Call;
29
+ private readonly startAfterMs: number;
30
+ private readonly intervalMs: number;
31
+ private sessionId: string | undefined;
32
+ private deadlineAt: number = 0;
33
+ private idleTimeoutId: number | undefined;
34
+ private intervalId: number | undefined;
35
+ private stopped: boolean = false;
36
+ private wasPolling: boolean = false;
37
+ private inFlight: boolean = false;
38
+ private unsubscribe: Array<() => void> = [];
39
+
40
+ constructor(call: Call, options: RingStatePollingOptions = {}) {
41
+ this.call = call;
42
+ this.startAfterMs = options.startAfterMs ?? 15_000;
43
+ this.intervalMs = options.intervalMs ?? 5_000;
44
+ }
45
+
46
+ /**
47
+ * Starts polling. Does nothing when the call has no session yet.
48
+ */
49
+ start = () => {
50
+ if (this.stopped || this.sessionId) return;
51
+ if (this.call.state.callingState !== CallingState.RINGING) return;
52
+
53
+ // captured once: `call.ended` clears the call's current session
54
+ const sessionId = this.call.state.session?.id;
55
+ if (!sessionId) {
56
+ this.logger.warn('the call has no session');
57
+ return;
58
+ }
59
+ this.sessionId = sessionId;
60
+
61
+ const ring = this.call.state.settings?.ring;
62
+ const maxDurationMs =
63
+ ring?.auto_cancel_timeout_ms || ring?.missed_call_timeout_ms || 30_000;
64
+ this.deadlineAt = Date.now() + maxDurationMs;
65
+
66
+ // an incoming event proves the socket is alive, so the quiet period starts
67
+ // over: in a group ring a single rejection does not settle the ring.
68
+ this.unsubscribe.push(
69
+ this.call.on('call.accepted', () => this.armIdleWindow()),
70
+ this.call.on('call.rejected', () => this.armIdleWindow()),
71
+ this.call.on('call.missed', () => this.armIdleWindow()),
72
+ // the ring is over, whichever way it went. Joining an accepted call
73
+ // never goes through `leave`, so this is the only signal for it.
74
+ createSubscription(this.call.state.callingState$, (callingState) => {
75
+ if (ringIsOver(callingState)) this.stop();
76
+ }),
77
+ );
78
+
79
+ this.armIdleWindow();
80
+ };
81
+
82
+ /**
83
+ * Pauses polling, keeping the captured session, the deadline and the event
84
+ * subscriptions. Resuming does not extend the ring window.
85
+ */
86
+ pause = () => {
87
+ if (this.stopped) return;
88
+ this.wasPolling = this.intervalId !== undefined;
89
+ const timers = getTimers();
90
+ timers.clearTimeout(this.idleTimeoutId);
91
+ timers.clearInterval(this.intervalId);
92
+ this.idleTimeoutId = undefined;
93
+ this.intervalId = undefined;
94
+ };
95
+
96
+ /**
97
+ * Resumes a paused poller. Goes straight back to polling if it was already
98
+ * past the quiet period, so a pause does not buy the ring another one.
99
+ */
100
+ resume = () => {
101
+ if (this.stopped || !this.sessionId) return;
102
+ if (this.idleTimeoutId !== undefined || this.intervalId !== undefined) {
103
+ return;
104
+ }
105
+ if (!this.wasPolling) {
106
+ this.armIdleWindow();
107
+ return;
108
+ }
109
+ this.intervalId = getTimers().setInterval(this.runTick, this.intervalMs);
110
+ this.runTick();
111
+ };
112
+
113
+ /**
114
+ * Stops polling. The poller cannot be restarted.
115
+ */
116
+ stop = () => {
117
+ if (this.stopped) return;
118
+ this.stopped = true;
119
+ const timers = getTimers();
120
+ timers.clearTimeout(this.idleTimeoutId);
121
+ timers.clearInterval(this.intervalId);
122
+ this.idleTimeoutId = undefined;
123
+ this.intervalId = undefined;
124
+ this.unsubscribe.forEach((off) => off());
125
+ this.unsubscribe = [];
126
+ };
127
+
128
+ private armIdleWindow = () => {
129
+ if (this.stopped) return;
130
+ const timers = getTimers();
131
+ timers.clearTimeout(this.idleTimeoutId);
132
+ timers.clearInterval(this.intervalId);
133
+ this.intervalId = undefined;
134
+ this.idleTimeoutId = timers.setTimeout(() => {
135
+ this.idleTimeoutId = undefined;
136
+ if (this.stopped) return;
137
+ this.intervalId = timers.setInterval(this.runTick, this.intervalMs);
138
+ this.runTick();
139
+ }, this.startAfterMs);
140
+ };
141
+
142
+ private runTick = () => {
143
+ this.tick().catch((err) => {
144
+ this.logger.warn('Failed to poll the ring state', err);
145
+ });
146
+ };
147
+
148
+ private tick = async () => {
149
+ if (this.stopped || this.inFlight || !this.sessionId) return;
150
+ const { callingState } = this.call.state;
151
+ if (ringIsOver(callingState) || Date.now() >= this.deadlineAt) {
152
+ this.stop();
153
+ return;
154
+ }
155
+ // mid-join: nothing to reconcile until we know whether it succeeded
156
+ if (callingState !== CallingState.RINGING) return;
157
+
158
+ this.inFlight = true;
159
+ try {
160
+ const ringState = await this.call.getRingState(this.sessionId);
161
+ if (this.stopped) return;
162
+ this.call.state.updateFromRingState(ringState);
163
+ if (await reconcileRingState(this.call, 'ring-poll-api')) this.stop();
164
+ } catch (err) {
165
+ // a missing session, or one of another call, will never resolve
166
+ const status = err instanceof ErrorFromResponse ? err.status : undefined;
167
+ if (status === 400 || status === 404) {
168
+ this.logger.warn('Stopped polling the ring state', err);
169
+ this.stop();
170
+ } else {
171
+ this.logger.debug('Failed to poll the ring state', err);
172
+ }
173
+ } finally {
174
+ this.inFlight = false;
175
+ }
176
+ };
177
+ }
@@ -0,0 +1,90 @@
1
+ import type { Call } from '../Call';
2
+ import { CallingState } from '../store';
3
+ import { getTimers } from '../timers';
4
+ import { videoLoggerSystem } from '../logger';
5
+
6
+ /**
7
+ * Drops a call that has been ringing for too long: the caller cancels after
8
+ * `auto_cancel_timeout_ms`, a callee after `incoming_call_timeout_ms`.
9
+ *
10
+ * Either timeout being `0` means the call rings until something else settles it.
11
+ */
12
+ export class RingTimeout {
13
+ private readonly call: Call;
14
+ private timeoutId: number | undefined;
15
+ private deadlineAt: number | undefined;
16
+ private stopped: boolean = false;
17
+
18
+ constructor(call: Call) {
19
+ this.call = call;
20
+ }
21
+
22
+ /**
23
+ * Schedules an auto-drop timeout based on the call settings.
24
+ * Applicable only for ringing calls.
25
+ */
26
+ start = () => {
27
+ if (this.stopped || this.timeoutId !== undefined) return;
28
+ // ignore if the call is not ringing
29
+ if (this.call.state.callingState !== CallingState.RINGING) return;
30
+
31
+ const ring = this.call.state.settings?.ring;
32
+ if (!ring) return;
33
+
34
+ const isCaller = this.call.isCreatedByMe;
35
+ const timeoutMs = isCaller
36
+ ? ring.auto_cancel_timeout_ms
37
+ : ring.incoming_call_timeout_ms;
38
+ // 0 means no auto-drop
39
+ if (timeoutMs <= 0) return;
40
+
41
+ const now = Date.now();
42
+ this.deadlineAt ??= now + timeoutMs;
43
+ const delayMs = Math.max(0, this.deadlineAt - now);
44
+ const timers = getTimers();
45
+ this.timeoutId = timers.setTimeout(() => {
46
+ this.timeoutId = undefined;
47
+ // A failed timeout-triggered leave can arm a fresh retry window.
48
+ this.deadlineAt = undefined;
49
+ if (this.stopped) return;
50
+ // the call might have stopped ringing by this point, e.g. it was already
51
+ // accepted and joined
52
+ if (this.call.state.callingState !== CallingState.RINGING) return;
53
+ this.call
54
+ .leave({
55
+ reject: true,
56
+ reason: 'timeout',
57
+ message: `ringing timeout - ${
58
+ isCaller
59
+ ? 'no one accepted'
60
+ : `user didn't interact with incoming call screen`
61
+ }`,
62
+ })
63
+ .catch((err) => {
64
+ videoLoggerSystem
65
+ .getLogger('RingTimeout')
66
+ .error('Failed to drop the call', err);
67
+ });
68
+ }, delayMs);
69
+ };
70
+
71
+ /**
72
+ * Pauses the timeout while preserving its original deadline.
73
+ */
74
+ pause = () => {
75
+ if (this.stopped || this.timeoutId === undefined) return;
76
+ getTimers().clearTimeout(this.timeoutId);
77
+ this.timeoutId = undefined;
78
+ };
79
+
80
+ /**
81
+ * Cancels a scheduled auto-drop timeout. It cannot be armed again.
82
+ */
83
+ stop = () => {
84
+ if (this.stopped) return;
85
+ this.stopped = true;
86
+ getTimers().clearTimeout(this.timeoutId);
87
+ this.timeoutId = undefined;
88
+ this.deadlineAt = undefined;
89
+ };
90
+ }