@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.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +248 -0
  3. package/dist/calls/api.d.ts +22 -0
  4. package/dist/calls/api.d.ts.map +1 -0
  5. package/dist/calls/api.js +104 -0
  6. package/dist/calls/api.js.map +1 -0
  7. package/dist/calls/backend.d.ts +85 -0
  8. package/dist/calls/backend.d.ts.map +1 -0
  9. package/dist/calls/backend.js +141 -0
  10. package/dist/calls/backend.js.map +1 -0
  11. package/dist/calls/client.d.ts +56 -0
  12. package/dist/calls/client.d.ts.map +1 -0
  13. package/dist/calls/client.js +260 -0
  14. package/dist/calls/client.js.map +1 -0
  15. package/dist/calls/controller.d.ts +329 -0
  16. package/dist/calls/controller.d.ts.map +1 -0
  17. package/dist/calls/controller.js +1328 -0
  18. package/dist/calls/controller.js.map +1 -0
  19. package/dist/calls/diagnostics.d.ts +52 -0
  20. package/dist/calls/diagnostics.d.ts.map +1 -0
  21. package/dist/calls/diagnostics.js +188 -0
  22. package/dist/calls/diagnostics.js.map +1 -0
  23. package/dist/calls/index.d.ts +3 -0
  24. package/dist/calls/index.d.ts.map +1 -0
  25. package/dist/calls/index.js +5 -0
  26. package/dist/calls/index.js.map +1 -0
  27. package/dist/calls/internal.d.ts +8 -0
  28. package/dist/calls/internal.d.ts.map +1 -0
  29. package/dist/calls/internal.js +8 -0
  30. package/dist/calls/internal.js.map +1 -0
  31. package/dist/calls/media.d.ts +144 -0
  32. package/dist/calls/media.d.ts.map +1 -0
  33. package/dist/calls/media.js +512 -0
  34. package/dist/calls/media.js.map +1 -0
  35. package/dist/calls/signaling.d.ts +74 -0
  36. package/dist/calls/signaling.d.ts.map +1 -0
  37. package/dist/calls/signaling.js +169 -0
  38. package/dist/calls/signaling.js.map +1 -0
  39. package/dist/calls/socket.d.ts +95 -0
  40. package/dist/calls/socket.d.ts.map +1 -0
  41. package/dist/calls/socket.js +267 -0
  42. package/dist/calls/socket.js.map +1 -0
  43. package/dist/chat/composer.d.ts +59 -0
  44. package/dist/chat/composer.d.ts.map +1 -0
  45. package/dist/chat/composer.js +217 -0
  46. package/dist/chat/composer.js.map +1 -0
  47. package/dist/chat/conversation.d.ts +71 -0
  48. package/dist/chat/conversation.d.ts.map +1 -0
  49. package/dist/chat/conversation.js +204 -0
  50. package/dist/chat/conversation.js.map +1 -0
  51. package/dist/chat/index.d.ts +4 -0
  52. package/dist/chat/index.d.ts.map +1 -0
  53. package/dist/chat/index.js +4 -0
  54. package/dist/chat/index.js.map +1 -0
  55. package/dist/chat/voice-recorder.d.ts +75 -0
  56. package/dist/chat/voice-recorder.d.ts.map +1 -0
  57. package/dist/chat/voice-recorder.js +346 -0
  58. package/dist/chat/voice-recorder.js.map +1 -0
  59. package/dist/controller.d.ts +16 -0
  60. package/dist/controller.d.ts.map +1 -0
  61. package/dist/controller.js +50 -0
  62. package/dist/controller.js.map +1 -0
  63. package/dist/diagnostics.d.ts +27 -0
  64. package/dist/diagnostics.d.ts.map +1 -0
  65. package/dist/diagnostics.js +2 -0
  66. package/dist/diagnostics.js.map +1 -0
  67. package/dist/dropin/client.d.ts +82 -0
  68. package/dist/dropin/client.d.ts.map +1 -0
  69. package/dist/dropin/client.js +314 -0
  70. package/dist/dropin/client.js.map +1 -0
  71. package/dist/dropin/connect.d.ts +23 -0
  72. package/dist/dropin/connect.d.ts.map +1 -0
  73. package/dist/dropin/connect.js +54 -0
  74. package/dist/dropin/connect.js.map +1 -0
  75. package/dist/dropin/inbox.d.ts +131 -0
  76. package/dist/dropin/inbox.d.ts.map +1 -0
  77. package/dist/dropin/inbox.js +406 -0
  78. package/dist/dropin/inbox.js.map +1 -0
  79. package/dist/dropin/index.d.ts +5 -0
  80. package/dist/dropin/index.d.ts.map +1 -0
  81. package/dist/dropin/index.js +5 -0
  82. package/dist/dropin/index.js.map +1 -0
  83. package/dist/dropin/permissions.d.ts +29 -0
  84. package/dist/dropin/permissions.d.ts.map +1 -0
  85. package/dist/dropin/permissions.js +53 -0
  86. package/dist/dropin/permissions.js.map +1 -0
  87. package/dist/errors.d.ts +39 -0
  88. package/dist/errors.d.ts.map +1 -0
  89. package/dist/errors.js +41 -0
  90. package/dist/errors.js.map +1 -0
  91. package/dist/index.d.ts +11 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +10 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/internal.d.ts +12 -0
  96. package/dist/internal.d.ts.map +1 -0
  97. package/dist/internal.js +13 -0
  98. package/dist/internal.js.map +1 -0
  99. package/dist/messaging/client.d.ts +54 -0
  100. package/dist/messaging/client.d.ts.map +1 -0
  101. package/dist/messaging/client.js +153 -0
  102. package/dist/messaging/client.js.map +1 -0
  103. package/dist/messaging/composer.d.ts +12 -0
  104. package/dist/messaging/composer.d.ts.map +1 -0
  105. package/dist/messaging/composer.js +36 -0
  106. package/dist/messaging/composer.js.map +1 -0
  107. package/dist/messaging/index.d.ts +4 -0
  108. package/dist/messaging/index.d.ts.map +1 -0
  109. package/dist/messaging/index.js +3 -0
  110. package/dist/messaging/index.js.map +1 -0
  111. package/dist/messaging/types.d.ts +209 -0
  112. package/dist/messaging/types.d.ts.map +1 -0
  113. package/dist/messaging/types.js +2 -0
  114. package/dist/messaging/types.js.map +1 -0
  115. package/dist/templates/controller.d.ts +45 -0
  116. package/dist/templates/controller.d.ts.map +1 -0
  117. package/dist/templates/controller.js +298 -0
  118. package/dist/templates/controller.js.map +1 -0
  119. package/dist/templates/index.d.ts +4 -0
  120. package/dist/templates/index.d.ts.map +1 -0
  121. package/dist/templates/index.js +3 -0
  122. package/dist/templates/index.js.map +1 -0
  123. package/dist/templates/transport.d.ts +7 -0
  124. package/dist/templates/transport.d.ts.map +1 -0
  125. package/dist/templates/transport.js +124 -0
  126. package/dist/templates/transport.js.map +1 -0
  127. package/dist/templates/types.d.ts +141 -0
  128. package/dist/templates/types.d.ts.map +1 -0
  129. package/dist/templates/types.js +2 -0
  130. package/dist/templates/types.js.map +1 -0
  131. package/dist/token.d.ts +37 -0
  132. package/dist/token.d.ts.map +1 -0
  133. package/dist/token.js +152 -0
  134. package/dist/token.js.map +1 -0
  135. package/dist/transport.d.ts +53 -0
  136. package/dist/transport.d.ts.map +1 -0
  137. package/dist/transport.js +328 -0
  138. package/dist/transport.js.map +1 -0
  139. 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