@vibexnpm/talkx 2.3.1
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 +327 -0
- package/dist/index.d.ts +1771 -0
- package/dist/talkflow-sdk.esm.js +2 -0
- package/dist/talkflow-sdk.esm.js.map +1 -0
- package/dist/talkflow-sdk.standalone.js +2 -0
- package/dist/talkflow-sdk.standalone.js.map +1 -0
- package/dist/talkflow-sdk.umd.js +2 -0
- package/dist/talkflow-sdk.umd.js.map +1 -0
- package/package.json +51 -0
- package/src/TalkFlowClient.js +481 -0
- package/src/chat/ChatClient.js +2221 -0
- package/src/constants.js +411 -0
- package/src/core/ConnectionManager.js +517 -0
- package/src/index.js +97 -0
- package/src/push/PushManager.js +893 -0
- package/src/talkflow/delegates.js +112 -0
- package/src/talkflow/eventForwarding.js +93 -0
- package/src/talkflow/session.js +355 -0
- package/src/utils/ApiClient.js +305 -0
- package/src/utils/EventEmitter.js +113 -0
- package/src/utils/Logger.js +88 -0
- package/src/utils/jwtUtils.js +213 -0
- package/src/webrtc/MediaStreamManager.js +478 -0
- package/src/webrtc/PeerConnectionManager.js +467 -0
- package/src/webrtc/WebRTCClient.js +1041 -0
- package/types/index.d.ts +1771 -0
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebRTCClient
|
|
3
|
+
* WebRTC 통화 기능 클라이언트
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import EventEmitter from '../utils/EventEmitter.js';
|
|
7
|
+
import Logger from '../utils/Logger.js';
|
|
8
|
+
import MediaStreamManager from './MediaStreamManager.js';
|
|
9
|
+
import PeerConnectionManager from './PeerConnectionManager.js';
|
|
10
|
+
import { WebSocketPaths, SignalTypes, ErrorTypes, DefaultConfig, LogLevel } from '../constants.js';
|
|
11
|
+
|
|
12
|
+
class WebRTCClient extends EventEmitter {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {Object} options.connectionManager - ConnectionManager 인스턴스
|
|
16
|
+
* @param {Object} options.apiClient - ApiClient 인스턴스
|
|
17
|
+
* @param {string} options.userId - 사용자 ID
|
|
18
|
+
* @param {Object[]} [options.iceServers] - ICE 서버 설정
|
|
19
|
+
* @param {number} [options.logLevel] - 로그 레벨
|
|
20
|
+
*/
|
|
21
|
+
constructor(options) {
|
|
22
|
+
super();
|
|
23
|
+
|
|
24
|
+
this.connectionManager = options.connectionManager;
|
|
25
|
+
this.apiClient = options.apiClient;
|
|
26
|
+
this.userId = options.userId;
|
|
27
|
+
this.logLevel = options.logLevel || LogLevel.WARN;
|
|
28
|
+
|
|
29
|
+
this.logger = new Logger(this.logLevel, 'WebRTCClient');
|
|
30
|
+
|
|
31
|
+
// 미디어 및 피어 연결 매니저
|
|
32
|
+
this.mediaManager = new MediaStreamManager({ logLevel: this.logLevel });
|
|
33
|
+
this.peerManager = new PeerConnectionManager({
|
|
34
|
+
iceServers: options.iceServers || DefaultConfig.iceServers,
|
|
35
|
+
logLevel: this.logLevel
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 현재 통화 상태
|
|
39
|
+
this.currentRoom = null;
|
|
40
|
+
this.isGroupCall = false;
|
|
41
|
+
this.participants = new Map();
|
|
42
|
+
|
|
43
|
+
// ICE 서버 초기화 상태
|
|
44
|
+
this._iceServersFetched = false;
|
|
45
|
+
|
|
46
|
+
// 1:1 통화 대기 상태 (CALL_REQUEST 전송 후 CALL_ACCEPT 대기)
|
|
47
|
+
this._waitingForCallAccept = false;
|
|
48
|
+
this._pendingCallTarget = null;
|
|
49
|
+
|
|
50
|
+
// ICE candidate 큐 (peer connection 생성 전에 도착한 candidate 저장)
|
|
51
|
+
this._pendingIceCandidates = new Map();
|
|
52
|
+
|
|
53
|
+
// 이벤트 연결
|
|
54
|
+
this._setupEventHandlers();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ICE 서버 설정 초기화 (TURN credentials 가져오기)
|
|
59
|
+
* 통화 시작 전 호출하면 더 빠른 연결 가능
|
|
60
|
+
* @returns {Promise<void>}
|
|
61
|
+
*/
|
|
62
|
+
async initializeIceServers() {
|
|
63
|
+
if (this._iceServersFetched) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const data = await this.getTurnCredentials();
|
|
69
|
+
if (data && data.iceServers) {
|
|
70
|
+
this.peerManager.setIceServers(data.iceServers);
|
|
71
|
+
this._iceServersFetched = true;
|
|
72
|
+
this.logger.info('ICE servers fetched successfully');
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.logger.warn('Failed to fetch TURN credentials, using fallback ICE servers:', error.message);
|
|
76
|
+
this._setFallbackIceServers();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 기본 ICE 서버 설정 (Fallback)
|
|
82
|
+
* @private
|
|
83
|
+
*/
|
|
84
|
+
_setFallbackIceServers() {
|
|
85
|
+
this.peerManager.setIceServers([
|
|
86
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
87
|
+
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 이벤트 핸들러 설정
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
_setupEventHandlers() {
|
|
96
|
+
// 미디어 이벤트
|
|
97
|
+
this.mediaManager.on('streamStarted', (data) => this.emit('localStreamStarted', data));
|
|
98
|
+
this.mediaManager.on('streamStopped', (data) => this.emit('localStreamStopped', data));
|
|
99
|
+
this.mediaManager.on('videoToggled', () => this._sendMediaState());
|
|
100
|
+
this.mediaManager.on('audioToggled', () => this._sendMediaState());
|
|
101
|
+
this.mediaManager.on('screenShareStarted', (data) => this.emit('screenShareStarted', data));
|
|
102
|
+
this.mediaManager.on('screenShareEnded', (data) => this.emit('screenShareEnded', data));
|
|
103
|
+
this.mediaManager.on('error', (error) => this.emit('error', error));
|
|
104
|
+
|
|
105
|
+
// 피어 연결 이벤트
|
|
106
|
+
this.peerManager.on('remoteTrack', (data) => this.emit('remoteTrack', data));
|
|
107
|
+
this.peerManager.on('peerConnected', (data) => this.emit('peerConnected', data));
|
|
108
|
+
this.peerManager.on('peerDisconnected', (data) => this.emit('peerDisconnected', data));
|
|
109
|
+
this.peerManager.on('peerClosed', (data) => this.emit('peerClosed', data));
|
|
110
|
+
this.peerManager.on('error', (error) => this.emit('error', error));
|
|
111
|
+
|
|
112
|
+
// ICE Candidate 전송
|
|
113
|
+
this.peerManager.on('iceCandidate', ({ peerId, candidate }) => {
|
|
114
|
+
this._sendSignal({
|
|
115
|
+
type: SignalTypes.ICE_CANDIDATE,
|
|
116
|
+
receiverId: peerId,
|
|
117
|
+
data: { candidate }
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Negotiation (Offer 전송)
|
|
122
|
+
this.peerManager.on('negotiationNeeded', ({ peerId, description }) => {
|
|
123
|
+
this._sendSignal({
|
|
124
|
+
type: SignalTypes.CALL_OFFER,
|
|
125
|
+
receiverId: peerId,
|
|
126
|
+
data: { sdp: description }
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Answer 전송
|
|
131
|
+
this.peerManager.on('answerCreated', ({ peerId, answer }) => {
|
|
132
|
+
this._sendSignal({
|
|
133
|
+
type: SignalTypes.CALL_ANSWER,
|
|
134
|
+
receiverId: peerId,
|
|
135
|
+
data: { sdp: answer }
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ==================== REST API ====================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* TURN 서버 인증 정보 가져오기
|
|
144
|
+
* @returns {Promise<Object>}
|
|
145
|
+
*/
|
|
146
|
+
async getTurnCredentials() {
|
|
147
|
+
const response = await this.apiClient.get('/api/v1/webrtc/turn-credentials');
|
|
148
|
+
return response.data;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 통화방 생성
|
|
153
|
+
* @public
|
|
154
|
+
* @param {Object} data
|
|
155
|
+
* @param {string} data.roomName - 방 이름
|
|
156
|
+
* @param {boolean} data.isGroup - 그룹 통화 여부
|
|
157
|
+
* @param {string} [data.chatRoomId] - 연결할 채팅방 ID
|
|
158
|
+
* @returns {Promise<Object>}
|
|
159
|
+
*/
|
|
160
|
+
// noinspection JSUnusedGlobalSymbols
|
|
161
|
+
async createCallRoom(data) {
|
|
162
|
+
return this.apiClient.post('/api/v1/webrtc/rooms', data);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 통화방 정보 조회
|
|
167
|
+
* @public
|
|
168
|
+
* @param {string} roomId - 통화방 ID
|
|
169
|
+
* @returns {Promise<Object>}
|
|
170
|
+
*/
|
|
171
|
+
// noinspection JSUnusedGlobalSymbols
|
|
172
|
+
async getCallRoom(roomId) {
|
|
173
|
+
return this.apiClient.get(`/api/v1/webrtc/rooms/${roomId}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 통화방 참여
|
|
178
|
+
* @public
|
|
179
|
+
* @param {string} roomId - 통화방 ID
|
|
180
|
+
* @returns {Promise<Object>}
|
|
181
|
+
*/
|
|
182
|
+
// noinspection JSUnusedGlobalSymbols
|
|
183
|
+
async joinCallRoomApi(roomId) {
|
|
184
|
+
return this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/join`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 통화방 나가기
|
|
189
|
+
* @public
|
|
190
|
+
* @param {string} roomId - 통화방 ID
|
|
191
|
+
* @returns {Promise<Object>}
|
|
192
|
+
*/
|
|
193
|
+
// noinspection JSUnusedGlobalSymbols
|
|
194
|
+
async leaveCallRoomApi(roomId) {
|
|
195
|
+
return this.apiClient.delete(`/api/v1/webrtc/rooms/${roomId}/leave`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ==================== 통화 수신 대기 ====================
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 통화 수신 대기 활성화
|
|
202
|
+
* connect() 후 자동 호출되지만, 수동으로도 호출 가능
|
|
203
|
+
* 개인 WebRTC 채널(/user/queue/webrtc)을 구독하여 초대 알림 수신
|
|
204
|
+
* @public
|
|
205
|
+
* @returns {Promise<void>}
|
|
206
|
+
*/
|
|
207
|
+
async enableIncomingCalls() {
|
|
208
|
+
if (this._incomingCallsEnabled) {
|
|
209
|
+
this.logger.debug('Incoming calls already enabled');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await this._subscribeToDirectSignaling();
|
|
214
|
+
this._incomingCallsEnabled = true;
|
|
215
|
+
this.logger.info('Incoming calls enabled - now listening for call invitations');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 통화 수신 대기 비활성화
|
|
220
|
+
* @public
|
|
221
|
+
*/
|
|
222
|
+
disableIncomingCalls() {
|
|
223
|
+
if (!this._incomingCallsEnabled) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const directDestination = WebSocketPaths.getWebRTCUserDestination();
|
|
228
|
+
this.connectionManager.unsubscribe(directDestination);
|
|
229
|
+
this._incomingCallsEnabled = false;
|
|
230
|
+
this.logger.info('Incoming calls disabled');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 통화 수신 대기 상태 확인
|
|
235
|
+
* @public
|
|
236
|
+
* @returns {boolean}
|
|
237
|
+
*/
|
|
238
|
+
isIncomingCallsEnabled() {
|
|
239
|
+
return this._incomingCallsEnabled || false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ==================== 통화 기능 ====================
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 통화 시작 (미디어 및 구독)
|
|
246
|
+
* @param {Object} options
|
|
247
|
+
* @param {string} options.roomId - 통화방 ID
|
|
248
|
+
* @param {boolean} [options.isGroup=false] - 그룹 통화 여부
|
|
249
|
+
* @param {Object} [options.mediaConstraints] - 미디어 제약조건
|
|
250
|
+
* @returns {Promise<MediaStream>}
|
|
251
|
+
*/
|
|
252
|
+
async startCall(options) {
|
|
253
|
+
const { roomId, isGroup = false, mediaConstraints = { video: true, audio: true } } = options;
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
// TURN 서버 설정 초기화
|
|
257
|
+
await this.initializeIceServers();
|
|
258
|
+
|
|
259
|
+
// 로컬 미디어 획득
|
|
260
|
+
const localStream = await this.mediaManager.getUserMedia(mediaConstraints);
|
|
261
|
+
this.peerManager.setLocalStream(localStream);
|
|
262
|
+
|
|
263
|
+
// 현재 방 설정
|
|
264
|
+
this.currentRoom = roomId;
|
|
265
|
+
this.isGroupCall = isGroup;
|
|
266
|
+
|
|
267
|
+
// WebSocket 구독
|
|
268
|
+
await this._subscribeToSignaling(roomId, isGroup);
|
|
269
|
+
|
|
270
|
+
// 입장 시그널 전송
|
|
271
|
+
this._sendSignal({
|
|
272
|
+
type: SignalTypes.JOIN_ROOM,
|
|
273
|
+
roomId
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
this.logger.info(`Call started in room: ${roomId}`);
|
|
277
|
+
this.emit('callStarted', { roomId, isGroup, localStream });
|
|
278
|
+
|
|
279
|
+
return localStream;
|
|
280
|
+
|
|
281
|
+
} catch (error) {
|
|
282
|
+
this.logger.error('Failed to start call:', error);
|
|
283
|
+
this.emit('error', {
|
|
284
|
+
type: ErrorTypes.PEER_CONNECTION_FAILED,
|
|
285
|
+
message: error.message,
|
|
286
|
+
error
|
|
287
|
+
});
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 1:1 통화 요청
|
|
294
|
+
* @param {string} targetUserId - 상대방 사용자 ID
|
|
295
|
+
* @param {Object} [mediaConstraints] - 미디어 제약조건
|
|
296
|
+
*/
|
|
297
|
+
async callUser(targetUserId, mediaConstraints = { video: true, audio: true }) {
|
|
298
|
+
try {
|
|
299
|
+
// TURN 서버 설정 초기화
|
|
300
|
+
await this.initializeIceServers();
|
|
301
|
+
|
|
302
|
+
// 로컬 미디어 획득
|
|
303
|
+
const localStream = await this.mediaManager.getUserMedia(mediaConstraints);
|
|
304
|
+
this.peerManager.setLocalStream(localStream);
|
|
305
|
+
|
|
306
|
+
// 1:1 통화 구독
|
|
307
|
+
await this._subscribeToDirectSignaling();
|
|
308
|
+
|
|
309
|
+
// CALL_ACCEPT 대기 상태 설정
|
|
310
|
+
// PeerConnection은 CALL_ACCEPT를 받은 후에 생성하여 자동 negotiation 방지
|
|
311
|
+
this._waitingForCallAccept = true;
|
|
312
|
+
this._pendingCallTarget = targetUserId;
|
|
313
|
+
|
|
314
|
+
// 통화 요청 시그널
|
|
315
|
+
this._sendSignal({
|
|
316
|
+
type: SignalTypes.CALL_REQUEST,
|
|
317
|
+
receiverId: targetUserId
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
this.currentRoom = null;
|
|
321
|
+
this.isGroupCall = false;
|
|
322
|
+
|
|
323
|
+
this.emit('callRequested', { targetUserId, localStream });
|
|
324
|
+
|
|
325
|
+
} catch (error) {
|
|
326
|
+
this.logger.error('Failed to call user:', error);
|
|
327
|
+
this._waitingForCallAccept = false;
|
|
328
|
+
this._pendingCallTarget = null;
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 통화 수락
|
|
335
|
+
* @param {string} callerId - 발신자 ID
|
|
336
|
+
* @param {Object} [mediaConstraints] - 미디어 제약조건
|
|
337
|
+
*/
|
|
338
|
+
async acceptCall(callerId, mediaConstraints = { video: true, audio: true }) {
|
|
339
|
+
try {
|
|
340
|
+
// TURN 서버 설정 초기화
|
|
341
|
+
await this.initializeIceServers();
|
|
342
|
+
|
|
343
|
+
// 로컬 미디어 획득 (수신자도 미디어 필요)
|
|
344
|
+
const localStream = await this.mediaManager.getUserMedia(mediaConstraints);
|
|
345
|
+
this.peerManager.setLocalStream(localStream);
|
|
346
|
+
|
|
347
|
+
// 피어 연결 생성 (Polite - 수신자)
|
|
348
|
+
// skipAutoNegotiation: true - 수신자는 offer를 받아서 answer를 보내므로 자동 negotiation 불필요
|
|
349
|
+
this.peerManager.createPeerConnection(callerId, true, { skipAutoNegotiation: true });
|
|
350
|
+
|
|
351
|
+
// 수락 시그널
|
|
352
|
+
this._sendSignal({
|
|
353
|
+
type: SignalTypes.CALL_ACCEPT,
|
|
354
|
+
receiverId: callerId
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
this.emit('callAccepted', { callerId });
|
|
358
|
+
|
|
359
|
+
} catch (error) {
|
|
360
|
+
this.logger.error('Failed to accept call:', error);
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 통화 거절
|
|
367
|
+
* @param {string} callerId - 발신자 ID
|
|
368
|
+
*/
|
|
369
|
+
rejectCall(callerId) {
|
|
370
|
+
this._sendSignal({
|
|
371
|
+
type: SignalTypes.CALL_REJECT,
|
|
372
|
+
receiverId: callerId
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
this.emit('callRejected', { callerId });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 통화 취소 (발신자가 수신자 응답 전 취소)
|
|
380
|
+
*/
|
|
381
|
+
cancelCall() {
|
|
382
|
+
if (this._waitingForCallAccept && this._pendingCallTarget) {
|
|
383
|
+
this._sendSignal({
|
|
384
|
+
type: SignalTypes.CALL_CANCEL,
|
|
385
|
+
receiverId: this._pendingCallTarget
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
this.emit('callCancelled', { targetUserId: this._pendingCallTarget });
|
|
389
|
+
|
|
390
|
+
// 대기 상태 초기화
|
|
391
|
+
this._waitingForCallAccept = false;
|
|
392
|
+
this._pendingCallTarget = null;
|
|
393
|
+
|
|
394
|
+
// 미디어 정리
|
|
395
|
+
this.mediaManager.stopAll();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 통화 종료
|
|
401
|
+
*/
|
|
402
|
+
endCall() {
|
|
403
|
+
// 대기 중인 통화가 있으면 취소 처리
|
|
404
|
+
if (this._waitingForCallAccept && this._pendingCallTarget) {
|
|
405
|
+
this._sendSignal({
|
|
406
|
+
type: SignalTypes.CALL_CANCEL,
|
|
407
|
+
receiverId: this._pendingCallTarget
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 대기 상태 초기화
|
|
412
|
+
this._waitingForCallAccept = false;
|
|
413
|
+
this._pendingCallTarget = null;
|
|
414
|
+
|
|
415
|
+
if (this.currentRoom) {
|
|
416
|
+
// 퇴장 시그널 전송
|
|
417
|
+
this._sendSignal({
|
|
418
|
+
type: SignalTypes.LEAVE_ROOM,
|
|
419
|
+
roomId: this.currentRoom
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// 구독 해제
|
|
423
|
+
this._unsubscribeFromSignaling();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 모든 피어에게 종료 시그널
|
|
427
|
+
this.peerManager.getPeerIds().forEach(peerId => {
|
|
428
|
+
this._sendSignal({
|
|
429
|
+
type: SignalTypes.CALL_END,
|
|
430
|
+
receiverId: peerId
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// 정리
|
|
435
|
+
this.peerManager.closeAllPeerConnections();
|
|
436
|
+
this.mediaManager.stopAll();
|
|
437
|
+
this.participants.clear();
|
|
438
|
+
this._pendingIceCandidates.clear();
|
|
439
|
+
|
|
440
|
+
const roomId = this.currentRoom;
|
|
441
|
+
this.currentRoom = null;
|
|
442
|
+
this.isGroupCall = false;
|
|
443
|
+
|
|
444
|
+
this.emit('callEnded', { roomId });
|
|
445
|
+
this.logger.info('Call ended');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ==================== 미디어 제어 ====================
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 비디오 토글
|
|
452
|
+
* @returns {boolean}
|
|
453
|
+
*/
|
|
454
|
+
toggleVideo() {
|
|
455
|
+
return this.mediaManager.toggleVideo();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 오디오 토글
|
|
460
|
+
* @returns {boolean}
|
|
461
|
+
*/
|
|
462
|
+
toggleAudio() {
|
|
463
|
+
return this.mediaManager.toggleAudio();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* 화면 공유 시작
|
|
468
|
+
* @returns {Promise<MediaStream>}
|
|
469
|
+
*/
|
|
470
|
+
async startScreenShare() {
|
|
471
|
+
const screenStream = await this.mediaManager.getDisplayMedia();
|
|
472
|
+
|
|
473
|
+
// 피어들에게 화면 공유 트랙 전송
|
|
474
|
+
const videoTrack = screenStream.getVideoTracks()[0];
|
|
475
|
+
const localVideoTrack = this.mediaManager.getTrack('video');
|
|
476
|
+
|
|
477
|
+
if (localVideoTrack && videoTrack) {
|
|
478
|
+
await this.peerManager.replaceTrack(localVideoTrack, videoTrack);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 화면 공유 종료 시 원복 — 복원 시점의 현재 카메라 트랙 사용 (공유 중 switchDevice 대응)
|
|
482
|
+
videoTrack.onended = async () => {
|
|
483
|
+
const currentVideoTrack = this.mediaManager.getTrack('video');
|
|
484
|
+
if (currentVideoTrack) {
|
|
485
|
+
await this.peerManager.replaceTrack(videoTrack, currentVideoTrack);
|
|
486
|
+
}
|
|
487
|
+
this.emit('screenShareEnded', {});
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
return screenStream;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 화면 공유 중지
|
|
495
|
+
*/
|
|
496
|
+
stopScreenShare() {
|
|
497
|
+
const screenStream = this.mediaManager.getScreenStream();
|
|
498
|
+
if (screenStream) {
|
|
499
|
+
screenStream.getTracks().forEach(track => track.stop());
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 로컬 스트림 가져오기
|
|
505
|
+
* @public
|
|
506
|
+
* @returns {MediaStream|null}
|
|
507
|
+
*/
|
|
508
|
+
// noinspection JSUnusedGlobalSymbols
|
|
509
|
+
getLocalStream() {
|
|
510
|
+
return this.mediaManager.getLocalStream();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* 디바이스 목록 가져오기
|
|
515
|
+
* @returns {Promise<Object>}
|
|
516
|
+
*/
|
|
517
|
+
async getDevices() {
|
|
518
|
+
return this.mediaManager.getDevices();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* 디바이스 전환
|
|
523
|
+
* @param {string} deviceId
|
|
524
|
+
* @param {string} kind - 'video' or 'audio'
|
|
525
|
+
*/
|
|
526
|
+
async switchDevice(deviceId, kind) {
|
|
527
|
+
// switchDevice가 localStream을 교체하므로, 교체 전 기존 트랙을 먼저 저장
|
|
528
|
+
const oldTrack = this.mediaManager.getTrack(kind);
|
|
529
|
+
const newTrack = await this.mediaManager.switchDevice(deviceId, kind);
|
|
530
|
+
|
|
531
|
+
// 피어들에게 트랙 교체
|
|
532
|
+
if (oldTrack) {
|
|
533
|
+
await this.peerManager.replaceTrack(oldTrack, newTrack);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return newTrack;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* 비디오 상태 직접 설정
|
|
541
|
+
* @public
|
|
542
|
+
* @param {boolean} enabled
|
|
543
|
+
*/
|
|
544
|
+
// noinspection JSUnusedGlobalSymbols
|
|
545
|
+
setVideoEnabled(enabled) {
|
|
546
|
+
this.mediaManager.setVideoEnabled(enabled);
|
|
547
|
+
this._sendMediaState();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 오디오 상태 직접 설정
|
|
552
|
+
* @public
|
|
553
|
+
* @param {boolean} enabled
|
|
554
|
+
*/
|
|
555
|
+
// noinspection JSUnusedGlobalSymbols
|
|
556
|
+
setAudioEnabled(enabled) {
|
|
557
|
+
this.mediaManager.setAudioEnabled(enabled);
|
|
558
|
+
this._sendMediaState();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* 디바이스 변경 감지 시작
|
|
563
|
+
* @public
|
|
564
|
+
*/
|
|
565
|
+
// noinspection JSUnusedGlobalSymbols
|
|
566
|
+
startDeviceChangeDetection() {
|
|
567
|
+
this.mediaManager.startDeviceChangeDetection();
|
|
568
|
+
this.mediaManager.on('deviceChange', (devices) => {
|
|
569
|
+
this.emit('deviceChange', devices);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 디바이스 변경 감지 중지
|
|
575
|
+
* @public
|
|
576
|
+
*/
|
|
577
|
+
// noinspection JSUnusedGlobalSymbols
|
|
578
|
+
stopDeviceChangeDetection() {
|
|
579
|
+
this.mediaManager.stopDeviceChangeDetection();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 비디오 해상도/프레임레이트 변경
|
|
584
|
+
* @public
|
|
585
|
+
* @param {Object} constraints - { width, height, frameRate }
|
|
586
|
+
* @returns {Promise<void>}
|
|
587
|
+
*/
|
|
588
|
+
// noinspection JSUnusedGlobalSymbols
|
|
589
|
+
async applyVideoConstraints(constraints) {
|
|
590
|
+
return this.mediaManager.applyVideoConstraints(constraints);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 현재 비디오 설정 가져오기
|
|
595
|
+
* @public
|
|
596
|
+
* @returns {MediaTrackSettings|null}
|
|
597
|
+
*/
|
|
598
|
+
// noinspection JSUnusedGlobalSymbols
|
|
599
|
+
getVideoSettings() {
|
|
600
|
+
return this.mediaManager.getVideoSettings();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 현재 오디오 설정 가져오기
|
|
605
|
+
* @public
|
|
606
|
+
* @returns {MediaTrackSettings|null}
|
|
607
|
+
*/
|
|
608
|
+
// noinspection JSUnusedGlobalSymbols
|
|
609
|
+
getAudioSettings() {
|
|
610
|
+
return this.mediaManager.getAudioSettings();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ==================== 시그널링 ====================
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* 시그널링 구독
|
|
617
|
+
* @private
|
|
618
|
+
*/
|
|
619
|
+
async _subscribeToSignaling(roomId, isGroup) {
|
|
620
|
+
if (isGroup) {
|
|
621
|
+
// 그룹 통화: /topic/webrtc/{roomId}
|
|
622
|
+
const destination = WebSocketPaths.getWebRTCDestination(roomId);
|
|
623
|
+
await this.connectionManager.subscribe(destination, (message) => {
|
|
624
|
+
this._handleSignal(message);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// 1:1 또는 그룹 모두에서 개인 큐 구독
|
|
629
|
+
await this._subscribeToDirectSignaling();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* 1:1 시그널링 구독
|
|
634
|
+
* @private
|
|
635
|
+
*/
|
|
636
|
+
async _subscribeToDirectSignaling() {
|
|
637
|
+
const destination = WebSocketPaths.getWebRTCUserDestination();
|
|
638
|
+
await this.connectionManager.subscribe(destination, (message) => {
|
|
639
|
+
this._handleSignal(message);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* 시그널링 구독 해제
|
|
645
|
+
* @private
|
|
646
|
+
*/
|
|
647
|
+
_unsubscribeFromSignaling() {
|
|
648
|
+
if (this.currentRoom && this.isGroupCall) {
|
|
649
|
+
const destination = WebSocketPaths.getWebRTCDestination(this.currentRoom);
|
|
650
|
+
this.connectionManager.unsubscribe(destination);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// 수신 대기가 활성화된 경우 direct 채널 구독 유지 (그룹 통화 종료 후에도 수신 가능)
|
|
654
|
+
if (!this._incomingCallsEnabled) {
|
|
655
|
+
const directDestination = WebSocketPaths.getWebRTCUserDestination();
|
|
656
|
+
this.connectionManager.unsubscribe(directDestination);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* 시그널 전송
|
|
662
|
+
* @private
|
|
663
|
+
*/
|
|
664
|
+
_sendSignal(signal) {
|
|
665
|
+
const payload = {
|
|
666
|
+
type: signal.type,
|
|
667
|
+
roomId: signal.roomId || this.currentRoom,
|
|
668
|
+
receiverId: signal.receiverId,
|
|
669
|
+
data: signal.data
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
this.connectionManager.send(WebSocketPaths.WEBRTC_SIGNAL, payload);
|
|
673
|
+
this.logger.debug('Signal sent:', payload);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 미디어 상태 전송
|
|
678
|
+
* @private
|
|
679
|
+
*/
|
|
680
|
+
_sendMediaState() {
|
|
681
|
+
if (!this.currentRoom && this.peerManager.getPeerIds().length === 0) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const mediaState = {
|
|
686
|
+
videoEnabled: this.mediaManager.isVideoEnabled(),
|
|
687
|
+
audioEnabled: this.mediaManager.isAudioEnabled()
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
this._sendSignal({
|
|
691
|
+
type: SignalTypes.VIDEO_STATE_CHANGED,
|
|
692
|
+
data: mediaState
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
this.emit('mediaStateChanged', mediaState);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* 시그널 처리
|
|
700
|
+
* @private
|
|
701
|
+
*/
|
|
702
|
+
async _handleSignal(message) {
|
|
703
|
+
const { type, senderId, data } = message;
|
|
704
|
+
|
|
705
|
+
// 자신의 시그널 무시
|
|
706
|
+
if (senderId === this.userId) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
this.logger.debug(`Signal received: ${type} from ${senderId}`);
|
|
711
|
+
|
|
712
|
+
switch (type) {
|
|
713
|
+
case SignalTypes.JOIN_ROOM:
|
|
714
|
+
case SignalTypes.PEER_JOINED:
|
|
715
|
+
await this._handleUserJoined(senderId);
|
|
716
|
+
break;
|
|
717
|
+
|
|
718
|
+
case SignalTypes.LEAVE_ROOM:
|
|
719
|
+
case SignalTypes.PEER_LEFT:
|
|
720
|
+
this._handleUserLeft(senderId);
|
|
721
|
+
break;
|
|
722
|
+
|
|
723
|
+
case SignalTypes.CALL_OFFER:
|
|
724
|
+
await this._handleOffer(senderId, data.sdp);
|
|
725
|
+
break;
|
|
726
|
+
|
|
727
|
+
case SignalTypes.CALL_ANSWER:
|
|
728
|
+
await this._handleAnswer(senderId, data.sdp);
|
|
729
|
+
break;
|
|
730
|
+
|
|
731
|
+
case SignalTypes.ICE_CANDIDATE:
|
|
732
|
+
await this._handleIceCandidate(senderId, data.candidate);
|
|
733
|
+
break;
|
|
734
|
+
|
|
735
|
+
case SignalTypes.VIDEO_STATE_CHANGED:
|
|
736
|
+
case SignalTypes.AUDIO_STATE_CHANGED:
|
|
737
|
+
this._handleMediaState(senderId, data);
|
|
738
|
+
break;
|
|
739
|
+
|
|
740
|
+
case SignalTypes.CALL_REQUEST:
|
|
741
|
+
// 통화 중이거나 통화 요청 대기 중이면 BUSY 응답
|
|
742
|
+
if (this.isInCall() || this._waitingForCallAccept) {
|
|
743
|
+
this._sendSignal({
|
|
744
|
+
type: SignalTypes.CALL_BUSY,
|
|
745
|
+
receiverId: senderId
|
|
746
|
+
});
|
|
747
|
+
this.emit('incomingCallWhileBusy', { callerId: senderId });
|
|
748
|
+
} else {
|
|
749
|
+
this.emit('incomingCall', { callerId: senderId });
|
|
750
|
+
}
|
|
751
|
+
break;
|
|
752
|
+
|
|
753
|
+
case SignalTypes.CALL_BUSY:
|
|
754
|
+
// 상대방이 통화 중
|
|
755
|
+
this._waitingForCallAccept = false;
|
|
756
|
+
this._pendingCallTarget = null;
|
|
757
|
+
this.mediaManager.stopAll();
|
|
758
|
+
this.emit('callBusy', { userId: senderId });
|
|
759
|
+
break;
|
|
760
|
+
|
|
761
|
+
case SignalTypes.CALL_INVITATION:
|
|
762
|
+
// 그룹 통화 초대 (createCallRoom으로 초대받은 경우)
|
|
763
|
+
this.emit('callInvitation', {
|
|
764
|
+
callRoomId: data?.callRoomId || message.roomId,
|
|
765
|
+
title: data?.title,
|
|
766
|
+
hostUserId: data?.hostUserId || senderId,
|
|
767
|
+
maxParticipants: data?.maxParticipants,
|
|
768
|
+
createdAt: data?.createdAt
|
|
769
|
+
});
|
|
770
|
+
break;
|
|
771
|
+
|
|
772
|
+
case SignalTypes.CALL_ACCEPT:
|
|
773
|
+
await this._handleCallAccepted(senderId);
|
|
774
|
+
break;
|
|
775
|
+
|
|
776
|
+
case SignalTypes.CALL_REJECT:
|
|
777
|
+
// 대기 상태 초기화
|
|
778
|
+
this._waitingForCallAccept = false;
|
|
779
|
+
this._pendingCallTarget = null;
|
|
780
|
+
// 발신자 미디어 정리 (거절당했으므로 통화 불가)
|
|
781
|
+
this.mediaManager.stopAll();
|
|
782
|
+
this.emit('callRejected', { userId: senderId });
|
|
783
|
+
break;
|
|
784
|
+
|
|
785
|
+
case SignalTypes.CALL_CANCEL:
|
|
786
|
+
// 대기 상태 초기화
|
|
787
|
+
this._waitingForCallAccept = false;
|
|
788
|
+
this._pendingCallTarget = null;
|
|
789
|
+
this.emit('callCancelled', { userId: senderId });
|
|
790
|
+
break;
|
|
791
|
+
|
|
792
|
+
case SignalTypes.CALL_END:
|
|
793
|
+
this._handleCallEnded(senderId);
|
|
794
|
+
break;
|
|
795
|
+
|
|
796
|
+
default:
|
|
797
|
+
this.logger.warn(`Unknown signal type: ${type}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* 사용자 입장 처리
|
|
803
|
+
* @private
|
|
804
|
+
*/
|
|
805
|
+
async _handleUserJoined(userId) {
|
|
806
|
+
this.participants.set(userId, { joinedAt: new Date() });
|
|
807
|
+
|
|
808
|
+
// userId 비교로 Polite/Impolite 결정 (작은 쪽이 Polite)
|
|
809
|
+
// Perfect Negotiation에서 충돌 시 한 쪽만 양보하도록 보장
|
|
810
|
+
const polite = this.userId < userId;
|
|
811
|
+
this.peerManager.createPeerConnection(userId, polite);
|
|
812
|
+
|
|
813
|
+
this.emit('userJoined', { userId });
|
|
814
|
+
this.logger.info(`User joined: ${userId} (I am ${polite ? 'polite' : 'impolite'})`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* 사용자 퇴장 처리
|
|
819
|
+
* @private
|
|
820
|
+
*/
|
|
821
|
+
_handleUserLeft(userId) {
|
|
822
|
+
this.participants.delete(userId);
|
|
823
|
+
this.peerManager.closePeerConnection(userId);
|
|
824
|
+
|
|
825
|
+
this.emit('userLeft', { userId });
|
|
826
|
+
this.logger.info(`User left: ${userId}`);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Offer 처리
|
|
831
|
+
* @private
|
|
832
|
+
*/
|
|
833
|
+
async _handleOffer(senderId, sdp) {
|
|
834
|
+
// 피어 연결이 없으면 생성
|
|
835
|
+
if (!this.peerManager.getPeerConnection(senderId)) {
|
|
836
|
+
// userId 비교로 Polite/Impolite 결정
|
|
837
|
+
const polite = this.userId < senderId;
|
|
838
|
+
// skipAutoNegotiation: true - offer를 먼저 처리한 후 answer 전송
|
|
839
|
+
// 트랙 추가 시 자동 negotiation이 발생하면 offer 충돌 발생
|
|
840
|
+
this.peerManager.createPeerConnection(senderId, polite, { skipAutoNegotiation: true });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
await this.peerManager.handleRemoteDescription(senderId, sdp);
|
|
844
|
+
|
|
845
|
+
// remoteDescription 설정 후 대기 중인 ICE candidates 처리
|
|
846
|
+
await this._processPendingIceCandidates(senderId);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Answer 처리
|
|
851
|
+
* @private
|
|
852
|
+
*/
|
|
853
|
+
async _handleAnswer(senderId, sdp) {
|
|
854
|
+
await this.peerManager.handleRemoteDescription(senderId, sdp);
|
|
855
|
+
// remoteDescription 설정 후 대기 중인 ICE candidates 처리
|
|
856
|
+
await this._processPendingIceCandidates(senderId);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* ICE Candidate 처리
|
|
861
|
+
* @private
|
|
862
|
+
*/
|
|
863
|
+
async _handleIceCandidate(senderId, candidate) {
|
|
864
|
+
// peer connection이 아직 없으면 큐에 저장
|
|
865
|
+
if (!this.peerManager.getPeerConnection(senderId)) {
|
|
866
|
+
this._queueIceCandidate(senderId, candidate);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// addIceCandidate가 false 반환하면 (remoteDescription 미설정) 큐에 저장
|
|
871
|
+
const added = await this.peerManager.addIceCandidate(senderId, new RTCIceCandidate(candidate));
|
|
872
|
+
if (!added) {
|
|
873
|
+
this._queueIceCandidate(senderId, candidate);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* ICE candidate를 큐에 저장
|
|
879
|
+
* @private
|
|
880
|
+
*/
|
|
881
|
+
_queueIceCandidate(peerId, candidate) {
|
|
882
|
+
if (!this._pendingIceCandidates.has(peerId)) {
|
|
883
|
+
this._pendingIceCandidates.set(peerId, []);
|
|
884
|
+
}
|
|
885
|
+
this._pendingIceCandidates.get(peerId).push(candidate);
|
|
886
|
+
this.logger.debug(`ICE candidate queued for ${peerId}`);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* 대기 중인 ICE candidate 처리
|
|
891
|
+
* @private
|
|
892
|
+
*/
|
|
893
|
+
async _processPendingIceCandidates(peerId) {
|
|
894
|
+
const candidates = this._pendingIceCandidates.get(peerId);
|
|
895
|
+
if (!candidates || candidates.length === 0) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
this.logger.debug(`Processing ${candidates.length} pending ICE candidates for ${peerId}`);
|
|
900
|
+
|
|
901
|
+
for (const candidate of candidates) {
|
|
902
|
+
await this.peerManager.addIceCandidate(peerId, new RTCIceCandidate(candidate));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
this._pendingIceCandidates.delete(peerId);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* 미디어 상태 처리
|
|
910
|
+
* @private
|
|
911
|
+
*/
|
|
912
|
+
_handleMediaState(userId, state) {
|
|
913
|
+
this.emit('participantMediaState', {
|
|
914
|
+
userId,
|
|
915
|
+
videoEnabled: state.videoEnabled,
|
|
916
|
+
audioEnabled: state.audioEnabled
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* 통화 수락 처리 (발신자 측에서 CALL_ACCEPT 수신 시)
|
|
922
|
+
* @private
|
|
923
|
+
*/
|
|
924
|
+
async _handleCallAccepted(userId) {
|
|
925
|
+
// 대기 상태 초기화
|
|
926
|
+
this._waitingForCallAccept = false;
|
|
927
|
+
this._pendingCallTarget = null;
|
|
928
|
+
|
|
929
|
+
// 이제 PeerConnection 생성 (Impolite - 발신자)
|
|
930
|
+
// skipAutoNegotiation: true - 트랙 추가 시 자동 negotiation 건너뛰고 수동으로 offer 전송
|
|
931
|
+
this.peerManager.createPeerConnection(userId, false, { skipAutoNegotiation: true });
|
|
932
|
+
|
|
933
|
+
// Offer 생성 및 전송
|
|
934
|
+
const offer = await this.peerManager.createOffer(userId);
|
|
935
|
+
this._sendSignal({
|
|
936
|
+
type: SignalTypes.CALL_OFFER,
|
|
937
|
+
receiverId: userId,
|
|
938
|
+
data: { sdp: offer }
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
this.emit('callAccepted', { userId });
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* 통화 종료 처리
|
|
946
|
+
* @private
|
|
947
|
+
*/
|
|
948
|
+
_handleCallEnded(userId) {
|
|
949
|
+
this.peerManager.closePeerConnection(userId);
|
|
950
|
+
this.participants.delete(userId);
|
|
951
|
+
|
|
952
|
+
this.emit('participantLeft', { userId });
|
|
953
|
+
|
|
954
|
+
// 모든 참여자가 나갔으면 통화 종료
|
|
955
|
+
if (this.peerManager.getPeerIds().length === 0) {
|
|
956
|
+
this.endCall();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ==================== 유틸리티 ====================
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* 현재 통화 중인지 확인
|
|
964
|
+
* @returns {boolean}
|
|
965
|
+
*/
|
|
966
|
+
isInCall() {
|
|
967
|
+
return this.currentRoom !== null || this.peerManager.getPeerIds().length > 0;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* 현재 통화방 ID
|
|
972
|
+
* @returns {string|null}
|
|
973
|
+
*/
|
|
974
|
+
getCurrentRoom() {
|
|
975
|
+
return this.currentRoom;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 참여자 목록
|
|
980
|
+
* @returns {string[]}
|
|
981
|
+
*/
|
|
982
|
+
getParticipants() {
|
|
983
|
+
return Array.from(this.participants.keys());
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* 연결 상태 요약
|
|
988
|
+
* @public
|
|
989
|
+
* @returns {Object}
|
|
990
|
+
*/
|
|
991
|
+
// noinspection JSUnusedGlobalSymbols
|
|
992
|
+
getConnectionSummary() {
|
|
993
|
+
return this.peerManager.getConnectionSummary();
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* 특정 피어의 연결 통계 가져오기 (디버깅/모니터링용)
|
|
998
|
+
* @public
|
|
999
|
+
* @param {string} peerId - 피어 ID
|
|
1000
|
+
* @returns {Promise<RTCStatsReport|null>}
|
|
1001
|
+
*/
|
|
1002
|
+
// noinspection JSUnusedGlobalSymbols
|
|
1003
|
+
async getStats(peerId) {
|
|
1004
|
+
return this.peerManager.getStats(peerId);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* 미디어 상태
|
|
1009
|
+
* @returns {Object}
|
|
1010
|
+
*/
|
|
1011
|
+
getMediaState() {
|
|
1012
|
+
return {
|
|
1013
|
+
videoEnabled: this.mediaManager.isVideoEnabled(),
|
|
1014
|
+
audioEnabled: this.mediaManager.isAudioEnabled(),
|
|
1015
|
+
streamInfo: this.mediaManager.getStreamInfo()
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* 로그 레벨 설정
|
|
1021
|
+
* @param {number} level
|
|
1022
|
+
*/
|
|
1023
|
+
setLogLevel(level) {
|
|
1024
|
+
this.logger.setLevel(level);
|
|
1025
|
+
this.mediaManager.setLogLevel(level);
|
|
1026
|
+
this.peerManager.setLogLevel(level);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* 리소스 정리
|
|
1031
|
+
*/
|
|
1032
|
+
destroy() {
|
|
1033
|
+
this.endCall();
|
|
1034
|
+
this.mediaManager.destroy();
|
|
1035
|
+
this.peerManager.destroy();
|
|
1036
|
+
this.removeAllListeners();
|
|
1037
|
+
this.logger.info('WebRTCClient destroyed');
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
export default WebRTCClient;
|