@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
package/src/Call.ts
CHANGED
|
@@ -183,6 +183,7 @@ import {
|
|
|
183
183
|
import { normalize } from './devices/devicePersistence';
|
|
184
184
|
import {
|
|
185
185
|
hasPending,
|
|
186
|
+
settled,
|
|
186
187
|
singleFlight,
|
|
187
188
|
withoutConcurrency,
|
|
188
189
|
} from './helpers/concurrency';
|
|
@@ -325,6 +326,15 @@ export class Call {
|
|
|
325
326
|
|
|
326
327
|
// (10 attempts per rolling 120 s window).
|
|
327
328
|
private readonly rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
329
|
+
// Backstop for slow REJOIN loops that stay under the short window
|
|
330
|
+
// (20 attempts per rolling 30 min window). Dropped once the integrator
|
|
331
|
+
// takes over the budget through `setRejoinAttemptLimit`.
|
|
332
|
+
private rejoinLongWindowRateLimiter?: SlidingWindowRateLimiter =
|
|
333
|
+
new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
|
|
334
|
+
// In-flight `doJoin` runs, awaited before acting on peer connections that
|
|
335
|
+
// gathered no local ICE candidates.
|
|
336
|
+
private readonly joinTasks = new Set<Promise<void>>();
|
|
337
|
+
private noLocalCandidatesCheckScheduled = false;
|
|
328
338
|
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
329
339
|
// failures where ICE never reached `connected`/`completed`.
|
|
330
340
|
private maxIceFailuresWithoutConnect = 2;
|
|
@@ -810,6 +820,7 @@ export class Call {
|
|
|
810
820
|
// `joinFlow` never runs, leaving stale values that would make the next
|
|
811
821
|
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
812
822
|
this.rejoinRateLimiter.reset();
|
|
823
|
+
this.rejoinLongWindowRateLimiter?.reset();
|
|
813
824
|
this.iceFailuresWithoutConnect = 0;
|
|
814
825
|
this.consecutiveNegotiationFailures = 0;
|
|
815
826
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
@@ -1313,6 +1324,16 @@ export class Call {
|
|
|
1313
1324
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
1314
1325
|
*/
|
|
1315
1326
|
private doJoin = async (data?: JoinCallData): Promise<void> => {
|
|
1327
|
+
const task = this.performJoin(data);
|
|
1328
|
+
this.joinTasks.add(task);
|
|
1329
|
+
try {
|
|
1330
|
+
await task;
|
|
1331
|
+
} finally {
|
|
1332
|
+
this.joinTasks.delete(task);
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
private performJoin = async (data?: JoinCallData): Promise<void> => {
|
|
1316
1337
|
const connectStartTime = Date.now();
|
|
1317
1338
|
const callingState = this.state.callingState;
|
|
1318
1339
|
const joinLeaveGeneration = this.leaveGeneration;
|
|
@@ -1761,6 +1782,9 @@ export class Call {
|
|
|
1761
1782
|
// "ICE never connected" failure budget can be cleared.
|
|
1762
1783
|
this.iceFailuresWithoutConnect = 0;
|
|
1763
1784
|
},
|
|
1785
|
+
onNoLocalCandidates: () => {
|
|
1786
|
+
this.checkNoLocalCandidates();
|
|
1787
|
+
},
|
|
1764
1788
|
onPeerConnectionStateChange: (event) => {
|
|
1765
1789
|
this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
|
|
1766
1790
|
},
|
|
@@ -1812,9 +1836,6 @@ export class Call {
|
|
|
1812
1836
|
options: statsOptions,
|
|
1813
1837
|
subscriber: this.subscriber,
|
|
1814
1838
|
publisher: this.publisher,
|
|
1815
|
-
microphone: this.microphone,
|
|
1816
|
-
camera: this.camera,
|
|
1817
|
-
state: this.state,
|
|
1818
1839
|
tracer: this.tracer,
|
|
1819
1840
|
unifiedSessionId,
|
|
1820
1841
|
});
|
|
@@ -1822,6 +1843,62 @@ export class Call {
|
|
|
1822
1843
|
}
|
|
1823
1844
|
};
|
|
1824
1845
|
|
|
1846
|
+
/**
|
|
1847
|
+
* Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
|
|
1848
|
+
* connections never connected and one of them completed ICE gathering
|
|
1849
|
+
* without a single local candidate, which means WebRTC networking is
|
|
1850
|
+
* blocked on this device.
|
|
1851
|
+
*
|
|
1852
|
+
* `reconnect` ignores requests while a join or another reconnect is in
|
|
1853
|
+
* flight, so the check is postponed until those settle and then re-run
|
|
1854
|
+
* against the peer connections current at that time.
|
|
1855
|
+
*/
|
|
1856
|
+
private checkNoLocalCandidates = () => {
|
|
1857
|
+
if (this.noLocalCandidatesCheckScheduled) return;
|
|
1858
|
+
if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
|
|
1859
|
+
this.noLocalCandidatesCheckScheduled = true;
|
|
1860
|
+
const leaveGeneration = this.leaveGeneration;
|
|
1861
|
+
this.waitForJoinsAndReconnects()
|
|
1862
|
+
.then(() => {
|
|
1863
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
1864
|
+
if (this.leaveGeneration !== leaveGeneration) return;
|
|
1865
|
+
this.checkNoLocalCandidates();
|
|
1866
|
+
})
|
|
1867
|
+
.catch((err) => {
|
|
1868
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
1869
|
+
this.logger.warn('[Reconnect] No-candidates check failed', err);
|
|
1870
|
+
});
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (this.state.callingState !== CallingState.JOINED) return;
|
|
1875
|
+
// without network, gathering legitimately yields no candidates
|
|
1876
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
|
|
1877
|
+
const peerConnections = [this.publisher, this.subscriber];
|
|
1878
|
+
if (peerConnections.some((pc) => pc?.hasIceEverConnected())) return;
|
|
1879
|
+
if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates())) return;
|
|
1880
|
+
|
|
1881
|
+
this.reconnect(
|
|
1882
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
1883
|
+
ReconnectReason.ICE_NEVER_CONNECTED,
|
|
1884
|
+
).catch((err) => {
|
|
1885
|
+
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
1886
|
+
});
|
|
1887
|
+
};
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* Resolves once no `doJoin` run and no reconnect is in flight.
|
|
1891
|
+
*/
|
|
1892
|
+
private waitForJoinsAndReconnects = async () => {
|
|
1893
|
+
while (
|
|
1894
|
+
this.joinTasks.size > 0 ||
|
|
1895
|
+
hasPending(this.reconnectConcurrencyTag)
|
|
1896
|
+
) {
|
|
1897
|
+
await Promise.allSettled(this.joinTasks);
|
|
1898
|
+
await settled(this.reconnectConcurrencyTag);
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1825
1902
|
/**
|
|
1826
1903
|
* Retrieves credentials for joining the call.
|
|
1827
1904
|
*
|
|
@@ -2063,6 +2140,16 @@ export class Call {
|
|
|
2063
2140
|
return;
|
|
2064
2141
|
}
|
|
2065
2142
|
}
|
|
2143
|
+
// MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
|
|
2144
|
+
// toward the long-window backstop.
|
|
2145
|
+
if (
|
|
2146
|
+
this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
|
|
2147
|
+
this.rejoinLongWindowRateLimiter &&
|
|
2148
|
+
!this.rejoinLongWindowRateLimiter.tryRegister()
|
|
2149
|
+
) {
|
|
2150
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2066
2153
|
|
|
2067
2154
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
2068
2155
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
@@ -2343,7 +2430,12 @@ export class Call {
|
|
|
2343
2430
|
});
|
|
2344
2431
|
|
|
2345
2432
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
2346
|
-
|
|
2433
|
+
// Only the active SFU client may drive a reconnect: a superseded one can
|
|
2434
|
+
// still report errors about its own (replaced) session, and none may
|
|
2435
|
+
// while `leave()` tears the call down.
|
|
2436
|
+
const activeSfuTag = () => this.sfuClient?.tag ?? '*';
|
|
2437
|
+
const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
|
|
2438
|
+
if (!this.sfuClient) return;
|
|
2347
2439
|
const { reconnectStrategy: strategy, error } = e;
|
|
2348
2440
|
if (!SfuJoinError.isJoinErrorCode(e)) {
|
|
2349
2441
|
const code = error?.code ? ErrorCode[error.code] : undefined;
|
|
@@ -2363,10 +2455,17 @@ export class Call {
|
|
|
2363
2455
|
this.logger.warn(`Can't leave call after disconnect request`, err);
|
|
2364
2456
|
});
|
|
2365
2457
|
} else {
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2458
|
+
// A media transport failure on a session whose peer connections
|
|
2459
|
+
// never connected is an "ICE never connected" failure, and must
|
|
2460
|
+
// count toward the unsupported-network budget.
|
|
2461
|
+
const neverConnected =
|
|
2462
|
+
error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
|
|
2463
|
+
!this.publisher?.hasIceEverConnected() &&
|
|
2464
|
+
!this.subscriber?.hasIceEverConnected();
|
|
2465
|
+
const reason = neverConnected
|
|
2466
|
+
? ReconnectReason.ICE_NEVER_CONNECTED
|
|
2467
|
+
: error?.message || ReconnectReason.SFU_ERROR;
|
|
2468
|
+
this.reconnect(strategy, reason).catch((err) => {
|
|
2370
2469
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
2371
2470
|
});
|
|
2372
2471
|
}
|
|
@@ -3804,9 +3903,11 @@ export class Call {
|
|
|
3804
3903
|
* `rejoin_attempt_limit_exceeded` leave message.
|
|
3805
3904
|
*
|
|
3806
3905
|
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
3807
|
-
* Both arguments are clamped to a minimum of 1.
|
|
3906
|
+
* Both arguments are clamped to a minimum of 1. Unless this method is
|
|
3907
|
+
* called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
|
|
3808
3908
|
*/
|
|
3809
3909
|
setRejoinAttemptLimit = (maxAttempts: number, windowSeconds: number) => {
|
|
3910
|
+
this.rejoinLongWindowRateLimiter = undefined;
|
|
3810
3911
|
this.rejoinRateLimiter.setLimits(
|
|
3811
3912
|
Math.max(1, maxAttempts),
|
|
3812
3913
|
Math.max(1, windowSeconds) * 1000,
|
package/src/StreamVideoClient.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { logToConsole, ScopedLogger, videoLoggerSystem } from './logger';
|
|
|
42
42
|
import { withoutConcurrency } from './helpers/concurrency';
|
|
43
43
|
import { enableTimerWorker } from './timers';
|
|
44
44
|
import { ClientEventReporter } from './reporting';
|
|
45
|
+
import { reconcileRingState } from './ringing';
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
|
|
@@ -285,6 +286,13 @@ export class StreamVideoClient {
|
|
|
285
286
|
|
|
286
287
|
if (call) {
|
|
287
288
|
call.updateFromCallStateResponse(c);
|
|
289
|
+
// ring events missed while the WS was down aren't replayed
|
|
290
|
+
reconcileRingState(call, 'ring-poll-api').catch((err) => {
|
|
291
|
+
call.logger.error(
|
|
292
|
+
'Failed to reconcile the ring state after rewatch',
|
|
293
|
+
err,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
288
296
|
}
|
|
289
297
|
}
|
|
290
298
|
|
|
@@ -12,7 +12,9 @@ import type {
|
|
|
12
12
|
CallResponse,
|
|
13
13
|
ConnectedEvent,
|
|
14
14
|
GetCallResponse,
|
|
15
|
+
MemberResponse,
|
|
15
16
|
QueryCallsResponse,
|
|
17
|
+
UserResponse,
|
|
16
18
|
} from '../gen/coordinator';
|
|
17
19
|
|
|
18
20
|
const apiKey = 'mock-api-key';
|
|
@@ -126,6 +128,140 @@ describe('StreamVideoClient re-watching calls on reconnect', () => {
|
|
|
126
128
|
await vi.waitFor(() => expect(leave).toHaveBeenCalled());
|
|
127
129
|
});
|
|
128
130
|
|
|
131
|
+
describe('ring reconciliation after a rewatch', () => {
|
|
132
|
+
const rewatchWith = async (
|
|
133
|
+
call: Call,
|
|
134
|
+
rejected_by: Record<string, string>,
|
|
135
|
+
extra: {
|
|
136
|
+
accepted_by?: Record<string, string>;
|
|
137
|
+
members?: MemberResponse[];
|
|
138
|
+
ended_at?: string;
|
|
139
|
+
} = {},
|
|
140
|
+
) => {
|
|
141
|
+
const leave = vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
142
|
+
const session = CallRingPayload.call.session!;
|
|
143
|
+
const response = queryCallsResponse({
|
|
144
|
+
...CallRingPayload.call,
|
|
145
|
+
// keep the creator the call was set up with
|
|
146
|
+
created_by: call.state.createdBy as UserResponse,
|
|
147
|
+
ended_at: extra.ended_at,
|
|
148
|
+
session: {
|
|
149
|
+
...session,
|
|
150
|
+
rejected_by,
|
|
151
|
+
accepted_by: extra.accepted_by ?? session.accepted_by,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
if (extra.members) response.calls[0].members = extra.members;
|
|
155
|
+
const post = vi
|
|
156
|
+
.spyOn(client.streamClient, 'doAxiosRequest')
|
|
157
|
+
.mockResolvedValue(response as never);
|
|
158
|
+
const updated = vi.spyOn(call, 'updateFromCallStateResponse');
|
|
159
|
+
reconnect();
|
|
160
|
+
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
|
161
|
+
await vi.waitFor(() => expect(updated).toHaveBeenCalled());
|
|
162
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
163
|
+
return leave;
|
|
164
|
+
};
|
|
165
|
+
const rejectedAt = () => '2025-08-14T14:49:00Z';
|
|
166
|
+
|
|
167
|
+
it('callee leaves when the creator cancelled while offline', async () => {
|
|
168
|
+
const call = await setupRingingCall();
|
|
169
|
+
const leave = await rewatchWith(call, { oliver_1: rejectedAt() });
|
|
170
|
+
expect(leave).toHaveBeenCalledWith({
|
|
171
|
+
reason: 'ended',
|
|
172
|
+
message: 'ring: creator rejected',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('callee keeps ringing without a rejection', async () => {
|
|
177
|
+
const call = await setupRingingCall();
|
|
178
|
+
const leave = await rewatchWith(call, {});
|
|
179
|
+
expect(leave).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('callee keeps ringing when only another member rejected', async () => {
|
|
183
|
+
const call = await setupRingingCall();
|
|
184
|
+
const leave = await rewatchWith(call, { someone_else: rejectedAt() });
|
|
185
|
+
expect(leave).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('callee leaves when the call ended while offline', async () => {
|
|
189
|
+
const call = await setupRingingCall();
|
|
190
|
+
const leave = await rewatchWith(call, {}, { ended_at: rejectedAt() });
|
|
191
|
+
expect(leave).toHaveBeenCalledWith({
|
|
192
|
+
reason: 'ended',
|
|
193
|
+
message: 'ring: call ended',
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('does not reconcile a call that is not in RINGING state', async () => {
|
|
198
|
+
const call = await setupRingingCall();
|
|
199
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
200
|
+
const leave = await rewatchWith(call, { oliver_1: rejectedAt() });
|
|
201
|
+
expect(leave).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('as the caller', () => {
|
|
205
|
+
const callee = CallRingPayload.members[0];
|
|
206
|
+
const members = [
|
|
207
|
+
callee,
|
|
208
|
+
{
|
|
209
|
+
...callee,
|
|
210
|
+
user_id: 'oliver_1',
|
|
211
|
+
user: { ...callee.user, id: 'oliver_1' },
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const setupOwnRingingCall = async () => {
|
|
216
|
+
const payload = {
|
|
217
|
+
...CallRingPayload,
|
|
218
|
+
call: {
|
|
219
|
+
...CallRingPayload.call,
|
|
220
|
+
created_by: { ...CallRingPayload.call.created_by, id: userId },
|
|
221
|
+
},
|
|
222
|
+
members,
|
|
223
|
+
};
|
|
224
|
+
vi.spyOn(client.streamClient, 'doAxiosRequest').mockResolvedValue({
|
|
225
|
+
duration: '1ms',
|
|
226
|
+
call: payload.call,
|
|
227
|
+
members,
|
|
228
|
+
own_capabilities: [],
|
|
229
|
+
} as GetCallResponse as never);
|
|
230
|
+
client.streamClient.dispatchEvent(payload as StreamVideoEvent);
|
|
231
|
+
await settled(getCallInitConcurrencyTag(payload.call_cid));
|
|
232
|
+
const [call] = client.state.calls;
|
|
233
|
+
expect(call.isCreatedByMe).toBeTruthy();
|
|
234
|
+
expect(call.state.callingState).toBe(CallingState.RINGING);
|
|
235
|
+
return call;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
it('leaves when every other member rejected while offline', async () => {
|
|
239
|
+
const call = await setupOwnRingingCall();
|
|
240
|
+
const leave = await rewatchWith(
|
|
241
|
+
call,
|
|
242
|
+
{ oliver_1: rejectedAt() },
|
|
243
|
+
{ members },
|
|
244
|
+
);
|
|
245
|
+
expect(leave).toHaveBeenCalledWith({
|
|
246
|
+
reject: true,
|
|
247
|
+
reason: 'cancel',
|
|
248
|
+
message: 'ring: everyone rejected',
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('joins when another member accepted while offline', async () => {
|
|
253
|
+
const call = await setupOwnRingingCall();
|
|
254
|
+
const join = vi.spyOn(call, 'join').mockResolvedValue(undefined);
|
|
255
|
+
await rewatchWith(
|
|
256
|
+
call,
|
|
257
|
+
{},
|
|
258
|
+
{ members, accepted_by: { oliver_1: rejectedAt() } },
|
|
259
|
+
);
|
|
260
|
+
expect(join).toHaveBeenCalledWith({ joinSource: 'ring-poll-api' });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
129
265
|
it('queryCalls returns an independent instance for registered calls', async () => {
|
|
130
266
|
// e.g. being on a call while watching a dashboard of calls:
|
|
131
267
|
// leaving the joined instance must not silence the dashboard instance
|
|
@@ -202,7 +202,7 @@ export type Logger = (
|
|
|
202
202
|
|
|
203
203
|
export type RingStatePollingOptions = {
|
|
204
204
|
/**
|
|
205
|
-
* Quiet time after the ring starts, before the first poll. Defaults to
|
|
205
|
+
* Quiet time after the ring starts, before the first poll. Defaults to 9_000.
|
|
206
206
|
*/
|
|
207
207
|
startAfterMs?: number;
|
|
208
208
|
|
|
@@ -298,6 +298,7 @@ export abstract class DeviceManager<
|
|
|
298
298
|
: (forceStopOrOptions?.forceStop ?? false);
|
|
299
299
|
|
|
300
300
|
this.state.prevStatus = this.state.optimisticStatus;
|
|
301
|
+
this.state.setError(undefined);
|
|
301
302
|
if (!forceStop && this.state.optimisticStatus === 'disabled') {
|
|
302
303
|
return;
|
|
303
304
|
}
|
|
@@ -310,6 +311,7 @@ export abstract class DeviceManager<
|
|
|
310
311
|
forceStop || this.state.disableMode === 'stop-tracks';
|
|
311
312
|
await this.muteStream(stopTracks);
|
|
312
313
|
this.state.setStatus('disabled');
|
|
314
|
+
this.state.setError(undefined);
|
|
313
315
|
} finally {
|
|
314
316
|
if (!signal.aborted) {
|
|
315
317
|
this.state.setPendingStatus(this.state.status);
|
|
@@ -638,7 +640,11 @@ export abstract class DeviceManager<
|
|
|
638
640
|
|
|
639
641
|
// the rootStream represents the stream coming from the actual device
|
|
640
642
|
// e.g. camera or microphone stream
|
|
643
|
+
this.state.setError(undefined);
|
|
641
644
|
rootStreamPromise = this.getSelectedStream(constraints as C);
|
|
645
|
+
rootStreamPromise.catch((error) => {
|
|
646
|
+
this.state.setError(error);
|
|
647
|
+
});
|
|
642
648
|
// we publish the last MediaStream of the chain
|
|
643
649
|
stream = await this.filters.reduce(
|
|
644
650
|
(parent, entry) =>
|
|
@@ -837,11 +843,30 @@ export abstract class DeviceManager<
|
|
|
837
843
|
const devices = getCurrentValue(this.listDevices()) || [];
|
|
838
844
|
const currentDevice =
|
|
839
845
|
this.findDevice(devices, selectedDevice) ??
|
|
840
|
-
createSyntheticDevice(
|
|
846
|
+
createSyntheticDevice(
|
|
847
|
+
selectedDevice,
|
|
848
|
+
deviceKind,
|
|
849
|
+
this.getActiveTrackLabel(selectedDevice),
|
|
850
|
+
);
|
|
841
851
|
|
|
842
852
|
writePreferences(currentDevice, deviceKey, muted, storageKey);
|
|
843
853
|
}
|
|
844
854
|
|
|
855
|
+
/**
|
|
856
|
+
* Returns the label of the active track if it was captured from the given
|
|
857
|
+
* device. Used as a fallback when the device list hasn't been enumerated yet,
|
|
858
|
+
* so the persisted preference still carries the device label.
|
|
859
|
+
*/
|
|
860
|
+
private getActiveTrackLabel = (deviceId: string) => {
|
|
861
|
+
const stream = this.state.rootMediaStream;
|
|
862
|
+
const tracks =
|
|
863
|
+
this.trackType === TrackType.AUDIO
|
|
864
|
+
? stream?.getAudioTracks()
|
|
865
|
+
: stream?.getVideoTracks();
|
|
866
|
+
const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
|
|
867
|
+
return track?.label;
|
|
868
|
+
};
|
|
869
|
+
|
|
845
870
|
protected async applyPersistedPreferences(
|
|
846
871
|
enabledInCallType: boolean,
|
|
847
872
|
forceDisabled = false,
|
|
@@ -28,6 +28,7 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
28
28
|
protected defaultConstraintsSubject = new BehaviorSubject<C | undefined>(
|
|
29
29
|
undefined,
|
|
30
30
|
);
|
|
31
|
+
protected errorSubject = new BehaviorSubject<Error | undefined>(undefined);
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* @internal
|
|
@@ -70,6 +71,14 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
70
71
|
*/
|
|
71
72
|
defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* An Observable that emits the error of the last failed attempt to start
|
|
76
|
+
* the device (e.g. the device is in use by another app), or `undefined`.
|
|
77
|
+
* For screen share, this includes the user dismissing the picker.
|
|
78
|
+
* Cleared when the device is started again or disabled.
|
|
79
|
+
*/
|
|
80
|
+
error$ = this.errorSubject.asObservable().pipe(distinctUntilChanged());
|
|
81
|
+
|
|
73
82
|
/**
|
|
74
83
|
* An observable that will emit `true` if browser/system permission
|
|
75
84
|
* is granted (or at least hasn't been denied), `false` otherwise.
|
|
@@ -152,6 +161,21 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
|
|
|
152
161
|
return RxUtils.getCurrentValue(this.rootMediaStream$);
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The error of the last failed attempt to start the device, or `undefined`.
|
|
166
|
+
*/
|
|
167
|
+
get error() {
|
|
168
|
+
return RxUtils.getCurrentValue(this.error$);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @internal
|
|
173
|
+
* @param error the error to set.
|
|
174
|
+
*/
|
|
175
|
+
setError(error: Error | undefined) {
|
|
176
|
+
RxUtils.setCurrentValue(this.errorSubject, error);
|
|
177
|
+
}
|
|
178
|
+
|
|
155
179
|
/**
|
|
156
180
|
* @internal
|
|
157
181
|
* @param status
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { type BrowserPermissionState } from './BrowserPermission';
|
|
9
9
|
import { MicrophoneManagerState } from './MicrophoneManagerState';
|
|
10
10
|
import { TrackDisableMode } from './DeviceManagerState';
|
|
11
|
-
import { getAudioDevices, getAudioStream } from './devices';
|
|
11
|
+
import { getAudioDevices, getAudioStream, resolveDeviceId } from './devices';
|
|
12
12
|
import { AudioBitrateProfile, TrackType } from '../gen/video/sfu/models/models';
|
|
13
13
|
import { createSoundDetector } from '../helpers/sound-detector';
|
|
14
14
|
import { createNoAudioDetector } from '../helpers/no-audio-detector';
|
|
@@ -64,16 +64,12 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
64
64
|
this.call.state.callingState$,
|
|
65
65
|
this.call.state.ownCapabilities$,
|
|
66
66
|
this.state.selectedDevice$,
|
|
67
|
-
this.state.status$,
|
|
68
67
|
this.state.browserPermissionState$,
|
|
68
|
+
// only a trigger: queued handlers can run after the status changed
|
|
69
|
+
// again, so the live status is read below
|
|
70
|
+
this.state.status$,
|
|
69
71
|
]),
|
|
70
|
-
async ([
|
|
71
|
-
callingState,
|
|
72
|
-
ownCapabilities,
|
|
73
|
-
deviceId,
|
|
74
|
-
status,
|
|
75
|
-
permissionState,
|
|
76
|
-
]) => {
|
|
72
|
+
async ([callingState, ownCapabilities, deviceId, permissionState]) => {
|
|
77
73
|
try {
|
|
78
74
|
if (callingState === CallingState.LEFT) {
|
|
79
75
|
// The muted-recording-prepared mode is reset in `callManager.stop()`
|
|
@@ -87,7 +83,7 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
87
83
|
|
|
88
84
|
if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
|
|
89
85
|
const hasPermission = await this.hasPermission(permissionState);
|
|
90
|
-
if (hasPermission && status !== 'enabled') {
|
|
86
|
+
if (hasPermission && this.state.status !== 'enabled') {
|
|
91
87
|
this.setMutedRecordingPrepared(true);
|
|
92
88
|
await this.startSpeakingWhileMutedDetection(deviceId);
|
|
93
89
|
} else {
|
|
@@ -450,6 +446,61 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
450
446
|
return getAudioStream(constraints, this.call.tracer);
|
|
451
447
|
}
|
|
452
448
|
|
|
449
|
+
/**
|
|
450
|
+
* On mute, hands a clone of the live mic track to the speaking while muted
|
|
451
|
+
* detection, so the device stays captured after the mic tracks are stopped.
|
|
452
|
+
* Releasing and re-acquiring the mic flips Bluetooth headsets between audio
|
|
453
|
+
* profiles.
|
|
454
|
+
*/
|
|
455
|
+
protected override async muteStream(stopTracks: boolean = true) {
|
|
456
|
+
const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
|
|
457
|
+
const clone =
|
|
458
|
+
track?.readyState === 'live' &&
|
|
459
|
+
this.state.optimisticStatus === 'disabled' &&
|
|
460
|
+
this.canHandOffMic()
|
|
461
|
+
? track.clone()
|
|
462
|
+
: undefined;
|
|
463
|
+
try {
|
|
464
|
+
await super.muteStream(stopTracks);
|
|
465
|
+
} catch (err) {
|
|
466
|
+
clone?.stop();
|
|
467
|
+
throw err;
|
|
468
|
+
}
|
|
469
|
+
if (!clone) return;
|
|
470
|
+
|
|
471
|
+
// the mic, the call, or the selected device may have changed while
|
|
472
|
+
// unpublishing
|
|
473
|
+
const deviceId = resolveDeviceId(
|
|
474
|
+
clone.getSettings().deviceId,
|
|
475
|
+
'audioinput',
|
|
476
|
+
);
|
|
477
|
+
if (
|
|
478
|
+
clone.readyState !== 'live' ||
|
|
479
|
+
!this.canHandOffMic() ||
|
|
480
|
+
deviceId !== this.state.selectedDevice
|
|
481
|
+
) {
|
|
482
|
+
clone.stop();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
|
|
486
|
+
clone.stop();
|
|
487
|
+
this.logger.warn('Could not hand off mic to speaking while muted', err);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Whether a mute can hand the mic over to the speaking while muted detection.
|
|
493
|
+
*/
|
|
494
|
+
private canHandOffMic = () => {
|
|
495
|
+
const { callingState, ownCapabilities } = this.call.state;
|
|
496
|
+
return (
|
|
497
|
+
!isReactNative() &&
|
|
498
|
+
this.speakingWhileMutedNotificationEnabled &&
|
|
499
|
+
callingState === CallingState.JOINED &&
|
|
500
|
+
ownCapabilities.includes(OwnCapability.SEND_AUDIO)
|
|
501
|
+
);
|
|
502
|
+
};
|
|
503
|
+
|
|
453
504
|
protected override doSetAudioBitrateProfile(profile: AudioBitrateProfile) {
|
|
454
505
|
this.setDefaultConstraints({
|
|
455
506
|
...this.state.defaultConstraints,
|
|
@@ -474,10 +525,18 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
474
525
|
}
|
|
475
526
|
}
|
|
476
527
|
|
|
477
|
-
private async startSpeakingWhileMutedDetection(
|
|
528
|
+
private async startSpeakingWhileMutedDetection(
|
|
529
|
+
deviceId?: string,
|
|
530
|
+
track?: MediaStreamTrack,
|
|
531
|
+
) {
|
|
478
532
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
479
|
-
if (
|
|
533
|
+
if (
|
|
534
|
+
this.soundDetectorCleanup &&
|
|
535
|
+
this.soundDetectorDeviceId === deviceId
|
|
536
|
+
) {
|
|
537
|
+
track?.stop();
|
|
480
538
|
return;
|
|
539
|
+
}
|
|
481
540
|
|
|
482
541
|
await this.teardownSpeakingWhileMutedDetection();
|
|
483
542
|
|
|
@@ -497,11 +556,14 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
497
556
|
unsubscribe();
|
|
498
557
|
};
|
|
499
558
|
} else {
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
559
|
+
// Use the handed-off mic track, or start a new stream that's not
|
|
560
|
+
// connected to publisher
|
|
561
|
+
const stream = track
|
|
562
|
+
? new MediaStream([track])
|
|
563
|
+
: await this.getStream({
|
|
564
|
+
...this.state.defaultConstraints,
|
|
565
|
+
deviceId: { exact: deviceId },
|
|
566
|
+
});
|
|
505
567
|
this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
|
|
506
568
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
507
569
|
});
|
|
@@ -49,7 +49,7 @@ export class SpeakerManager {
|
|
|
49
49
|
|
|
50
50
|
private async applyWeb() {
|
|
51
51
|
const { enabled, storageKey } = this.devicePersistence;
|
|
52
|
-
if (!enabled) return;
|
|
52
|
+
if (!enabled || !this.state.isDeviceSelectionSupported) return;
|
|
53
53
|
|
|
54
54
|
const preferences = readPreferences(storageKey);
|
|
55
55
|
const preferenceList = toPreferenceList(preferences.speaker);
|