@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.
- package/dist/ConsoleLogger.d.ts +6 -0
- package/dist/ConsoleLogger.d.ts.map +1 -0
- package/dist/ConsoleLogger.js +21 -0
- package/dist/ConsoleLogger.js.map +1 -0
- package/dist/RoomWatcher.d.ts +34 -0
- package/dist/RoomWatcher.d.ts.map +1 -0
- package/dist/RoomWatcher.js +103 -0
- package/dist/RoomWatcher.js.map +1 -0
- package/dist/SerenadaCore.d.ts +47 -0
- package/dist/SerenadaCore.d.ts.map +1 -0
- package/dist/SerenadaCore.js +141 -0
- package/dist/SerenadaCore.js.map +1 -0
- package/dist/SerenadaDiagnostics.d.ts +49 -0
- package/dist/SerenadaDiagnostics.d.ts.map +1 -0
- package/dist/SerenadaDiagnostics.js +421 -0
- package/dist/SerenadaDiagnostics.js.map +1 -0
- package/dist/SerenadaServerProvider.d.ts +48 -0
- package/dist/SerenadaServerProvider.d.ts.map +1 -0
- package/dist/SerenadaServerProvider.js +296 -0
- package/dist/SerenadaServerProvider.js.map +1 -0
- package/dist/SerenadaSession.d.ts +180 -0
- package/dist/SerenadaSession.d.ts.map +1 -0
- package/dist/SerenadaSession.js +1082 -0
- package/dist/SerenadaSession.js.map +1 -0
- package/dist/SignalingProvider.d.ts +132 -0
- package/dist/SignalingProvider.d.ts.map +1 -0
- package/dist/SignalingProvider.js +50 -0
- package/dist/SignalingProvider.js.map +1 -0
- package/dist/api/roomApi.d.ts +2 -0
- package/dist/api/roomApi.d.ts.map +1 -0
- package/dist/api/roomApi.js +14 -0
- package/dist/api/roomApi.js.map +1 -0
- package/dist/cameraModes.d.ts +13 -0
- package/dist/cameraModes.d.ts.map +1 -0
- package/dist/cameraModes.js +35 -0
- package/dist/cameraModes.js.map +1 -0
- package/dist/configValidation.d.ts +10 -0
- package/dist/configValidation.d.ts.map +1 -0
- package/dist/configValidation.js +24 -0
- package/dist/configValidation.js.map +1 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +65 -0
- package/dist/constants.js.map +1 -0
- package/dist/formatError.d.ts +3 -0
- package/dist/formatError.d.ts.map +1 -0
- package/dist/formatError.js +7 -0
- package/dist/formatError.js.map +1 -0
- package/dist/iceServers.d.ts +2 -0
- package/dist/iceServers.d.ts.map +1 -0
- package/dist/iceServers.js +21 -0
- package/dist/iceServers.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/computeLayout.d.ts +81 -0
- package/dist/layout/computeLayout.d.ts.map +1 -0
- package/dist/layout/computeLayout.js +380 -0
- package/dist/layout/computeLayout.js.map +1 -0
- package/dist/media/AudioLevelMonitor.d.ts +51 -0
- package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
- package/dist/media/AudioLevelMonitor.js +179 -0
- package/dist/media/AudioLevelMonitor.js.map +1 -0
- package/dist/media/MediaEngine.d.ts +137 -0
- package/dist/media/MediaEngine.d.ts.map +1 -0
- package/dist/media/MediaEngine.js +1224 -0
- package/dist/media/MediaEngine.js.map +1 -0
- package/dist/media/callStats.d.ts +16 -0
- package/dist/media/callStats.d.ts.map +1 -0
- package/dist/media/callStats.js +214 -0
- package/dist/media/callStats.js.map +1 -0
- package/dist/media/localVideoRecovery.d.ts +16 -0
- package/dist/media/localVideoRecovery.d.ts.map +1 -0
- package/dist/media/localVideoRecovery.js +14 -0
- package/dist/media/localVideoRecovery.js.map +1 -0
- package/dist/recoveryStorage.d.ts +33 -0
- package/dist/recoveryStorage.d.ts.map +1 -0
- package/dist/recoveryStorage.js +88 -0
- package/dist/recoveryStorage.js.map +1 -0
- package/dist/serverUrls.d.ts +8 -0
- package/dist/serverUrls.d.ts.map +1 -0
- package/dist/serverUrls.js +65 -0
- package/dist/serverUrls.js.map +1 -0
- package/dist/signaling/SignalingEngine.d.ts +126 -0
- package/dist/signaling/SignalingEngine.d.ts.map +1 -0
- package/dist/signaling/SignalingEngine.js +720 -0
- package/dist/signaling/SignalingEngine.js.map +1 -0
- package/dist/signaling/payloads.d.ts +76 -0
- package/dist/signaling/payloads.d.ts.map +1 -0
- package/dist/signaling/payloads.js +160 -0
- package/dist/signaling/payloads.js.map +1 -0
- package/dist/signaling/roomStatuses.d.ts +9 -0
- package/dist/signaling/roomStatuses.d.ts.map +1 -0
- package/dist/signaling/roomStatuses.js +71 -0
- package/dist/signaling/roomStatuses.js.map +1 -0
- package/dist/signaling/transportConfig.d.ts +3 -0
- package/dist/signaling/transportConfig.d.ts.map +1 -0
- package/dist/signaling/transportConfig.js +27 -0
- package/dist/signaling/transportConfig.js.map +1 -0
- package/dist/signaling/transports/index.d.ts +13 -0
- package/dist/signaling/transports/index.d.ts.map +1 -0
- package/dist/signaling/transports/index.js +11 -0
- package/dist/signaling/transports/index.js.map +1 -0
- package/dist/signaling/transports/sse.d.ts +26 -0
- package/dist/signaling/transports/sse.d.ts.map +1 -0
- package/dist/signaling/transports/sse.js +131 -0
- package/dist/signaling/transports/sse.js.map +1 -0
- package/dist/signaling/transports/types.d.ts +17 -0
- package/dist/signaling/transports/types.d.ts.map +1 -0
- package/dist/signaling/transports/types.js +2 -0
- package/dist/signaling/transports/types.js.map +1 -0
- package/dist/signaling/transports/ws.d.ts +21 -0
- package/dist/signaling/transports/ws.d.ts.map +1 -0
- package/dist/signaling/transports/ws.js +93 -0
- package/dist/signaling/transports/ws.js.map +1 -0
- package/dist/signaling/types.d.ts +53 -0
- package/dist/signaling/types.d.ts.map +1 -0
- package/dist/signaling/types.js +2 -0
- package/dist/signaling/types.js.map +1 -0
- package/dist/types.d.ts +279 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
- package/src/ConsoleLogger.ts +14 -0
- package/src/RoomWatcher.ts +127 -0
- package/src/SerenadaCore.ts +163 -0
- package/src/SerenadaDiagnostics.ts +485 -0
- package/src/SerenadaServerProvider.ts +362 -0
- package/src/SerenadaSession.ts +1258 -0
- package/src/SignalingProvider.ts +207 -0
- package/src/api/roomApi.ts +16 -0
- package/src/cameraModes.ts +34 -0
- package/src/configValidation.ts +35 -0
- package/src/constants.ts +77 -0
- package/src/formatError.ts +5 -0
- package/src/iceServers.ts +20 -0
- package/src/index.ts +155 -0
- package/src/layout/computeLayout.ts +639 -0
- package/src/media/AudioLevelMonitor.ts +190 -0
- package/src/media/MediaEngine.ts +1183 -0
- package/src/media/callStats.ts +260 -0
- package/src/media/localVideoRecovery.ts +39 -0
- package/src/recoveryStorage.ts +101 -0
- package/src/serverUrls.ts +69 -0
- package/src/signaling/SignalingEngine.ts +762 -0
- package/src/signaling/payloads.ts +215 -0
- package/src/signaling/roomStatuses.ts +89 -0
- package/src/signaling/transportConfig.ts +30 -0
- package/src/signaling/transports/index.ts +26 -0
- package/src/signaling/transports/sse.ts +146 -0
- package/src/signaling/transports/types.ts +19 -0
- package/src/signaling/transports/ws.ts +108 -0
- package/src/signaling/types.ts +68 -0
- 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
|