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