@polymorfa/browser 0.1.0-dev.20260922094209
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/LICENSE +21 -0
- package/README.md +248 -0
- package/dist/calls/api.d.ts +22 -0
- package/dist/calls/api.d.ts.map +1 -0
- package/dist/calls/api.js +104 -0
- package/dist/calls/api.js.map +1 -0
- package/dist/calls/backend.d.ts +85 -0
- package/dist/calls/backend.d.ts.map +1 -0
- package/dist/calls/backend.js +141 -0
- package/dist/calls/backend.js.map +1 -0
- package/dist/calls/client.d.ts +56 -0
- package/dist/calls/client.d.ts.map +1 -0
- package/dist/calls/client.js +260 -0
- package/dist/calls/client.js.map +1 -0
- package/dist/calls/controller.d.ts +329 -0
- package/dist/calls/controller.d.ts.map +1 -0
- package/dist/calls/controller.js +1328 -0
- package/dist/calls/controller.js.map +1 -0
- package/dist/calls/diagnostics.d.ts +52 -0
- package/dist/calls/diagnostics.d.ts.map +1 -0
- package/dist/calls/diagnostics.js +188 -0
- package/dist/calls/diagnostics.js.map +1 -0
- package/dist/calls/index.d.ts +3 -0
- package/dist/calls/index.d.ts.map +1 -0
- package/dist/calls/index.js +5 -0
- package/dist/calls/index.js.map +1 -0
- package/dist/calls/internal.d.ts +8 -0
- package/dist/calls/internal.d.ts.map +1 -0
- package/dist/calls/internal.js +8 -0
- package/dist/calls/internal.js.map +1 -0
- package/dist/calls/media.d.ts +144 -0
- package/dist/calls/media.d.ts.map +1 -0
- package/dist/calls/media.js +512 -0
- package/dist/calls/media.js.map +1 -0
- package/dist/calls/signaling.d.ts +74 -0
- package/dist/calls/signaling.d.ts.map +1 -0
- package/dist/calls/signaling.js +169 -0
- package/dist/calls/signaling.js.map +1 -0
- package/dist/calls/socket.d.ts +95 -0
- package/dist/calls/socket.d.ts.map +1 -0
- package/dist/calls/socket.js +267 -0
- package/dist/calls/socket.js.map +1 -0
- package/dist/chat/composer.d.ts +59 -0
- package/dist/chat/composer.d.ts.map +1 -0
- package/dist/chat/composer.js +217 -0
- package/dist/chat/composer.js.map +1 -0
- package/dist/chat/conversation.d.ts +71 -0
- package/dist/chat/conversation.d.ts.map +1 -0
- package/dist/chat/conversation.js +204 -0
- package/dist/chat/conversation.js.map +1 -0
- package/dist/chat/index.d.ts +4 -0
- package/dist/chat/index.d.ts.map +1 -0
- package/dist/chat/index.js +4 -0
- package/dist/chat/index.js.map +1 -0
- package/dist/chat/voice-recorder.d.ts +75 -0
- package/dist/chat/voice-recorder.d.ts.map +1 -0
- package/dist/chat/voice-recorder.js +346 -0
- package/dist/chat/voice-recorder.js.map +1 -0
- package/dist/controller.d.ts +16 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +50 -0
- package/dist/controller.js.map +1 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +2 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/dropin/client.d.ts +82 -0
- package/dist/dropin/client.d.ts.map +1 -0
- package/dist/dropin/client.js +314 -0
- package/dist/dropin/client.js.map +1 -0
- package/dist/dropin/connect.d.ts +23 -0
- package/dist/dropin/connect.d.ts.map +1 -0
- package/dist/dropin/connect.js +54 -0
- package/dist/dropin/connect.js.map +1 -0
- package/dist/dropin/inbox.d.ts +131 -0
- package/dist/dropin/inbox.d.ts.map +1 -0
- package/dist/dropin/inbox.js +406 -0
- package/dist/dropin/inbox.js.map +1 -0
- package/dist/dropin/index.d.ts +5 -0
- package/dist/dropin/index.d.ts.map +1 -0
- package/dist/dropin/index.js +5 -0
- package/dist/dropin/index.js.map +1 -0
- package/dist/dropin/permissions.d.ts +29 -0
- package/dist/dropin/permissions.d.ts.map +1 -0
- package/dist/dropin/permissions.js +53 -0
- package/dist/dropin/permissions.js.map +1 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +12 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +13 -0
- package/dist/internal.js.map +1 -0
- package/dist/messaging/client.d.ts +54 -0
- package/dist/messaging/client.d.ts.map +1 -0
- package/dist/messaging/client.js +153 -0
- package/dist/messaging/client.js.map +1 -0
- package/dist/messaging/composer.d.ts +12 -0
- package/dist/messaging/composer.d.ts.map +1 -0
- package/dist/messaging/composer.js +36 -0
- package/dist/messaging/composer.js.map +1 -0
- package/dist/messaging/index.d.ts +4 -0
- package/dist/messaging/index.d.ts.map +1 -0
- package/dist/messaging/index.js +3 -0
- package/dist/messaging/index.js.map +1 -0
- package/dist/messaging/types.d.ts +209 -0
- package/dist/messaging/types.d.ts.map +1 -0
- package/dist/messaging/types.js +2 -0
- package/dist/messaging/types.js.map +1 -0
- package/dist/templates/controller.d.ts +45 -0
- package/dist/templates/controller.d.ts.map +1 -0
- package/dist/templates/controller.js +298 -0
- package/dist/templates/controller.js.map +1 -0
- package/dist/templates/index.d.ts +4 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +3 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/templates/transport.d.ts +7 -0
- package/dist/templates/transport.d.ts.map +1 -0
- package/dist/templates/transport.js +124 -0
- package/dist/templates/transport.js.map +1 -0
- package/dist/templates/types.d.ts +141 -0
- package/dist/templates/types.d.ts.map +1 -0
- package/dist/templates/types.js +2 -0
- package/dist/templates/types.js.map +1 -0
- package/dist/token.d.ts +37 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +152 -0
- package/dist/token.js.map +1 -0
- package/dist/transport.d.ts +53 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +328 -0
- package/dist/transport.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
import { CallClaimedError, CallReporter, createConnectionId, } from "@polymorfa/sdk/calls/internal";
|
|
2
|
+
import { BROWSER_REPORT_CLIENT, ConnectionDiagnostics, mediaErrorCode, } from "./diagnostics.js";
|
|
3
|
+
import { ObservableController, } from "../controller.js";
|
|
4
|
+
import { isCallClaimed } from "./signaling.js";
|
|
5
|
+
const DEFAULT_CAPABILITIES = Object.freeze({
|
|
6
|
+
video: true,
|
|
7
|
+
mute: true,
|
|
8
|
+
invite: true,
|
|
9
|
+
});
|
|
10
|
+
function capabilitiesOf(reported) {
|
|
11
|
+
return reported === undefined
|
|
12
|
+
? DEFAULT_CAPABILITIES
|
|
13
|
+
: { video: reported.video, invite: reported.invite, mute: true };
|
|
14
|
+
}
|
|
15
|
+
/** HTTP statuses on the offer that mean the call cannot be established at all. */
|
|
16
|
+
const TERMINAL_OFFER_REASONS = {
|
|
17
|
+
410: "pod_lost",
|
|
18
|
+
503: "capacity",
|
|
19
|
+
};
|
|
20
|
+
const ACTIVE = new Set([
|
|
21
|
+
"ringing",
|
|
22
|
+
"accepted",
|
|
23
|
+
"connecting",
|
|
24
|
+
"connected",
|
|
25
|
+
"reconnecting",
|
|
26
|
+
]);
|
|
27
|
+
const EMPTY_CALL = {
|
|
28
|
+
claimedByOther: false,
|
|
29
|
+
canJoin: false,
|
|
30
|
+
exclusive: false,
|
|
31
|
+
participants: [],
|
|
32
|
+
remoteVideos: [],
|
|
33
|
+
};
|
|
34
|
+
export class CallsController extends ObservableController {
|
|
35
|
+
#backend;
|
|
36
|
+
#mediaFactory;
|
|
37
|
+
#createKey;
|
|
38
|
+
#now;
|
|
39
|
+
#resumptionWindowMs;
|
|
40
|
+
#iceRestartAfterMs;
|
|
41
|
+
#setTimeout;
|
|
42
|
+
#clearTimeout;
|
|
43
|
+
#abort = new AbortController();
|
|
44
|
+
#unsubscribe;
|
|
45
|
+
#media;
|
|
46
|
+
#operation = 0;
|
|
47
|
+
#placing = false;
|
|
48
|
+
#resumeTimer;
|
|
49
|
+
#giveUpTimer;
|
|
50
|
+
/** The status resumption interrupted, restored when media comes back. */
|
|
51
|
+
#resumedFrom;
|
|
52
|
+
/** Per-kind switch counter, so a stale failure cannot undo a newer switch. */
|
|
53
|
+
#deviceSwitches = new Map();
|
|
54
|
+
/**
|
|
55
|
+
* Per kind, the device the live capture uses and the switch generation that
|
|
56
|
+
* applied it. Seeded when media opens; only successful switches move it, so
|
|
57
|
+
* a failed switch restores a device that is actually in use.
|
|
58
|
+
*/
|
|
59
|
+
#appliedDevices = new Map();
|
|
60
|
+
/** Incoming calls by id, oldest first. */
|
|
61
|
+
#invitations = new Map();
|
|
62
|
+
/** Rosters of listed invitations, kept current while another call shows. */
|
|
63
|
+
#rosters = new Map();
|
|
64
|
+
/**
|
|
65
|
+
* Recent calls this controller placed, answered or joined, or that were
|
|
66
|
+
* reported ended, oldest first, so a replayed or late invitation for one is
|
|
67
|
+
* not listed. Bounded like the Calls client's ended-call history.
|
|
68
|
+
*/
|
|
69
|
+
#answered = new Set();
|
|
70
|
+
#answering;
|
|
71
|
+
/** The user dismissed the call `#answering` names before the answer settled. */
|
|
72
|
+
#answerDismissed = false;
|
|
73
|
+
/** The call `#answering` names ended, or was hung up, before it settled. */
|
|
74
|
+
#answerCancelled = false;
|
|
75
|
+
/**
|
|
76
|
+
* Aborts the in-flight answer request. Only that call's end and disposal
|
|
77
|
+
* use it; other calls' events never invalidate the answer.
|
|
78
|
+
*/
|
|
79
|
+
#answerAbort = new AbortController();
|
|
80
|
+
#disposed = false;
|
|
81
|
+
#diagnosticsEnabled;
|
|
82
|
+
/** Reports for the open media connection. */
|
|
83
|
+
#diagnostics;
|
|
84
|
+
#remoteVideos = [];
|
|
85
|
+
#publicVideos = { source: [], videos: [] };
|
|
86
|
+
constructor(backend, mediaFactory, options = {}) {
|
|
87
|
+
super({
|
|
88
|
+
status: "idle",
|
|
89
|
+
capabilities: DEFAULT_CAPABILITIES,
|
|
90
|
+
video: false,
|
|
91
|
+
audioMuted: false,
|
|
92
|
+
videoMuted: false,
|
|
93
|
+
devices: [],
|
|
94
|
+
selectedDevices: {},
|
|
95
|
+
invitations: [],
|
|
96
|
+
answering: false,
|
|
97
|
+
...EMPTY_CALL,
|
|
98
|
+
}, options.now);
|
|
99
|
+
this.#backend = backend;
|
|
100
|
+
this.#mediaFactory = mediaFactory;
|
|
101
|
+
this.#now = options.now ?? Date.now;
|
|
102
|
+
this.#createKey =
|
|
103
|
+
options.createIdempotencyKey ?? (() => crypto.randomUUID());
|
|
104
|
+
this.#resumptionWindowMs = options.resumptionWindowMs ?? 15_000;
|
|
105
|
+
this.#iceRestartAfterMs = options.iceRestartAfterMs ?? 2_000;
|
|
106
|
+
this.#setTimeout =
|
|
107
|
+
options.setTimeout ?? globalThis.setTimeout.bind(globalThis);
|
|
108
|
+
this.#clearTimeout =
|
|
109
|
+
options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
|
|
110
|
+
this.#diagnosticsEnabled = options.diagnostics !== false;
|
|
111
|
+
}
|
|
112
|
+
initialize() {
|
|
113
|
+
this.assertActive();
|
|
114
|
+
if (this.#unsubscribe !== undefined)
|
|
115
|
+
return;
|
|
116
|
+
this.#unsubscribe = this.#backend.subscribe((event) => this.#receive(event));
|
|
117
|
+
this.transition({ ...callFields(this.getSnapshot()), status: "ready" });
|
|
118
|
+
}
|
|
119
|
+
/** The shared call model for the displayed call, when supplied by the backend. */
|
|
120
|
+
get call() {
|
|
121
|
+
const id = this.getSnapshot().callId;
|
|
122
|
+
return id === undefined ? undefined : this.#backend.getCall?.(id);
|
|
123
|
+
}
|
|
124
|
+
async place(to, options = {}) {
|
|
125
|
+
this.assertActive();
|
|
126
|
+
if (this.#placing)
|
|
127
|
+
throw new Error("A call placement is already in progress.");
|
|
128
|
+
this.#assertNotAnswering("place a call");
|
|
129
|
+
const current = this.getSnapshot();
|
|
130
|
+
if ((this.call && !this.call.ended && current.status !== "incoming") ||
|
|
131
|
+
(current.callId !== undefined &&
|
|
132
|
+
[...ACTIVE, "incoming"].includes(current.status)))
|
|
133
|
+
throw new Error("Finish the active call before placing another.");
|
|
134
|
+
const operation = this.#begin();
|
|
135
|
+
const video = options.video ?? false;
|
|
136
|
+
// Only a refused offer carries the pod's terminal hints.
|
|
137
|
+
let offering = false;
|
|
138
|
+
this.#placing = true;
|
|
139
|
+
try {
|
|
140
|
+
const { callId } = await this.#backend.place({
|
|
141
|
+
to,
|
|
142
|
+
video,
|
|
143
|
+
idempotencyKey: this.#createKey(),
|
|
144
|
+
...(options.exclusive === undefined
|
|
145
|
+
? {}
|
|
146
|
+
: { exclusive: options.exclusive }),
|
|
147
|
+
}, this.#abort.signal);
|
|
148
|
+
if (operation !== this.#operation)
|
|
149
|
+
return;
|
|
150
|
+
this.#remember(callId);
|
|
151
|
+
this.#remoteVideos = [];
|
|
152
|
+
// Roster and acceptance events that raced the placement request were
|
|
153
|
+
// applied to the shared call before this controller knew the id; start
|
|
154
|
+
// from them. An ended shared call is adopted below.
|
|
155
|
+
const shared = this.#backend.getCall?.(callId);
|
|
156
|
+
const answered = shared !== undefined &&
|
|
157
|
+
(shared.state === "connecting" || shared.state === "connected");
|
|
158
|
+
const capabilities = shared === undefined
|
|
159
|
+
? DEFAULT_CAPABILITIES
|
|
160
|
+
: capabilitiesOf(shared.capabilities);
|
|
161
|
+
// A call the platform says cannot carry video opens no camera.
|
|
162
|
+
const offered = video && capabilities.video;
|
|
163
|
+
this.transition({
|
|
164
|
+
...this.#baseFields(),
|
|
165
|
+
status: answered ? "accepted" : "ringing",
|
|
166
|
+
callId,
|
|
167
|
+
peer: to,
|
|
168
|
+
direction: "outgoing",
|
|
169
|
+
capabilities,
|
|
170
|
+
video: offered,
|
|
171
|
+
audioMuted: false,
|
|
172
|
+
videoMuted: false,
|
|
173
|
+
exclusive: options.exclusive === true,
|
|
174
|
+
...(shared === undefined ? {} : { participants: shared.participants }),
|
|
175
|
+
});
|
|
176
|
+
const call = this.call;
|
|
177
|
+
if (call?.ended) {
|
|
178
|
+
this.#receive({
|
|
179
|
+
type: "ended",
|
|
180
|
+
callId,
|
|
181
|
+
...(call.endReason === undefined ? {} : { reason: call.endReason }),
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
offering = true;
|
|
186
|
+
await this.#openMedia(callId, offered, operation);
|
|
187
|
+
}
|
|
188
|
+
catch (cause) {
|
|
189
|
+
this.#fail(cause, operation, "place_failed", offering);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
this.#placing = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Answer the displayed incoming call, or the invitation named by `callId`.
|
|
197
|
+
* `exclusive` defaults to `false`: other participants keep ringing and can
|
|
198
|
+
* join. A call claimed by another participant is not answered; the snapshot
|
|
199
|
+
* reports `claimedByOther`.
|
|
200
|
+
*/
|
|
201
|
+
async answer(options = {}) {
|
|
202
|
+
this.#assertNotAnswering("answer a call");
|
|
203
|
+
const current = this.#showInvitation(options.callId, "answer");
|
|
204
|
+
if (current.claimedByOther)
|
|
205
|
+
throw new CallClaimedError();
|
|
206
|
+
await this.#accept(current, {
|
|
207
|
+
video: (options.video ?? current.video) && current.capabilities.video,
|
|
208
|
+
exclusive: options.exclusive === true,
|
|
209
|
+
join: false,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Join the displayed (or named) call that another participant answered
|
|
214
|
+
* without claiming it. Never claims.
|
|
215
|
+
*/
|
|
216
|
+
async join(options = {}) {
|
|
217
|
+
this.#assertNotAnswering("join a call");
|
|
218
|
+
const current = this.#showInvitation(options.callId, "join");
|
|
219
|
+
if (current.claimedByOther)
|
|
220
|
+
throw new CallClaimedError();
|
|
221
|
+
if (!current.canJoin)
|
|
222
|
+
throw new Error("This call has not been answered without a claim.");
|
|
223
|
+
await this.#accept(current, {
|
|
224
|
+
video: (options.video ?? current.video) && current.capabilities.video,
|
|
225
|
+
exclusive: false,
|
|
226
|
+
join: true,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Decline the displayed (or named) ringing call. This ends the call for
|
|
231
|
+
* every participant; only call it on an explicit user action.
|
|
232
|
+
*/
|
|
233
|
+
async reject(options = {}) {
|
|
234
|
+
this.#assertNotAnswering("decline a call");
|
|
235
|
+
const current = this.#showInvitation(options.callId, "reject");
|
|
236
|
+
if (current.canJoin || current.claimedByOther || current.answeredBy)
|
|
237
|
+
throw new Error("The call is already answered and can no longer be declined.");
|
|
238
|
+
await this.#finish(current.callId, "rejected", (signal) => this.#backend.reject(current.callId, signal));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Stop showing an incoming call without declining it. Other participants
|
|
242
|
+
* keep ringing; the call is not affected.
|
|
243
|
+
*/
|
|
244
|
+
dismiss(callId) {
|
|
245
|
+
this.assertActive();
|
|
246
|
+
const current = this.getSnapshot();
|
|
247
|
+
const id = callId ?? current.callId;
|
|
248
|
+
if (id === undefined)
|
|
249
|
+
return;
|
|
250
|
+
// The answer in flight completes; it then leaves or ends this call.
|
|
251
|
+
if (id === this.#answering)
|
|
252
|
+
this.#answerDismissed = true;
|
|
253
|
+
const known = this.#forget(id);
|
|
254
|
+
if (current.callId === id && current.status === "incoming") {
|
|
255
|
+
this.#advance({ ...this.#baseFields(), status: "ready" });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (known)
|
|
259
|
+
this.transition({ ...callFields(current), status: current.status });
|
|
260
|
+
}
|
|
261
|
+
/** Show a known invitation while no call is active. */
|
|
262
|
+
select(callId) {
|
|
263
|
+
this.#showInvitation(callId, "select");
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Leave the displayed call: close this client's media connection. The call
|
|
267
|
+
* continues for the other participants. An outgoing call that has not
|
|
268
|
+
* connected has no other participants, so leaving it ends it instead;
|
|
269
|
+
* otherwise the callee would keep ringing.
|
|
270
|
+
*/
|
|
271
|
+
async leave() {
|
|
272
|
+
const current = this.getSnapshot();
|
|
273
|
+
const callId = current.callId;
|
|
274
|
+
if (callId === undefined)
|
|
275
|
+
return;
|
|
276
|
+
if (current.status === "incoming") {
|
|
277
|
+
this.dismiss(callId);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (current.status === "ended" || current.status === "error")
|
|
281
|
+
return;
|
|
282
|
+
if (isUnconnectedOutgoing(current)) {
|
|
283
|
+
await this.end();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const leave = this.#backend.leave;
|
|
287
|
+
const connectionId = this.#media?.connectionId ?? this.#backend.connectionId?.(callId);
|
|
288
|
+
await this.#finish(callId, "left", (signal) => leave === undefined
|
|
289
|
+
? Promise.resolve()
|
|
290
|
+
: leave.call(this.#backend, callId, connectionId, signal), { leave: leave === undefined });
|
|
291
|
+
}
|
|
292
|
+
/** End the displayed call for every participant. */
|
|
293
|
+
async end() {
|
|
294
|
+
const callId = this.getSnapshot().callId;
|
|
295
|
+
if (this.#answering !== undefined && callId !== this.#answering)
|
|
296
|
+
this.#assertNotAnswering("end another call");
|
|
297
|
+
if (callId !== undefined)
|
|
298
|
+
await this.#finish(callId, "hangup", (signal) => this.#backend.hangup(callId, signal));
|
|
299
|
+
}
|
|
300
|
+
/** Same as {@link end}: ends the call for every participant. */
|
|
301
|
+
hangup() {
|
|
302
|
+
return this.end();
|
|
303
|
+
}
|
|
304
|
+
setMuted(muted) {
|
|
305
|
+
this.assertActive();
|
|
306
|
+
this.#media?.setMuted(muted);
|
|
307
|
+
const current = this.getSnapshot();
|
|
308
|
+
this.transition({
|
|
309
|
+
...callFields(current),
|
|
310
|
+
status: current.status,
|
|
311
|
+
audioMuted: muted.audio ?? current.audioMuted,
|
|
312
|
+
videoMuted: muted.video ?? current.videoMuted,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Whether an audio→video upgrade is possible right now: the call carries
|
|
317
|
+
* video, the call is not already video, and the media session can
|
|
318
|
+
* renegotiate.
|
|
319
|
+
*/
|
|
320
|
+
get canEnableVideo() {
|
|
321
|
+
const current = this.getSnapshot();
|
|
322
|
+
return (current.capabilities.video &&
|
|
323
|
+
!current.video &&
|
|
324
|
+
this.#media?.enableVideo !== undefined);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Start sending the camera on an audio call and renegotiate on the same
|
|
328
|
+
* connection. No-op when the call has no video, when video is already on,
|
|
329
|
+
* or when the media session cannot renegotiate.
|
|
330
|
+
*/
|
|
331
|
+
async enableVideo() {
|
|
332
|
+
this.assertActive();
|
|
333
|
+
const current = this.getSnapshot();
|
|
334
|
+
const media = this.#media;
|
|
335
|
+
if (!current.capabilities.video ||
|
|
336
|
+
current.video ||
|
|
337
|
+
media?.enableVideo === undefined)
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
await media.enableVideo(this.#abort.signal, this.getSnapshot().selectedDevices);
|
|
341
|
+
}
|
|
342
|
+
catch (cause) {
|
|
343
|
+
// A denied camera or a failed re-offer; the audio call carries on.
|
|
344
|
+
if (this.#media === media)
|
|
345
|
+
this.#reportError(mediaErrorCode(cause));
|
|
346
|
+
throw cause;
|
|
347
|
+
}
|
|
348
|
+
const after = this.getSnapshot();
|
|
349
|
+
if (this.#abort.signal.aborted)
|
|
350
|
+
return;
|
|
351
|
+
if (after.callId !== current.callId ||
|
|
352
|
+
after.status === "ended" ||
|
|
353
|
+
after.status === "error")
|
|
354
|
+
return;
|
|
355
|
+
this.transition({
|
|
356
|
+
...callFields(after),
|
|
357
|
+
status: after.status,
|
|
358
|
+
video: true,
|
|
359
|
+
videoMuted: false,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/** Choose the devices the next call acquires (and the speaker the UI plays through). */
|
|
363
|
+
setPreferredDevices(devices) {
|
|
364
|
+
this.assertActive();
|
|
365
|
+
const current = this.getSnapshot();
|
|
366
|
+
this.transition({
|
|
367
|
+
...callFields(current),
|
|
368
|
+
status: current.status,
|
|
369
|
+
selectedDevices: { ...current.selectedDevices, ...devices },
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Switch the live microphone or camera mid-call (the outgoing track is
|
|
374
|
+
* replaced in place, mute state carries over) and remember the choice for
|
|
375
|
+
* the next call.
|
|
376
|
+
*/
|
|
377
|
+
async switchDevice(kind, deviceId) {
|
|
378
|
+
if (this.#abort.signal.aborted)
|
|
379
|
+
return;
|
|
380
|
+
const media = this.#media;
|
|
381
|
+
if (media !== undefined && !this.#appliedDevices.has(kind))
|
|
382
|
+
this.#appliedDevices.set(kind, {
|
|
383
|
+
deviceId: this.getSnapshot().selectedDevices[kind],
|
|
384
|
+
generation: 0,
|
|
385
|
+
});
|
|
386
|
+
const generation = (this.#deviceSwitches.get(kind) ?? 0) + 1;
|
|
387
|
+
this.#deviceSwitches.set(kind, generation);
|
|
388
|
+
this.setPreferredDevices({ [kind]: deviceId });
|
|
389
|
+
if (media?.switchInput === undefined)
|
|
390
|
+
return;
|
|
391
|
+
try {
|
|
392
|
+
await media.switchInput(kind === "audioInput" ? "audio" : "video", deviceId, this.#abort.signal);
|
|
393
|
+
// Switches of one kind run in order, so a success, even a superseded
|
|
394
|
+
// one, is the device in use until a later switch succeeds.
|
|
395
|
+
if (this.#media === media &&
|
|
396
|
+
generation > (this.#appliedDevices.get(kind)?.generation ?? 0))
|
|
397
|
+
this.#appliedDevices.set(kind, { deviceId, generation });
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
// Device unavailable: the capture kept its track, so the stored
|
|
401
|
+
// preference goes back to the device in use, unless a newer switch has
|
|
402
|
+
// been requested (it restores for itself when it fails).
|
|
403
|
+
if (this.#deviceSwitches.get(kind) !== generation)
|
|
404
|
+
return;
|
|
405
|
+
if (this.#abort.signal.aborted || this.#media !== media)
|
|
406
|
+
return;
|
|
407
|
+
const applied = this.#appliedDevices.get(kind)?.deviceId;
|
|
408
|
+
const current = this.getSnapshot();
|
|
409
|
+
const restored = {
|
|
410
|
+
...current.selectedDevices,
|
|
411
|
+
};
|
|
412
|
+
if (applied === undefined)
|
|
413
|
+
delete restored[kind];
|
|
414
|
+
else
|
|
415
|
+
restored[kind] = applied;
|
|
416
|
+
this.transition({
|
|
417
|
+
...callFields(current),
|
|
418
|
+
status: current.status,
|
|
419
|
+
selectedDevices: restored,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** Re-enumerate capture/playback devices into the snapshot. */
|
|
424
|
+
async refreshDevices() {
|
|
425
|
+
this.assertActive();
|
|
426
|
+
if (this.#mediaFactory.listDevices === undefined)
|
|
427
|
+
return;
|
|
428
|
+
const devices = await this.#mediaFactory.listDevices();
|
|
429
|
+
if (this.#abort.signal.aborted)
|
|
430
|
+
return;
|
|
431
|
+
const current = this.getSnapshot();
|
|
432
|
+
this.transition({
|
|
433
|
+
...callFields(current),
|
|
434
|
+
status: current.status,
|
|
435
|
+
devices,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
get localStream() {
|
|
439
|
+
return this.#media?.localStream;
|
|
440
|
+
}
|
|
441
|
+
/** Merged call audio. */
|
|
442
|
+
get remoteStream() {
|
|
443
|
+
return this.#media?.remoteStream;
|
|
444
|
+
}
|
|
445
|
+
/** One video per remote source, keyed like `snapshot.remoteVideos`. */
|
|
446
|
+
get remoteVideos() {
|
|
447
|
+
if (this.#publicVideos.source !== this.#remoteVideos)
|
|
448
|
+
this.#publicVideos = {
|
|
449
|
+
source: this.#remoteVideos,
|
|
450
|
+
videos: this.#remoteVideos.map((video) => ({
|
|
451
|
+
...videoInfo(video),
|
|
452
|
+
stream: video.stream,
|
|
453
|
+
})),
|
|
454
|
+
};
|
|
455
|
+
return this.#publicVideos.videos;
|
|
456
|
+
}
|
|
457
|
+
onDispose() {
|
|
458
|
+
this.#disposed = true;
|
|
459
|
+
this.#answerAbort.abort();
|
|
460
|
+
this.#operation += 1;
|
|
461
|
+
this.#clearResumption();
|
|
462
|
+
this.#abort.abort();
|
|
463
|
+
this.#unsubscribe?.();
|
|
464
|
+
this.#unsubscribe = undefined;
|
|
465
|
+
this.#backend.dispose?.();
|
|
466
|
+
// Disposal leaves the call; it never ends it for other participants.
|
|
467
|
+
void this.#closeMedia().catch(() => undefined);
|
|
468
|
+
}
|
|
469
|
+
#showInvitation(callId, action) {
|
|
470
|
+
this.assertActive();
|
|
471
|
+
const current = this.getSnapshot();
|
|
472
|
+
if (callId !== undefined && callId !== current.callId) {
|
|
473
|
+
const invitation = this.#invitations.get(callId);
|
|
474
|
+
if (invitation === undefined)
|
|
475
|
+
throw new Error(`No incoming call ${callId} is available to ${action}.`);
|
|
476
|
+
if (ACTIVE.has(current.status))
|
|
477
|
+
throw new Error(`Finish the active call before you ${action} another one.`);
|
|
478
|
+
this.#display(invitation);
|
|
479
|
+
}
|
|
480
|
+
const shown = this.getSnapshot();
|
|
481
|
+
if (shown.status !== "incoming" || shown.callId === undefined)
|
|
482
|
+
throw new Error(`No incoming call is available to ${action}.`);
|
|
483
|
+
return shown;
|
|
484
|
+
}
|
|
485
|
+
#display(invitation, error) {
|
|
486
|
+
const capabilities = invitation.capabilities;
|
|
487
|
+
this.#remoteVideos = [];
|
|
488
|
+
this.transition({
|
|
489
|
+
...this.#baseFields(),
|
|
490
|
+
status: "incoming",
|
|
491
|
+
callId: invitation.callId,
|
|
492
|
+
peer: invitation.from,
|
|
493
|
+
direction: "incoming",
|
|
494
|
+
capabilities,
|
|
495
|
+
video: invitation.video && capabilities.video,
|
|
496
|
+
audioMuted: false,
|
|
497
|
+
videoMuted: false,
|
|
498
|
+
...invitationFields(invitation),
|
|
499
|
+
participants: this.#rosters.get(invitation.callId) ?? [],
|
|
500
|
+
...(error === undefined ? {} : { error }),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/** Stop listing an invitation, with its roster. */
|
|
504
|
+
#forget(callId) {
|
|
505
|
+
this.#rosters.delete(callId);
|
|
506
|
+
return this.#invitations.delete(callId);
|
|
507
|
+
}
|
|
508
|
+
#remember(callId) {
|
|
509
|
+
this.#answered.delete(callId);
|
|
510
|
+
this.#answered.add(callId);
|
|
511
|
+
for (const oldest of this.#answered) {
|
|
512
|
+
if (this.#answered.size <= ANSWERED_RETENTION)
|
|
513
|
+
break;
|
|
514
|
+
this.#answered.delete(oldest);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async #accept(current, options) {
|
|
518
|
+
this.#begin(false);
|
|
519
|
+
const callId = current.callId;
|
|
520
|
+
this.#answering = callId;
|
|
521
|
+
this.#answerDismissed = false;
|
|
522
|
+
this.#answerCancelled = false;
|
|
523
|
+
this.#answerAbort = new AbortController();
|
|
524
|
+
// Publish `answering`, dropping any failure left from an earlier attempt.
|
|
525
|
+
this.transition({ ...callFields(current, false), status: current.status });
|
|
526
|
+
const code = options.join ? "join_failed" : "answer_failed";
|
|
527
|
+
// Media operation, taken once the answer succeeded and the call shows.
|
|
528
|
+
let operation;
|
|
529
|
+
try {
|
|
530
|
+
const signal = this.#answerAbort.signal;
|
|
531
|
+
const join = this.#backend.join;
|
|
532
|
+
const result = options.join && join !== undefined
|
|
533
|
+
? await join.call(this.#backend, callId, signal, {
|
|
534
|
+
video: options.video,
|
|
535
|
+
})
|
|
536
|
+
: await this.#backend.answer(callId, signal, {
|
|
537
|
+
exclusive: options.exclusive,
|
|
538
|
+
video: options.video,
|
|
539
|
+
});
|
|
540
|
+
// The answer belongs to its own call: only that call ending, or
|
|
541
|
+
// disposal, invalidates it. Anything else the user did meanwhile
|
|
542
|
+
// (selecting or dismissing invitations, another call ending) does not.
|
|
543
|
+
if (this.#disposed || this.#answerCancelled)
|
|
544
|
+
return;
|
|
545
|
+
this.#remember(callId);
|
|
546
|
+
const listed = this.#invitations.has(callId);
|
|
547
|
+
const roster = this.#rosters.get(callId);
|
|
548
|
+
this.#forget(callId);
|
|
549
|
+
const accepted = result ?? undefined;
|
|
550
|
+
if (this.#answerDismissed || !listed) {
|
|
551
|
+
await this.#release(callId, accepted?.exclusive ?? options.exclusive);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const latest = this.getSnapshot();
|
|
555
|
+
const base = latest.callId === callId
|
|
556
|
+
? latest
|
|
557
|
+
: {
|
|
558
|
+
...current,
|
|
559
|
+
devices: latest.devices,
|
|
560
|
+
selectedDevices: latest.selectedDevices,
|
|
561
|
+
participants: roster ?? current.participants,
|
|
562
|
+
};
|
|
563
|
+
operation = this.#begin(false);
|
|
564
|
+
this.#remoteVideos = [];
|
|
565
|
+
this.transition({
|
|
566
|
+
...callFields(base, false),
|
|
567
|
+
status: "accepted",
|
|
568
|
+
video: options.video,
|
|
569
|
+
claimedByOther: false,
|
|
570
|
+
canJoin: false,
|
|
571
|
+
exclusive: accepted?.exclusive ?? options.exclusive,
|
|
572
|
+
...(accepted?.answeredBy ? { answeredBy: accepted.answeredBy } : {}),
|
|
573
|
+
});
|
|
574
|
+
await this.#openMedia(callId, options.video, operation);
|
|
575
|
+
}
|
|
576
|
+
catch (cause) {
|
|
577
|
+
if (operation !== undefined) {
|
|
578
|
+
// Accepted, then media failed.
|
|
579
|
+
this.#fail(cause, operation, code, true);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (this.#disposed || this.#answerCancelled || this.#answerDismissed)
|
|
583
|
+
return;
|
|
584
|
+
const invitation = this.#invitations.get(callId);
|
|
585
|
+
if (invitation === undefined)
|
|
586
|
+
return;
|
|
587
|
+
const claimed = isCallClaimed(cause);
|
|
588
|
+
// Another participant claimed it. Keep the invitation visible so the
|
|
589
|
+
// application can stop ringing; never decline it.
|
|
590
|
+
const next = claimed
|
|
591
|
+
? {
|
|
592
|
+
...invitation,
|
|
593
|
+
answered: true,
|
|
594
|
+
exclusive: true,
|
|
595
|
+
claimedByOther: true,
|
|
596
|
+
canJoin: false,
|
|
597
|
+
}
|
|
598
|
+
: invitation;
|
|
599
|
+
this.#invitations.set(callId, next);
|
|
600
|
+
const snapshot = this.getSnapshot();
|
|
601
|
+
// Another invitation is displayed: only the listed call changes.
|
|
602
|
+
if (snapshot.callId !== callId) {
|
|
603
|
+
this.transition({ ...snapshot });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// The platform refused the answer, so the call is still ringing. Show
|
|
607
|
+
// it again with the failure, so it can be retried or declined.
|
|
608
|
+
this.transition({
|
|
609
|
+
...callFields(snapshot, false),
|
|
610
|
+
status: "incoming",
|
|
611
|
+
...invitationFields(next),
|
|
612
|
+
error: {
|
|
613
|
+
code: claimed ? "call_claimed" : code,
|
|
614
|
+
message: cause instanceof Error
|
|
615
|
+
? cause.message
|
|
616
|
+
: claimed
|
|
617
|
+
? "Another participant claimed this call."
|
|
618
|
+
: "Call operation failed.",
|
|
619
|
+
recoverable: !claimed,
|
|
620
|
+
callId,
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
if (this.#answering === callId) {
|
|
626
|
+
this.#answering = undefined;
|
|
627
|
+
this.#answerDismissed = false;
|
|
628
|
+
this.#answerCancelled = false;
|
|
629
|
+
if (!this.#disposed)
|
|
630
|
+
this.#republish();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** The call being answered ended: the answer must not display it. */
|
|
635
|
+
#cancelAnswer(callId) {
|
|
636
|
+
if (this.#answering !== callId)
|
|
637
|
+
return;
|
|
638
|
+
this.#answerCancelled = true;
|
|
639
|
+
this.#answerAbort.abort();
|
|
640
|
+
}
|
|
641
|
+
#assertNotAnswering(action) {
|
|
642
|
+
if (this.#answering !== undefined)
|
|
643
|
+
throw new Error(`An answer is in progress; wait for it before you ${action}.`);
|
|
644
|
+
}
|
|
645
|
+
/** Publish the current snapshot again, with derived fields refreshed. */
|
|
646
|
+
#republish() {
|
|
647
|
+
const current = this.getSnapshot();
|
|
648
|
+
this.transition({ ...current });
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Undo an answer that succeeded for a call nothing displays any more (the
|
|
652
|
+
* user dismissed it while the answer was in flight). A claimed call
|
|
653
|
+
* has no one else to take it, so it is ended; otherwise only this client
|
|
654
|
+
* leaves and other participants can still join. Without a leave hook no
|
|
655
|
+
* media connection was opened, so there is nothing to leave.
|
|
656
|
+
*/
|
|
657
|
+
async #release(callId, exclusive) {
|
|
658
|
+
const signal = new AbortController().signal;
|
|
659
|
+
try {
|
|
660
|
+
if (exclusive)
|
|
661
|
+
await this.#backend.hangup(callId, signal);
|
|
662
|
+
else
|
|
663
|
+
await this.#backend.leave?.(callId, this.#backend.connectionId?.(callId), signal);
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
// The call was dismissed; nothing displays it to report the failure on.
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
#begin(closeMedia = true) {
|
|
670
|
+
this.assertActive();
|
|
671
|
+
this.#operation += 1;
|
|
672
|
+
this.#abort.abort();
|
|
673
|
+
this.#abort = new AbortController();
|
|
674
|
+
if (closeMedia)
|
|
675
|
+
void this.#closeMedia().catch(() => undefined);
|
|
676
|
+
return this.#operation;
|
|
677
|
+
}
|
|
678
|
+
async #openMedia(callId, video, operation) {
|
|
679
|
+
// A placed call keeps ringing while its media opens: it is connecting
|
|
680
|
+
// only once the remote party has answered.
|
|
681
|
+
const before = this.getSnapshot();
|
|
682
|
+
this.transition({
|
|
683
|
+
...callFields(before),
|
|
684
|
+
status: before.status === "ringing" ? "ringing" : "connecting",
|
|
685
|
+
});
|
|
686
|
+
// Named here, not by the media session, so a failure to open can be
|
|
687
|
+
// reported for the connection it was meant to be.
|
|
688
|
+
const connectionId = this.#backend.connectionId?.(callId) ?? createConnectionId();
|
|
689
|
+
let media;
|
|
690
|
+
try {
|
|
691
|
+
media = await this.#openWith(callId, video, connectionId);
|
|
692
|
+
}
|
|
693
|
+
catch (cause) {
|
|
694
|
+
if (operation === this.#operation)
|
|
695
|
+
this.#reportOnce(callId, connectionId, cause);
|
|
696
|
+
throw cause;
|
|
697
|
+
}
|
|
698
|
+
if (operation !== this.#operation) {
|
|
699
|
+
await media.close();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.#media = media;
|
|
703
|
+
this.#diagnostics?.close();
|
|
704
|
+
this.#diagnostics = this.#diagnosticsFor(callId, media.connectionId ?? connectionId, media);
|
|
705
|
+
this.#diagnostics?.start();
|
|
706
|
+
this.#afterOpen(callId, media);
|
|
707
|
+
}
|
|
708
|
+
/** Report a failure of a connection that never opened. */
|
|
709
|
+
#reportOnce(callId, connectionId, cause) {
|
|
710
|
+
const code = mediaErrorCode(cause);
|
|
711
|
+
const send = this.#backend.report;
|
|
712
|
+
if (!this.#diagnosticsEnabled || code === undefined || send === undefined)
|
|
713
|
+
return;
|
|
714
|
+
new CallReporter({
|
|
715
|
+
connectionId,
|
|
716
|
+
client: BROWSER_REPORT_CLIENT,
|
|
717
|
+
send: (report) => send.call(this.#backend, callId, report),
|
|
718
|
+
now: this.#now,
|
|
719
|
+
}).error(code);
|
|
720
|
+
}
|
|
721
|
+
#diagnosticsFor(callId, connectionId, media) {
|
|
722
|
+
const send = this.#backend.report;
|
|
723
|
+
if (!this.#diagnosticsEnabled || send === undefined)
|
|
724
|
+
return undefined;
|
|
725
|
+
return new ConnectionDiagnostics({
|
|
726
|
+
connectionId,
|
|
727
|
+
send: (report) => send.call(this.#backend, callId, report),
|
|
728
|
+
...(media.getStats === undefined
|
|
729
|
+
? {}
|
|
730
|
+
: { getStats: () => media.getStats() }),
|
|
731
|
+
live: () => {
|
|
732
|
+
const now = this.getSnapshot();
|
|
733
|
+
return now.callId === callId && now.status === "connected";
|
|
734
|
+
},
|
|
735
|
+
setTimeout: this.#setTimeout,
|
|
736
|
+
clearTimeout: this.#clearTimeout,
|
|
737
|
+
now: this.#now,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
/** Report an error for the open media connection. */
|
|
741
|
+
#reportError(code) {
|
|
742
|
+
if (code !== undefined)
|
|
743
|
+
this.#diagnostics?.error(code);
|
|
744
|
+
}
|
|
745
|
+
#openWith(callId, video, connectionId) {
|
|
746
|
+
let media;
|
|
747
|
+
return this.#mediaFactory
|
|
748
|
+
.open(callId, video, {
|
|
749
|
+
onConnectionState: (state) => this.#connection(callId, state),
|
|
750
|
+
onIceConnectionState: (state) => this.#ice(callId, state),
|
|
751
|
+
onRemoteStream: () => {
|
|
752
|
+
const current = this.getSnapshot();
|
|
753
|
+
if (current.callId === callId)
|
|
754
|
+
this.transition({
|
|
755
|
+
...callFields(current),
|
|
756
|
+
status: current.status,
|
|
757
|
+
});
|
|
758
|
+
},
|
|
759
|
+
onRemoteVideos: (videos) => {
|
|
760
|
+
const current = this.getSnapshot();
|
|
761
|
+
if (current.callId !== callId || this.#media !== media)
|
|
762
|
+
return;
|
|
763
|
+
if (current.status === "ended" || current.status === "error")
|
|
764
|
+
return;
|
|
765
|
+
this.#remoteVideos = videos;
|
|
766
|
+
this.transition({
|
|
767
|
+
...callFields(current),
|
|
768
|
+
status: current.status,
|
|
769
|
+
remoteVideos: videos.map(videoInfo),
|
|
770
|
+
});
|
|
771
|
+
},
|
|
772
|
+
}, this.#abort.signal, {
|
|
773
|
+
devices: this.getSnapshot().selectedDevices,
|
|
774
|
+
connectionId,
|
|
775
|
+
})
|
|
776
|
+
.then((opened) => (media = opened));
|
|
777
|
+
}
|
|
778
|
+
/** Bring the snapshot in line with media that just opened. */
|
|
779
|
+
#afterOpen(callId, media) {
|
|
780
|
+
// Answered while the capture was opening: media is connecting now.
|
|
781
|
+
const answeredMeanwhile = this.getSnapshot();
|
|
782
|
+
if (answeredMeanwhile.callId === callId &&
|
|
783
|
+
answeredMeanwhile.status === "accepted")
|
|
784
|
+
this.transition({
|
|
785
|
+
...callFields(answeredMeanwhile),
|
|
786
|
+
status: "connecting",
|
|
787
|
+
});
|
|
788
|
+
// The capture opened with the preferred devices, or the track's own
|
|
789
|
+
// device when the browser reports it.
|
|
790
|
+
this.#appliedDevices.clear();
|
|
791
|
+
const opened = this.getSnapshot().selectedDevices;
|
|
792
|
+
for (const [kind, track] of [
|
|
793
|
+
["audioInput", media.localStream.getAudioTracks?.()[0]],
|
|
794
|
+
["videoInput", media.localStream.getVideoTracks?.()[0]],
|
|
795
|
+
]) {
|
|
796
|
+
const reported = track?.getSettings?.().deviceId;
|
|
797
|
+
this.#appliedDevices.set(kind, {
|
|
798
|
+
deviceId: typeof reported === "string" && reported !== ""
|
|
799
|
+
? reported
|
|
800
|
+
: opened[kind],
|
|
801
|
+
generation: this.#deviceSwitches.get(kind) ?? 0,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
const initial = media.remoteVideos ?? [];
|
|
805
|
+
if (initial.length > 0) {
|
|
806
|
+
this.#remoteVideos = initial;
|
|
807
|
+
const current = this.getSnapshot();
|
|
808
|
+
this.transition({
|
|
809
|
+
...callFields(current),
|
|
810
|
+
status: current.status,
|
|
811
|
+
remoteVideos: initial.map(videoInfo),
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
// Mute pressed while capture was still being acquired only reached the
|
|
815
|
+
// snapshot. Apply it now.
|
|
816
|
+
const pending = this.getSnapshot();
|
|
817
|
+
if (pending.audioMuted || pending.videoMuted)
|
|
818
|
+
media.setMuted({ audio: pending.audioMuted, video: pending.videoMuted });
|
|
819
|
+
}
|
|
820
|
+
#connection(callId, state) {
|
|
821
|
+
const current = this.getSnapshot();
|
|
822
|
+
if (current.callId !== callId)
|
|
823
|
+
return;
|
|
824
|
+
// A terminal snapshot keeps its callId: late callbacks must not revive it.
|
|
825
|
+
if (current.status === "ended" || current.status === "error")
|
|
826
|
+
return;
|
|
827
|
+
if (state === "connected") {
|
|
828
|
+
const call = this.call;
|
|
829
|
+
if (call !== undefined) {
|
|
830
|
+
call.mediaConnected();
|
|
831
|
+
if (current.status !== "reconnecting")
|
|
832
|
+
return;
|
|
833
|
+
if (call.state !== "connected")
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (current.status === "reconnecting")
|
|
837
|
+
this.#diagnostics?.reconnected();
|
|
838
|
+
this.#clearResumption();
|
|
839
|
+
this.transition({
|
|
840
|
+
...callFields(current),
|
|
841
|
+
status: "connected",
|
|
842
|
+
connectedAt: current.connectedAt ?? this.#now(),
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
else if (state === "closed") {
|
|
846
|
+
void this.#closeMedia().catch(() => undefined);
|
|
847
|
+
this.#advance({
|
|
848
|
+
...callFields(current, false),
|
|
849
|
+
status: "ended",
|
|
850
|
+
endReason: "connection_failed",
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
else if (state === "failed") {
|
|
854
|
+
this.#reportError("ice_failed");
|
|
855
|
+
// One ICE restart within the resumption window before giving up.
|
|
856
|
+
this.#beginResumption(callId, 0);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
#ice(callId, state) {
|
|
860
|
+
const current = this.getSnapshot();
|
|
861
|
+
if (current.callId !== callId)
|
|
862
|
+
return;
|
|
863
|
+
if (state === "disconnected") {
|
|
864
|
+
this.#beginResumption(callId, this.#iceRestartAfterMs);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (state === "failed") {
|
|
868
|
+
this.#reportError("ice_failed");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (state !== "connected" && state !== "completed")
|
|
872
|
+
return;
|
|
873
|
+
const resumed = this.#resumedFrom;
|
|
874
|
+
this.#clearResumption();
|
|
875
|
+
if (current.status !== "reconnecting")
|
|
876
|
+
return;
|
|
877
|
+
this.#diagnostics?.reconnected();
|
|
878
|
+
const status = resumed ?? "connected";
|
|
879
|
+
this.transition({
|
|
880
|
+
...callFields(current),
|
|
881
|
+
status,
|
|
882
|
+
...(status === "connected"
|
|
883
|
+
? { connectedAt: current.connectedAt ?? this.#now() }
|
|
884
|
+
: {}),
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Media dropped on a live call: show `reconnecting`, try an ICE restart
|
|
889
|
+
* after `restartAfterMs`, and end the call locally as `connection_failed`
|
|
890
|
+
* once the resumption window closes without media coming back.
|
|
891
|
+
*/
|
|
892
|
+
#beginResumption(callId, restartAfterMs) {
|
|
893
|
+
if (this.#giveUpTimer !== undefined)
|
|
894
|
+
return;
|
|
895
|
+
const current = this.getSnapshot();
|
|
896
|
+
if (current.status !== "connected" &&
|
|
897
|
+
current.status !== "connecting" &&
|
|
898
|
+
current.status !== "ringing")
|
|
899
|
+
return;
|
|
900
|
+
this.#resumedFrom = current.status;
|
|
901
|
+
this.transition({ ...callFields(current), status: "reconnecting" });
|
|
902
|
+
this.#giveUpTimer = this.#setTimeout(() => {
|
|
903
|
+
this.#giveUpTimer = undefined;
|
|
904
|
+
this.#clearResumption();
|
|
905
|
+
const now = this.getSnapshot();
|
|
906
|
+
if (now.callId !== callId || now.status !== "reconnecting")
|
|
907
|
+
return;
|
|
908
|
+
this.#reportError("reconnect_exhausted");
|
|
909
|
+
void this.#closeMedia().catch(() => undefined);
|
|
910
|
+
this.#advance({
|
|
911
|
+
...callFields(now, false),
|
|
912
|
+
status: "ended",
|
|
913
|
+
endReason: "connection_failed",
|
|
914
|
+
});
|
|
915
|
+
}, this.#resumptionWindowMs);
|
|
916
|
+
this.#resumeTimer = this.#setTimeout(() => {
|
|
917
|
+
this.#resumeTimer = undefined;
|
|
918
|
+
const media = this.#media;
|
|
919
|
+
if (media?.restartIce === undefined)
|
|
920
|
+
return;
|
|
921
|
+
if (this.getSnapshot().callId !== callId)
|
|
922
|
+
return;
|
|
923
|
+
void media.restartIce(this.#abort.signal).catch(() => undefined);
|
|
924
|
+
}, restartAfterMs);
|
|
925
|
+
}
|
|
926
|
+
#clearResumption() {
|
|
927
|
+
this.#resumedFrom = undefined;
|
|
928
|
+
if (this.#resumeTimer !== undefined) {
|
|
929
|
+
this.#clearTimeout(this.#resumeTimer);
|
|
930
|
+
this.#resumeTimer = undefined;
|
|
931
|
+
}
|
|
932
|
+
if (this.#giveUpTimer !== undefined) {
|
|
933
|
+
this.#clearTimeout(this.#giveUpTimer);
|
|
934
|
+
this.#giveUpTimer = undefined;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
async #finish(callId, reason, action, close = { leave: false }) {
|
|
938
|
+
const operation = this.#operation;
|
|
939
|
+
try {
|
|
940
|
+
await action(this.#abort.signal);
|
|
941
|
+
}
|
|
942
|
+
catch (cause) {
|
|
943
|
+
if (operation !== this.#operation || this.#abort.signal.aborted)
|
|
944
|
+
return;
|
|
945
|
+
// A refused control request does not end the remote call. Keep its live
|
|
946
|
+
// state and media so the visible controls can retry.
|
|
947
|
+
this.transition({
|
|
948
|
+
...this.getSnapshot(),
|
|
949
|
+
error: {
|
|
950
|
+
code: "call_control_failed",
|
|
951
|
+
message: cause instanceof Error ? cause.message : "Call operation failed.",
|
|
952
|
+
recoverable: true,
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
this.#cancelAnswer(callId);
|
|
958
|
+
if (operation !== this.#operation)
|
|
959
|
+
return;
|
|
960
|
+
const finishedOperation = ++this.#operation;
|
|
961
|
+
this.#forget(callId);
|
|
962
|
+
try {
|
|
963
|
+
await this.#closeMedia(close);
|
|
964
|
+
}
|
|
965
|
+
finally {
|
|
966
|
+
if (finishedOperation === this.#operation)
|
|
967
|
+
this.#advance({
|
|
968
|
+
...callFields(this.getSnapshot(), false),
|
|
969
|
+
status: "ended",
|
|
970
|
+
endReason: reason,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Apply a terminal (or dismissed) snapshot, then show the next ringing
|
|
976
|
+
* invitation, if any, so a waiting call is never hidden.
|
|
977
|
+
*/
|
|
978
|
+
#advance(next) {
|
|
979
|
+
this.#remoteVideos = [];
|
|
980
|
+
const closedId = next.callId;
|
|
981
|
+
if (closedId !== undefined)
|
|
982
|
+
this.#forget(closedId);
|
|
983
|
+
const waiting = [...this.#invitations.values()][0];
|
|
984
|
+
if (waiting !== undefined) {
|
|
985
|
+
// Publish the closed call's terminal state first: observers such as the
|
|
986
|
+
// shared call model clean up the call the snapshot names.
|
|
987
|
+
if (closedId !== undefined &&
|
|
988
|
+
(next.status === "ended" || next.status === "error"))
|
|
989
|
+
this.transition({ ...next, remoteVideos: [] });
|
|
990
|
+
this.#display(waiting, next.status === "error" ? next.error : undefined);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
this.transition({
|
|
994
|
+
...next,
|
|
995
|
+
invitations: [...this.#invitations.values()],
|
|
996
|
+
remoteVideos: [],
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
#receive(event) {
|
|
1000
|
+
if ((event.type === "capabilities" || event.type === "accepted") &&
|
|
1001
|
+
event.capabilities !== undefined)
|
|
1002
|
+
this.#applyCapabilities(event.callId, event.capabilities);
|
|
1003
|
+
if (event.type === "capabilities")
|
|
1004
|
+
return;
|
|
1005
|
+
const current = this.getSnapshot();
|
|
1006
|
+
if (event.type === "incomingCall") {
|
|
1007
|
+
if (this.#invitations.has(event.call.callId))
|
|
1008
|
+
return;
|
|
1009
|
+
if (this.#answered.has(event.call.callId))
|
|
1010
|
+
return;
|
|
1011
|
+
const invitation = {
|
|
1012
|
+
callId: event.call.callId,
|
|
1013
|
+
from: event.call.from,
|
|
1014
|
+
video: event.call.video,
|
|
1015
|
+
capabilities: capabilitiesOf(event.call.capabilities),
|
|
1016
|
+
answered: false,
|
|
1017
|
+
exclusive: false,
|
|
1018
|
+
claimedByOther: false,
|
|
1019
|
+
canJoin: false,
|
|
1020
|
+
};
|
|
1021
|
+
this.#invitations.set(invitation.callId, invitation);
|
|
1022
|
+
if (["idle", "ready", "ended", "error"].includes(current.status)) {
|
|
1023
|
+
this.#display(invitation);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
// Another call is showing: keep this one listed. Never decline it.
|
|
1027
|
+
this.transition({ ...callFields(current), status: current.status });
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
// An end can arrive before the invitation it ends (relayed webhooks are
|
|
1031
|
+
// not ordered): remember it so the late invitation is not listed.
|
|
1032
|
+
if (event.type === "ended")
|
|
1033
|
+
this.#remember(event.callId);
|
|
1034
|
+
const displayed = current.callId === event.callId;
|
|
1035
|
+
const invitation = this.#invitations.get(event.callId);
|
|
1036
|
+
// Track a listed call's roster whether or not it is displayed, so
|
|
1037
|
+
// selecting it later shows who is already on it.
|
|
1038
|
+
if (invitation !== undefined &&
|
|
1039
|
+
(event.type === "participant" || event.type === "participantLeft"))
|
|
1040
|
+
this.#rosters.set(event.callId, applyRoster(this.#rosters.get(event.callId) ?? [], event));
|
|
1041
|
+
if (!displayed) {
|
|
1042
|
+
if (invitation === undefined)
|
|
1043
|
+
return;
|
|
1044
|
+
if (event.type === "ended") {
|
|
1045
|
+
this.#cancelAnswer(event.callId);
|
|
1046
|
+
this.#forget(event.callId);
|
|
1047
|
+
this.transition({ ...callFields(current), status: current.status });
|
|
1048
|
+
}
|
|
1049
|
+
else if (event.type === "accepted") {
|
|
1050
|
+
this.#invitations.set(event.callId, claimFrom(invitation, event, false));
|
|
1051
|
+
this.transition({ ...callFields(current), status: current.status });
|
|
1052
|
+
}
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
// A terminal snapshot keeps its callId; only `ended` stays idempotent.
|
|
1056
|
+
if (event.type !== "ended" &&
|
|
1057
|
+
(current.status === "ended" || current.status === "error"))
|
|
1058
|
+
return;
|
|
1059
|
+
switch (event.type) {
|
|
1060
|
+
case "ended": {
|
|
1061
|
+
if (current.status === "ended")
|
|
1062
|
+
return;
|
|
1063
|
+
this.#cancelAnswer(event.callId);
|
|
1064
|
+
this.#operation += 1;
|
|
1065
|
+
this.#abort.abort();
|
|
1066
|
+
this.#abort = new AbortController();
|
|
1067
|
+
void this.#closeMedia({ leave: false }).catch(() => undefined);
|
|
1068
|
+
this.#advance({
|
|
1069
|
+
...callFields(current, false),
|
|
1070
|
+
status: "ended",
|
|
1071
|
+
...(event.reason === undefined ? {} : { endReason: event.reason }),
|
|
1072
|
+
});
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
case "accepted": {
|
|
1076
|
+
if (current.status === "incoming" && invitation !== undefined) {
|
|
1077
|
+
const ours = this.#answering === event.callId;
|
|
1078
|
+
const next = claimFrom(invitation, event, ours);
|
|
1079
|
+
this.#invitations.set(event.callId, next);
|
|
1080
|
+
this.transition({
|
|
1081
|
+
...callFields(current),
|
|
1082
|
+
status: "incoming",
|
|
1083
|
+
...invitationFields(next),
|
|
1084
|
+
});
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
// The remote party picked up a call this client placed. With media
|
|
1088
|
+
// already open it is connecting; otherwise media opens next.
|
|
1089
|
+
if (current.status === "reconnecting" &&
|
|
1090
|
+
this.#resumedFrom === "ringing")
|
|
1091
|
+
this.#resumedFrom = "connecting";
|
|
1092
|
+
if (current.status === "ringing")
|
|
1093
|
+
this.transition({
|
|
1094
|
+
...callFields(current),
|
|
1095
|
+
status: this.#media === undefined ? "accepted" : "connecting",
|
|
1096
|
+
});
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
case "videostate":
|
|
1100
|
+
this.transition({
|
|
1101
|
+
...callFields(current),
|
|
1102
|
+
status: current.status,
|
|
1103
|
+
video: event.video,
|
|
1104
|
+
});
|
|
1105
|
+
return;
|
|
1106
|
+
case "participant":
|
|
1107
|
+
case "participantLeft": {
|
|
1108
|
+
const participants = applyRoster(current.participants, event);
|
|
1109
|
+
if (participants === current.participants)
|
|
1110
|
+
return;
|
|
1111
|
+
this.transition({
|
|
1112
|
+
...callFields(current),
|
|
1113
|
+
status: current.status,
|
|
1114
|
+
participants,
|
|
1115
|
+
});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
case "connected":
|
|
1119
|
+
// The backend can beat the WebRTC callback to this.
|
|
1120
|
+
this.#clearResumption();
|
|
1121
|
+
this.transition({
|
|
1122
|
+
...callFields(current),
|
|
1123
|
+
status: "connected",
|
|
1124
|
+
connectedAt: current.connectedAt ?? this.#now(),
|
|
1125
|
+
});
|
|
1126
|
+
return;
|
|
1127
|
+
case "ringing":
|
|
1128
|
+
this.transition({ ...callFields(current), status: "ringing" });
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Take a call's reported capabilities: a listed invitation keeps them for
|
|
1134
|
+
* when it is displayed, and the displayed call updates its controls. Video
|
|
1135
|
+
* the call cannot carry is dropped.
|
|
1136
|
+
*/
|
|
1137
|
+
#applyCapabilities(callId, reported) {
|
|
1138
|
+
// Reported fields only: a partial report must not reset the other one.
|
|
1139
|
+
const merge = (base) => ({
|
|
1140
|
+
video: reported.video ?? base.video,
|
|
1141
|
+
invite: reported.invite ?? base.invite,
|
|
1142
|
+
mute: base.mute,
|
|
1143
|
+
});
|
|
1144
|
+
const invitation = this.#invitations.get(callId);
|
|
1145
|
+
if (invitation !== undefined)
|
|
1146
|
+
this.#invitations.set(callId, {
|
|
1147
|
+
...invitation,
|
|
1148
|
+
capabilities: merge(invitation.capabilities),
|
|
1149
|
+
});
|
|
1150
|
+
const current = this.getSnapshot();
|
|
1151
|
+
const capabilities = merge(current.capabilities);
|
|
1152
|
+
if (current.callId !== callId ||
|
|
1153
|
+
current.status === "ended" ||
|
|
1154
|
+
current.status === "error") {
|
|
1155
|
+
if (invitation !== undefined)
|
|
1156
|
+
this.transition({ ...callFields(current), status: current.status });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (current.capabilities.video === capabilities.video &&
|
|
1160
|
+
current.capabilities.invite === capabilities.invite)
|
|
1161
|
+
return;
|
|
1162
|
+
this.transition({
|
|
1163
|
+
...callFields(current),
|
|
1164
|
+
status: current.status,
|
|
1165
|
+
capabilities,
|
|
1166
|
+
video: current.video && capabilities.video,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
/** Fields every new displayed call starts from. */
|
|
1170
|
+
#baseFields() {
|
|
1171
|
+
const { devices, selectedDevices } = this.getSnapshot();
|
|
1172
|
+
return {
|
|
1173
|
+
capabilities: DEFAULT_CAPABILITIES,
|
|
1174
|
+
video: false,
|
|
1175
|
+
audioMuted: false,
|
|
1176
|
+
videoMuted: false,
|
|
1177
|
+
devices,
|
|
1178
|
+
selectedDevices,
|
|
1179
|
+
invitations: [...this.#invitations.values()],
|
|
1180
|
+
...EMPTY_CALL,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
async #closeMedia(options = {}) {
|
|
1184
|
+
this.#clearResumption();
|
|
1185
|
+
// Final figures are read before the connection closes.
|
|
1186
|
+
this.#diagnostics?.close();
|
|
1187
|
+
this.#diagnostics = undefined;
|
|
1188
|
+
const media = this.#media;
|
|
1189
|
+
this.#media = undefined;
|
|
1190
|
+
await media?.close(options);
|
|
1191
|
+
}
|
|
1192
|
+
#fail(cause, operation, code, fromOffer = false) {
|
|
1193
|
+
if (operation !== this.#operation || this.#abort.signal.aborted)
|
|
1194
|
+
return;
|
|
1195
|
+
void this.#closeMedia().catch(() => undefined);
|
|
1196
|
+
// A pod-lost (410) or capacity (503) answer to the offer ends the call.
|
|
1197
|
+
const status = fromOffer
|
|
1198
|
+
? cause?.status
|
|
1199
|
+
: undefined;
|
|
1200
|
+
const terminal = typeof status === "number" ? TERMINAL_OFFER_REASONS[status] : undefined;
|
|
1201
|
+
if (terminal !== undefined) {
|
|
1202
|
+
this.#advance({
|
|
1203
|
+
...callFields(this.getSnapshot(), false),
|
|
1204
|
+
status: "ended",
|
|
1205
|
+
endReason: terminal,
|
|
1206
|
+
});
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const current = this.getSnapshot();
|
|
1210
|
+
const failed = {
|
|
1211
|
+
...callFields(current),
|
|
1212
|
+
status: "error",
|
|
1213
|
+
error: {
|
|
1214
|
+
code,
|
|
1215
|
+
message: cause instanceof Error ? cause.message : "Call operation failed.",
|
|
1216
|
+
recoverable: true,
|
|
1217
|
+
...(current.callId === undefined ? {} : { callId: current.callId }),
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
// A waiting invitation is shown next, carrying the failure.
|
|
1221
|
+
if ([...this.#invitations.keys()].some((id) => id !== current.callId))
|
|
1222
|
+
this.#advance(failed);
|
|
1223
|
+
else
|
|
1224
|
+
this.transition(failed);
|
|
1225
|
+
}
|
|
1226
|
+
/** Keeps `invitations` and `answering` current on every transition. */
|
|
1227
|
+
transition(next) {
|
|
1228
|
+
super.transition({
|
|
1229
|
+
...next,
|
|
1230
|
+
invitations: [...this.#invitations.values()],
|
|
1231
|
+
answering: this.#answering !== undefined,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function claimFrom(invitation, event, ours) {
|
|
1236
|
+
const exclusive = event.exclusive === true;
|
|
1237
|
+
const claimedByOther = ours ? false : (event.claimedByOther ?? exclusive);
|
|
1238
|
+
return {
|
|
1239
|
+
...invitation,
|
|
1240
|
+
answered: true,
|
|
1241
|
+
...(event.answeredBy === undefined ? {} : { answeredBy: event.answeredBy }),
|
|
1242
|
+
exclusive,
|
|
1243
|
+
claimedByOther,
|
|
1244
|
+
canJoin: !exclusive && !claimedByOther,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
function invitationFields(invitation) {
|
|
1248
|
+
return {
|
|
1249
|
+
claimedByOther: invitation.claimedByOther,
|
|
1250
|
+
canJoin: invitation.canJoin,
|
|
1251
|
+
exclusive: invitation.exclusive,
|
|
1252
|
+
...(invitation.answeredBy === undefined
|
|
1253
|
+
? {}
|
|
1254
|
+
: { answeredBy: invitation.answeredBy }),
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function videoInfo(video) {
|
|
1258
|
+
return {
|
|
1259
|
+
key: video.key,
|
|
1260
|
+
label: video.participant?.phoneNumber ??
|
|
1261
|
+
video.participant?.id ??
|
|
1262
|
+
video.connectionParticipant ??
|
|
1263
|
+
video.connectionId ??
|
|
1264
|
+
video.key,
|
|
1265
|
+
...(video.connectionId === undefined
|
|
1266
|
+
? {}
|
|
1267
|
+
: { connectionId: video.connectionId }),
|
|
1268
|
+
...(video.connectionParticipant === undefined
|
|
1269
|
+
? {}
|
|
1270
|
+
: { connectionParticipant: video.connectionParticipant }),
|
|
1271
|
+
...(video.participant === undefined
|
|
1272
|
+
? {}
|
|
1273
|
+
: { participant: video.participant }),
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
/** How many placed, answered or joined call ids the controller remembers. */
|
|
1277
|
+
const ANSWERED_RETENTION = 200;
|
|
1278
|
+
/** Apply one roster event; returns `roster` itself when nothing changed. */
|
|
1279
|
+
function applyRoster(roster, event) {
|
|
1280
|
+
if (event.type === "participant")
|
|
1281
|
+
return [
|
|
1282
|
+
...roster.filter((p) => p.id !== event.participant.id),
|
|
1283
|
+
event.participant,
|
|
1284
|
+
];
|
|
1285
|
+
return roster.some((p) => p.id === event.participantId)
|
|
1286
|
+
? roster.filter((p) => p.id !== event.participantId)
|
|
1287
|
+
: roster;
|
|
1288
|
+
}
|
|
1289
|
+
/** A call this client placed that never connected: nobody else is on it. */
|
|
1290
|
+
function isUnconnectedOutgoing(snapshot) {
|
|
1291
|
+
return (snapshot.direction === "outgoing" &&
|
|
1292
|
+
snapshot.status !== "connected" &&
|
|
1293
|
+
snapshot.connectedAt === undefined);
|
|
1294
|
+
}
|
|
1295
|
+
function callFields(snapshot, preserveError = true) {
|
|
1296
|
+
return {
|
|
1297
|
+
...(snapshot.callId === undefined ? {} : { callId: snapshot.callId }),
|
|
1298
|
+
...(snapshot.peer === undefined ? {} : { peer: snapshot.peer }),
|
|
1299
|
+
...(snapshot.direction === undefined
|
|
1300
|
+
? {}
|
|
1301
|
+
: { direction: snapshot.direction }),
|
|
1302
|
+
capabilities: snapshot.capabilities,
|
|
1303
|
+
video: snapshot.video,
|
|
1304
|
+
audioMuted: snapshot.audioMuted,
|
|
1305
|
+
videoMuted: snapshot.videoMuted,
|
|
1306
|
+
...(snapshot.connectedAt === undefined
|
|
1307
|
+
? {}
|
|
1308
|
+
: { connectedAt: snapshot.connectedAt }),
|
|
1309
|
+
devices: snapshot.devices,
|
|
1310
|
+
selectedDevices: snapshot.selectedDevices,
|
|
1311
|
+
...(snapshot.endReason === undefined
|
|
1312
|
+
? {}
|
|
1313
|
+
: { endReason: snapshot.endReason }),
|
|
1314
|
+
...(!preserveError || snapshot.error === undefined
|
|
1315
|
+
? {}
|
|
1316
|
+
: { error: snapshot.error }),
|
|
1317
|
+
invitations: snapshot.invitations,
|
|
1318
|
+
claimedByOther: snapshot.claimedByOther,
|
|
1319
|
+
canJoin: snapshot.canJoin,
|
|
1320
|
+
exclusive: snapshot.exclusive,
|
|
1321
|
+
...(snapshot.answeredBy === undefined
|
|
1322
|
+
? {}
|
|
1323
|
+
: { answeredBy: snapshot.answeredBy }),
|
|
1324
|
+
participants: snapshot.participants,
|
|
1325
|
+
remoteVideos: snapshot.remoteVideos,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
//# sourceMappingURL=controller.js.map
|