@stream-io/video-client 1.61.1 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/index.browser.es.js +281 -152
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +281 -152
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +281 -152
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/coordinator/connection/types.d.ts +1 -1
  9. package/dist/src/devices/DeviceManager.d.ts +6 -0
  10. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
  11. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
  12. package/dist/src/helpers/DynascaleManager.d.ts +15 -0
  13. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
  14. package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
  15. package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
  16. package/dist/src/ringing/reconcileRingState.d.ts +8 -7
  17. package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
  18. package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
  19. package/dist/src/stats/rtc/types.d.ts +0 -11
  20. package/package.json +1 -1
  21. package/src/Call.ts +0 -3
  22. package/src/StreamVideoClient.ts +8 -0
  23. package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
  24. package/src/coordinator/connection/types.ts +1 -1
  25. package/src/devices/DeviceManager.ts +20 -1
  26. package/src/devices/__tests__/CameraManager.test.ts +50 -1
  27. package/src/devices/__tests__/mediaStreamTestHelpers.ts +10 -6
  28. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
  29. package/src/helpers/AudioBindingsWatchdog.ts +91 -35
  30. package/src/helpers/DynascaleManager.ts +84 -9
  31. package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
  32. package/src/helpers/__tests__/DynascaleManager.test.ts +247 -9
  33. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
  34. package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
  35. package/src/helpers/mediaInterruptions.ts +37 -0
  36. package/src/reporting/ClientEventReporter.ts +1 -1
  37. package/src/ringing/RingStatePoller.ts +1 -1
  38. package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
  39. package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
  40. package/src/ringing/reconcileRingState.ts +18 -10
  41. package/src/stats/SfuStatsReporter.ts +19 -93
  42. package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
  43. package/src/stats/rtc/StatsTracer.ts +6 -6
  44. package/src/stats/rtc/types.ts +0 -13
@@ -138,7 +138,7 @@ export type CallEventListener<E extends keyof AllCallEvents> = (event: AllCallEv
138
138
  export type Logger = (logLevel: LogLevel, message: string, ...args: unknown[]) => void;
139
139
  export type RingStatePollingOptions = {
140
140
  /**
141
- * Quiet time after the ring starts, before the first poll. Defaults to 15_000.
141
+ * Quiet time after the ring starts, before the first poll. Defaults to 9_000.
142
142
  */
143
143
  startAfterMs?: number;
144
144
  /**
@@ -130,6 +130,12 @@ export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C =
130
130
  protected findDevice(devices: MediaDeviceInfo[], deviceId: string): MediaDeviceInfo | undefined;
131
131
  private dispatchDeviceDisconnectedEvent;
132
132
  private persistPreference;
133
+ /**
134
+ * Returns the label of the active track if it was captured from the given
135
+ * device. Used as a fallback when the device list hasn't been enumerated yet,
136
+ * so the persisted preference still carries the device label.
137
+ */
138
+ private getActiveTrackLabel;
133
139
  protected applyPersistedPreferences(enabledInCallType: boolean, forceDisabled?: boolean): Promise<boolean>;
134
140
  private applyMutedState;
135
141
  }
@@ -8,7 +8,6 @@ import { PerformanceStats } from '../models/models';
8
8
  import { RTMPIngress } from '../models/models';
9
9
  import { AppleState } from '../models/models';
10
10
  import { AndroidState } from '../models/models';
11
- import { InputDevices } from '../models/models';
12
11
  import { RemoteOutboundRtp } from '../models/models';
13
12
  import { RemoteInboundRtp } from '../models/models';
14
13
  import { OutboundRtp } from '../models/models';
@@ -129,14 +128,6 @@ export interface SendStatsRequest {
129
128
  * @generated from protobuf field: string session_id = 1;
130
129
  */
131
130
  sessionId: string;
132
- /**
133
- * @generated from protobuf field: string subscriber_stats = 2;
134
- */
135
- subscriberStats: string;
136
- /**
137
- * @generated from protobuf field: string publisher_stats = 3;
138
- */
139
- publisherStats: string;
140
131
  /**
141
132
  * @generated from protobuf field: string webrtc_version = 4;
142
133
  */
@@ -149,14 +140,6 @@ export interface SendStatsRequest {
149
140
  * @generated from protobuf field: string sdk_version = 6;
150
141
  */
151
142
  sdkVersion: string;
152
- /**
153
- * @generated from protobuf field: stream.video.sfu.models.InputDevices audio_devices = 7;
154
- */
155
- audioDevices?: InputDevices;
156
- /**
157
- * @generated from protobuf field: stream.video.sfu.models.InputDevices video_devices = 8;
158
- */
159
- videoDevices?: InputDevices;
160
143
  /**
161
144
  * @generated from protobuf oneof: device_state
162
145
  */
@@ -183,16 +166,6 @@ export interface SendStatsRequest {
183
166
  * @generated from protobuf field: stream.video.sfu.models.RTMPIngress rtmp = 12;
184
167
  */
185
168
  rtmp?: RTMPIngress;
186
- /**
187
- * @deprecated
188
- * @generated from protobuf field: string subscriber_rtc_stats = 13 [deprecated = true];
189
- */
190
- subscriberRtcStats: string;
191
- /**
192
- * @deprecated
193
- * @generated from protobuf field: string publisher_rtc_stats = 14 [deprecated = true];
194
- */
195
- publisherRtcStats: string;
196
169
  /**
197
170
  * @generated from protobuf field: string rtc_stats = 15;
198
171
  */
@@ -4,9 +4,12 @@ import { Tracer } from '../stats';
4
4
  /**
5
5
  * Tracks audio element bindings and periodically warns about
6
6
  * remote participants whose audio streams have no bound element.
7
+ * On the same tick, it traces the playback state of the bound elements,
8
+ * limited to the bindings whose state changed since the previous trace.
7
9
  */
8
10
  export declare class AudioBindingsWatchdog {
9
11
  private bindings;
12
+ private tracedStates;
10
13
  private enabled;
11
14
  private watchdogInterval?;
12
15
  private readonly unsubscribeCallingState;
@@ -24,8 +27,8 @@ export declare class AudioBindingsWatchdog {
24
27
  */
25
28
  unregister: (sessionId: string, trackType: AudioTrackType) => void;
26
29
  /**
27
- * Enables or disables the watchdog.
28
- * When disabled, the periodic check stops but bindings are still tracked.
30
+ * Enables or disables the dangling binding warnings.
31
+ * Bindings are still tracked and their playback state is still traced.
29
32
  */
30
33
  setEnabled: (enabled: boolean) => void;
31
34
  /**
@@ -33,5 +36,7 @@ export declare class AudioBindingsWatchdog {
33
36
  */
34
37
  dispose: () => void;
35
38
  private start;
39
+ private warnAboutDanglingBindings;
40
+ private traceBindingStates;
36
41
  private stop;
37
42
  }
@@ -17,6 +17,9 @@ export declare class DynascaleManager {
17
17
  private readonly tracer;
18
18
  private useWebAudio;
19
19
  private audioContext;
20
+ private playbackWatchdogs;
21
+ private stopWatchingInterruptions;
22
+ private stopTracingAudioSession;
20
23
  private trackSubscriptionManager;
21
24
  private blockedAudioTracker;
22
25
  /**
@@ -64,6 +67,18 @@ export declare class DynascaleManager {
64
67
  * @returns a cleanup function that will unbind the audio element.
65
68
  */
66
69
  bindAudioElement: (audioElement: HTMLAudioElement, sessionId: string, trackType: AudioTrackType) => (() => void) | undefined;
70
+ /**
71
+ * Creates a playback watchdog that is re-armed whenever a media
72
+ * interruption (backgrounding, phone call, Siri) ends.
73
+ */
74
+ private createPlaybackWatchdog;
75
+ private disposePlaybackWatchdog;
76
+ private rearmPlayback;
77
+ /**
78
+ * Traces the WebKit audio session state (initial value and changes) in
79
+ * both playback modes, as it explains most iOS playback interruptions.
80
+ */
81
+ private traceAudioSessionState;
67
82
  private getOrCreateAudioContext;
68
83
  private resumeAudioContext;
69
84
  }
@@ -4,7 +4,10 @@ export type MediaPlaybackWatchdogOptions = {
4
4
  element: HTMLMediaElement;
5
5
  kind: MediaKind;
6
6
  tracer: Tracer;
7
+ sessionId?: string;
8
+ trackType?: string;
7
9
  isBlocked?: () => boolean;
10
+ onGiveUp?: () => void;
8
11
  };
9
12
  /**
10
13
  * Watches a single audio or video element and attempts to recover playback
@@ -13,7 +16,9 @@ export type MediaPlaybackWatchdogOptions = {
13
16
  export declare class MediaPlaybackWatchdog {
14
17
  private logger;
15
18
  private readonly kind;
19
+ private readonly traceInfo;
16
20
  private readonly isBlocked;
21
+ private readonly onGiveUp;
17
22
  private element;
18
23
  private tracer;
19
24
  private controller;
@@ -25,9 +30,22 @@ export declare class MediaPlaybackWatchdog {
25
30
  constructor(opts: MediaPlaybackWatchdogOptions);
26
31
  private attach;
27
32
  dispose: () => void;
33
+ /**
34
+ * Starts a fresh recovery cycle, e.g. after a media interruption ended.
35
+ * Unlike a regular pause-triggered recovery, it also tries elements that
36
+ * were skipped as blocked or not ready, since those conditions may have
37
+ * cleared together with the interruption.
38
+ */
39
+ rearm: () => void;
28
40
  private clearTimers;
29
41
  private onPlaying;
30
42
  private onSettled;
43
+ /**
44
+ * Media elements backed by a MediaStream fire `suspend` routinely (after
45
+ * the initial load, on source swaps, on layer switches), so a `suspend`
46
+ * that needs no recovery is ignored without a trace. An explicit `pause`
47
+ * is rare and always traced, along with the reason recovery was skipped.
48
+ */
31
49
  private onPauseOrSuspend;
32
50
  private scheduleRecovery;
33
51
  private computeSkipReason;
@@ -0,0 +1,14 @@
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 declare const isMediaInterrupted: () => boolean;
7
+ /**
8
+ * Invokes `onResumed` every time the page leaves a media interruption,
9
+ * as defined by {@link isMediaInterrupted}.
10
+ *
11
+ * @param onResumed the callback to invoke.
12
+ * @returns a function that stops watching.
13
+ */
14
+ export declare const watchMediaInterruptions: (onResumed: () => void) => (() => void);
@@ -9,7 +9,7 @@ export type JoinReason = 'first-attempt' | 'network-available' | 'migration' | '
9
9
  /**
10
10
  * What triggered an automatic join, reported as `source` on the call's
11
11
  * CoordinatorJoin events. `ring-ws` is a ring WebSocket event, `ring-poll-api`
12
- * is the ring state poller.
12
+ * is the ring state poller, or the call refresh after a WS reconnect.
13
13
  */
14
14
  export type JoinSource = 'ring-ws' | 'ring-poll-api';
15
15
  export type ClientEventStandardCode = 'CLIENT_ABORTED' | 'BACKEND_LEAVE' | 'REQUEST_TIMEOUT' | 'NETWORK_ERROR' | 'SFU_ERROR' | 'SFU_GO_AWAY' | 'ICE_CONNECTIVITY_FAILED' | 'DTLS_CONNECTIVITY_FAILED';
@@ -3,15 +3,16 @@ import type { JoinSource } from '../reporting';
3
3
  /**
4
4
  * Decides what a ringing call should do next, based on the current call state.
5
5
  *
6
- * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
7
- * state poller both run this. They differ only in how the state got there: the
8
- * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
9
- * poller applies the polled ring state itself.
6
+ * The `call.accepted` and `call.rejected` handlers, the ring state poller and
7
+ * the rewatch after a WS reconnect all run this. They differ only in how the
8
+ * state got there: the handlers rely on `CallState.updateFromEvent`, which runs
9
+ * before them, while the poller applies the polled ring state itself and the
10
+ * rewatch applies the queried call state.
10
11
  *
11
12
  * @param call the call to reconcile.
12
- * @param joinSource which of the two triggered this run, reported on the
13
- * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
14
- * poller.
13
+ * @param joinSource what triggered this run, reported on the caller's join:
14
+ * `ring-ws` for the event handlers, `ring-poll-api` for the poller and the
15
+ * rewatch (both come from an API response).
15
16
  * @returns whether the ring reached a terminal state. A failed join is not
16
17
  * terminal: the caller should keep trying while the ring is open.
17
18
  */
@@ -3,16 +3,11 @@ import { StatsOptions } from '../gen/coordinator';
3
3
  import { Publisher, Subscriber } from '../rtc';
4
4
  import { Tracer } from './rtc';
5
5
  import { ClientDetails, WebsocketReconnectStrategy } from '../gen/video/sfu/models/models';
6
- import { CameraManager, MicrophoneManager } from '../devices';
7
- import { CallState } from '../store';
8
6
  export type SfuStatsReporterOptions = {
9
7
  options: StatsOptions;
10
8
  clientDetails: ClientDetails;
11
9
  subscriber: Subscriber;
12
10
  publisher?: Publisher;
13
- microphone: MicrophoneManager;
14
- camera: CameraManager;
15
- state: CallState;
16
11
  tracer: Tracer;
17
12
  unifiedSessionId: string;
18
13
  };
@@ -22,24 +17,17 @@ export declare class SfuStatsReporter {
22
17
  private readonly sfuClient;
23
18
  private readonly subscriber;
24
19
  private readonly publisher?;
25
- private readonly microphone;
26
- private readonly camera;
27
- private readonly state;
28
20
  private readonly tracer;
29
21
  private readonly unifiedSessionId;
30
22
  private intervalId;
31
23
  private timeoutId;
32
24
  private reportCount;
33
- private unsubscribeDevicePermissionsSubscription?;
34
- private unsubscribeListDevicesSubscription?;
35
25
  private readonly sdkName;
36
26
  private readonly sdkVersion;
37
27
  private readonly webRTCVersion;
38
- private readonly inputDevices;
39
28
  private readonly statsConcurrencyTag;
40
29
  private isStopped;
41
- constructor(sfuClient: StreamSfuClient, { options, clientDetails, subscriber, publisher, microphone, camera, state, tracer, unifiedSessionId, }: SfuStatsReporterOptions);
42
- private observeDevice;
30
+ constructor(sfuClient: StreamSfuClient, { options, clientDetails, subscriber, publisher, tracer, unifiedSessionId, }: SfuStatsReporterOptions);
43
31
  sendConnectionTime: (connectionTimeSeconds: number) => void;
44
32
  sendReconnectionTime: (strategy: WebsocketReconnectStrategy, timeSeconds: number) => void;
45
33
  private sendTelemetryData;
@@ -1,5 +1,5 @@
1
- import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
2
- import type { ComputedStats, PendingDelta } from './types';
1
+ import { PeerType, PerformanceStats, TrackType } from '../../gen/video/sfu/models/models';
2
+ import type { PendingDelta } from './types';
3
3
  /**
4
4
  * StatsTracer is a class that collects and processes WebRTC stats.
5
5
  * It is used to track the performance of the WebRTC connection
@@ -32,13 +32,13 @@ export declare class StatsTracer {
32
32
  */
33
33
  constructor(pc: RTCPeerConnection, peerType: PeerType, trackIdToTrackType: Map<string, TrackType>, statsTimestampDriftThresholdMs?: number, maxPendingDeltas?: number);
34
34
  /**
35
- * Samples the RTCPeerConnection: returns the current stats report and the
36
- * derived performance stats, and appends the delta-compressed sample to the
37
- * un-acked delivery chain (retrieved via `getPendingDeltas`).
35
+ * Samples the RTCPeerConnection: returns the derived performance stats and
36
+ * appends the delta-compressed sample to the un-acked delivery chain
37
+ * (retrieved via `getPendingDeltas`).
38
38
  *
39
39
  * @internal
40
40
  */
41
- takeSample: () => Promise<ComputedStats>;
41
+ takeSample: () => Promise<PerformanceStats[]>;
42
42
  /**
43
43
  * Returns a stable copy of the un-acked delta chain to transmit, oldest first.
44
44
  *
@@ -1,4 +1,3 @@
1
- import type { PerformanceStats } from '../../gen/video/sfu/models/models';
2
1
  export type RTCStatsDataType = RTCConfiguration | RTCIceCandidate | RTCSignalingState | RTCIceConnectionState | RTCIceGatheringState | RTCPeerConnectionState | [number | null | string] | string | boolean | RTCOfferOptions | [string | RTCDataChannelInit | undefined] | (RTCOfferOptions | undefined) | RTCSessionDescriptionInit | (RTCIceCandidateInit | RTCIceCandidate) | object | number | null | undefined;
3
2
  export type TraceKey = 'device-enumeration' | (string & {});
4
3
  export type Trace = (tag: string, data: RTCStatsDataType) => void;
@@ -12,16 +11,6 @@ export type TraceSlice = {
12
11
  snapshot: TraceRecord[];
13
12
  rollback: () => void;
14
13
  };
15
- export type ComputedStats = {
16
- /**
17
- * Current stats from the RTCPeerConnection.
18
- */
19
- stats: RTCStatsReport;
20
- /**
21
- * The current iteration of the stats.
22
- */
23
- performanceStats: PerformanceStats[];
24
- };
25
14
  /**
26
15
  * A single, not-yet-delivered delta-compressed stats sample.
27
16
  * The `delta` is computed against the immediately preceding sample, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.61.1",
3
+ "version": "1.61.2",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -1812,9 +1812,6 @@ export class Call {
1812
1812
  options: statsOptions,
1813
1813
  subscriber: this.subscriber,
1814
1814
  publisher: this.publisher,
1815
- microphone: this.microphone,
1816
- camera: this.camera,
1817
- state: this.state,
1818
1815
  tracer: this.tracer,
1819
1816
  unifiedSessionId,
1820
1817
  });
@@ -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 15_000.
205
+ * Quiet time after the ring starts, before the first poll. Defaults to 9_000.
206
206
  */
207
207
  startAfterMs?: number;
208
208
 
@@ -837,11 +837,30 @@ export abstract class DeviceManager<
837
837
  const devices = getCurrentValue(this.listDevices()) || [];
838
838
  const currentDevice =
839
839
  this.findDevice(devices, selectedDevice) ??
840
- createSyntheticDevice(selectedDevice, deviceKind);
840
+ createSyntheticDevice(
841
+ selectedDevice,
842
+ deviceKind,
843
+ this.getActiveTrackLabel(selectedDevice),
844
+ );
841
845
 
842
846
  writePreferences(currentDevice, deviceKey, muted, storageKey);
843
847
  }
844
848
 
849
+ /**
850
+ * Returns the label of the active track if it was captured from the given
851
+ * device. Used as a fallback when the device list hasn't been enumerated yet,
852
+ * so the persisted preference still carries the device label.
853
+ */
854
+ private getActiveTrackLabel = (deviceId: string) => {
855
+ const stream = this.state.rootMediaStream;
856
+ const tracks =
857
+ this.trackType === TrackType.AUDIO
858
+ ? stream?.getAudioTracks()
859
+ : stream?.getVideoTracks();
860
+ const track = tracks?.find((t) => t.getSettings().deviceId === deviceId);
861
+ return track?.label;
862
+ };
863
+
845
864
  protected async applyPersistedPreferences(
846
865
  enabledInCallType: boolean,
847
866
  forceDisabled = false,
@@ -17,7 +17,8 @@ import {
17
17
  import { createVideoStreamForDevice } from './mediaStreamTestHelpers';
18
18
  import { TrackType } from '../../gen/video/sfu/models/models';
19
19
  import { CameraManager } from '../CameraManager';
20
- import { of } from 'rxjs';
20
+ import { getVideoDevices } from '../devices';
21
+ import { NEVER, of } from 'rxjs';
21
22
  import { PermissionsContext } from '../../permissions';
22
23
  import { Tracer } from '../../stats';
23
24
  import {
@@ -598,6 +599,54 @@ describe('CameraManager', () => {
598
599
  });
599
600
  }
600
601
  });
602
+
603
+ it('persists the label from the active track when the device list is not enumerated yet', async () => {
604
+ const storageKey = '@test/device-preferences-camera-cold-list';
605
+ const localStorageMock = createLocalStorageMock();
606
+ const originalWindow = globalThis.window;
607
+ Object.defineProperty(globalThis, 'window', {
608
+ configurable: true,
609
+ value: { localStorage: localStorageMock },
610
+ });
611
+
612
+ const device = mockVideoDevices[1];
613
+ vi.mocked(getVideoDevices).mockReturnValue(NEVER);
614
+ vi.mocked(getVideoStream).mockResolvedValue(
615
+ createVideoStreamForDevice(device.deviceId, 'user', device.label),
616
+ );
617
+ vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
618
+ of('granted'),
619
+ );
620
+
621
+ const coldListManager = new CameraManager(call, {
622
+ enabled: true,
623
+ storageKey,
624
+ });
625
+
626
+ try {
627
+ await coldListManager.enable();
628
+ await coldListManager.statusChangeSettled();
629
+
630
+ expect(coldListManager.state.selectedDevice).toBe(device.deviceId);
631
+ const [persisted] = toPreferenceList(
632
+ readPreferences(storageKey).camera,
633
+ );
634
+ expect(persisted).toEqual({
635
+ selectedDeviceId: device.deviceId,
636
+ selectedDeviceLabel: device.label,
637
+ muted: false,
638
+ });
639
+ } finally {
640
+ coldListManager.dispose();
641
+ vi.mocked(getVideoDevices).mockImplementation(() =>
642
+ of(mockVideoDevices),
643
+ );
644
+ Object.defineProperty(globalThis, 'window', {
645
+ configurable: true,
646
+ value: originalWindow,
647
+ });
648
+ }
649
+ });
601
650
  });
602
651
 
603
652
  afterEach(() => {
@@ -43,13 +43,17 @@ export const createAudioStreamForDevice = (
43
43
  export const createVideoStreamForDevice = (
44
44
  deviceId: string,
45
45
  facingMode: 'user' | 'environment' = 'user',
46
+ label = '',
46
47
  ): MediaStream => {
47
- const track = createTrack({
48
- deviceId,
49
- width: 1280,
50
- height: 720,
51
- facingMode,
52
- });
48
+ const track = createTrack(
49
+ {
50
+ deviceId,
51
+ width: 1280,
52
+ height: 720,
53
+ facingMode,
54
+ },
55
+ { label },
56
+ );
53
57
 
54
58
  return {
55
59
  getTracks: () => [track],