@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.
@@ -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;