@agatx/serenada-core 0.6.10

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 (156) hide show
  1. package/dist/ConsoleLogger.d.ts +6 -0
  2. package/dist/ConsoleLogger.d.ts.map +1 -0
  3. package/dist/ConsoleLogger.js +21 -0
  4. package/dist/ConsoleLogger.js.map +1 -0
  5. package/dist/RoomWatcher.d.ts +34 -0
  6. package/dist/RoomWatcher.d.ts.map +1 -0
  7. package/dist/RoomWatcher.js +103 -0
  8. package/dist/RoomWatcher.js.map +1 -0
  9. package/dist/SerenadaCore.d.ts +47 -0
  10. package/dist/SerenadaCore.d.ts.map +1 -0
  11. package/dist/SerenadaCore.js +141 -0
  12. package/dist/SerenadaCore.js.map +1 -0
  13. package/dist/SerenadaDiagnostics.d.ts +49 -0
  14. package/dist/SerenadaDiagnostics.d.ts.map +1 -0
  15. package/dist/SerenadaDiagnostics.js +421 -0
  16. package/dist/SerenadaDiagnostics.js.map +1 -0
  17. package/dist/SerenadaServerProvider.d.ts +48 -0
  18. package/dist/SerenadaServerProvider.d.ts.map +1 -0
  19. package/dist/SerenadaServerProvider.js +296 -0
  20. package/dist/SerenadaServerProvider.js.map +1 -0
  21. package/dist/SerenadaSession.d.ts +180 -0
  22. package/dist/SerenadaSession.d.ts.map +1 -0
  23. package/dist/SerenadaSession.js +1082 -0
  24. package/dist/SerenadaSession.js.map +1 -0
  25. package/dist/SignalingProvider.d.ts +132 -0
  26. package/dist/SignalingProvider.d.ts.map +1 -0
  27. package/dist/SignalingProvider.js +50 -0
  28. package/dist/SignalingProvider.js.map +1 -0
  29. package/dist/api/roomApi.d.ts +2 -0
  30. package/dist/api/roomApi.d.ts.map +1 -0
  31. package/dist/api/roomApi.js +14 -0
  32. package/dist/api/roomApi.js.map +1 -0
  33. package/dist/cameraModes.d.ts +13 -0
  34. package/dist/cameraModes.d.ts.map +1 -0
  35. package/dist/cameraModes.js +35 -0
  36. package/dist/cameraModes.js.map +1 -0
  37. package/dist/configValidation.d.ts +10 -0
  38. package/dist/configValidation.d.ts.map +1 -0
  39. package/dist/configValidation.js +24 -0
  40. package/dist/configValidation.js.map +1 -0
  41. package/dist/constants.d.ts +33 -0
  42. package/dist/constants.d.ts.map +1 -0
  43. package/dist/constants.js +65 -0
  44. package/dist/constants.js.map +1 -0
  45. package/dist/formatError.d.ts +3 -0
  46. package/dist/formatError.d.ts.map +1 -0
  47. package/dist/formatError.js +7 -0
  48. package/dist/formatError.js.map +1 -0
  49. package/dist/iceServers.d.ts +2 -0
  50. package/dist/iceServers.d.ts.map +1 -0
  51. package/dist/iceServers.js +21 -0
  52. package/dist/iceServers.js.map +1 -0
  53. package/dist/index.d.ts +55 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +44 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/layout/computeLayout.d.ts +81 -0
  58. package/dist/layout/computeLayout.d.ts.map +1 -0
  59. package/dist/layout/computeLayout.js +380 -0
  60. package/dist/layout/computeLayout.js.map +1 -0
  61. package/dist/media/AudioLevelMonitor.d.ts +51 -0
  62. package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
  63. package/dist/media/AudioLevelMonitor.js +179 -0
  64. package/dist/media/AudioLevelMonitor.js.map +1 -0
  65. package/dist/media/MediaEngine.d.ts +137 -0
  66. package/dist/media/MediaEngine.d.ts.map +1 -0
  67. package/dist/media/MediaEngine.js +1224 -0
  68. package/dist/media/MediaEngine.js.map +1 -0
  69. package/dist/media/callStats.d.ts +16 -0
  70. package/dist/media/callStats.d.ts.map +1 -0
  71. package/dist/media/callStats.js +214 -0
  72. package/dist/media/callStats.js.map +1 -0
  73. package/dist/media/localVideoRecovery.d.ts +16 -0
  74. package/dist/media/localVideoRecovery.d.ts.map +1 -0
  75. package/dist/media/localVideoRecovery.js +14 -0
  76. package/dist/media/localVideoRecovery.js.map +1 -0
  77. package/dist/recoveryStorage.d.ts +33 -0
  78. package/dist/recoveryStorage.d.ts.map +1 -0
  79. package/dist/recoveryStorage.js +88 -0
  80. package/dist/recoveryStorage.js.map +1 -0
  81. package/dist/serverUrls.d.ts +8 -0
  82. package/dist/serverUrls.d.ts.map +1 -0
  83. package/dist/serverUrls.js +65 -0
  84. package/dist/serverUrls.js.map +1 -0
  85. package/dist/signaling/SignalingEngine.d.ts +126 -0
  86. package/dist/signaling/SignalingEngine.d.ts.map +1 -0
  87. package/dist/signaling/SignalingEngine.js +720 -0
  88. package/dist/signaling/SignalingEngine.js.map +1 -0
  89. package/dist/signaling/payloads.d.ts +76 -0
  90. package/dist/signaling/payloads.d.ts.map +1 -0
  91. package/dist/signaling/payloads.js +160 -0
  92. package/dist/signaling/payloads.js.map +1 -0
  93. package/dist/signaling/roomStatuses.d.ts +9 -0
  94. package/dist/signaling/roomStatuses.d.ts.map +1 -0
  95. package/dist/signaling/roomStatuses.js +71 -0
  96. package/dist/signaling/roomStatuses.js.map +1 -0
  97. package/dist/signaling/transportConfig.d.ts +3 -0
  98. package/dist/signaling/transportConfig.d.ts.map +1 -0
  99. package/dist/signaling/transportConfig.js +27 -0
  100. package/dist/signaling/transportConfig.js.map +1 -0
  101. package/dist/signaling/transports/index.d.ts +13 -0
  102. package/dist/signaling/transports/index.d.ts.map +1 -0
  103. package/dist/signaling/transports/index.js +11 -0
  104. package/dist/signaling/transports/index.js.map +1 -0
  105. package/dist/signaling/transports/sse.d.ts +26 -0
  106. package/dist/signaling/transports/sse.d.ts.map +1 -0
  107. package/dist/signaling/transports/sse.js +131 -0
  108. package/dist/signaling/transports/sse.js.map +1 -0
  109. package/dist/signaling/transports/types.d.ts +17 -0
  110. package/dist/signaling/transports/types.d.ts.map +1 -0
  111. package/dist/signaling/transports/types.js +2 -0
  112. package/dist/signaling/transports/types.js.map +1 -0
  113. package/dist/signaling/transports/ws.d.ts +21 -0
  114. package/dist/signaling/transports/ws.d.ts.map +1 -0
  115. package/dist/signaling/transports/ws.js +93 -0
  116. package/dist/signaling/transports/ws.js.map +1 -0
  117. package/dist/signaling/types.d.ts +53 -0
  118. package/dist/signaling/types.d.ts.map +1 -0
  119. package/dist/signaling/types.js +2 -0
  120. package/dist/signaling/types.js.map +1 -0
  121. package/dist/types.d.ts +279 -0
  122. package/dist/types.d.ts.map +1 -0
  123. package/dist/types.js +3 -0
  124. package/dist/types.js.map +1 -0
  125. package/package.json +43 -0
  126. package/src/ConsoleLogger.ts +14 -0
  127. package/src/RoomWatcher.ts +127 -0
  128. package/src/SerenadaCore.ts +163 -0
  129. package/src/SerenadaDiagnostics.ts +485 -0
  130. package/src/SerenadaServerProvider.ts +362 -0
  131. package/src/SerenadaSession.ts +1258 -0
  132. package/src/SignalingProvider.ts +207 -0
  133. package/src/api/roomApi.ts +16 -0
  134. package/src/cameraModes.ts +34 -0
  135. package/src/configValidation.ts +35 -0
  136. package/src/constants.ts +77 -0
  137. package/src/formatError.ts +5 -0
  138. package/src/iceServers.ts +20 -0
  139. package/src/index.ts +155 -0
  140. package/src/layout/computeLayout.ts +639 -0
  141. package/src/media/AudioLevelMonitor.ts +190 -0
  142. package/src/media/MediaEngine.ts +1183 -0
  143. package/src/media/callStats.ts +260 -0
  144. package/src/media/localVideoRecovery.ts +39 -0
  145. package/src/recoveryStorage.ts +101 -0
  146. package/src/serverUrls.ts +69 -0
  147. package/src/signaling/SignalingEngine.ts +762 -0
  148. package/src/signaling/payloads.ts +215 -0
  149. package/src/signaling/roomStatuses.ts +89 -0
  150. package/src/signaling/transportConfig.ts +30 -0
  151. package/src/signaling/transports/index.ts +26 -0
  152. package/src/signaling/transports/sse.ts +146 -0
  153. package/src/signaling/transports/types.ts +19 -0
  154. package/src/signaling/transports/ws.ts +108 -0
  155. package/src/signaling/types.ts +68 -0
  156. package/src/types.ts +299 -0
@@ -0,0 +1,1258 @@
1
+ import type {
2
+ ActiveTransport,
3
+ CallErrorCode,
4
+ CallState,
5
+ CallStats,
6
+ CameraMode,
7
+ ConfigurableCameraMode,
8
+ ConnectionStatus,
9
+ MediaCapability,
10
+ SerenadaConfig,
11
+ SerenadaSessionHandle,
12
+ SignalingState,
13
+ } from './types.js';
14
+ import { resolveCameraModes } from './cameraModes.js';
15
+ import type {
16
+ ConnectionInfo,
17
+ SignalingErrorEvent,
18
+ JoinOptions,
19
+ NegotiationDirtyEvent,
20
+ PeerEvent,
21
+ PeerMessage,
22
+ RelayFailedEvent,
23
+ RoomStateEvent,
24
+ SignalingProvider,
25
+ SignalingProviderEventMap,
26
+ SignalingProviderEventName,
27
+ SignalingProviderParticipant,
28
+ } from './SignalingProvider.js';
29
+ import { MediaEngine } from './media/MediaEngine.js';
30
+ import { CallStatsCollector } from './media/callStats.js';
31
+ import {
32
+ ICE_FETCH_RETRY_DELAYS_MS,
33
+ RECONNECT_BACKOFF_BASE_MS,
34
+ RECONNECT_BACKOFF_CAP_MS,
35
+ JOIN_HARD_TIMEOUT_MS,
36
+ ENDING_SCREEN_MS,
37
+ EPOCH_RESYNC_TIMEOUT_MS,
38
+ MEDIA_LIVENESS_INTERVAL_MS,
39
+ PEER_SUSPENDED_UI_TIMEOUT_MS,
40
+ SUSPEND_HARD_EVICTION_TIMEOUT_MS,
41
+ } from './constants.js';
42
+ import { formatError } from './formatError.js';
43
+ import type { RoomParticipant, RoomState, SignalingMessage } from './signaling/types.js';
44
+
45
+ interface SessionDependencies {
46
+ media?: MediaEngine;
47
+ statsCollector?: CallStatsCollector;
48
+ autoStart?: boolean;
49
+ displayName?: string;
50
+ peerId?: string;
51
+ }
52
+
53
+ function mapErrorCode(serverCode: string): CallErrorCode {
54
+ switch (serverCode) {
55
+ case 'JOIN_TIMEOUT':
56
+ return 'signalingTimeout';
57
+ case 'ROOM_FULL':
58
+ case 'ROOM_CAPACITY_UNSUPPORTED':
59
+ return 'roomFull';
60
+ case 'ROOM_ENDED':
61
+ return 'roomEnded';
62
+ case 'CONNECTION_FAILED':
63
+ return 'connectionFailed';
64
+ case 'ICE_SERVER_FETCH_FAILED':
65
+ return 'serverError';
66
+ case 'BAD_REQUEST':
67
+ case 'UNSUPPORTED_VERSION':
68
+ case 'INVALID_ROOM_ID':
69
+ case 'SERVER_NOT_CONFIGURED':
70
+ case 'INVALID_RECONNECT_TOKEN':
71
+ case 'TURN_REFRESH_FAILED':
72
+ case 'NOT_IN_ROOM':
73
+ case 'NOT_HOST':
74
+ return 'serverError';
75
+ default:
76
+ return 'unknown';
77
+ }
78
+ }
79
+
80
+ function toRoomParticipant(participant: SignalingProviderParticipant): RoomParticipant {
81
+ return {
82
+ cid: participant.peerId,
83
+ joinedAt: participant.joinedAt,
84
+ displayName: participant.displayName,
85
+ peerId: participant.appPeerId,
86
+ audioEnabled: participant.audioEnabled,
87
+ videoEnabled: participant.videoEnabled,
88
+ connectionStatus: participant.connectionStatus,
89
+ };
90
+ }
91
+
92
+ function dedupeParticipants(
93
+ participants: RoomParticipant[],
94
+ localPeerId: string | null,
95
+ ): RoomParticipant[] {
96
+ const deduped = new Map<string, RoomParticipant>();
97
+ for (const participant of participants) {
98
+ if (participant.cid.length === 0) {
99
+ continue;
100
+ }
101
+ deduped.set(participant.cid, participant);
102
+ }
103
+ if (localPeerId && !deduped.has(localPeerId)) {
104
+ deduped.set(localPeerId, { cid: localPeerId });
105
+ }
106
+ return Array.from(deduped.values());
107
+ }
108
+
109
+ function resolveHostCid(
110
+ participants: RoomParticipant[],
111
+ nextHostCid: string | null | undefined,
112
+ localPeerId: string | null,
113
+ ): string | null {
114
+ const candidateHostCid = nextHostCid ?? localPeerId ?? null;
115
+ if (!candidateHostCid) {
116
+ return participants[0]?.cid ?? null;
117
+ }
118
+ const participantCids = new Set(participants.map((participant) => participant.cid));
119
+ if (participantCids.size > 0 && !participantCids.has(candidateHostCid)) {
120
+ return participants[0]?.cid ?? null;
121
+ }
122
+ return candidateHostCid;
123
+ }
124
+
125
+ function buildRoomState(
126
+ event: Pick<RoomStateEvent, 'participants' | 'hostPeerId' | 'maxParticipants'>,
127
+ currentHostCid: string | null,
128
+ localPeerId: string | null,
129
+ ): RoomState {
130
+ const participants = dedupeParticipants(event.participants.map(toRoomParticipant), localPeerId);
131
+ return {
132
+ hostCid: resolveHostCid(participants, event.hostPeerId ?? currentHostCid, localPeerId),
133
+ participants,
134
+ maxParticipants: event.maxParticipants,
135
+ };
136
+ }
137
+
138
+ function upsertParticipant(
139
+ roomState: RoomState | null,
140
+ event: PeerEvent,
141
+ localPeerId: string | null,
142
+ ): RoomState | null {
143
+ if (!roomState && !localPeerId) {
144
+ return null;
145
+ }
146
+ const participants = dedupeParticipants([
147
+ ...(roomState?.participants ?? []),
148
+ {
149
+ cid: event.peerId,
150
+ joinedAt: event.joinedAt,
151
+ displayName: event.displayName,
152
+ peerId: event.appPeerId,
153
+ },
154
+ ], localPeerId);
155
+ return {
156
+ hostCid: resolveHostCid(participants, roomState?.hostCid ?? null, localPeerId),
157
+ participants,
158
+ maxParticipants: roomState?.maxParticipants,
159
+ };
160
+ }
161
+
162
+ function removeParticipant(roomState: RoomState | null, peerId: string, localPeerId: string | null): RoomState | null {
163
+ if (!roomState) {
164
+ return null;
165
+ }
166
+ const participants = dedupeParticipants(
167
+ roomState.participants.filter((participant) => participant.cid !== peerId),
168
+ localPeerId,
169
+ );
170
+ if (participants.length === 0) {
171
+ return null;
172
+ }
173
+ const nextHostCid = roomState.hostCid === peerId ? null : roomState.hostCid;
174
+ return {
175
+ hostCid: resolveHostCid(participants, nextHostCid, localPeerId),
176
+ participants,
177
+ maxParticipants: roomState.maxParticipants,
178
+ };
179
+ }
180
+
181
+ function toMediaSignalingMessage(message: PeerMessage): SignalingMessage {
182
+ const payload = message.payload;
183
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
184
+ return {
185
+ v: 1,
186
+ type: message.type,
187
+ cid: message.from,
188
+ payload: {
189
+ ...(payload as Record<string, unknown>),
190
+ from: typeof (payload as Record<string, unknown>).from === 'string'
191
+ ? (payload as Record<string, unknown>).from
192
+ : message.from,
193
+ },
194
+ };
195
+ }
196
+
197
+ return {
198
+ v: 1,
199
+ type: message.type,
200
+ cid: message.from,
201
+ payload: {
202
+ from: message.from,
203
+ value: payload,
204
+ },
205
+ };
206
+ }
207
+
208
+ function isMediaSignalingMessageType(type: string): boolean {
209
+ return type === 'content_state' || type === 'offer' || type === 'answer' || type === 'ice';
210
+ }
211
+
212
+ /**
213
+ * Represents an active call session. Created via {@link SerenadaCore.join} or
214
+ * {@link SerenadaCore.createRoom}. Manages media, signaling, and call state.
215
+ */
216
+ export class SerenadaSession implements SerenadaSessionHandle {
217
+ private readonly signaling: SignalingProvider;
218
+ private readonly media: MediaEngine;
219
+ private readonly statsCollector: CallStatsCollector;
220
+ private readonly config: SerenadaConfig;
221
+ private readonly roomId: string;
222
+ private readonly roomUrl: string | null;
223
+ private readonly handlesReconnection: boolean;
224
+ private readonly displayName?: string;
225
+ private readonly appPeerId?: string;
226
+
227
+ private _state: CallState;
228
+ private stateListeners: Array<(state: CallState) => void> = [];
229
+ private readonly peerMessageListeners = new Set<(message: PeerMessage) => void>();
230
+ private readonly providerUnsubscribers: Array<() => void> = [];
231
+
232
+ private _destroyed = false;
233
+ private permissionCheckDone = false;
234
+ private permissionCheckInFlight = false;
235
+ private endingTimer: number | null = null;
236
+ private joinTimeoutTimer: number | null = null;
237
+ private reconnectTimer: number | null = null;
238
+ private reconnectAttempts = 0;
239
+ private pendingJoinOptions: JoinOptions | null = null;
240
+ private joinInFlight = false;
241
+ private reconnectRecoveryPending = false;
242
+ // True between transport reconnect and the first authoritative room_state
243
+ // snapshot; gates ICE restart so it runs against a confirmed peer set.
244
+ private pendingPostReconnectResync = false;
245
+ private postReconnectResyncTimer: number | null = null;
246
+ private iceFetchGeneration = 0;
247
+ private started = false;
248
+ private terminated = false;
249
+
250
+ private isConnected = false;
251
+ private activeTransport: ActiveTransport | null = null;
252
+ private clientId: string | null = null;
253
+ private roomState: RoomState | null = null;
254
+ private error: SignalingErrorEvent | null = null;
255
+ private readonly remoteMediaStates = new Map<string, { audioEnabled?: boolean; videoEnabled?: boolean }>();
256
+ private readonly availableCameraModes: ConfigurableCameraMode[];
257
+ private userPreferredVideoEnabled: boolean;
258
+
259
+ // Wall-clock ms when the local transport last dropped while a roomState
260
+ // was present (i.e. mid-call). Cleared on reconnect.
261
+ private localSuspendedSinceMs: number | null = null;
262
+
263
+ // After a peer transitions to suspended, we start a 30s timer; on expiry
264
+ // we flip `presumedLost=true` for that CID so call UIs can move it out of
265
+ // the active grid. Timers cancel when the peer goes back to active or is
266
+ // removed from the room.
267
+ private readonly suspendedPresentationTimers = new Map<string, number>();
268
+ private readonly presumedLostRemoteCids = new Set<string>();
269
+
270
+ // #3 — periodic `media_liveness` emission. Active across the in-call
271
+ // window so the server can defer hard-eviction of suspended peers whose
272
+ // media is still flowing locally. Emission skipped while transport is
273
+ // disconnected (ticks just no-op).
274
+ private mediaLivenessTimer: number | null = null;
275
+ private mediaLivenessEmitInFlight = false;
276
+
277
+ private get isInactive(): boolean {
278
+ return this._destroyed || this.terminated;
279
+ }
280
+
281
+ onPermissionsRequired: ((permissions: MediaCapability[]) => void) | null = null;
282
+
283
+ constructor(
284
+ config: SerenadaConfig,
285
+ roomId: string,
286
+ roomUrl: string | null,
287
+ signaling: SignalingProvider,
288
+ deps: SessionDependencies = {},
289
+ ) {
290
+ this.config = config;
291
+ this.roomId = roomId;
292
+ this.roomUrl = roomUrl;
293
+ this.signaling = signaling;
294
+ this.handlesReconnection = signaling.capabilities?.handlesReconnection === true;
295
+ this.displayName = deps.displayName;
296
+ this.appPeerId = deps.peerId;
297
+ this.availableCameraModes = Object.freeze(resolveCameraModes(config.cameraModes)) as ConfigurableCameraMode[];
298
+ this.userPreferredVideoEnabled = this.availableCameraModes.length > 0 && config.defaultVideoEnabled !== false;
299
+
300
+ this._state = {
301
+ phase: 'joining',
302
+ roomId,
303
+ roomUrl,
304
+ localParticipant: null,
305
+ remoteParticipants: [],
306
+ connectionStatus: 'connected',
307
+ signalingState: { kind: 'connected' },
308
+ activeTransport: null,
309
+ requiredPermissions: null,
310
+ error: null,
311
+ };
312
+
313
+ const initialCameraMode = this.availableCameraModes[0];
314
+ this.media = deps.media ?? new MediaEngine(
315
+ {
316
+ turnsOnly: config.turnsOnly,
317
+ logger: config.logger,
318
+ initialFacingMode: initialCameraMode === 'world' ? 'environment' : 'user',
319
+ initialVideoEnabled: this.userPreferredVideoEnabled,
320
+ videoCaptureSupported: this.availableCameraModes.length > 0,
321
+ },
322
+ (type, payload, to) => {
323
+ if (to) {
324
+ this.signaling.sendToPeer(to, type, payload);
325
+ return;
326
+ }
327
+ this.signaling.broadcast(type, payload);
328
+ },
329
+ );
330
+ this.statsCollector = deps.statsCollector ?? new CallStatsCollector(config.logger);
331
+
332
+ this.bindProviderEvents();
333
+ this.media.setOnChange(() => {
334
+ if (this.isInactive) {
335
+ return;
336
+ }
337
+ this.rebuildState();
338
+ });
339
+
340
+ // Skip periodic TURN refresh while every peer is on a direct ICE
341
+ // path — the credentials go unused and the call can continue
342
+ // through arbitrary-length signaling outages. A path that falls
343
+ // back to relay causes the next cycle to refresh normally.
344
+ signaling.setTurnRefreshGate?.(
345
+ () => this.media.arePeerPathsAllDirect().then((direct) => !direct),
346
+ );
347
+
348
+ if (deps.autoStart !== false) {
349
+ this.start();
350
+ }
351
+ }
352
+
353
+ /** Current call state. Subscribe via {@link subscribe} for updates. */
354
+ get state(): CallState { return this._state; }
355
+ /** Local media stream (camera/microphone), or `null` before media is acquired. */
356
+ get localStream(): MediaStream | null { return this.media.localStream; }
357
+ /** Map of remote participant CID to their media stream. */
358
+ get remoteStreams(): Map<string, MediaStream> { return this.media.remoteStreams; }
359
+ /** Current WebRTC call statistics, or `null` if not yet collecting. */
360
+ get callStats(): CallStats | null { return this.statsCollector.stats; }
361
+ get hasMultipleCameras(): boolean { return this.media.hasMultipleCameras; }
362
+ get canScreenShare(): boolean { return this.media.canScreenShare; }
363
+ get isSignalingConnected(): boolean { return this.isConnected; }
364
+ get iceConnectionState(): RTCIceConnectionState { return this.media.iceConnectionState; }
365
+ get peerConnectionState(): RTCPeerConnectionState { return this.media.connectionState; }
366
+ get rtcSignalingState(): RTCSignalingState { return this.media.signalingState; }
367
+
368
+ /** Subscribe to state changes. Returns an unsubscribe function. */
369
+ subscribe(callback: (state: CallState) => void): () => void {
370
+ this.stateListeners.push(callback);
371
+ return () => {
372
+ this.stateListeners = this.stateListeners.filter((listener) => listener !== callback);
373
+ };
374
+ }
375
+
376
+ onPeerMessage(callback: (message: PeerMessage) => void): () => void {
377
+ this.peerMessageListeners.add(callback);
378
+ return () => {
379
+ this.peerMessageListeners.delete(callback);
380
+ };
381
+ }
382
+
383
+ /** Resume joining after media permissions have been granted. */
384
+ async resumeJoin(): Promise<void> {
385
+ if (this.isInactive) {
386
+ return;
387
+ }
388
+ this.permissionCheckDone = true;
389
+ const stream = await this.media.startLocalMedia();
390
+ if (stream) {
391
+ this.broadcastLocalMediaState();
392
+ this.rebuildState();
393
+ }
394
+ }
395
+
396
+ /** Cancel an in-progress join and destroy the session. */
397
+ cancelJoin(): void {
398
+ this.permissionCheckDone = true;
399
+ this._state = { ...this._state, phase: 'idle', requiredPermissions: null };
400
+ this.notifyListeners();
401
+ this.destroy();
402
+ }
403
+
404
+ /** Leave the call gracefully. The other participant stays connected. */
405
+ leave(): void {
406
+ if (this.isInactive) return;
407
+ this.clearReconnectTimer();
408
+ this.invalidateIceFetches();
409
+ this.pendingJoinOptions = null;
410
+ this.joinInFlight = false;
411
+ this.signaling.leaveRoom();
412
+ this.media.cleanupAllPeers();
413
+ this.statsCollector.stop();
414
+ this.roomState = null;
415
+ this.remoteMediaStates.clear();
416
+ this._state = { ...this._state, phase: 'idle' };
417
+ this.notifyListeners();
418
+ this.destroy();
419
+ }
420
+
421
+ /** End the call for all participants. */
422
+ end(): void {
423
+ if (this.isInactive) return;
424
+ this.signaling.endRoom();
425
+ this.leave();
426
+ }
427
+
428
+ /** Toggle local audio on/off. */
429
+ toggleAudio(): void { this.setTrackEnabled('audio'); }
430
+ /** Toggle local video on/off. */
431
+ toggleVideo(): void { this.setTrackEnabled('video'); }
432
+
433
+ /** Set local audio enabled state explicitly. */
434
+ setAudioEnabled(enabled: boolean): void { this.setTrackEnabled('audio', enabled); }
435
+ /** Set local video enabled state explicitly. */
436
+ setVideoEnabled(enabled: boolean): void { this.setTrackEnabled('video', enabled); }
437
+
438
+ /** Switch camera mode (selfie/world). Composite is not available on web. */
439
+ setCameraMode(mode: CameraMode): void {
440
+ if (mode !== 'selfie' && mode !== 'world') return;
441
+ if (!this.availableCameraModes.includes(mode)) return;
442
+ if (mode === 'world' && this.media.facingMode === 'user') {
443
+ void this.media.flipCamera();
444
+ } else if (mode === 'selfie' && this.media.facingMode === 'environment') {
445
+ void this.media.flipCamera();
446
+ }
447
+ }
448
+
449
+ /** Cycle to the next camera mode in the configured order. */
450
+ async flipCamera(): Promise<void> {
451
+ if (this.availableCameraModes.length <= 1) return;
452
+ await this.media.flipCamera();
453
+ }
454
+
455
+ /** Start sharing the screen, replacing the camera video track. */
456
+ async startScreenShare(): Promise<void> {
457
+ await this.media.startScreenShare();
458
+ }
459
+
460
+ /** Stop screen sharing and restore the camera video track. */
461
+ async stopScreenShare(): Promise<void> {
462
+ await this.media.stopScreenShare();
463
+ }
464
+
465
+ /** Clean up all resources. Call when done with the session. */
466
+ destroy(): void {
467
+ if (this._destroyed) return;
468
+ this._destroyed = true;
469
+ this.invalidateIceFetches();
470
+ this.clearReconnectTimer();
471
+ this.clearEndingTimer();
472
+ this.clearJoinTimeout();
473
+ this.cancelPostReconnectResync();
474
+ this.clearAllRemoteSuspensionTracking();
475
+ this.stopMediaLivenessTimer();
476
+ for (const unsubscribe of this.providerUnsubscribers) {
477
+ unsubscribe();
478
+ }
479
+ this.providerUnsubscribers.length = 0;
480
+ this.statsCollector.stop();
481
+ this.media.destroy();
482
+ this.signaling.disconnect();
483
+ }
484
+
485
+ private start(): void {
486
+ if (this.started) {
487
+ return;
488
+ }
489
+ this.started = true;
490
+ this.pendingJoinOptions = {
491
+ displayName: this.displayName,
492
+ appPeerId: this.appPeerId,
493
+ };
494
+ this.scheduleJoinTimeout();
495
+ this.signaling.connect();
496
+ }
497
+
498
+ private bindProviderEvents(): void {
499
+ this.bindProviderEvent('connected', this.handleConnected);
500
+ this.bindProviderEvent('disconnected', this.handleDisconnected);
501
+ this.bindProviderEvent('joined', this.handleJoined);
502
+ this.bindProviderEvent('roomStateUpdated', this.handleRoomStateUpdated);
503
+ this.bindProviderEvent('peerJoined', this.handlePeerJoined);
504
+ this.bindProviderEvent('peerLeft', this.handlePeerLeft);
505
+ this.bindProviderEvent('message', this.handlePeerMessage);
506
+ this.bindProviderEvent('roomEnded', this.handleRoomEnded);
507
+ this.bindProviderEvent('error', this.handleError);
508
+ this.bindProviderEvent('iceServersChanged', this.handleIceServersChanged);
509
+ this.bindProviderEvent('negotiationDirty', this.handleNegotiationDirty);
510
+ this.bindProviderEvent('relayFailed', this.handleRelayFailed);
511
+ }
512
+
513
+ private bindProviderEvent<K extends SignalingProviderEventName>(
514
+ event: K,
515
+ callback: (payload: SignalingProviderEventMap[K]) => void,
516
+ ): void {
517
+ this.signaling.on(event, callback);
518
+ this.providerUnsubscribers.push(() => {
519
+ this.signaling.off(event, callback);
520
+ });
521
+ }
522
+
523
+ private readonly handleConnected = (info: ConnectionInfo | undefined): void => {
524
+ if (this.isInactive) {
525
+ return;
526
+ }
527
+ const wasConnected = this.isConnected;
528
+ this.isConnected = true;
529
+ this.activeTransport = info?.transport ?? null;
530
+ this.localSuspendedSinceMs = null;
531
+ this.clearReconnectTimer();
532
+ this.reconnectAttempts = 0;
533
+ this.media.updateSignalingConnected(true);
534
+
535
+ if (this.pendingJoinOptions && !this.joinInFlight) {
536
+ this.joinInFlight = true;
537
+ this.signaling.joinRoom(this.roomId, this.pendingJoinOptions);
538
+ } else if (!wasConnected && this.handlesReconnection && this.reconnectRecoveryPending && this.roomState) {
539
+ this.reconnectRecoveryPending = false;
540
+ this.armPostReconnectResync();
541
+ }
542
+
543
+ this.rebuildState();
544
+ };
545
+
546
+ private readonly handleDisconnected = (): void => {
547
+ if (this.isInactive) {
548
+ return;
549
+ }
550
+ const hadRoomState = this.roomState !== null;
551
+ this.isConnected = false;
552
+ this.activeTransport = null;
553
+ this.joinInFlight = false;
554
+ if (hadRoomState && this.localSuspendedSinceMs === null) {
555
+ this.localSuspendedSinceMs = Date.now();
556
+ }
557
+ this.media.updateSignalingConnected(false);
558
+
559
+ if (this.handlesReconnection) {
560
+ this.reconnectRecoveryPending = hadRoomState;
561
+ } else {
562
+ this.pendingJoinOptions = {
563
+ reconnectPeerId: this.clientId ?? undefined,
564
+ displayName: this.displayName,
565
+ appPeerId: this.appPeerId,
566
+ };
567
+ this.scheduleReconnect();
568
+ }
569
+
570
+ this.rebuildState();
571
+ };
572
+
573
+ private readonly handleJoined = (event: SignalingProviderEventMap['joined']): void => {
574
+ if (this.isInactive) {
575
+ return;
576
+ }
577
+ this.joinInFlight = false;
578
+ this.pendingJoinOptions = null;
579
+ this.reconnectRecoveryPending = false;
580
+ this.clearJoinTimeout();
581
+ this.error = null;
582
+ this.clientId = event.peerId;
583
+ this.roomState = buildRoomState(event, null, event.peerId);
584
+ this.media.updateRoomState(this.roomState, this.clientId);
585
+ this.maybeStartMediaLivenessTimer();
586
+ this.rebuildState();
587
+ this.broadcastLocalMediaState();
588
+ void this.fetchInitialIceServers();
589
+ };
590
+
591
+ private readonly handleRoomStateUpdated = (event: RoomStateEvent): void => {
592
+ if (this.isInactive) {
593
+ return;
594
+ }
595
+ this.error = null;
596
+ this.roomState = buildRoomState(event, this.roomState?.hostCid ?? null, this.clientId);
597
+ this.media.updateRoomState(this.roomState, this.clientId);
598
+ this.maybeStartMediaLivenessTimer();
599
+ this.flushPostReconnectResync('snapshot');
600
+ this.rebuildState();
601
+ };
602
+
603
+ private readonly handlePeerJoined = (event: PeerEvent): void => {
604
+ if (this.isInactive) {
605
+ return;
606
+ }
607
+ this.error = null;
608
+ this.roomState = upsertParticipant(this.roomState, event, this.clientId);
609
+ this.media.updateRoomState(this.roomState, this.clientId);
610
+ this.maybeStartMediaLivenessTimer();
611
+ this.rebuildState();
612
+ this.broadcastLocalMediaState();
613
+ };
614
+
615
+ private readonly handlePeerLeft = (event: PeerEvent): void => {
616
+ if (this.isInactive) {
617
+ return;
618
+ }
619
+ this.remoteMediaStates.delete(event.peerId);
620
+ this.roomState = removeParticipant(this.roomState, event.peerId, this.clientId);
621
+ this.media.updateRoomState(this.roomState, this.clientId);
622
+ this.rebuildState();
623
+ };
624
+
625
+ private readonly handlePeerMessage = (message: PeerMessage): void => {
626
+ if (this.isInactive) {
627
+ return;
628
+ }
629
+ if (isMediaSignalingMessageType(message.type)) {
630
+ this.media.processSignalingMessage(toMediaSignalingMessage(message));
631
+ }
632
+ if (message.type === 'participant_media_state') {
633
+ this.handleRemoteMediaState(message);
634
+ }
635
+ for (const listener of this.peerMessageListeners) {
636
+ try {
637
+ listener(message);
638
+ } catch (error) {
639
+ this.config.logger?.log('error', 'Session', `onPeerMessage listener failed for ${message.type}: ${formatError(error)}`);
640
+ }
641
+ }
642
+ };
643
+
644
+ private readonly handleRoomEnded = (): void => {
645
+ if (this.isInactive) {
646
+ return;
647
+ }
648
+ this.cleanupCall();
649
+ };
650
+
651
+ private readonly handleError = (event: SignalingErrorEvent): void => {
652
+ if (this.isInactive) {
653
+ return;
654
+ }
655
+ this.failWithError(event);
656
+ };
657
+
658
+ private readonly handleIceServersChanged = (iceServers: RTCIceServer[]): void => {
659
+ if (this.isInactive) {
660
+ return;
661
+ }
662
+ this.media.setIceServers(iceServers);
663
+ };
664
+
665
+ private readonly handleNegotiationDirty = (event: NegotiationDirtyEvent): void => {
666
+ if (this.isInactive) {
667
+ return;
668
+ }
669
+ this.media.scheduleDirtyPairRestart(event.withCid);
670
+ };
671
+
672
+ private readonly handleRelayFailed = (event: RelayFailedEvent): void => {
673
+ if (this.isInactive) {
674
+ return;
675
+ }
676
+ // The server has the dirty-pair record; once the suspended target
677
+ // reattaches we'll get `negotiation_dirty` and renegotiate then.
678
+ // For now, just surface in logs so suppressed offers/ICE are visible.
679
+ this.config.logger?.log(
680
+ 'debug',
681
+ 'Session',
682
+ `relay_failed reason=${event.reason} of=${event.of ?? 'n/a'} targets=${event.targets.join(',')}`,
683
+ );
684
+ };
685
+
686
+ private async fetchInitialIceServers(): Promise<void> {
687
+ const generation = this.iceFetchGeneration + 1;
688
+ this.iceFetchGeneration = generation;
689
+ let lastError: unknown = null;
690
+
691
+ for (const delayMs of ICE_FETCH_RETRY_DELAYS_MS) {
692
+ if (delayMs > 0) {
693
+ await this.wait(delayMs);
694
+ }
695
+ if (!this.isCurrentIceFetch(generation)) {
696
+ return;
697
+ }
698
+ try {
699
+ const iceServers = await this.signaling.getIceServers();
700
+ if (!this.isCurrentIceFetch(generation)) {
701
+ return;
702
+ }
703
+ this.media.setIceServers(iceServers);
704
+ return;
705
+ } catch (error) {
706
+ lastError = error;
707
+ }
708
+ }
709
+
710
+ if (!this.isCurrentIceFetch(generation)) {
711
+ return;
712
+ }
713
+ this.failWithError({
714
+ code: 'ICE_SERVER_FETCH_FAILED',
715
+ message: formatError(lastError),
716
+ });
717
+ }
718
+
719
+ private isCurrentIceFetch(generation: number): boolean {
720
+ return !this._destroyed && generation === this.iceFetchGeneration;
721
+ }
722
+
723
+ private invalidateIceFetches(): void {
724
+ this.iceFetchGeneration += 1;
725
+ }
726
+
727
+ private wait(delayMs: number): Promise<void> {
728
+ return new Promise((resolve) => {
729
+ window.setTimeout(resolve, delayMs);
730
+ });
731
+ }
732
+
733
+ private scheduleJoinTimeout(): void {
734
+ if (this.isInactive) {
735
+ return;
736
+ }
737
+ this.clearJoinTimeout();
738
+ this.joinTimeoutTimer = window.setTimeout(() => {
739
+ this.joinTimeoutTimer = null;
740
+ if (this.isInactive || this.roomState || this.error || !this.pendingJoinOptions) {
741
+ return;
742
+ }
743
+ this.failWithError({
744
+ code: 'JOIN_TIMEOUT',
745
+ message: 'Join timed out',
746
+ });
747
+ }, JOIN_HARD_TIMEOUT_MS);
748
+ }
749
+
750
+ private clearJoinTimeout(): void {
751
+ if (this.joinTimeoutTimer !== null) {
752
+ window.clearTimeout(this.joinTimeoutTimer);
753
+ this.joinTimeoutTimer = null;
754
+ }
755
+ }
756
+
757
+ private scheduleReconnect(): void {
758
+ if (this.isInactive || this.handlesReconnection || !this.started) {
759
+ return;
760
+ }
761
+ if (!this.pendingJoinOptions && !this.roomState && !this.clientId) {
762
+ return;
763
+ }
764
+ if (this.reconnectTimer !== null) {
765
+ return;
766
+ }
767
+
768
+ const attempt = this.reconnectAttempts + 1;
769
+ const delayMs = Math.min(
770
+ RECONNECT_BACKOFF_BASE_MS * Math.pow(2, attempt - 1),
771
+ RECONNECT_BACKOFF_CAP_MS,
772
+ );
773
+
774
+ this.reconnectTimer = window.setTimeout(() => {
775
+ this.reconnectTimer = null;
776
+ this.reconnectAttempts = attempt;
777
+ if (this.isInactive) {
778
+ return;
779
+ }
780
+ this.pendingJoinOptions = {
781
+ reconnectPeerId: this.clientId ?? undefined,
782
+ displayName: this.displayName,
783
+ appPeerId: this.appPeerId,
784
+ };
785
+ this.signaling.connect();
786
+ this.rebuildState();
787
+ }, delayMs);
788
+ }
789
+
790
+ private clearReconnectTimer(): void {
791
+ if (this.reconnectTimer !== null) {
792
+ window.clearTimeout(this.reconnectTimer);
793
+ this.reconnectTimer = null;
794
+ }
795
+ }
796
+
797
+ private armPostReconnectResync(): void {
798
+ if (this.postReconnectResyncTimer !== null) {
799
+ window.clearTimeout(this.postReconnectResyncTimer);
800
+ }
801
+ this.pendingPostReconnectResync = true;
802
+ this.postReconnectResyncTimer = window.setTimeout(() => {
803
+ this.postReconnectResyncTimer = null;
804
+ this.flushPostReconnectResync('timeout');
805
+ }, EPOCH_RESYNC_TIMEOUT_MS);
806
+ }
807
+
808
+ private flushPostReconnectResync(reason: 'snapshot' | 'timeout'): void {
809
+ if (!this.pendingPostReconnectResync) {
810
+ return;
811
+ }
812
+ this.cancelPostReconnectResync();
813
+ if (reason === 'timeout') {
814
+ this.config.logger?.log(
815
+ 'warning',
816
+ 'Session',
817
+ `Post-reconnect snapshot timeout after ${EPOCH_RESYNC_TIMEOUT_MS}ms; firing ICE restart against last-known peer map`,
818
+ );
819
+ }
820
+ this.media.handleSignalingReconnect();
821
+ }
822
+
823
+ private cancelPostReconnectResync(): void {
824
+ this.pendingPostReconnectResync = false;
825
+ if (this.postReconnectResyncTimer !== null) {
826
+ window.clearTimeout(this.postReconnectResyncTimer);
827
+ this.postReconnectResyncTimer = null;
828
+ }
829
+ }
830
+
831
+ private clearEndingTimer(): void {
832
+ if (this.endingTimer !== null) {
833
+ window.clearTimeout(this.endingTimer);
834
+ this.endingTimer = null;
835
+ }
836
+ }
837
+
838
+ private resetSessionResources(): void {
839
+ this.clearReconnectTimer();
840
+ this.clearJoinTimeout();
841
+ this.clearEndingTimer();
842
+ this.cancelPostReconnectResync();
843
+ this.clearAllRemoteSuspensionTracking();
844
+ this.stopMediaLivenessTimer();
845
+ this.invalidateIceFetches();
846
+ this.statsCollector.stop();
847
+
848
+ this.started = false;
849
+ this.isConnected = false;
850
+ this.activeTransport = null;
851
+ this.pendingJoinOptions = null;
852
+ this.joinInFlight = false;
853
+ this.reconnectRecoveryPending = false;
854
+ this.reconnectAttempts = 0;
855
+ this.localSuspendedSinceMs = null;
856
+ this.roomState = null;
857
+ this.clientId = null;
858
+ this.remoteMediaStates.clear();
859
+
860
+ this.media.updateRoomState(null, null);
861
+ this.media.updateSignalingConnected(false);
862
+ this.media.cleanupAllPeers();
863
+ this.media.stopLocalMedia();
864
+
865
+ this.signaling.disconnect();
866
+ }
867
+
868
+ private commitTerminalState(
869
+ phase: CallState['phase'],
870
+ error: CallState['error'] = null,
871
+ ): void {
872
+ const signalingState: SignalingState = error
873
+ ? { kind: 'failed', reason: error.code }
874
+ : this._state.signalingState;
875
+ this._state = {
876
+ phase,
877
+ roomId: this.roomId,
878
+ roomUrl: this.roomUrl,
879
+ localParticipant: null,
880
+ remoteParticipants: [],
881
+ connectionStatus: 'disconnected',
882
+ signalingState,
883
+ activeTransport: null,
884
+ requiredPermissions: null,
885
+ error,
886
+ };
887
+ this.notifyListeners();
888
+ }
889
+
890
+ private cleanupCall(): void {
891
+ this.terminated = true;
892
+ this.error = null;
893
+ this.resetSessionResources();
894
+ this.commitTerminalState('ending');
895
+ this.endingTimer = window.setTimeout(() => {
896
+ this.endingTimer = null;
897
+ if (this._destroyed) {
898
+ return;
899
+ }
900
+ this.commitTerminalState('idle');
901
+ }, ENDING_SCREEN_MS);
902
+ }
903
+
904
+ private failWithError(event: SignalingErrorEvent): void {
905
+ this.terminated = true;
906
+ this.error = event;
907
+ this.resetSessionResources();
908
+ this.commitTerminalState('error', {
909
+ code: mapErrorCode(event.code),
910
+ message: event.message,
911
+ });
912
+ }
913
+
914
+ private setTrackEnabled(kind: 'audio' | 'video', enabled?: boolean): void {
915
+ const stream = this.media.localStream;
916
+ if (!stream) return;
917
+ if (kind === 'video') {
918
+ if (this.availableCameraModes.length === 0) return;
919
+ const videoTrack = stream.getVideoTracks()[0];
920
+ const newEnabled = enabled ?? !(videoTrack?.enabled ?? this.userPreferredVideoEnabled);
921
+ this.userPreferredVideoEnabled = newEnabled;
922
+ const swap = newEnabled ? this.media.reacquireVideoTrack() : this.media.releaseVideoTrack();
923
+ void swap.then(() => {
924
+ if (!this.isInactive) {
925
+ this.broadcastLocalMediaState();
926
+ this.rebuildState();
927
+ }
928
+ });
929
+ this.rebuildState();
930
+ } else {
931
+ const track = stream.getAudioTracks()[0];
932
+ if (track) track.enabled = enabled ?? !track.enabled;
933
+ this.broadcastLocalMediaState();
934
+ this.rebuildState();
935
+ }
936
+ }
937
+
938
+ private broadcastLocalMediaState(): void {
939
+ const stream = this.media.localStream;
940
+ const audioTrack = stream?.getAudioTracks()[0];
941
+ const videoTrack = stream?.getVideoTracks()[0];
942
+ // Audio: track is always present once media starts; we toggle the
943
+ // `enabled` flag in place. Video: track may be absent (released to free
944
+ // the camera) — derive from track presence so we never advertise
945
+ // camera-on while reacquire is pending or has failed. Pre-media-start
946
+ // (no stream) we fall back to the user's stated preference.
947
+ this.signaling.broadcast('participant_media_state', {
948
+ audioEnabled: audioTrack?.enabled ?? (this.config.defaultAudioEnabled !== false),
949
+ videoEnabled: stream ? !!videoTrack && videoTrack.enabled : this.userPreferredVideoEnabled,
950
+ });
951
+ }
952
+
953
+ private handleRemoteMediaState(message: PeerMessage): void {
954
+ const payload = message.payload as Record<string, unknown> | null;
955
+ if (!payload) return;
956
+ const existing = this.remoteMediaStates.get(message.from);
957
+ this.remoteMediaStates.set(message.from, {
958
+ audioEnabled: typeof payload.audioEnabled === 'boolean' ? payload.audioEnabled : existing?.audioEnabled,
959
+ videoEnabled: typeof payload.videoEnabled === 'boolean' ? payload.videoEnabled : existing?.videoEnabled,
960
+ });
961
+ this.rebuildState();
962
+ }
963
+
964
+ private rebuildState(): void {
965
+ if (this.isInactive) return;
966
+ const signalingState = this.roomState;
967
+ const clientId = this.clientId;
968
+
969
+ let phase = this._state.phase;
970
+
971
+ if (this.error) {
972
+ phase = 'error';
973
+ } else if (!signalingState && phase !== 'idle' && phase !== 'ending') {
974
+ if (this.isConnected && this._state.phase === 'joining') {
975
+ phase = 'joining';
976
+ }
977
+ } else if (signalingState) {
978
+ const hasRemote = (signalingState.participants?.length ?? 0) > 1;
979
+ if (hasRemote) {
980
+ phase = 'inCall';
981
+ this.ensureStatsCollection();
982
+ } else {
983
+ phase = 'waiting';
984
+ }
985
+
986
+ if (!this.permissionCheckDone && !this.media.localStream) {
987
+ void this.checkPermissionsAndStartMedia();
988
+ }
989
+ }
990
+
991
+ const stream = this.media.localStream;
992
+ const audioTrack = stream?.getAudioTracks()[0];
993
+ const videoTrack = stream?.getVideoTracks()[0];
994
+
995
+ const localParticipant = clientId ? {
996
+ cid: clientId,
997
+ displayName: this.displayName,
998
+ peerId: this.appPeerId,
999
+ audioEnabled: audioTrack?.enabled ?? (this.config.defaultAudioEnabled !== false),
1000
+ // Mirror broadcast: derive from real track presence/state so the
1001
+ // local UI matches what peers see. Pre-media-start (no stream),
1002
+ // fall back to the user's preference.
1003
+ videoEnabled: stream ? !!videoTrack && videoTrack.enabled : this.userPreferredVideoEnabled,
1004
+ cameraMode: (this.media.isScreenSharing
1005
+ ? 'screenShare'
1006
+ : this.media.facingMode === 'user'
1007
+ ? 'selfie'
1008
+ : 'world') as CameraMode,
1009
+ availableCameraModes: this.availableCameraModes,
1010
+ isHost: signalingState?.hostCid === clientId,
1011
+ } : null;
1012
+
1013
+ this.reconcileRemoteSuspensionTimers(signalingState?.participants ?? []);
1014
+
1015
+ const remoteParticipants = (signalingState?.participants ?? [])
1016
+ .filter((participant) => participant.cid !== clientId)
1017
+ .map((participant) => {
1018
+ const peerState = this.remoteMediaStates.get(participant.cid);
1019
+ const status = participant.connectionStatus ?? 'active';
1020
+ return {
1021
+ cid: participant.cid,
1022
+ displayName: participant.displayName,
1023
+ peerId: participant.peerId,
1024
+ audioEnabled: peerState?.audioEnabled ?? participant.audioEnabled ?? true,
1025
+ videoEnabled: peerState?.videoEnabled ?? participant.videoEnabled ?? true,
1026
+ connectionState: this.media.connectionState,
1027
+ signalingStatus: status,
1028
+ presumedLost: status === 'suspended' && this.presumedLostRemoteCids.has(participant.cid),
1029
+ };
1030
+ });
1031
+
1032
+ const errorPayload = this.error ? { code: mapErrorCode(this.error.code), message: this.error.message } : null;
1033
+
1034
+ this._state = {
1035
+ phase,
1036
+ roomId: this.roomId,
1037
+ roomUrl: this.roomUrl,
1038
+ localParticipant,
1039
+ remoteParticipants,
1040
+ connectionStatus: this.media.connectionStatus as ConnectionStatus,
1041
+ signalingState: this.computeSignalingState(errorPayload),
1042
+ activeTransport: this.activeTransport,
1043
+ requiredPermissions: this._state.requiredPermissions,
1044
+ error: errorPayload,
1045
+ };
1046
+
1047
+ this.notifyListeners();
1048
+ }
1049
+
1050
+ /**
1051
+ * Compute the public {@link SignalingState} surface from current internal
1052
+ * state. Mid-call transport drops surface as `suspended` (carries
1053
+ * `suspendedSinceMs` + estimated hard-eviction deadline); pre-join drops
1054
+ * surface as `reconnecting`. Terminal errors map to `failed`.
1055
+ */
1056
+ private computeSignalingState(error: CallState['error']): SignalingState {
1057
+ if (error) {
1058
+ return { kind: 'failed', reason: error.code };
1059
+ }
1060
+ if (this.isConnected) {
1061
+ return { kind: 'connected' };
1062
+ }
1063
+ if (this.localSuspendedSinceMs !== null) {
1064
+ return {
1065
+ kind: 'suspended',
1066
+ suspendedSinceMs: this.localSuspendedSinceMs,
1067
+ estimatedHardEvictionAtMs: this.localSuspendedSinceMs + SUSPEND_HARD_EVICTION_TIMEOUT_MS,
1068
+ };
1069
+ }
1070
+ return {
1071
+ kind: 'reconnecting',
1072
+ attempt: this.reconnectAttempts,
1073
+ nextRetryAtMs: null,
1074
+ };
1075
+ }
1076
+
1077
+ /**
1078
+ * Walk the latest authoritative participant list and start/cancel per-CID
1079
+ * suspended-presentation timers. Cancels cleanly when peers go back to
1080
+ * active or are removed; flips `presumedLost=true` on timer expiry.
1081
+ *
1082
+ * "Already presumed lost" is a sticky state: once the timer has fired,
1083
+ * we don't reschedule a new one if the peer remains suspended across
1084
+ * subsequent room_state updates. The flag clears the moment the peer
1085
+ * transitions back to active or leaves the room.
1086
+ */
1087
+ private reconcileRemoteSuspensionTimers(participants: RoomParticipant[]): void {
1088
+ const remoteCids = new Set<string>();
1089
+ for (const participant of participants) {
1090
+ if (participant.cid === this.clientId) {
1091
+ continue;
1092
+ }
1093
+ remoteCids.add(participant.cid);
1094
+ const isSuspended = participant.connectionStatus === 'suspended';
1095
+ const hasTimer = this.suspendedPresentationTimers.has(participant.cid);
1096
+ const isPresumedLost = this.presumedLostRemoteCids.has(participant.cid);
1097
+ if (isSuspended) {
1098
+ if (!hasTimer && !isPresumedLost) {
1099
+ this.startRemoteSuspensionTimer(participant.cid);
1100
+ }
1101
+ } else {
1102
+ this.clearRemoteSuspensionTracking(participant.cid);
1103
+ }
1104
+ }
1105
+ const trackedCids = new Set<string>([
1106
+ ...this.suspendedPresentationTimers.keys(),
1107
+ ...this.presumedLostRemoteCids,
1108
+ ]);
1109
+ for (const cid of trackedCids) {
1110
+ if (!remoteCids.has(cid)) {
1111
+ this.clearRemoteSuspensionTracking(cid);
1112
+ }
1113
+ }
1114
+ }
1115
+
1116
+ private startRemoteSuspensionTimer(cid: string): void {
1117
+ const handle = window.setTimeout(() => {
1118
+ if (this.isInactive) return;
1119
+ this.suspendedPresentationTimers.delete(cid);
1120
+ this.presumedLostRemoteCids.add(cid);
1121
+ this.config.logger?.log(
1122
+ 'info',
1123
+ 'Session',
1124
+ `Remote ${cid} presumed lost after ${PEER_SUSPENDED_UI_TIMEOUT_MS}ms suspended`,
1125
+ );
1126
+ this.rebuildState();
1127
+ }, PEER_SUSPENDED_UI_TIMEOUT_MS);
1128
+ this.suspendedPresentationTimers.set(cid, handle);
1129
+ }
1130
+
1131
+ /**
1132
+ * Clear all per-CID suspension state (timer + presumed-lost flag).
1133
+ * Called when a peer transitions back to active, leaves the room, or
1134
+ * the session is reset.
1135
+ */
1136
+ private clearRemoteSuspensionTracking(cid: string): void {
1137
+ const handle = this.suspendedPresentationTimers.get(cid);
1138
+ if (handle !== undefined) {
1139
+ window.clearTimeout(handle);
1140
+ this.suspendedPresentationTimers.delete(cid);
1141
+ }
1142
+ this.presumedLostRemoteCids.delete(cid);
1143
+ }
1144
+
1145
+ private clearAllRemoteSuspensionTracking(): void {
1146
+ for (const handle of this.suspendedPresentationTimers.values()) {
1147
+ window.clearTimeout(handle);
1148
+ }
1149
+ this.suspendedPresentationTimers.clear();
1150
+ this.presumedLostRemoteCids.clear();
1151
+ }
1152
+
1153
+ /**
1154
+ * Periodic `media_liveness{cids}` emission for #3. Started once we have
1155
+ * remote peers (i.e. the call reaches `inCall`); runs across reconnects
1156
+ * (ticks no-op while disconnected but baseline samples persist so the
1157
+ * next post-reconnect tick can detect flow). Stopped on session
1158
+ * reset/destroy.
1159
+ */
1160
+ private maybeStartMediaLivenessTimer(): void {
1161
+ if (this.mediaLivenessTimer !== null) return;
1162
+ const remoteCount = (this.roomState?.participants?.length ?? 0) - (this.clientId ? 1 : 0);
1163
+ if (remoteCount <= 0) return;
1164
+ this.mediaLivenessTimer = window.setInterval(() => {
1165
+ void this.emitMediaLiveness();
1166
+ }, MEDIA_LIVENESS_INTERVAL_MS);
1167
+ }
1168
+
1169
+ private stopMediaLivenessTimer(): void {
1170
+ if (this.mediaLivenessTimer !== null) {
1171
+ window.clearInterval(this.mediaLivenessTimer);
1172
+ this.mediaLivenessTimer = null;
1173
+ }
1174
+ }
1175
+
1176
+ private async emitMediaLiveness(): Promise<void> {
1177
+ if (this.isInactive || this.mediaLivenessEmitInFlight) return;
1178
+ if (!this.isConnected || this.roomState === null) return;
1179
+ this.mediaLivenessEmitInFlight = true;
1180
+ try {
1181
+ const flowing = await this.media.getInboundFlowingCids();
1182
+ if (this.isInactive || flowing.length === 0) return;
1183
+ this.signaling.broadcast('media_liveness', { cids: flowing });
1184
+ } catch (error) {
1185
+ this.config.logger?.log(
1186
+ 'debug',
1187
+ 'Session',
1188
+ `media_liveness emit failed: ${formatError(error)}`,
1189
+ );
1190
+ } finally {
1191
+ this.mediaLivenessEmitInFlight = false;
1192
+ }
1193
+ }
1194
+
1195
+ private async checkPermissionsAndStartMedia(): Promise<void> {
1196
+ if (this.permissionCheckDone || this.permissionCheckInFlight) return;
1197
+ this.permissionCheckInFlight = true;
1198
+
1199
+ const needsCamera = this.availableCameraModes.length > 0 && this.userPreferredVideoEnabled;
1200
+ const permissionsNeeded: MediaCapability[] = [];
1201
+ try {
1202
+ if (navigator.permissions) {
1203
+ const [cameraResult, micResult] = await Promise.all([
1204
+ needsCamera
1205
+ ? navigator.permissions.query({ name: 'camera' as PermissionName }).catch(() => null)
1206
+ : Promise.resolve(null),
1207
+ navigator.permissions.query({ name: 'microphone' as PermissionName }).catch(() => null),
1208
+ ]);
1209
+ if (cameraResult?.state === 'denied') permissionsNeeded.push('camera');
1210
+ if (micResult?.state === 'denied') permissionsNeeded.push('microphone');
1211
+
1212
+ if (cameraResult?.state === 'prompt' || micResult?.state === 'prompt') {
1213
+ const required: MediaCapability[] = [];
1214
+ if (cameraResult?.state === 'prompt') required.push('camera');
1215
+ if (micResult?.state === 'prompt') required.push('microphone');
1216
+ this.permissionCheckInFlight = false;
1217
+ this._state = { ...this._state, phase: 'awaitingPermissions', requiredPermissions: required };
1218
+ this.notifyListeners();
1219
+ this.onPermissionsRequired?.(required);
1220
+ return;
1221
+ }
1222
+ }
1223
+ } catch {
1224
+ this.permissionCheckInFlight = false;
1225
+ const required: MediaCapability[] = needsCamera ? ['camera', 'microphone'] : ['microphone'];
1226
+ this._state = { ...this._state, phase: 'awaitingPermissions', requiredPermissions: required };
1227
+ this.notifyListeners();
1228
+ this.onPermissionsRequired?.(required);
1229
+ return;
1230
+ }
1231
+
1232
+ if (permissionsNeeded.length > 0) {
1233
+ this.permissionCheckInFlight = false;
1234
+ this._state = { ...this._state, phase: 'awaitingPermissions', requiredPermissions: permissionsNeeded };
1235
+ this.notifyListeners();
1236
+ this.onPermissionsRequired?.(permissionsNeeded);
1237
+ return;
1238
+ }
1239
+
1240
+ this.permissionCheckDone = true;
1241
+ this.permissionCheckInFlight = false;
1242
+ await this.media.startLocalMedia();
1243
+ this.rebuildState();
1244
+ }
1245
+
1246
+ private ensureStatsCollection(): void {
1247
+ if (this.statsCollector.stats !== null) return;
1248
+ this.statsCollector.start(
1249
+ () => this.media.getPeerConnections(),
1250
+ () => this.notifyListeners(),
1251
+ );
1252
+ }
1253
+
1254
+ private notifyListeners(): void {
1255
+ const state = this._state;
1256
+ [...this.stateListeners].forEach((callback) => callback(state));
1257
+ }
1258
+ }