@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.
- package/CHANGELOG.md +9 -0
- package/dist/index.browser.es.js +281 -152
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +281 -152
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +281 -152
- 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 +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/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
|
@@ -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
|
|
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
|
|
28
|
-
*
|
|
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
|
|
7
|
-
*
|
|
8
|
-
* handlers rely on `CallState.updateFromEvent`, which runs
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,
|
|
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 {
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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<
|
|
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
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
|
});
|
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
|
|
|
@@ -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(
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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],
|