seatalk-call 0.0.0-alpha1
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/README.md +26 -0
- package/index.js +1448 -0
- package/package.json +1 -0
package/index.js
ADDED
@@ -0,0 +1,1448 @@
|
|
1
|
+
// index.js
|
2
|
+
//
|
3
|
+
// seatalk-call: A simulated library for managing SeaTalk calls using standard JavaScript.
|
4
|
+
// This package provides a conceptual framework for initiating, joining, leaving, and managing
|
5
|
+
// participants within a simulated SeaTalk call environment. It demonstrates patterns for
|
6
|
+
// state management, event handling, input validation, and error handling without relying
|
7
|
+
// on external dependencies, focusing purely on standard built-in JavaScript features.
|
8
|
+
// It simulates interactions with a SeaTalk call service through internal state changes
|
9
|
+
// and delayed promise resolutions.
|
10
|
+
|
11
|
+
// --- Constants ---
|
12
|
+
|
13
|
+
/**
|
14
|
+
* @typedef {'IDLE' | 'CONNECTING' | 'CONNECTED' | 'DISCONNECTING' | 'ERROR'} ClientState
|
15
|
+
*/
|
16
|
+
|
17
|
+
/**
|
18
|
+
* @typedef {'INITIALIZING' | 'RINGING' | 'ACTIVE' | 'ENDED' | 'FAILED'} CallState
|
19
|
+
*/
|
20
|
+
|
21
|
+
/**
|
22
|
+
* @typedef {'participantJoined' | 'participantLeft' | 'callEnded' | 'localMuteStatusChanged' | 'remoteMuteStatusChanged' | 'error'} CallEventType
|
23
|
+
*/
|
24
|
+
|
25
|
+
/**
|
26
|
+
* @typedef {'clientStateChanged' | 'callStarted' | 'callEnded' | 'callFailed'} ClientEventType
|
27
|
+
*/
|
28
|
+
|
29
|
+
/**
|
30
|
+
* Standard delays to simulate network latency or processing time.
|
31
|
+
*/
|
32
|
+
const SIMULATED_DELAY_MS = 150;
|
33
|
+
const SIMULATED_ASYNC_PROCESSING_MS = 50;
|
34
|
+
|
35
|
+
/**
|
36
|
+
* Default client state upon initialization.
|
37
|
+
* @type {ClientState}
|
38
|
+
*/
|
39
|
+
const DEFAULT_CLIENT_STATE = 'IDLE';
|
40
|
+
|
41
|
+
/**
|
42
|
+
* Maximum number of participants allowed in a simulated call.
|
43
|
+
*/
|
44
|
+
const MAX_PARTICIPANTS_PER_CALL = 50;
|
45
|
+
|
46
|
+
/**
|
47
|
+
* Standard error messages.
|
48
|
+
*/
|
49
|
+
const ERROR_MESSAGES = {
|
50
|
+
NOT_CONFIGURED: 'Client is not configured. Call configure() first.',
|
51
|
+
INVALID_CONFIG: 'Invalid configuration provided.',
|
52
|
+
INVALID_PARTICIPANT_ID: 'Invalid participant ID. Must be a non-empty string or number.',
|
53
|
+
INVALID_PARTICIPANT_LIST: 'Invalid participant list. Must be an array of valid participant IDs.',
|
54
|
+
MAX_PARTICIPANTS_EXCEEDED: `Maximum participants (${MAX_PARTICIPANTS_PER_CALL}) exceeded.`,
|
55
|
+
CALL_NOT_FOUND: 'Call not found with the given ID.',
|
56
|
+
NOT_IN_CALL: 'Cannot perform action: Not currently in the specified call.',
|
57
|
+
ALREADY_IN_CALL: 'Cannot perform action: Already in the specified call.',
|
58
|
+
INVALID_CALL_STATE: 'Cannot perform action in the current call state.',
|
59
|
+
ALREADY_MUTED: 'Local participant is already muted.',
|
60
|
+
NOT_MUTED: 'Local participant is not currently muted.',
|
61
|
+
INVALID_CALL_ID: 'Invalid call ID. Must be a non-empty string.',
|
62
|
+
INVALID_EVENT_TYPE: 'Invalid event type provided for listener registration.',
|
63
|
+
INVALID_LISTENER: 'Invalid listener provided. Must be a function.',
|
64
|
+
PARTICIPANT_ALREADY_IN_CALL: 'Participant is already in the call.',
|
65
|
+
PARTICIPANT_NOT_IN_CALL: 'Participant is not in the call.',
|
66
|
+
NO_LOCAL_USER_CONFIGURED: 'Local user ID is not configured.',
|
67
|
+
};
|
68
|
+
|
69
|
+
// --- Custom Error Classes ---
|
70
|
+
|
71
|
+
/**
|
72
|
+
* Base custom error for Seatalk Call operations.
|
73
|
+
*/
|
74
|
+
class SeatalkCallError extends Error {
|
75
|
+
/**
|
76
|
+
* @param {string} message - The error message.
|
77
|
+
* @param {string} [code] - An optional error code.
|
78
|
+
*/
|
79
|
+
constructor(message, code) {
|
80
|
+
super(message);
|
81
|
+
this.name = 'SeatalkCallError';
|
82
|
+
this.code = code || 'SEATALK_CALL_ERROR';
|
83
|
+
// Ensure the stack trace is captured correctly in V8 environments
|
84
|
+
if (Error.captureStackTrace) {
|
85
|
+
Error.captureStackTrace(this, SeatalkCallError);
|
86
|
+
}
|
87
|
+
}
|
88
|
+
}
|
89
|
+
|
90
|
+
/**
|
91
|
+
* Error specifically for invalid client configuration.
|
92
|
+
*/
|
93
|
+
class ClientConfigurationError extends SeatalkCallError {
|
94
|
+
/**
|
95
|
+
* @param {string} message - The error message.
|
96
|
+
*/
|
97
|
+
constructor(message) {
|
98
|
+
super(message, 'CLIENT_CONFIG_ERROR');
|
99
|
+
this.name = 'ClientConfigurationError';
|
100
|
+
if (Error.captureStackTrace) {
|
101
|
+
Error.captureStackTrace(this, ClientConfigurationError);
|
102
|
+
}
|
103
|
+
}
|
104
|
+
}
|
105
|
+
|
106
|
+
/**
|
107
|
+
* Error specifically for operations attempted in an invalid call state.
|
108
|
+
*/
|
109
|
+
class InvalidCallStateError extends SeatalkCallError {
|
110
|
+
/**
|
111
|
+
* @param {string} message - The error message.
|
112
|
+
* @param {string} currentCallState - The state of the call when the error occurred.
|
113
|
+
* @param {string[]} [allowedStates] - Optional array of states that would allow the action.
|
114
|
+
*/
|
115
|
+
constructor(message, currentCallState, allowedStates) {
|
116
|
+
super(message, 'INVALID_CALL_STATE');
|
117
|
+
this.name = 'InvalidCallStateError';
|
118
|
+
this.currentCallState = currentCallState;
|
119
|
+
this.allowedStates = allowedStates;
|
120
|
+
if (Error.captureStackTrace) {
|
121
|
+
Error.captureStackTrace(this, InvalidCallStateError);
|
122
|
+
}
|
123
|
+
}
|
124
|
+
}
|
125
|
+
|
126
|
+
/**
|
127
|
+
* Error specifically for invalid input parameters.
|
128
|
+
*/
|
129
|
+
class InvalidInputError extends SeatalkCallError {
|
130
|
+
/**
|
131
|
+
* @param {string} message - The error message.
|
132
|
+
*/
|
133
|
+
constructor(message) {
|
134
|
+
super(message, 'INVALID_INPUT_ERROR');
|
135
|
+
this.name = 'InvalidInputError';
|
136
|
+
if (Error.captureStackTrace) {
|
137
|
+
Error.captureStackTrace(this, InvalidInputError);
|
138
|
+
}
|
139
|
+
}
|
140
|
+
}
|
141
|
+
|
142
|
+
/**
|
143
|
+
* Error specifically when a call could not be found.
|
144
|
+
*/
|
145
|
+
class CallNotFoundError extends SeatalkCallError {
|
146
|
+
/**
|
147
|
+
* @param {string} callId - The ID of the call that was not found.
|
148
|
+
*/
|
149
|
+
constructor(callId) {
|
150
|
+
super(ERROR_MESSAGES.CALL_NOT_FOUND, 'CALL_NOT_FOUND');
|
151
|
+
this.name = 'CallNotFoundError';
|
152
|
+
this.callId = callId;
|
153
|
+
if (Error.captureStackTrace) {
|
154
|
+
Error.captureStackTrace(this, CallNotFoundError);
|
155
|
+
}
|
156
|
+
}
|
157
|
+
}
|
158
|
+
|
159
|
+
/**
|
160
|
+
* Error specifically when a participant is not found in a call.
|
161
|
+
*/
|
162
|
+
class ParticipantNotFoundError extends SeatalkCallError {
|
163
|
+
/**
|
164
|
+
* @param {string | number} participantId - The ID of the participant not found.
|
165
|
+
* @param {string} callId - The ID of the call.
|
166
|
+
*/
|
167
|
+
constructor(participantId, callId) {
|
168
|
+
super(ERROR_MESSAGES.PARTICIPANT_NOT_IN_CALL, 'PARTICIPANT_NOT_FOUND');
|
169
|
+
this.name = 'ParticipantNotFoundError';
|
170
|
+
this.participantId = participantId;
|
171
|
+
this.callId = callId;
|
172
|
+
if (Error.captureStackTrace) {
|
173
|
+
Error.captureStackTrace(this, ParticipantNotFoundError);
|
174
|
+
}
|
175
|
+
}
|
176
|
+
}
|
177
|
+
|
178
|
+
// --- Helper Functions ---
|
179
|
+
|
180
|
+
/**
|
181
|
+
* Simulates an asynchronous delay.
|
182
|
+
* @param {number} ms - The number of milliseconds to wait.
|
183
|
+
* @returns {Promise<void>} A promise that resolves after the specified delay.
|
184
|
+
*/
|
185
|
+
function delay(ms) {
|
186
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
187
|
+
}
|
188
|
+
|
189
|
+
/**
|
190
|
+
* Generates a simple unique ID.
|
191
|
+
* In a real scenario, this would likely come from a server API response.
|
192
|
+
* @returns {string} A unique ID string.
|
193
|
+
*/
|
194
|
+
function generateUniqueId() {
|
195
|
+
return 'call-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
196
|
+
}
|
197
|
+
|
198
|
+
/**
|
199
|
+
* Gets the current timestamp in ISO format.
|
200
|
+
* @returns {string} Current ISO timestamp string.
|
201
|
+
*/
|
202
|
+
function getCurrentTimestamp() {
|
203
|
+
return new Date().toISOString();
|
204
|
+
}
|
205
|
+
|
206
|
+
/**
|
207
|
+
* Checks if a value is a valid participant ID (non-empty string or number).
|
208
|
+
* @param {*} id - The value to check.
|
209
|
+
* @returns {boolean} True if the value is a valid participant ID.
|
210
|
+
*/
|
211
|
+
function isValidParticipantId(id) {
|
212
|
+
return (typeof id === 'string' && id.trim().length > 0) || (typeof id === 'number' && !isNaN(id));
|
213
|
+
}
|
214
|
+
|
215
|
+
/**
|
216
|
+
* Validates an array of participant IDs.
|
217
|
+
* @param {any[]} participants - The array to validate.
|
218
|
+
* @throws {InvalidInputError} If the array is not valid or contains invalid IDs.
|
219
|
+
*/
|
220
|
+
function validateParticipantList(participants) {
|
221
|
+
if (!Array.isArray(participants) || participants.length === 0) {
|
222
|
+
throw new InvalidInputError(ERROR_MESSAGES.INVALID_PARTICIPANT_LIST);
|
223
|
+
}
|
224
|
+
for (const participantId of participants) {
|
225
|
+
if (!isValidParticipantId(participantId)) {
|
226
|
+
throw new InvalidInputError(`${ERROR_MESSAGES.INVALID_PARTICIPANT_LIST}: Contains invalid ID '${participantId}'`);
|
227
|
+
}
|
228
|
+
}
|
229
|
+
if (participants.length > MAX_PARTICIPANTS_PER_CALL) {
|
230
|
+
throw new InvalidInputError(`${ERROR_MESSAGES.MAX_PARTICIPANTS_EXCEEDED} Found ${participants.length}`);
|
231
|
+
}
|
232
|
+
}
|
233
|
+
|
234
|
+
/**
|
235
|
+
* Validates a call ID.
|
236
|
+
* @param {*} callId - The value to check.
|
237
|
+
* @throws {InvalidInputError} If the call ID is invalid.
|
238
|
+
*/
|
239
|
+
function validateCallId(callId) {
|
240
|
+
if (typeof callId !== 'string' || callId.trim().length === 0) {
|
241
|
+
throw new InvalidInputError(ERROR_MESSAGES.INVALID_CALL_ID);
|
242
|
+
}
|
243
|
+
}
|
244
|
+
|
245
|
+
/**
|
246
|
+
* Checks if the client is configured.
|
247
|
+
* @param {object | null} config - The client configuration object.
|
248
|
+
* @returns {boolean} True if the client configuration seems valid.
|
249
|
+
*/
|
250
|
+
function isClientConfigured(config) {
|
251
|
+
return config !== null && typeof config === 'object' && typeof config.localUserId !== 'undefined';
|
252
|
+
}
|
253
|
+
|
254
|
+
/**
|
255
|
+
* Validates the provided client configuration.
|
256
|
+
* @param {object} config - The configuration object.
|
257
|
+
* @throws {ClientConfigurationError} If the configuration is invalid.
|
258
|
+
*/
|
259
|
+
function validateConfiguration(config) {
|
260
|
+
if (typeof config !== 'object' || config === null) {
|
261
|
+
throw new ClientConfigurationError(ERROR_MESSAGES.INVALID_CONFIG + ': Configuration must be an object.');
|
262
|
+
}
|
263
|
+
if (!isValidParticipantId(config.localUserId)) {
|
264
|
+
throw new ClientConfigurationError(ERROR_MESSAGES.INVALID_CONFIG + ': Missing or invalid `localUserId`.');
|
265
|
+
}
|
266
|
+
// Add checks for other potential config properties if needed
|
267
|
+
// e.g., if (typeof config.apiEndpoint !== 'string' || config.apiEndpoint.length === 0) { ... }
|
268
|
+
}
|
269
|
+
|
270
|
+
|
271
|
+
/**
|
272
|
+
* Formats call duration from start and end timestamps.
|
273
|
+
* @param {string} startTimeIso - ISO string for start time.
|
274
|
+
* @param {string | null} endTimeIso - ISO string for end time, or null if ongoing.
|
275
|
+
* @returns {string} Formatted duration string (e.g., "0h 5m 30s") or "Ongoing".
|
276
|
+
*/
|
277
|
+
function formatCallDuration(startTimeIso, endTimeIso) {
|
278
|
+
try {
|
279
|
+
const start = new Date(startTimeIso);
|
280
|
+
const end = endTimeIso ? new Date(endTimeIso) : new Date();
|
281
|
+
const durationMs = end.getTime() - start.getTime();
|
282
|
+
|
283
|
+
if (durationMs < 0) return "Invalid Duration"; // Should not happen
|
284
|
+
|
285
|
+
const seconds = Math.floor((durationMs / 1000) % 60);
|
286
|
+
const minutes = Math.floor((durationMs / (1000 * 60)) % 60);
|
287
|
+
const hours = Math.floor((durationMs / (1000 * 60 * 60)) % 24);
|
288
|
+
|
289
|
+
const parts = [];
|
290
|
+
if (hours > 0) parts.push(`${hours}h`);
|
291
|
+
if (minutes > 0 || hours > 0) parts.push(`${minutes}m`); // Include minutes if hours > 0 or minutes > 0
|
292
|
+
parts.push(`${seconds}s`);
|
293
|
+
|
294
|
+
return parts.join(' ');
|
295
|
+
} catch (e) {
|
296
|
+
// Handle potential invalid date strings gracefully
|
297
|
+
return "Calculation Error";
|
298
|
+
}
|
299
|
+
}
|
300
|
+
|
301
|
+
/**
|
302
|
+
* Simple logger utility.
|
303
|
+
*/
|
304
|
+
const Logger = {
|
305
|
+
/**
|
306
|
+
* Logs an informational message.
|
307
|
+
* @param {string} message - The message to log.
|
308
|
+
* @param {any} [data] - Optional data to log with the message.
|
309
|
+
*/
|
310
|
+
info(message, data) {
|
311
|
+
console.log(`[SeatalkCallClient][INFO] ${message}`, data !== undefined ? data : '');
|
312
|
+
},
|
313
|
+
|
314
|
+
/**
|
315
|
+
* Logs a warning message.
|
316
|
+
* @param {string} message - The message to log.
|
317
|
+
* @param {any} [data] - Optional data to log with the message.
|
318
|
+
*/
|
319
|
+
warn(message, data) {
|
320
|
+
console.warn(`[SeatalkCallClient][WARN] ${message}`, data !== undefined ? data : '');
|
321
|
+
},
|
322
|
+
|
323
|
+
/**
|
324
|
+
* Logs an error message.
|
325
|
+
* @param {string} message - The message to log.
|
326
|
+
* @param {Error | any} [err] - The error object or data related to the error.
|
327
|
+
*/
|
328
|
+
error(message, err) {
|
329
|
+
console.error(`[SeatalkCallClient][ERROR] ${message}`, err);
|
330
|
+
}
|
331
|
+
};
|
332
|
+
|
333
|
+
/**
|
334
|
+
* Checks if a call state indicates an active call session where participants are connected.
|
335
|
+
* @param {CallState} state - The call state to check.
|
336
|
+
* @returns {boolean} True if the state is 'ACTIVE'.
|
337
|
+
*/
|
338
|
+
function isCallActiveState(state) {
|
339
|
+
return state === 'ACTIVE';
|
340
|
+
}
|
341
|
+
|
342
|
+
/**
|
343
|
+
* Checks if a call state indicates the call is still in progress (not ended or failed).
|
344
|
+
* @param {CallState} state - The call state to check.
|
345
|
+
* @returns {boolean} True if the state is 'INITIALIZING', 'RINGING', or 'ACTIVE'.
|
346
|
+
*/
|
347
|
+
function isCallInProgressState(state) {
|
348
|
+
return state === 'INITIALIZING' || state === 'RINGING' || state === 'ACTIVE';
|
349
|
+
}
|
350
|
+
|
351
|
+
|
352
|
+
// --- Core Class ---
|
353
|
+
|
354
|
+
/**
|
355
|
+
* Represents a simulated Seatalk Call session.
|
356
|
+
* This internal class holds the state for a single call.
|
357
|
+
*/
|
358
|
+
class CallSession {
|
359
|
+
/**
|
360
|
+
* @param {string} callId - Unique identifier for the call.
|
361
|
+
* @param {string | number} hostId - The ID of the user who initiated the call.
|
362
|
+
* @param {(string | number)[]} initialParticipants - Participants initially invited or in the call.
|
363
|
+
* @param {string | number} localUserId - The ID of the current user running the client.
|
364
|
+
*/
|
365
|
+
constructor(callId, hostId, initialParticipants, localUserId) {
|
366
|
+
this.callId = callId;
|
367
|
+
this.hostId = hostId;
|
368
|
+
this.localUserId = localUserId;
|
369
|
+
/** @type {CallState} */
|
370
|
+
this.state = 'INITIALIZING';
|
371
|
+
/** @type {Set<string | number>} */
|
372
|
+
this.participants = new Set(initialParticipants.map(String)); // Use Set for unique participants
|
373
|
+
/** @type {Map<string | number, boolean>} */
|
374
|
+
this.participantMuteStatus = new Map(); // participantId -> isMuted
|
375
|
+
/** @type {string | null} */
|
376
|
+
this.startTime = null;
|
377
|
+
/** @type {string | null} */
|
378
|
+
this.endTime = null;
|
379
|
+
/** @type {string | null} */
|
380
|
+
this.failureReason = null;
|
381
|
+
|
382
|
+
// Initialize mute status for all participants
|
383
|
+
this.participants.forEach(pId => this.participantMuteStatus.set(pId, false)); // Assume everyone starts unmuted
|
384
|
+
|
385
|
+
Logger.info(`Created new call session ${this.callId} by host ${this.hostId} with initial participants: ${Array.from(this.participants).join(', ')}`);
|
386
|
+
}
|
387
|
+
|
388
|
+
/**
|
389
|
+
* Updates the state of the call session.
|
390
|
+
* @param {CallState} newState - The new state for the call.
|
391
|
+
*/
|
392
|
+
updateState(newState) {
|
393
|
+
if (this.state === newState) {
|
394
|
+
Logger.info(`Call ${this.callId}: State already ${newState}. No change.`);
|
395
|
+
return;
|
396
|
+
}
|
397
|
+
Logger.info(`Call ${this.callId}: State changing from ${this.state} to ${newState}`);
|
398
|
+
this.state = newState;
|
399
|
+
|
400
|
+
if (newState === 'ACTIVE' && this.startTime === null) {
|
401
|
+
this.startTime = getCurrentTimestamp();
|
402
|
+
Logger.info(`Call ${this.callId}: Call started at ${this.startTime}`);
|
403
|
+
} else if (newState === 'ENDED' || newState === 'FAILED') {
|
404
|
+
this.endTime = getCurrentTimestamp();
|
405
|
+
Logger.info(`Call ${this.callId}: Call ended at ${this.endTime}. Duration: ${formatCallDuration(this.startTime || getCurrentTimestamp(), this.endTime)}`);
|
406
|
+
if (newState === 'FAILED' && !this.failureReason) {
|
407
|
+
this.failureReason = 'Unknown failure'; // Default if not set explicitly
|
408
|
+
}
|
409
|
+
}
|
410
|
+
}
|
411
|
+
|
412
|
+
/**
|
413
|
+
* Checks if the local user is a participant in this call session.
|
414
|
+
* @returns {boolean}
|
415
|
+
*/
|
416
|
+
isLocalUserInCall() {
|
417
|
+
return this.participants.has(String(this.localUserId));
|
418
|
+
}
|
419
|
+
|
420
|
+
/**
|
421
|
+
* Adds a participant to the session's participant list and initializes their mute status.
|
422
|
+
* @param {string | number} participantId - The ID of the participant to add.
|
423
|
+
* @returns {boolean} True if the participant was added, false if they were already present.
|
424
|
+
*/
|
425
|
+
addParticipant(participantId) {
|
426
|
+
const pIdStr = String(participantId);
|
427
|
+
if (this.participants.has(pIdStr)) {
|
428
|
+
Logger.warn(`Call ${this.callId}: Participant ${pIdStr} already in call.`);
|
429
|
+
return false;
|
430
|
+
}
|
431
|
+
this.participants.add(pIdStr);
|
432
|
+
this.participantMuteStatus.set(pIdStr, false); // Assume new participants join unmuted
|
433
|
+
Logger.info(`Call ${this.callId}: Added participant ${pIdStr}. Total participants: ${this.participants.size}`);
|
434
|
+
return true;
|
435
|
+
}
|
436
|
+
|
437
|
+
/**
|
438
|
+
* Removes a participant from the session's participant list.
|
439
|
+
* @param {string | number} participantId - The ID of the participant to remove.
|
440
|
+
* @returns {boolean} True if the participant was removed, false if they were not present.
|
441
|
+
*/
|
442
|
+
removeParticipant(participantId) {
|
443
|
+
const pIdStr = String(participantId);
|
444
|
+
if (!this.participants.has(pIdStr)) {
|
445
|
+
Logger.warn(`Call ${this.callId}: Participant ${pIdStr} not found in call.`);
|
446
|
+
return false;
|
447
|
+
}
|
448
|
+
this.participants.delete(pIdStr);
|
449
|
+
this.participantMuteStatus.delete(pIdStr);
|
450
|
+
Logger.info(`Call ${this.callId}: Removed participant ${pIdStr}. Total participants: ${this.participants.size}`);
|
451
|
+
return true;
|
452
|
+
}
|
453
|
+
|
454
|
+
/**
|
455
|
+
* Gets the current mute status of a participant.
|
456
|
+
* @param {string | number} participantId - The ID of the participant.
|
457
|
+
* @returns {boolean | undefined} True if muted, false if unmuted, undefined if participant not found.
|
458
|
+
*/
|
459
|
+
getParticipantMuteStatus(participantId) {
|
460
|
+
const pIdStr = String(participantId);
|
461
|
+
return this.participantMuteStatus.get(pIdStr);
|
462
|
+
}
|
463
|
+
|
464
|
+
/**
|
465
|
+
* Sets the mute status for a participant.
|
466
|
+
* @param {string | number} participantId - The ID of the participant.
|
467
|
+
* @param {boolean} isMuted - The new mute status.
|
468
|
+
* @returns {boolean} True if the status was changed, false if the participant was not found or status was already the same.
|
469
|
+
*/
|
470
|
+
setParticipantMuteStatus(participantId, isMuted) {
|
471
|
+
const pIdStr = String(participantId);
|
472
|
+
if (!this.participantMuteStatus.has(pIdStr)) {
|
473
|
+
Logger.warn(`Call ${this.callId}: Cannot set mute status for participant ${pIdStr} not in call.`);
|
474
|
+
return false;
|
475
|
+
}
|
476
|
+
if (this.participantMuteStatus.get(pIdStr) === isMuted) {
|
477
|
+
Logger.info(`Call ${this.callId}: Participant ${pIdStr} mute status already ${isMuted}. No change.`);
|
478
|
+
return false;
|
479
|
+
}
|
480
|
+
this.participantMuteStatus.set(pIdStr, isMuted);
|
481
|
+
Logger.info(`Call ${this.callId}: Participant ${pIdStr} mute status set to ${isMuted}`);
|
482
|
+
return true;
|
483
|
+
}
|
484
|
+
|
485
|
+
/**
|
486
|
+
* Gets a snapshot of the current call details.
|
487
|
+
* @returns {object} An object containing call details.
|
488
|
+
*/
|
489
|
+
getDetails() {
|
490
|
+
return {
|
491
|
+
callId: this.callId,
|
492
|
+
hostId: this.hostId,
|
493
|
+
state: this.state,
|
494
|
+
participants: Array.from(this.participants),
|
495
|
+
participantMuteStatus: Object.fromEntries(this.participantMuteStatus),
|
496
|
+
localUserInCall: this.isLocalUserInCall(),
|
497
|
+
localUserIsMuted: this.participantMuteStatus.get(String(this.localUserId)) || false,
|
498
|
+
startTime: this.startTime,
|
499
|
+
endTime: this.endTime,
|
500
|
+
duration: this.startTime ? formatCallDuration(this.startTime, this.endTime) : 'N/A',
|
501
|
+
failureReason: this.failureReason,
|
502
|
+
};
|
503
|
+
}
|
504
|
+
}
|
505
|
+
|
506
|
+
|
507
|
+
/**
|
508
|
+
* Main client class for interacting with simulated SeaTalk call features.
|
509
|
+
*/
|
510
|
+
class SeatalkCallClient {
|
511
|
+
|
512
|
+
constructor() {
|
513
|
+
/** @type {object | null} */
|
514
|
+
this._configuration = null;
|
515
|
+
/** @type {ClientState} */
|
516
|
+
this._clientState = DEFAULT_CLIENT_STATE;
|
517
|
+
/** @type {Map<string, CallSession>} */
|
518
|
+
this._activeCalls = new Map(); // Maps callId to CallSession instance
|
519
|
+
/** @type {Map<string, Function[]>} */
|
520
|
+
this._eventListeners = new Map(); // Maps eventType to an array of listener functions
|
521
|
+
|
522
|
+
Logger.info('SeatalkCallClient initialized.');
|
523
|
+
}
|
524
|
+
|
525
|
+
/**
|
526
|
+
* Configures the client with necessary settings.
|
527
|
+
* Must be called before initiating or joining calls.
|
528
|
+
* @param {object} config - Configuration object.
|
529
|
+
* @param {string | number} config.localUserId - The ID of the user using this client instance.
|
530
|
+
* // Add other simulated config like apiEndpoint, apiKey, etc.
|
531
|
+
* @throws {ClientConfigurationError} If the configuration is invalid.
|
532
|
+
* @throws {InvalidCallStateError} If configuration is attempted while client is not IDLE.
|
533
|
+
*/
|
534
|
+
configure(config) {
|
535
|
+
if (this._clientState !== 'IDLE') {
|
536
|
+
throw new InvalidCallStateError(`Cannot configure client while state is ${this._clientState}. Must be IDLE.`, this._clientState, ['IDLE']);
|
537
|
+
}
|
538
|
+
validateConfiguration(config);
|
539
|
+
|
540
|
+
// Simulate configuration process
|
541
|
+
this._setClientState('CONNECTING');
|
542
|
+
this._configuration = config;
|
543
|
+
Logger.info('Client configuration received. Simulating connection.');
|
544
|
+
|
545
|
+
// Simulate async connection success
|
546
|
+
delay(SIMULATED_DELAY_MS)
|
547
|
+
.then(() => {
|
548
|
+
this._setClientState('CONNECTED');
|
549
|
+
Logger.info('Client successfully configured and connected.');
|
550
|
+
})
|
551
|
+
.catch(err => {
|
552
|
+
Logger.error('Failed to configure/connect client during simulation.', err);
|
553
|
+
this._configuration = null; // Reset config on simulated failure
|
554
|
+
this._setClientState('ERROR'); // Or DISCONNECTED, depending on desired state
|
555
|
+
this._emit('error', new SeatalkCallError('Simulated client connection failed.', 'SIMULATED_CONNECT_FAILED'));
|
556
|
+
});
|
557
|
+
}
|
558
|
+
|
559
|
+
/**
|
560
|
+
* Gets the current client state.
|
561
|
+
* @returns {ClientState} The current state.
|
562
|
+
*/
|
563
|
+
getClientState() {
|
564
|
+
return this._clientState;
|
565
|
+
}
|
566
|
+
|
567
|
+
/**
|
568
|
+
* Internal method to update client state and emit event.
|
569
|
+
* @param {ClientState} newState - The new state.
|
570
|
+
*/
|
571
|
+
_setClientState(newState) {
|
572
|
+
if (this._clientState === newState) {
|
573
|
+
return;
|
574
|
+
}
|
575
|
+
const oldState = this._clientState;
|
576
|
+
this._clientState = newState;
|
577
|
+
Logger.info(`Client state changed: ${oldState} -> ${newState}`);
|
578
|
+
this._emit('clientStateChanged', { oldState, newState });
|
579
|
+
}
|
580
|
+
|
581
|
+
/**
|
582
|
+
* Registers a listener function for a specific event type.
|
583
|
+
* @param {ClientEventType | CallEventType} eventType - The type of event to listen for.
|
584
|
+
* @param {Function} listener - The function to call when the event occurs.
|
585
|
+
* @throws {InvalidInputError} If eventType or listener is invalid.
|
586
|
+
*/
|
587
|
+
on(eventType, listener) {
|
588
|
+
if (typeof eventType !== 'string' || eventType.length === 0) {
|
589
|
+
throw new InvalidInputError(ERROR_MESSAGES.INVALID_EVENT_TYPE);
|
590
|
+
}
|
591
|
+
if (typeof listener !== 'function') {
|
592
|
+
throw new InvalidInputError(ERROR_MESSAGES.INVALID_LISTENER);
|
593
|
+
}
|
594
|
+
|
595
|
+
if (!this._eventListeners.has(eventType)) {
|
596
|
+
this._eventListeners.set(eventType, []);
|
597
|
+
}
|
598
|
+
const listeners = this._eventListeners.get(eventType);
|
599
|
+
if (!listeners.includes(listener)) {
|
600
|
+
listeners.push(listener);
|
601
|
+
Logger.info(`Registered listener for event: ${eventType}`);
|
602
|
+
} else {
|
603
|
+
Logger.warn(`Listener already registered for event: ${eventType}`);
|
604
|
+
}
|
605
|
+
}
|
606
|
+
|
607
|
+
/**
|
608
|
+
* Removes a registered listener function for a specific event type.
|
609
|
+
* @param {ClientEventType | CallEventType} eventType - The type of event the listener is for.
|
610
|
+
* @param {Function} listener - The listener function to remove.
|
611
|
+
* @returns {boolean} True if the listener was found and removed, false otherwise.
|
612
|
+
* @throws {InvalidInputError} If eventType or listener is invalid.
|
613
|
+
*/
|
614
|
+
off(eventType, listener) {
|
615
|
+
if (typeof eventType !== 'string' || eventType.length === 0) {
|
616
|
+
throw new InvalidInputError(ERROR_MESSAGES.INVALID_EVENT_TYPE);
|
617
|
+
}
|
618
|
+
if (typeof listener !== 'function') {
|
619
|
+
throw new InvalidInputError(ERROR_MESSAGES.INVALID_LISTENER);
|
620
|
+
}
|
621
|
+
|
622
|
+
if (!this._eventListeners.has(eventType)) {
|
623
|
+
Logger.warn(`No listeners found for event type: ${eventType}`);
|
624
|
+
return false;
|
625
|
+
}
|
626
|
+
|
627
|
+
const listeners = this._eventListeners.get(eventType);
|
628
|
+
const index = listeners.indexOf(listener);
|
629
|
+
|
630
|
+
if (index === -1) {
|
631
|
+
Logger.warn(`Listener not found for event type: ${eventType}`);
|
632
|
+
return false;
|
633
|
+
}
|
634
|
+
|
635
|
+
listeners.splice(index, 1);
|
636
|
+
Logger.info(`Removed listener for event: ${eventType}`);
|
637
|
+
if (listeners.length === 0) {
|
638
|
+
this._eventListeners.delete(eventType);
|
639
|
+
Logger.info(`No more listeners for event type: ${eventType}`);
|
640
|
+
}
|
641
|
+
return true;
|
642
|
+
}
|
643
|
+
|
644
|
+
/**
|
645
|
+
* Internal method to emit an event to all registered listeners.
|
646
|
+
* @param {string} eventType - The type of event to emit.
|
647
|
+
* @param {any} data - The data to pass to the listeners.
|
648
|
+
*/
|
649
|
+
_emit(eventType, data) {
|
650
|
+
const listeners = this._eventListeners.get(eventType);
|
651
|
+
if (listeners) {
|
652
|
+
Logger.info(`Emitting event: ${eventType}`, data);
|
653
|
+
// Call listeners asynchronously to prevent blocking if a listener is slow
|
654
|
+
listeners.forEach(listener => {
|
655
|
+
setTimeout(() => {
|
656
|
+
try {
|
657
|
+
listener(data);
|
658
|
+
} catch (e) {
|
659
|
+
Logger.error(`Error in listener for event ${eventType}`, e);
|
660
|
+
// Optionally, emit an internal error event here
|
661
|
+
}
|
662
|
+
}, SIMULATED_ASYNC_PROCESSING_MS);
|
663
|
+
});
|
664
|
+
} else {
|
665
|
+
Logger.info(`No listeners for event: ${eventType}`);
|
666
|
+
}
|
667
|
+
}
|
668
|
+
|
669
|
+
/**
|
670
|
+
* Internal method to ensure client is configured and connected.
|
671
|
+
* @throws {SeatalkCallError} If client is not configured or not in a connected state.
|
672
|
+
*/
|
673
|
+
_ensureClientReady() {
|
674
|
+
if (!isClientConfigured(this._configuration)) {
|
675
|
+
throw new SeatalkCallError(ERROR_MESSAGES.NOT_CONFIGURED);
|
676
|
+
}
|
677
|
+
if (this._clientState !== 'CONNECTED') {
|
678
|
+
throw new InvalidCallStateError(`Client is not CONNECTED. Current state: ${this._clientState}`, this._clientState, ['CONNECTED']);
|
679
|
+
}
|
680
|
+
if (!isValidParticipantId(this._configuration.localUserId)) {
|
681
|
+
throw new ClientConfigurationError(ERROR_MESSAGES.NO_LOCAL_USER_CONFIGURED);
|
682
|
+
}
|
683
|
+
}
|
684
|
+
|
685
|
+
/**
|
686
|
+
* Internal method to find an active call session by ID.
|
687
|
+
* @param {string} callId - The ID of the call.
|
688
|
+
* @returns {CallSession} The found call session.
|
689
|
+
* @throws {CallNotFoundError} If the call is not found or not active.
|
690
|
+
*/
|
691
|
+
_findCall(callId) {
|
692
|
+
validateCallId(callId);
|
693
|
+
const call = this._activeCalls.get(callId);
|
694
|
+
if (!call) {
|
695
|
+
Logger.warn(`Attempted operation on non-existent call: ${callId}`);
|
696
|
+
throw new CallNotFoundError(callId);
|
697
|
+
}
|
698
|
+
// Optionally check if call is in a state where operations are allowed (e.g., not ENDED/FAILED)
|
699
|
+
if (!isCallInProgressState(call.state)) {
|
700
|
+
Logger.warn(`Attempted operation on call ${callId} in invalid state: ${call.state}`);
|
701
|
+
throw new InvalidCallStateError(`Call ${callId} is in state ${call.state}. Cannot perform this action.`, call.state);
|
702
|
+
}
|
703
|
+
return call;
|
704
|
+
}
|
705
|
+
|
706
|
+
/**
|
707
|
+
* Internal method to simulate receiving an update or event for a specific call from the service.
|
708
|
+
* In a real library, this would be driven by a WebSocket or server-sent event listener.
|
709
|
+
* Here, we trigger state changes and events internally.
|
710
|
+
* @param {string} callId - The ID of the call receiving the update.
|
711
|
+
* @param {object} update - The simulated update data.
|
712
|
+
* @param {'stateChange' | 'participantJoin' | 'participantLeave' | 'muteChange' | 'callEnded' | 'callFailed'} update.type - Type of update.
|
713
|
+
* @param {any} [update.data] - Data associated with the update (e.g., new state, participant ID).
|
714
|
+
*/
|
715
|
+
_simulateIncomingUpdate(callId, update) {
|
716
|
+
// Wrap in a simulated async delay to mimic network transport
|
717
|
+
delay(SIMULATED_DELAY_MS).then(() => {
|
718
|
+
const call = this._activeCalls.get(callId);
|
719
|
+
if (!call || !isCallInProgressState(call.state)) {
|
720
|
+
Logger.warn(`Received simulated update for non-active or non-existent call ${callId}. Update ignored.`, update);
|
721
|
+
return; // Ignore updates for calls that are ended or not found
|
722
|
+
}
|
723
|
+
|
724
|
+
Logger.info(`Processing simulated incoming update for call ${callId}: Type=${update.type}`, update.data);
|
725
|
+
|
726
|
+
try {
|
727
|
+
const localUserId = String(this._configuration.localUserId);
|
728
|
+
let eventData;
|
729
|
+
|
730
|
+
switch (update.type) {
|
731
|
+
case 'stateChange':
|
732
|
+
/** @type {CallState} */
|
733
|
+
const newState = update.data;
|
734
|
+
call.updateState(newState);
|
735
|
+
// Emit client-level call event if state change is significant
|
736
|
+
if (newState === 'ACTIVE') {
|
737
|
+
this._emit('callStarted', call.getDetails());
|
738
|
+
} else if (newState === 'ENDED') {
|
739
|
+
this._emit('callEnded', call.getDetails());
|
740
|
+
this._cleanupCall(callId); // Remove from active calls map
|
741
|
+
} else if (newState === 'FAILED') {
|
742
|
+
call.failureReason = update.data.reason || 'Unknown error';
|
743
|
+
this._emit('callFailed', call.getDetails());
|
744
|
+
this._cleanupCall(callId); // Remove from active calls map
|
745
|
+
}
|
746
|
+
// No specific call event for general stateChange unless tied to specific actions
|
747
|
+
break;
|
748
|
+
|
749
|
+
case 'participantJoin':
|
750
|
+
const joinedParticipantId = String(update.data.participantId);
|
751
|
+
// Ensure participant ID is not local user if this update is triggered by local join
|
752
|
+
if (joinedParticipantId === localUserId) {
|
753
|
+
// Local join is handled by the local API call method, not simulated incoming events
|
754
|
+
Logger.info(`Simulated participantJoin for local user ${localUserId} ignored.`);
|
755
|
+
return;
|
756
|
+
}
|
757
|
+
if (call.addParticipant(joinedParticipantId)) {
|
758
|
+
eventData = {
|
759
|
+
callId: call.callId,
|
760
|
+
participantId: joinedParticipantId,
|
761
|
+
timestamp: getCurrentTimestamp(),
|
762
|
+
};
|
763
|
+
this._emit('participantJoined', eventData);
|
764
|
+
}
|
765
|
+
break;
|
766
|
+
|
767
|
+
case 'participantLeave':
|
768
|
+
const leftParticipantId = String(update.data.participantId);
|
769
|
+
if (leftParticipantId === localUserId) {
|
770
|
+
// Local leave is handled by the local API call method
|
771
|
+
Logger.info(`Simulated participantLeave for local user ${localUserId} ignored.`);
|
772
|
+
return;
|
773
|
+
}
|
774
|
+
if (call.removeParticipant(leftParticipantId)) {
|
775
|
+
eventData = {
|
776
|
+
callId: call.callId,
|
777
|
+
participantId: leftParticipantId,
|
778
|
+
timestamp: getCurrentTimestamp(),
|
779
|
+
};
|
780
|
+
this._emit('participantLeft', eventData);
|
781
|
+
}
|
782
|
+
break;
|
783
|
+
|
784
|
+
case 'muteChange':
|
785
|
+
const muteParticipantId = String(update.data.participantId);
|
786
|
+
const isMuted = Boolean(update.data.isMuted);
|
787
|
+
if (call.setParticipantMuteStatus(muteParticipantId, isMuted)) {
|
788
|
+
eventData = {
|
789
|
+
callId: call.callId,
|
790
|
+
participantId: muteParticipantId,
|
791
|
+
isMuted: isMuted,
|
792
|
+
timestamp: getCurrentTimestamp(),
|
793
|
+
};
|
794
|
+
if (muteParticipantId === localUserId) {
|
795
|
+
// This should ideally not happen for local user via simulated remote update,
|
796
|
+
// but handle defensively.
|
797
|
+
this._emit('localMuteStatusChanged', eventData);
|
798
|
+
} else {
|
799
|
+
this._emit('remoteMuteStatusChanged', eventData);
|
800
|
+
}
|
801
|
+
}
|
802
|
+
break;
|
803
|
+
|
804
|
+
case 'callEnded':
|
805
|
+
call.updateState('ENDED');
|
806
|
+
this._emit('callEnded', call.getDetails());
|
807
|
+
this._cleanupCall(callId); // Remove from active calls map
|
808
|
+
break;
|
809
|
+
|
810
|
+
case 'callFailed':
|
811
|
+
call.failureReason = update.data.reason || 'Simulated failure';
|
812
|
+
call.updateState('FAILED');
|
813
|
+
this._emit('callFailed', call.getDetails());
|
814
|
+
this._cleanupCall(callId); // Remove from active calls map
|
815
|
+
break;
|
816
|
+
|
817
|
+
default:
|
818
|
+
Logger.warn(`Received simulated update of unknown type: ${update.type}`);
|
819
|
+
break;
|
820
|
+
}
|
821
|
+
} catch (e) {
|
822
|
+
Logger.error(`Error processing simulated incoming update for call ${callId}`, e);
|
823
|
+
this._emit('error', new SeatalkCallError(`Error processing call update for ${callId}: ${e.message}`, 'CALL_UPDATE_PROCESSING_ERROR'));
|
824
|
+
}
|
825
|
+
}).catch(err => {
|
826
|
+
Logger.error(`Error during simulated update delay for call ${callId}`, err);
|
827
|
+
});
|
828
|
+
}
|
829
|
+
|
830
|
+
/**
|
831
|
+
* Internal method to clean up resources associated with an ended or failed call.
|
832
|
+
* @param {string} callId - The ID of the call to clean up.
|
833
|
+
*/
|
834
|
+
_cleanupCall(callId) {
|
835
|
+
if (this._activeCalls.has(callId)) {
|
836
|
+
this._activeCalls.delete(callId);
|
837
|
+
Logger.info(`Cleaned up resources for call ${callId}. Removed from active calls.`);
|
838
|
+
// In a real scenario, might also clean up resources like WebRTC connections here.
|
839
|
+
} else {
|
840
|
+
Logger.warn(`Attempted to cleanup non-existent or already cleaned up call: ${callId}`);
|
841
|
+
}
|
842
|
+
}
|
843
|
+
|
844
|
+
|
845
|
+
/**
|
846
|
+
* Initiates a new call with a list of participants.
|
847
|
+
* @param {(string | number)[]} participants - Array of participant IDs to invite/start call with.
|
848
|
+
* Must include the local user's ID.
|
849
|
+
* @param {object} [options] - Optional call parameters (simulated).
|
850
|
+
* @returns {Promise<object>} A promise that resolves with details of the initiated call session.
|
851
|
+
* @throws {SeatalkCallError | InvalidInputError | InvalidCallStateError} If the operation fails.
|
852
|
+
*/
|
853
|
+
async startCall(participants, options = {}) {
|
854
|
+
this._ensureClientReady();
|
855
|
+
|
856
|
+
const localUserId = String(this._configuration.localUserId);
|
857
|
+
|
858
|
+
// Validate participants list
|
859
|
+
validateParticipantList(participants);
|
860
|
+
const participantIds = participants.map(String);
|
861
|
+
|
862
|
+
// Ensure local user is in the participant list
|
863
|
+
if (!participantIds.includes(localUserId)) {
|
864
|
+
// Automatically add local user if not present, or throw? Let's throw for strict input.
|
865
|
+
throw new InvalidInputError(`Participant list must include the local user ID: ${localUserId}`);
|
866
|
+
}
|
867
|
+
|
868
|
+
// Check if local user is already in an active call
|
869
|
+
const ongoingCall = Array.from(this._activeCalls.values()).find(call => call.isLocalUserInCall() && isCallInProgressState(call.state));
|
870
|
+
if (ongoingCall) {
|
871
|
+
throw new InvalidCallStateError(`Local user ${localUserId} is already in call ${ongoingCall.callId}. Cannot start a new call.`, ongoingCall.state);
|
872
|
+
}
|
873
|
+
|
874
|
+
// Simulate API call to start a new call
|
875
|
+
Logger.info(`Attempting to start new call with participants: ${participantIds.join(', ')}`, options);
|
876
|
+
|
877
|
+
await delay(SIMULATED_DELAY_MS); // Simulate network delay
|
878
|
+
|
879
|
+
// Simulate API response - a successful call initiation
|
880
|
+
const newCallId = generateUniqueId();
|
881
|
+
const hostId = localUserId; // Assume the initiator is the host
|
882
|
+
|
883
|
+
const callSession = new CallSession(newCallId, hostId, participantIds, localUserId);
|
884
|
+
|
885
|
+
// Add to active calls map immediately upon "successful API response"
|
886
|
+
this._activeCalls.set(newCallId, callSession);
|
887
|
+
Logger.info(`Simulated API success: Call ${newCallId} initiated.`);
|
888
|
+
|
889
|
+
// Simulate call state transition events
|
890
|
+
// Initializing -> Ringing -> Active (or FAILED)
|
891
|
+
callSession.updateState('RINGING');
|
892
|
+
// Simulate ringing state duration
|
893
|
+
await delay(SIMULATED_DELAY_MS * 2);
|
894
|
+
|
895
|
+
// Simulate call becoming active
|
896
|
+
if (Math.random() < 0.1) { // Simulate a small chance of failure during setup
|
897
|
+
callSession.failureReason = 'Simulated setup failure';
|
898
|
+
callSession.updateState('FAILED');
|
899
|
+
this._emit('callFailed', callSession.getDetails());
|
900
|
+
this._cleanupCall(newCallId);
|
901
|
+
throw new SeatalkCallError(`Call ${newCallId} failed during setup.`, 'SIMULATED_SETUP_FAILED');
|
902
|
+
} else {
|
903
|
+
callSession.updateState('ACTIVE');
|
904
|
+
this._emit('callStarted', callSession.getDetails()); // Emit client-level event
|
905
|
+
|
906
|
+
// Simulate participants joining after the call becomes active
|
907
|
+
for (const participantId of participantIds) {
|
908
|
+
if (String(participantId) !== localUserId) {
|
909
|
+
// Simulate remote participant join event arriving shortly
|
910
|
+
this._simulateIncomingUpdate(newCallId, {
|
911
|
+
type: 'participantJoin',
|
912
|
+
data: { participantId: participantId }
|
913
|
+
});
|
914
|
+
}
|
915
|
+
}
|
916
|
+
|
917
|
+
// Return the details of the now active call
|
918
|
+
return callSession.getDetails();
|
919
|
+
}
|
920
|
+
}
|
921
|
+
|
922
|
+
/**
|
923
|
+
* Joins an existing call.
|
924
|
+
* @param {string} callId - The ID of the call to join.
|
925
|
+
* @returns {Promise<object>} A promise that resolves with details of the joined call session.
|
926
|
+
* @throws {SeatalkCallError | InvalidInputError | CallNotFoundError | InvalidCallStateError} If the operation fails.
|
927
|
+
*/
|
928
|
+
async joinCall(callId) {
|
929
|
+
this._ensureClientReady();
|
930
|
+
validateCallId(callId);
|
931
|
+
const localUserId = String(this._configuration.localUserId);
|
932
|
+
|
933
|
+
// Check if local user is already in this call
|
934
|
+
const existingCall = this._activeCalls.get(callId);
|
935
|
+
if (existingCall && isCallInProgressState(existingCall.state) && existingCall.isLocalUserInCall()) {
|
936
|
+
throw new InvalidCallStateError(`Local user ${localUserId} is already in call ${callId}.`, existingCall.state);
|
937
|
+
}
|
938
|
+
|
939
|
+
// Check if local user is in *any* other active call
|
940
|
+
const ongoingCall = Array.from(this._activeCalls.values()).find(call => call.isLocalUserInCall() && isCallInProgressState(call.state));
|
941
|
+
if (ongoingCall && ongoingCall.callId !== callId) {
|
942
|
+
throw new InvalidCallStateError(`Local user ${localUserId} is already in call ${ongoingCall.callId}. Cannot join another call.`, ongoingCall.state);
|
943
|
+
}
|
944
|
+
|
945
|
+
|
946
|
+
Logger.info(`Attempting to join call: ${callId}`);
|
947
|
+
|
948
|
+
// Simulate API call to join the call
|
949
|
+
await delay(SIMULATED_DELAY_MS); // Simulate network delay
|
950
|
+
|
951
|
+
// Simulate API response
|
952
|
+
const call = this._activeCalls.get(callId); // Try finding the call again after potential delay
|
953
|
+
if (!call || !isCallInProgressState(call.state)) {
|
954
|
+
// If the call ended or was not found during the join process
|
955
|
+
throw new CallNotFoundError(`${ERROR_MESSAGES.CALL_NOT_FOUND} or ended during join process: ${callId}`);
|
956
|
+
}
|
957
|
+
|
958
|
+
// Simulate joining the call - update local state
|
959
|
+
const participantAdded = call.addParticipant(localUserId);
|
960
|
+
if (!participantAdded) {
|
961
|
+
// This state should ideally be caught earlier, but double-check
|
962
|
+
throw new InvalidCallStateError(`Local user ${localUserId} failed to join call ${callId} (already listed as participant).`, call.state);
|
963
|
+
}
|
964
|
+
|
965
|
+
// Simulate the service confirming the join and potentially sending participantJoin events for others
|
966
|
+
// In a real system, joining might also trigger state change to ACTIVE if it was RINGING and local user was needed.
|
967
|
+
// For simplicity here, we assume joining an ACTIVE or RINGING call.
|
968
|
+
if (call.state === 'RINGING') {
|
969
|
+
// If joining a ringing call, maybe it moves to active? Or just adds participant?
|
970
|
+
// Let's assume it adds participant, and a separate event might make it active.
|
971
|
+
} else if (call.state !== 'ACTIVE') {
|
972
|
+
// Joining is typically only allowed for RINGING or ACTIVE calls
|
973
|
+
call.removeParticipant(localUserId); // Roll back local state change
|
974
|
+
throw new InvalidCallStateError(`Cannot join call ${callId} in state ${call.state}. Must be RINGING or ACTIVE.`, call.state, ['RINGING', 'ACTIVE']);
|
975
|
+
}
|
976
|
+
|
977
|
+
// Simulate receiving participantJoin event for THIS user from the service
|
978
|
+
// This confirms the join server-side.
|
979
|
+
this._simulateIncomingUpdate(callId, {
|
980
|
+
type: 'participantJoin',
|
981
|
+
data: { participantId: localUserId }
|
982
|
+
});
|
983
|
+
|
984
|
+
|
985
|
+
Logger.info(`Simulated API success: Joined call ${callId}.`);
|
986
|
+
|
987
|
+
// Return the details of the call after joining
|
988
|
+
return call.getDetails();
|
989
|
+
}
|
990
|
+
|
991
|
+
/**
|
992
|
+
* Leaves an active call.
|
993
|
+
* @param {string} callId - The ID of the call to leave.
|
994
|
+
* @returns {Promise<void>} A promise that resolves when the local user has left the call.
|
995
|
+
* @throws {SeatalkCallError | InvalidInputError | CallNotFoundError | InvalidCallStateError} If the operation fails.
|
996
|
+
*/
|
997
|
+
async leaveCall(callId) {
|
998
|
+
this._ensureClientReady();
|
999
|
+
validateCallId(callId);
|
1000
|
+
const localUserId = String(this._configuration.localUserId);
|
1001
|
+
|
1002
|
+
const call = this._findCall(callId);
|
1003
|
+
|
1004
|
+
if (!call.isLocalUserInCall()) {
|
1005
|
+
throw new InvalidCallStateError(ERROR_MESSAGES.NOT_IN_CALL + `: ${callId}`, call.state);
|
1006
|
+
}
|
1007
|
+
if (!isCallInProgressState(call.state)) {
|
1008
|
+
throw new InvalidCallStateError(`Cannot leave call ${callId} in state ${call.state}.`, call.state);
|
1009
|
+
}
|
1010
|
+
|
1011
|
+
Logger.info(`Attempting to leave call: ${callId}`);
|
1012
|
+
|
1013
|
+
// Simulate API call to leave the call
|
1014
|
+
await delay(SIMULATED_DELAY_MS); // Simulate network delay
|
1015
|
+
|
1016
|
+
// Simulate API response / service confirmation
|
1017
|
+
// Remove participant from local state
|
1018
|
+
const participantRemoved = call.removeParticipant(localUserId);
|
1019
|
+
if (!participantRemoved) {
|
1020
|
+
// Should not happen if isLocalUserInCall() was true, but defensive check
|
1021
|
+
throw new SeatalkCallError(`Failed to remove local user ${localUserId} from call ${callId} internally.`, 'INTERNAL_LEAVE_ERROR');
|
1022
|
+
}
|
1023
|
+
|
1024
|
+
// Simulate receiving participantLeave event for THIS user from the service
|
1025
|
+
// This confirms the leave server-side and triggers local cleanup/events.
|
1026
|
+
this._simulateIncomingUpdate(callId, {
|
1027
|
+
type: 'participantLeave',
|
1028
|
+
data: { participantId: localUserId }
|
1029
|
+
});
|
1030
|
+
|
1031
|
+
Logger.info(`Simulated API success: Left call ${callId}.`);
|
1032
|
+
|
1033
|
+
// If the call has no participants left (e.g., local user was the last one), simulate it ending
|
1034
|
+
if (call.participants.size === 0) {
|
1035
|
+
Logger.info(`Call ${callId} is now empty. Simulating call end.`);
|
1036
|
+
this._simulateIncomingUpdate(callId, { type: 'callEnded' });
|
1037
|
+
}
|
1038
|
+
|
1039
|
+
// The call details might still be available briefly until the 'callEnded' event leads to cleanup
|
1040
|
+
// Returning void as the 'participantLeft' and potentially 'callEnded' events signal completion.
|
1041
|
+
}
|
1042
|
+
|
1043
|
+
/**
|
1044
|
+
* Mutes the local user's audio in a call.
|
1045
|
+
* @param {string} callId - The ID of the call.
|
1046
|
+
* @returns {Promise<void>} A promise that resolves when the local user is muted.
|
1047
|
+
* @throws {SeatalkCallError | InvalidInputError | CallNotFoundError | InvalidCallStateError} If the operation fails.
|
1048
|
+
*/
|
1049
|
+
async muteSelf(callId) {
|
1050
|
+
this._ensureClientReady();
|
1051
|
+
validateCallId(callId);
|
1052
|
+
const localUserId = String(this._configuration.localUserId);
|
1053
|
+
|
1054
|
+
const call = this._findCall(callId);
|
1055
|
+
|
1056
|
+
if (!call.isLocalUserInCall()) {
|
1057
|
+
throw new InvalidCallStateError(ERROR_MESSAGES.NOT_IN_CALL + `: ${callId}`, call.state);
|
1058
|
+
}
|
1059
|
+
if (!isCallActiveState(call.state)) {
|
1060
|
+
throw new InvalidCallStateError(`Cannot mute in call ${callId} state ${call.state}. Must be ACTIVE.`, call.state, ['ACTIVE']);
|
1061
|
+
}
|
1062
|
+
if (call.getParticipantMuteStatus(localUserId)) {
|
1063
|
+
throw new InvalidCallStateError(ERROR_MESSAGES.ALREADY_MUTED + `: ${callId}`, call.state);
|
1064
|
+
}
|
1065
|
+
|
1066
|
+
Logger.info(`Attempting to mute local user ${localUserId} in call: ${callId}`);
|
1067
|
+
|
1068
|
+
// Simulate API call to mute
|
1069
|
+
await delay(SIMULATED_DELAY_MS); // Simulate network delay
|
1070
|
+
|
1071
|
+
// Simulate API response / service confirmation
|
1072
|
+
// Update local state immediately upon "successful API response"
|
1073
|
+
const statusChanged = call.setParticipantMuteStatus(localUserId, true);
|
1074
|
+
|
1075
|
+
if (statusChanged) {
|
1076
|
+
// Simulate receiving local mute status change event from the service
|
1077
|
+
this._simulateIncomingUpdate(callId, {
|
1078
|
+
type: 'muteChange',
|
1079
|
+
data: { participantId: localUserId, isMuted: true }
|
1080
|
+
});
|
1081
|
+
Logger.info(`Simulated API success: Local user ${localUserId} muted in call ${callId}.`);
|
1082
|
+
} else {
|
1083
|
+
// Should ideally not happen if the check before the API call was correct
|
1084
|
+
Logger.warn(`Mute action reported no status change for ${localUserId} in ${callId}.`);
|
1085
|
+
}
|
1086
|
+
}
|
1087
|
+
|
1088
|
+
/**
|
1089
|
+
* Unmutes the local user's audio in a call.
|
1090
|
+
* @param {string} callId - The ID of the call.
|
1091
|
+
* @returns {Promise<void>} A promise that resolves when the local user is unmuted.
|
1092
|
+
* @throws {SeatalkCallError | InvalidInputError | CallNotFoundError | InvalidCallStateError} If the operation fails.
|
1093
|
+
*/
|
1094
|
+
async unmuteSelf(callId) {
|
1095
|
+
this._ensureClientReady();
|
1096
|
+
validateCallId(callId);
|
1097
|
+
const localUserId = String(this._configuration.localUserId);
|
1098
|
+
|
1099
|
+
const call = this._findCall(callId);
|
1100
|
+
|
1101
|
+
if (!call.isLocalUserInCall()) {
|
1102
|
+
throw new InvalidCallStateError(ERROR_MESSAGES.NOT_IN_CALL + `: ${callId}`, call.state);
|
1103
|
+
}
|
1104
|
+
if (!isCallActiveState(call.state)) {
|
1105
|
+
throw new InvalidCallStateError(`Cannot unmute in call ${callId} state ${call.state}. Must be ACTIVE.`, call.state, ['ACTIVE']);
|
1106
|
+
}
|
1107
|
+
if (!call.getParticipantMuteStatus(localUserId)) {
|
1108
|
+
throw new InvalidCallStateError(ERROR_MESSAGES.NOT_MUTED + `: ${callId}`, call.state);
|
1109
|
+
}
|
1110
|
+
|
1111
|
+
Logger.info(`Attempting to unmute local user ${localUserId} in call: ${callId}`);
|
1112
|
+
|
1113
|
+
// Simulate API call to unmute
|
1114
|
+
await delay(SIMULATED_DELAY_MS); // Simulate network delay
|
1115
|
+
|
1116
|
+
// Simulate API response / service confirmation
|
1117
|
+
// Update local state immediately upon "successful API response"
|
1118
|
+
const statusChanged = call.setParticipantMuteStatus(localUserId, false);
|
1119
|
+
|
1120
|
+
if (statusChanged) {
|
1121
|
+
// Simulate receiving local mute status change event from the service
|
1122
|
+
this._simulateIncomingUpdate(callId, {
|
1123
|
+
type: 'muteChange',
|
1124
|
+
data: { participantId: localUserId, isMuted: false }
|
1125
|
+
});
|
1126
|
+
Logger.info(`Simulated API success: Local user ${localUserId} unmuted in call ${callId}.`);
|
1127
|
+
} else {
|
1128
|
+
Logger.warn(`Unmute action reported no status change for ${localUserId} in ${callId}.`);
|
1129
|
+
}
|
1130
|
+
}
|
1131
|
+
|
1132
|
+
/**
|
1133
|
+
* Adds a participant to an ongoing call.
|
1134
|
+
* Only the host or authorized users might be able to do this in a real system.
|
1135
|
+
* Assuming local user is authorized for this simulation.
|
1136
|
+
* @param {string} callId - The ID of the call.
|
1137
|
+
* @param {string | number} participantId - The ID of the participant to add.
|
1138
|
+
* @returns {Promise<void>} A promise that resolves when the participant is added (or simulation complete).
|
1139
|
+
* @throws {SeatalkCallError | InvalidInputError | CallNotFoundError | InvalidCallStateError | ParticipantNotFoundError} If the operation fails.
|
1140
|
+
*/
|
1141
|
+
async addParticipant(callId, participantId) {
|
1142
|
+
this._ensureClientReady();
|
1143
|
+
validateCallId(callId);
|
1144
|
+
if (!isValidParticipantId(participantId)) {
|
1145
|
+
throw new InvalidInputError(ERROR_MESSAGES.INVALID_PARTICIPANT_ID);
|
1146
|
+
}
|
1147
|
+
const participantIdStr = String(participantId);
|
1148
|
+
const localUserId = String(this._configuration.localUserId);
|
1149
|
+
|
1150
|
+
const call = this._findCall(callId);
|
1151
|
+
|
1152
|
+
if (!call.isLocalUserInCall() && call.hostId !== localUserId) {
|
1153
|
+
// Basic authorization check simulation
|
1154
|
+
throw new SeatalkCallError(`Local user ${localUserId} is not in call ${callId} and not the host. Cannot add participants.`, 'UNAUTHORIZED_ADD_PARTICIPANT');
|
1155
|
+
}
|
1156
|
+
|
1157
|
+
if (!isCallActiveState(call.state)) {
|
1158
|
+
throw new InvalidCallStateError(`Cannot add participant to call ${callId} in state ${call.state}. Must be ACTIVE.`, call.state, ['ACTIVE']);
|
1159
|
+
}
|
1160
|
+
|
1161
|
+
if (call.participants.has(participantIdStr)) {
|
1162
|
+
Logger.warn(`Attempted to add participant ${participantIdStr} to call ${callId} who is already in the call.`);
|
1163
|
+
// Decide whether to throw or just resolve. Resolving might be friendlier API.
|
1164
|
+
// Let's resolve with a warning.
|
1165
|
+
Logger.warn(`Participant ${participantIdStr} is already in call ${callId}. Operation skipped.`);
|
1166
|
+
return; // Resolve immediately if already present
|
1167
|
+
}
|
1168
|
+
|
1169
|
+
if (call.participants.size >= MAX_PARTICIPANTS_PER_CALL) {
|
1170
|
+
throw new InvalidInputError(`${ERROR_MESSAGES.MAX_PARTICIPANTS_EXCEEDED} (current size ${call.participants.size})`);
|
1171
|
+
}
|
1172
|
+
|
1173
|
+
|
1174
|
+
Logger.info(`Attempting to add participant ${participantIdStr} to call: ${callId}`);
|
1175
|
+
|
1176
|
+
// Simulate API call to add participant
|
1177
|
+
await delay(SIMULATED_DELAY_MS); // Simulate network delay
|
1178
|
+
|
1179
|
+
// Simulate API response / service confirmation
|
1180
|
+
// Simulate service sending participantJoin event to all participants (including local client)
|
1181
|
+
this._simulateIncomingUpdate(callId, {
|
1182
|
+
type: 'participantJoin',
|
1183
|
+
data: { participantId: participantIdStr }
|
1184
|
+
});
|
1185
|
+
|
1186
|
+
Logger.info(`Simulated API success: Requested to add participant ${participantIdStr} to call ${callId}. Wait for participantJoined event.`);
|
1187
|
+
// Note: We don't add to local state immediately. The _simulateIncomingUpdate will do it when the event arrives.
|
1188
|
+
}
|
1189
|
+
|
1190
|
+
/**
|
1191
|
+
* Removes a participant from an ongoing call.
|
1192
|
+
* Only the host or authorized users might be able to do this.
|
1193
|
+
* @param {string} callId - The ID of the call.
|
1194
|
+
* @param {string | number} participantId - The ID of the participant to remove.
|
1195
|
+
* @returns {Promise<void>} A promise that resolves when the participant is removed (or simulation complete).
|
1196
|
+
* @throws {SeatalkCallError | InvalidInputError | CallNotFoundError | InvalidCallStateError | ParticipantNotFoundError} If the operation fails.
|
1197
|
+
*/
|
1198
|
+
async removeParticipant(callId, participantId) {
|
1199
|
+
this._ensureClientReady();
|
1200
|
+
validateCallId(callId);
|
1201
|
+
if (!isValidParticipantId(participantId)) {
|
1202
|
+
throw new InvalidInputError(ERROR_MESSAGES.INVALID_PARTICIPANT_ID);
|
1203
|
+
}
|
1204
|
+
const participantIdStr = String(participantId);
|
1205
|
+
const localUserId = String(this._configuration.localUserId);
|
1206
|
+
|
1207
|
+
const call = this._findCall(callId);
|
1208
|
+
|
1209
|
+
if (!call.isLocalUserInCall() && call.hostId !== localUserId) {
|
1210
|
+
// Basic authorization check simulation
|
1211
|
+
throw new SeatalkCallError(`Local user ${localUserId} is not in call ${callId} and not the host. Cannot remove participants.`, 'UNAUTHORIZED_REMOVE_PARTICIPANT');
|
1212
|
+
}
|
1213
|
+
|
1214
|
+
if (participantIdStr === localUserId) {
|
1215
|
+
// Cannot remove self, use leaveCall instead
|
1216
|
+
throw new InvalidInputError(`Cannot remove local user ${localUserId} using removeParticipant. Use leaveCall() instead.`);
|
1217
|
+
}
|
1218
|
+
|
1219
|
+
if (!isCallActiveState(call.state)) {
|
1220
|
+
throw new InvalidCallStateError(`Cannot remove participant from call ${callId} in state ${call.state}. Must be ACTIVE.`, call.state, ['ACTIVE']);
|
1221
|
+
}
|
1222
|
+
|
1223
|
+
if (!call.participants.has(participantIdStr)) {
|
1224
|
+
throw new ParticipantNotFoundError(participantIdStr, callId);
|
1225
|
+
}
|
1226
|
+
|
1227
|
+
|
1228
|
+
Logger.info(`Attempting to remove participant ${participantIdStr} from call: ${callId}`);
|
1229
|
+
|
1230
|
+
// Simulate API call to remove participant
|
1231
|
+
await delay(SIMULATED_DELAY_MS); // Simulate network delay
|
1232
|
+
|
1233
|
+
// Simulate API response / service confirmation
|
1234
|
+
// Simulate service sending participantLeave event to all participants (including local client)
|
1235
|
+
this._simulateIncomingUpdate(callId, {
|
1236
|
+
type: 'participantLeave',
|
1237
|
+
data: { participantId: participantIdStr }
|
1238
|
+
});
|
1239
|
+
|
1240
|
+
Logger.info(`Simulated API success: Requested to remove participant ${participantIdStr} from call ${callId}. Wait for participantLeft event.`);
|
1241
|
+
// Note: We don't remove from local state immediately. The _simulateIncomingUpdate will do it when the event arrives.
|
1242
|
+
}
|
1243
|
+
|
1244
|
+
/**
|
1245
|
+
* Forcefully ends a call. Typically only the host or authorized users can do this.
|
1246
|
+
* @param {string} callId - The ID of the call to end.
|
1247
|
+
* @returns {Promise<void>} A promise that resolves when the call is ended (simulation complete).
|
1248
|
+
* @throws {SeatalkCallError | InvalidInputError | CallNotFoundError | InvalidCallStateError} If the operation fails.
|
1249
|
+
*/
|
1250
|
+
async endCall(callId) {
|
1251
|
+
this._ensureClientReady();
|
1252
|
+
validateCallId(callId);
|
1253
|
+
const localUserId = String(this._configuration.localUserId);
|
1254
|
+
|
1255
|
+
const call = this._findCall(callId);
|
1256
|
+
|
1257
|
+
if (call.hostId !== localUserId) {
|
1258
|
+
// Basic authorization check simulation
|
1259
|
+
throw new SeatalkCallError(`Local user ${localUserId} is not the host of call ${callId}. Cannot end the call.`, 'UNAUTHORIZED_END_CALL');
|
1260
|
+
}
|
1261
|
+
|
1262
|
+
if (!isCallInProgressState(call.state)) {
|
1263
|
+
throw new InvalidCallStateError(`Cannot end call ${callId} in state ${call.state}.`, call.state);
|
1264
|
+
}
|
1265
|
+
|
1266
|
+
Logger.info(`Attempting to end call: ${callId}`);
|
1267
|
+
|
1268
|
+
// Simulate API call to end the call
|
1269
|
+
await delay(SIMULATED_DELAY_MS); // Simulate network delay
|
1270
|
+
|
1271
|
+
// Simulate API response / service confirmation
|
1272
|
+
// Simulate service sending callEnded event to all participants (including local client)
|
1273
|
+
this._simulateIncomingUpdate(callId, { type: 'callEnded' });
|
1274
|
+
|
1275
|
+
Logger.info(`Simulated API success: Requested to end call ${callId}. Wait for callEnded event.`);
|
1276
|
+
// The _simulateIncomingUpdate for 'callEnded' will handle state change and cleanup.
|
1277
|
+
}
|
1278
|
+
|
1279
|
+
|
1280
|
+
/**
|
1281
|
+
* Gets details about the current active calls managed by this client instance.
|
1282
|
+
* @returns {object[]} An array of call detail objects.
|
1283
|
+
*/
|
1284
|
+
getCurrentCalls() {
|
1285
|
+
this._ensureClientReady(); // Ensure client is in a state where call data might exist
|
1286
|
+
|
1287
|
+
return Array.from(this._activeCalls.values())
|
1288
|
+
.filter(call => isCallInProgressState(call.state)) // Only return calls that are not ended/failed
|
1289
|
+
.map(call => call.getDetails());
|
1290
|
+
}
|
1291
|
+
|
1292
|
+
/**
|
1293
|
+
* Gets details about a specific call managed by this client instance, active or recently ended/failed.
|
1294
|
+
* @param {string} callId - The ID of the call.
|
1295
|
+
* @returns {object | null} Call details object, or null if the call is not found.
|
1296
|
+
* @throws {InvalidInputError} If callId is invalid.
|
1297
|
+
*/
|
1298
|
+
getCallDetails(callId) {
|
1299
|
+
this._ensureClientReady(); // Ensure client is in a state where call data might exist
|
1300
|
+
validateCallId(callId);
|
1301
|
+
|
1302
|
+
// We can return details even if it's ended/failed, useful for post-call summaries
|
1303
|
+
const call = this._activeCalls.get(callId);
|
1304
|
+
|
1305
|
+
if (!call) {
|
1306
|
+
Logger.warn(`getCallDetails: Call ${callId} not found in active calls.`);
|
1307
|
+
return null;
|
1308
|
+
}
|
1309
|
+
|
1310
|
+
return call.getDetails();
|
1311
|
+
}
|
1312
|
+
|
1313
|
+
/**
|
1314
|
+
* Checks if the local user is currently in any active call.
|
1315
|
+
* @returns {boolean} True if the local user is in an active call.
|
1316
|
+
*/
|
1317
|
+
isLocalUserInAnyCall() {
|
1318
|
+
this._ensureClientReady(); // Ensure client is in a state where call data might exist
|
1319
|
+
|
1320
|
+
const localUserId = String(this._configuration.localUserId);
|
1321
|
+
return Array.from(this._activeCalls.values()).some(call =>
|
1322
|
+
isCallInProgressState(call.state) && call.isLocalUserInCall()
|
1323
|
+
);
|
1324
|
+
}
|
1325
|
+
|
1326
|
+
/**
|
1327
|
+
* Disconnects the client. Simulates cleaning up connections.
|
1328
|
+
* Any active calls the local user is in will be left.
|
1329
|
+
* @returns {Promise<void>} A promise that resolves when disconnection is complete.
|
1330
|
+
*/
|
1331
|
+
async disconnect() {
|
1332
|
+
if (this._clientState === 'IDLE' || this._clientState === 'DISCONNECTING') {
|
1333
|
+
Logger.info(`Client already ${this._clientState}. Disconnect operation skipped.`);
|
1334
|
+
return;
|
1335
|
+
}
|
1336
|
+
if (this._clientState === 'ERROR') {
|
1337
|
+
Logger.warn('Client is in ERROR state. Attempting to reset state.');
|
1338
|
+
this._setClientState('DISCONNECTING'); // Allow disconnecting from error state
|
1339
|
+
} else {
|
1340
|
+
this._setClientState('DISCONNECTING');
|
1341
|
+
}
|
1342
|
+
|
1343
|
+
|
1344
|
+
Logger.info('Attempting to disconnect client.');
|
1345
|
+
|
1346
|
+
// Simulate leaving all active calls the local user is in
|
1347
|
+
const callsToLeave = Array.from(this._activeCalls.values()).filter(call => call.isLocalUserInCall() && isCallInProgressState(call.state));
|
1348
|
+
|
1349
|
+
// Use Promise.all to wait for all leave operations to attempt completion
|
1350
|
+
await Promise.all(callsToLeave.map(call => {
|
1351
|
+
// Catch errors for individual leaves so one failure doesn't stop others
|
1352
|
+
return this.leaveCall(call.callId).catch(err => {
|
1353
|
+
Logger.error(`Failed to leave call ${call.callId} during disconnect`, err);
|
1354
|
+
// If leaving fails, we might need a forced local cleanup
|
1355
|
+
this._cleanupCall(call.callId); // Force cleanup local state for failed leaves
|
1356
|
+
});
|
1357
|
+
}));
|
1358
|
+
|
1359
|
+
|
1360
|
+
// Simulate cleaning up other resources / network connections
|
1361
|
+
await delay(SIMULATED_DELAY_MS * 2); // Simulate more significant delay for disconnection
|
1362
|
+
|
1363
|
+
// Clear any remaining active calls that weren't cleaned up by leave/end events
|
1364
|
+
// This handles cases where leaving failed or remote user was the last to leave.
|
1365
|
+
// All calls associated with this client instance should conceptually end upon disconnect.
|
1366
|
+
Array.from(this._activeCalls.keys()).forEach(callId => {
|
1367
|
+
const call = this._activeCalls.get(callId);
|
1368
|
+
if (call && isCallInProgressState(call.state)) {
|
1369
|
+
Logger.warn(`Call ${callId} still in progress state (${call.state}) during client disconnect. Forcing local cleanup and simulating end.`);
|
1370
|
+
call.failureReason = 'Client Disconnected'; // Mark as failed/ended due to disconnect
|
1371
|
+
call.updateState('FAILED'); // Or ENDED, depending on desired semantic
|
1372
|
+
this._emit('callFailed', call.getDetails()); // Emit event for calls left hanging
|
1373
|
+
}
|
1374
|
+
this._cleanupCall(callId); // Ensure map is cleared
|
1375
|
+
});
|
1376
|
+
|
1377
|
+
|
1378
|
+
// Clear all event listeners as client is shutting down
|
1379
|
+
this._eventListeners.clear();
|
1380
|
+
Logger.info('All event listeners cleared.');
|
1381
|
+
|
1382
|
+
// Reset configuration
|
1383
|
+
this._configuration = null;
|
1384
|
+
|
1385
|
+
this._setClientState('IDLE');
|
1386
|
+
Logger.info('Client disconnected and returned to IDLE state.');
|
1387
|
+
}
|
1388
|
+
|
1389
|
+
/**
|
1390
|
+
* Simulates receiving a remote mute/unmute request for another participant.
|
1391
|
+
* (Helper for demonstrating remoteMuteStatusChanged events)
|
1392
|
+
* @param {string} callId - The ID of the call.
|
1393
|
+
* @param {string | number} participantId - The ID of the participant whose status changed.
|
1394
|
+
* @param {boolean} isMuted - The new mute status.
|
1395
|
+
* @returns {boolean} True if the update was processed, false otherwise.
|
1396
|
+
* @internal For simulation purposes only.
|
1397
|
+
*/
|
1398
|
+
_simulateRemoteMuteStatusChange(callId, participantId, isMuted) {
|
1399
|
+
validateCallId(callId);
|
1400
|
+
if (!isValidParticipantId(participantId)) {
|
1401
|
+
Logger.warn(`_simulateRemoteMuteStatusChange: Invalid participant ID ${participantId}`);
|
1402
|
+
return false;
|
1403
|
+
}
|
1404
|
+
const participantIdStr = String(participantId);
|
1405
|
+
const localUserId = String(this._configuration?.localUserId);
|
1406
|
+
|
1407
|
+
if (participantIdStr === localUserId) {
|
1408
|
+
Logger.warn(`_simulateRemoteMuteStatusChange: Cannot simulate remote change for local user ${localUserId}.`);
|
1409
|
+
return false;
|
1410
|
+
}
|
1411
|
+
|
1412
|
+
const call = this._activeCalls.get(callId);
|
1413
|
+
if (!call || !isCallActiveState(call.state)) {
|
1414
|
+
Logger.warn(`_simulateRemoteMuteStatusChange: Call ${callId} not found or not active.`);
|
1415
|
+
return false;
|
1416
|
+
}
|
1417
|
+
|
1418
|
+
if (!call.participants.has(participantIdStr)) {
|
1419
|
+
Logger.warn(`_simulateRemoteMuteStatusChange: Participant ${participantIdStr} not found in call ${callId}.`);
|
1420
|
+
return false;
|
1421
|
+
}
|
1422
|
+
|
1423
|
+
// Simulate the incoming event from the service
|
1424
|
+
this._simulateIncomingUpdate(callId, {
|
1425
|
+
type: 'muteChange',
|
1426
|
+
data: { participantId: participantIdStr, isMuted: isMuted }
|
1427
|
+
});
|
1428
|
+
|
1429
|
+
return true;
|
1430
|
+
}
|
1431
|
+
|
1432
|
+
}
|
1433
|
+
|
1434
|
+
// --- Exports ---
|
1435
|
+
|
1436
|
+
/**
|
1437
|
+
* Default export is the SeatalkCallClient class.
|
1438
|
+
* @type {typeof SeatalkCallClient}
|
1439
|
+
*/
|
1440
|
+
module.exports = SeatalkCallClient;
|
1441
|
+
|
1442
|
+
// Optionally export specific error classes or constants if they are part of the public API
|
1443
|
+
// module.exports.SeatalkCallError = SeatalkCallError;
|
1444
|
+
// module.exports.ClientConfigurationError = ClientConfigurationError;
|
1445
|
+
// module.exports.InvalidCallStateError = InvalidCallStateError;
|
1446
|
+
// module.exports.InvalidInputError = InvalidInputError;
|
1447
|
+
// module.exports.CallNotFoundError = CallNotFoundError;
|
1448
|
+
// module.exports.ParticipantNotFoundError = ParticipantNotFoundError;
|