@vibexnpm/talkx 8.12.0 → 9.1.0

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.
@@ -1,22 +1,37 @@
1
1
  /**
2
2
  * WebRTCClient
3
- * WebRTC 통화 기능 클라이언트
3
+ * 화상통화 클라이언트 — 통화 제어는 서버 REST, 미디어는 LiveKit(SFU), 서버 → 클라이언트 알림은 STOMP(화상통화 SFU 설계 v0.7
4
+ * §5.3·§8). 클라이언트는 통화 신호를 보내지 않는다(I7).
5
+ *
6
+ * <p>한 번에 한 통화만 미디어에 붙는다(방을 만드는 중부터 그 통화로 잡는다). 울리는 통화는 여러 개일 수 있다(통화 중 초대 — 앱이
7
+ * BUSY 로 거절할 수 있다).</p>
8
+ *
9
+ * 내보내는 이벤트(TalkFlowClient 에도 같은 이름으로 나간다):
10
+ * <ul>
11
+ * <li>{@code incomingCall} — {@code {callRoomId, kind, callerId, callerName, callerProfileUrl, title}}</li>
12
+ * <li>{@code ringStopped} — {@code {callRoomId, reason}}: accepted·rejected·answered_elsewhere·timeout·stale 또는 서버 사유</li>
13
+ * <li>{@code callStateChanged} — {@code {callRoomId, state, reason?}} ({@link CallState}). 받지 않고 끝난 울림도 ended 로 끝난다.</li>
14
+ * <li>{@code callEnded} — {@code {callRoomId, reason}} (state ended 와 같은 때)</li>
15
+ * <li>{@code callRejected} — {@code {callRoomId, userId, reason}} (방장: 초대 대상의 거절·무응답)</li>
16
+ * <li>{@code participantJoined} / {@code participantLeft} — {@code {callRoomId, userId}}</li>
17
+ * <li>{@code trackSubscribed} / {@code trackUnsubscribed} — {@code {callRoomId, userId, source, track}}</li>
18
+ * <li>{@code localTrackPublished} / {@code localTrackUnpublished} — {@code {callRoomId, source, track}}</li>
19
+ * <li>{@code participantMediaChanged} — {@code {callRoomId, userId, camera, microphone, screen}} (내 것이면 userId 는 null)</li>
20
+ * <li>{@code callRecoveryFailed} — {@code {callRoomId, reason}} (미디어 연결이 끊긴 채 돌아오지 못했다 — 이 기기에서 끝난다)</li>
21
+ * <li>{@code callError} — {@code {callRoomId, code, message, error}}</li>
22
+ * </ul>
4
23
  */
5
24
 
6
25
  import EventEmitter from '../utils/EventEmitter.js';
7
26
  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';
27
+ import CallLedger from './CallLedger.js';
28
+ import LiveKitMediaSession from './LiveKitMediaSession.js';
29
+ import { WebSocketPaths, CallEventTypes, CallKind, CallState, ErrorTypes, DefaultConfig, LogLevel } from '../constants.js';
11
30
 
12
- /** 1:1 통화 제어 신호. 받는 쪽은 roomId 가 없어야 1:1 신호로 받는다(_acceptsSignal). */
13
- const DIRECT_CONTROL_SIGNALS = new Set([
14
- SignalTypes.CALL_REQUEST, SignalTypes.CALL_ACCEPT, SignalTypes.CALL_REJECT,
15
- SignalTypes.CALL_CANCEL, SignalTypes.CALL_BUSY
16
- ]);
17
-
18
- /** 받은 초대·내가 연 방은 응답 전까지만 의미가 있다. 오래된 것부터 잊는다. */
19
- const MAX_REMEMBERED_CALL_ROOMS = 32;
31
+ const ROOMS = '/api/v1/webrtc/rooms';
32
+ /** 서버가 이미 16번 시도한 뒤의 409 — 같은 요청을 이만큼 더 보낸다(§5.3). */
33
+ const CONFLICT_ATTEMPTS = 3;
34
+ const CONFLICT_CODE = 'CALL_CONCURRENT_UPDATE';
20
35
 
21
36
  class WebRTCClient extends EventEmitter {
22
37
  /**
@@ -24,8 +39,9 @@ class WebRTCClient extends EventEmitter {
24
39
  * @param {Object} options.connectionManager - ConnectionManager 인스턴스
25
40
  * @param {Object} options.apiClient - ApiClient 인스턴스
26
41
  * @param {string} options.userId - 사용자 ID
27
- * @param {Object[]} [options.iceServers] - ICE 서버 설정
42
+ * @param {number} [options.ringTimeoutMs] - 받는 쪽 울림을 스스로 끄는 시각
28
43
  * @param {number} [options.logLevel] - 로그 레벨
44
+ * @param {() => LiveKitMediaSession} [options.createMediaSession] - 미디어 세션 생성(시험은 가짜를 넣는다)
29
45
  */
30
46
  constructor(options) {
31
47
  super();
@@ -33,1268 +49,1093 @@ class WebRTCClient extends EventEmitter {
33
49
  this.connectionManager = options.connectionManager;
34
50
  this.apiClient = options.apiClient;
35
51
  this.userId = options.userId;
36
- this.logLevel = options.logLevel || LogLevel.WARN;
37
-
52
+ this.logLevel = options.logLevel ?? LogLevel.WARN;
38
53
  this.logger = new Logger(this.logLevel, 'WebRTCClient');
39
-
40
- // 미디어 및 피어 연결 매니저
41
- this.mediaManager = new MediaStreamManager({ logLevel: this.logLevel });
42
- this.peerManager = new PeerConnectionManager({
43
- iceServers: options.iceServers || DefaultConfig.iceServers,
44
- logLevel: this.logLevel
45
- });
46
-
47
- // 현재 통화 상태
48
- this.currentRoom = null;
49
- this.isGroupCall = false;
50
- this.participants = new Map();
51
- this._callActive = false;
52
- this._callGeneration = 0;
53
- this._deferredGroupPeers = new Map();
54
- this._joinedGroupRoom = null;
55
- this._groupJoin = null;
56
- this._directCallPeer = null;
57
- this._incomingCallPeer = null;
58
- // 서버(REST)가 roomId 를 붙여 보내는 그룹 초대 취소·거절을 받을 방. callRoomId -> host / 나
59
- this._receivedInvitations = new Map();
60
- this._hostedCallRooms = new Map();
61
- // 서버는 생성 응답 전에 초대를 보낸다(CreateCallRoomService). 응답보다 먼저 온 거절은 응답을 받을 때까지 보관한다.
62
- this._pendingRoomCreations = 0;
63
- this._earlyRoomRejects = [];
64
- this._groupRecoveryTimer = null;
65
- this._groupRecoveryAttempts = 0;
66
- this._onReconnected = () => {
67
- this._recoverGroupParticipation().catch(error => this.logger.error('Call recovery failed:', error));
54
+ this.ringTimeoutMs = options.ringTimeoutMs ?? DefaultConfig.callRingTimeoutMs;
55
+ this._createMediaSession = options.createMediaSession ?? (() => new LiveKitMediaSession({ logLevel: this.logLevel }));
56
+
57
+ this._ledger = new CallLedger();
58
+ /** @type {Map<string, {callRoomId: string, kind: string, callerId: string|null, title: string|null, timer: *}>} */
59
+ this._ringing = new Map();
60
+ /** 미디어에 붙은(붙는 중·방을 만드는 중인) 통화 하나. */
61
+ this._call = null;
62
+ this._incomingCallsEnabled = false;
63
+ /** 알림 구독을 켜고 끈 횟수 — 대기 줄에 섰던 구독이 붙었을 때 그 사이 끄라고 했는지 본다. */
64
+ this._incomingGeneration = 0;
65
+ /** destroy 뒤 — 새 통화도, 늦게 끝나는 요청(만드는 중·입장 중이던 통화의 정리·재시도)도 보내지 않는다. */
66
+ this._closed = false;
67
+ /**
68
+ * 지금 로그인 신원의 번호 — 신원이 끝날 때마다(_resetIdentity) 올린다. 서버 요청은 그 요청을 시작한 신원이 지금 신원일 때만
69
+ * 나간다(재시도마다 다시 본다) — 로그아웃·사용자 전환 뒤에 옛 신원의 요청이 새 자격으로 나가지 않게.
70
+ */
71
+ this._identity = 0;
72
+ /**
73
+ * 방마다 진행 중인 나가기·취소·거절·정리 요청(끝나면 지운다). 그 방의 새 입장은 이 요청들의 응답 뒤에 보낸다 — 서버 참가는 사용자마다
74
+ * 하나라, 늦게 처리된 나가기가 새 참가를 닫는다(보낸 순서와 서버 처리 순서는 다를 수 있다). 요청마다 시간 상한(apiTimeout)이 있다.
75
+ * @type {Map<string, Promise<void>>}
76
+ */
77
+ this._exits = new Map();
78
+
79
+ // STOMP 가 끊겼거나 탭이 쉬는 사이 울림이 멈췄을 수 있다 — 돌아오면 울리던 통화를 서버에 다시 묻는다(§5.3 울림 상한).
80
+ // ConnectionManager 계약: resumed(연결이 살아 있었음)와 reconnected(다시 붙음)는 서로 배타적이라 둘 다 들어야 한다.
81
+ this._onConnectionRestored = () => {
82
+ this._recheckRinging().catch(error => this.logger.warn('Ring re-check failed:', error));
68
83
  };
69
- this.connectionManager.on?.('reconnected', this._onReconnected);
70
-
71
- // ICE 서버 초기화 상태
72
- this._iceServersFetched = false;
73
-
74
- // 1:1 통화 대기 상태 (CALL_REQUEST 전송 후 CALL_ACCEPT 대기)
75
- this._waitingForCallAccept = false;
76
- this._pendingCallTarget = null;
77
-
78
- // ICE candidate 큐 (peer connection 생성 전에 도착한 candidate 저장)
79
- this._pendingIceCandidates = new Map();
80
-
81
- // 이벤트 연결
82
- this._setupEventHandlers();
84
+ this.connectionManager.on?.('reconnected', this._onConnectionRestored);
85
+ this.connectionManager.on?.('resumed', this._onConnectionRestored);
83
86
  }
84
87
 
88
+ // ==================== 수신 대기 ====================
89
+
85
90
  /**
86
- * ICE 서버 설정 초기화 (TURN credentials 가져오기)
87
- * 통화 시작 전 호출하면 더 빠른 연결 가능
91
+ * 통화 알림 수신 — 개인 큐({@code /user/queue/webrtc})를 구독한다. connect() 가 부른다.
88
92
  * @returns {Promise<void>}
89
93
  */
90
- async initializeIceServers() {
91
- if (this._iceServersFetched) {
94
+ async enableIncomingCalls() {
95
+ if (this._incomingCallsEnabled) {
92
96
  return;
93
97
  }
94
-
98
+ // 구독을 기다리기 전에 켠 것으로 표시한다 — 그 사이 다시 불러도 구독을 두 번 걸지 않게. STOMP 가 끊겨 구독이 대기 줄에 선 사이 끄라고
99
+ // 했으면(disable·destroy) 붙은 뒤 이 구독을 푼다 — ConnectionManager.unsubscribe 는 붙은 구독만 풀고 대기 줄에는 닿지 않는다.
100
+ this._incomingCallsEnabled = true;
101
+ const generation = ++this._incomingGeneration;
102
+ const destination = WebSocketPaths.getWebRTCUserDestination();
103
+ let subscription;
95
104
  try {
96
- const data = await this.getTurnCredentials();
97
- if (data && data.iceServers) {
98
- this.peerManager.setIceServers(data.iceServers);
99
- this._iceServersFetched = true;
100
- this.logger.info('ICE servers fetched successfully');
101
- }
105
+ subscription = await this.connectionManager.subscribe(destination, (message) => this._handleEvent(message));
102
106
  } catch (error) {
103
- this.logger.warn('Failed to fetch TURN credentials, using fallback ICE servers:', error.message);
104
- this._setFallbackIceServers();
107
+ if (generation === this._incomingGeneration) {
108
+ this._incomingCallsEnabled = false;
109
+ }
110
+ throw error;
111
+ }
112
+ if (generation !== this._incomingGeneration) {
113
+ // 기다리는 사이 껐다(다시 켰을 수도 있다) — 이 구독만 푼다. 대기 줄에 같은 목적지 구독이 여럿 섰다가 함께 붙으면 맵에는 마지막
114
+ // 것만 있어 목적지로 풀면 남의 구독을 풀고 이 구독은 남는다.
115
+ this.connectionManager.unsubscribe(destination, subscription);
105
116
  }
106
117
  }
107
118
 
108
- /**
109
- * 기본 ICE 서버 설정 (Fallback)
110
- * @private
111
- */
112
- _setFallbackIceServers() {
113
- this.peerManager.setIceServers([
114
- { urls: 'stun:stun.l.google.com:19302' },
115
- { urls: 'stun:stun1.l.google.com:19302' }
116
- ]);
119
+ /** 통화 알림 수신을 끈다. */
120
+ disableIncomingCalls() {
121
+ if (!this._incomingCallsEnabled) {
122
+ return;
123
+ }
124
+ this._incomingGeneration += 1;
125
+ this.connectionManager.unsubscribe(WebSocketPaths.getWebRTCUserDestination());
126
+ this._incomingCallsEnabled = false;
117
127
  }
118
128
 
119
- /**
120
- * 이벤트 핸들러 설정
121
- * @private
122
- */
123
- _setupEventHandlers() {
124
- // 미디어 이벤트
125
- this.mediaManager.on('streamStarted', (data) => this.emit('localStreamStarted', data));
126
- this.mediaManager.on('streamStopped', (data) => this.emit('localStreamStopped', data));
127
- this.mediaManager.on('videoToggled', () => this._sendMediaState());
128
- this.mediaManager.on('audioToggled', () => this._sendMediaState());
129
- this.mediaManager.on('screenShareStarted', (data) => this.emit('screenShareStarted', data));
130
- this.mediaManager.on('screenShareEnded', (data) => this.emit('screenShareEnded', data));
131
- this.mediaManager.on('error', (error) => this.emit('error', error));
132
-
133
- // 피어 연결 이벤트
134
- this.peerManager.on('remoteTrack', (data) => this.emit('remoteTrack', data));
135
- this.peerManager.on('peerConnected', (data) => this.emit('peerConnected', data));
136
- this.peerManager.on('peerDisconnected', (data) => this.emit('peerDisconnected', data));
137
- this.peerManager.on('peerClosed', (data) => this.emit('peerClosed', data));
138
- this.peerManager.on('error', (error) => this.emit('error', error));
139
-
140
- // ICE Candidate 전송
141
- this.peerManager.on('iceCandidate', ({ peerId, candidate }) => {
142
- this._sendSignal({
143
- type: SignalTypes.ICE_CANDIDATE,
144
- receiverId: peerId,
145
- data: { candidate }
146
- });
147
- });
148
-
149
- // Negotiation (Offer 전송)
150
- this.peerManager.on('negotiationNeeded', ({ peerId, description }) => {
151
- this._sendSignal({
152
- type: SignalTypes.CALL_OFFER,
153
- receiverId: peerId,
154
- data: { sdp: description }
155
- });
156
- });
157
-
158
- // Answer 전송
159
- this.peerManager.on('answerCreated', ({ peerId, answer }) => {
160
- this._sendSignal({
161
- type: SignalTypes.CALL_ANSWER,
162
- receiverId: peerId,
163
- data: { sdp: answer }
164
- });
165
- });
129
+ /** @returns {boolean} */
130
+ isIncomingCallsEnabled() {
131
+ return this._incomingCallsEnabled;
166
132
  }
167
133
 
168
- // ==================== REST API ====================
134
+ // ==================== 통화 시작·응답 ====================
169
135
 
170
136
  /**
171
- * TURN 서버 인증 정보 가져오기
172
- * @returns {Promise<Object>}
137
+ * 1:1 통화를 건다 — 방을 만들고 입장해 미디어에 붙은 채 상대를 기다린다.
138
+ * @param {string} targetUserId
139
+ * @param {{video?: boolean, audio?: boolean}} [media]
140
+ * @returns {Promise<string>} callRoomId
173
141
  */
174
- async getTurnCredentials() {
175
- const response = await this.apiClient.get('/api/v1/webrtc/turn-credentials');
176
- return response.data;
142
+ async callUser(targetUserId, media = {}) {
143
+ const call = this._beginCall({ kind: CallKind.DIRECT, media, host: true });
144
+ return this._startHosted(call, { kind: CallKind.DIRECT, invitedUsers: [targetUserId] });
177
145
  }
178
146
 
179
147
  /**
180
- * 통화방 생성
181
- * @public
182
- * @param {Object} data
183
- * @param {string[]} [data.invitedUsers] - 초대할 사용자 ID
184
- * @param {string} [data.title] - 통화방 제목
185
- * @param {number} [data.maxParticipants] - 최대 참가자 수 (생략 시 서버 기본값)
186
- * @returns {Promise<Object>}
148
+ * 그룹 통화를 연다 — 방을 만들고 입장해 미디어에 붙는다.
149
+ * @param {{invitedUsers: string[], title?: string, maxParticipants?: number, video?: boolean, audio?: boolean}} options
150
+ * @returns {Promise<string>} callRoomId
187
151
  */
188
- // noinspection JSUnusedGlobalSymbols
189
- async createCallRoom(data) {
190
- this._pendingRoomCreations++;
191
- try {
192
- const response = await this.apiClient.post('/api/v1/webrtc/rooms', data);
193
- const callRoomId = response?.data?.callRoomId;
194
- if (callRoomId) this._rememberCallRoom(this._hostedCallRooms, callRoomId, this.userId);
195
- return response;
196
- } finally {
197
- this._pendingRoomCreations--;
198
- // 보관한 거절을 같은 경로로 다시 판정한다. 연 방의 것은 전달되고, 진행 중인 생성이 남았으면 다시 보관되며, 아니면 버려진다.
199
- for (const message of this._earlyRoomRejects.splice(0)) {
200
- this._handleSignal(message).catch(error => this.logger.error('Early rejection handling failed:', error));
201
- }
202
- }
152
+ async createGroupCall({ invitedUsers, title, maxParticipants, video, audio } = {}) {
153
+ const call = this._beginCall({ kind: CallKind.GROUP, media: { video, audio }, host: true });
154
+ return this._startHosted(call, { kind: CallKind.GROUP, invitedUsers, title, maxParticipants });
203
155
  }
204
156
 
205
157
  /**
206
- * 통화방 정보 조회
207
- * @public
208
- * @param {string} roomId - 통화방 ID
209
- * @returns {Promise<Object>}
158
+ * 울리는 통화를 받는다. 이 기기의 울림을 끄고 입장한다 — 다른 기기는 서버 «응답함» 으로 울림을 끈다.
159
+ * @param {string} callRoomId
160
+ * @param {{video?: boolean, audio?: boolean}} [media]
161
+ * @returns {Promise<string>}
210
162
  */
211
- // noinspection JSUnusedGlobalSymbols
212
- async getCallRoom(roomId) {
213
- return this.apiClient.get(`/api/v1/webrtc/rooms/${roomId}`);
163
+ async acceptCall(callRoomId, media = {}) {
164
+ requireCallRoomId(callRoomId);
165
+ const ring = this._ringing.get(callRoomId);
166
+ const call = this._beginCall({ kind: ring?.kind ?? null, media, host: false, callRoomId });
167
+ call.fromRing = Boolean(ring);
168
+ // 울림 → 통화를 한 번에 바꾼다 — 아래 양보 사이에 나가기·신원 끝남이 끼어도 울림이 남거나 끝남이 두 번 나가지 않게.
169
+ this._stopRinging(callRoomId, 'accepted', true);
170
+ // 끝남 알림 안에서 불렸으면(앱이 callEnded 에서 같은 통화에 바로 다시 들어감) 그 알림 쌍이 다 나간 뒤 시작한다 — 새 통화의 상태
171
+ // 알림이 옛 통화의 끝남보다 먼저 가지 않게. 끝남 알림은 동기로 나가므로 한 번 양보하면 된다.
172
+ await Promise.resolve();
173
+ if (this._call !== call) {
174
+ // 그 사이 끝났다(나가기·신원 끝남) — 결과는 callEnded 로 이미 알렸다.
175
+ return callRoomId;
176
+ }
177
+ return this._enter(call);
214
178
  }
215
179
 
216
180
  /**
217
- * 통화방 참여
218
- * @public
219
- * @param {string} roomId - 통화방 ID
220
- * @returns {Promise<Object>}
181
+ * 그룹 통화에 들어가거나 다시 들어간다(초대 수락·재참가·이 기기에서 끊긴 뒤 복귀).
182
+ * @param {string} callRoomId
183
+ * @param {{video?: boolean, audio?: boolean}} [media]
184
+ * @returns {Promise<string>}
221
185
  */
222
- // noinspection JSUnusedGlobalSymbols
223
- async joinCallRoomApi(roomId) {
224
- // REST 응답보다 PEER_JOINED에 대한 offer가 먼저 도착할 수 있다.
225
- // 로컬에서 요청한 방만 허용하고, 실패·종료 뒤에는 이 권한을 남기지 않는다.
226
- const join = { roomId, generation: this._callGeneration };
227
- this._groupJoin = join;
228
- try {
229
- const response = await this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/join`);
230
- if (this._groupJoin === join && this._callGeneration === join.generation) this._joinedGroupRoom = roomId;
231
- return response;
232
- } catch (error) {
233
- if (this._groupJoin === join && this.currentRoom !== roomId) this._clearDeferredGroup(roomId);
234
- throw error;
235
- } finally {
236
- if (this._groupJoin === join) this._groupJoin = null;
237
- }
186
+ async joinCall(callRoomId, media = {}) {
187
+ return this.acceptCall(callRoomId, media);
238
188
  }
239
189
 
240
190
  /**
241
- * 통화방 나가기
242
- * @public
243
- * @param {string} roomId - 통화방 ID
244
- * @returns {Promise<Object>}
191
+ * 울리는 통화를 거절한다.
192
+ * @param {string} callRoomId
193
+ * @param {{reason?: 'DECLINED'|'BUSY'}} [options]
245
194
  */
246
- // noinspection JSUnusedGlobalSymbols
247
- async leaveCallRoomApi(roomId) {
248
- const response = await this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/leave`);
249
- if (this._joinedGroupRoom === roomId) this._joinedGroupRoom = null;
250
- if (this._groupJoin?.roomId === roomId) this._groupJoin = null;
251
- if (this.currentRoom !== roomId) this._clearDeferredGroup(roomId);
252
- return response;
195
+ async rejectCall(callRoomId, { reason = 'DECLINED' } = {}) {
196
+ requireCallRoomId(callRoomId);
197
+ this._stopRinging(callRoomId, 'rejected');
198
+ await this._trackExit(callRoomId,
199
+ this._send(this._identity, () => this.apiClient.post(`${ROOMS}/${callRoomId}/reject`, undefined, { reason })));
253
200
  }
254
201
 
255
- // ==================== 통화 수신 대기 ====================
202
+ // ==================== 통화 끝내기 ====================
256
203
 
257
204
  /**
258
- * 통화 수신 대기 활성화
259
- * connect() 후 자동 호출되지만, 수동으로도 호출 가능
260
- * 개인 WebRTC 채널(/user/queue/webrtc)을 구독하여 초대 알림 수신
261
- * @public
262
- * @returns {Promise<void>}
205
+ * 건 통화를 취소한다(방장, 상대가 입장하기 전). 방을 만드는 중이면 만든 뒤 바로 취소한다.
206
+ * @param {string} [callRoomId] - 생략하면 지금 통화
263
207
  */
264
- async enableIncomingCalls() {
265
- if (this._incomingCallsEnabled) {
266
- this.logger.debug('Incoming calls already enabled');
208
+ async cancelCall(callRoomId) {
209
+ const call = this._call;
210
+ if (call && (callRoomId === undefined || callRoomId === call.callRoomId)) {
211
+ if (!call.callRoomId) {
212
+ return this._endWhileCreating(call, 'cancelled', (id) => this.apiClient.post(`${ROOMS}/${id}/cancel`));
213
+ }
214
+ // 방장이 아니면 서버가 403 으로 거절한다 — 통화는 그대로다. 응답 전에 나가고 같은 통화에 다시 들어가면 새 입장은 이 취소 뒤에 간다.
215
+ await this._trackExit(call.callRoomId, this._send(call.identity, () => this.apiClient.post(`${ROOMS}/${call.callRoomId}/cancel`)));
216
+ await this._finish(call, 'cancelled', { callOver: true });
267
217
  return;
268
218
  }
269
-
270
- await this._subscribeToDirectSignaling();
271
- this._incomingCallsEnabled = true;
272
- this.logger.info('Incoming calls enabled - now listening for call invitations');
219
+ if (!callRoomId) {
220
+ throw callError(ErrorTypes.NO_ACTIVE_CALL, 'No call to cancel');
221
+ }
222
+ await this._trackExit(callRoomId, this._send(this._identity, () => this.apiClient.post(`${ROOMS}/${callRoomId}/cancel`)));
273
223
  }
274
224
 
275
225
  /**
276
- * 통화 수신 대기 비활성화
277
- * @public
226
+ * 지금 통화에서 나간다 — 이 기기의 미디어를 먼저 끊기 시작하고 같은 틱에 서버에 나가기를 보낸다. 서버 요청이 실패하면 던지지만
227
+ * 이 기기의 통화는 이미 끝났다(서버는 끊김 유예 뒤 닫는다). 방을 만드는 중이면 만든 뒤 끝내고 취소를 보낸다.
278
228
  */
279
- disableIncomingCalls() {
280
- if (!this._incomingCallsEnabled) {
281
- return;
229
+ async leaveCall() {
230
+ const call = this._requireCall();
231
+ if (!call.callRoomId) {
232
+ return this._endWhileCreating(call, 'left', null);
233
+ }
234
+ // 나가기는 끝남 알림보다 먼저(누른 그 틱에) 보내 방별 추적에 올린다 — 알림 안에서 같은 통화에 다시 들어가도 새 입장은 이 나가기
235
+ // 뒤에 나간다. 끊기(수백 ms)를 기다리지 않으니 그 사이 로그아웃·사용자 전환이 끼어도 누른 순간의 신원으로 나간다.
236
+ const exit = this._exitRequest(call);
237
+ const left = exit ? this._trackExit(call.callRoomId, this._send(call.identity, exit)) : null;
238
+ const finished = this._finish(call, 'left');
239
+ const failure = (await Promise.allSettled(left ? [finished, left] : [finished])).find(result => result.status === 'rejected');
240
+ if (failure) {
241
+ throw failure.reason;
282
242
  }
283
-
284
- const directDestination = WebSocketPaths.getWebRTCUserDestination();
285
- this.connectionManager.unsubscribe(directDestination);
286
- this._incomingCallsEnabled = false;
287
- this.logger.info('Incoming calls disabled');
288
243
  }
289
244
 
290
245
  /**
291
- * 통화 수신 대기 상태 확인
292
- * @public
293
- * @returns {boolean}
246
+ * 나가기에 보낼 서버 요청. 방장이거나 입장을 보냈으면 나가기. 받는 쪽이 입장을 보내기 전에 나가면 서버 참가가 없어 나가기는 아무것도
247
+ * 하지 않는다 — 울리던 초대를 받은 것이면 거절(DECLINED)로 서버 초대를 정리하고(안 하면 다른 기기가 울림 상한까지 울리고 방장이
248
+ * 기다린다), 울림 없이 다시 들어가던 중이면 보낼 것이 없다.
249
+ * @returns {(() => Promise<*>)|null}
294
250
  */
295
- isIncomingCallsEnabled() {
296
- return this._incomingCallsEnabled || false;
251
+ _exitRequest(call) {
252
+ if (call.host || call.joinAttempted) {
253
+ return () => this.apiClient.post(`${ROOMS}/${call.callRoomId}/leave`);
254
+ }
255
+ if (call.fromRing) {
256
+ return () => this.apiClient.post(`${ROOMS}/${call.callRoomId}/reject`, undefined, { reason: 'DECLINED' });
257
+ }
258
+ return null;
297
259
  }
298
260
 
299
- // ==================== 통화 기능 ====================
300
-
301
- /**
302
- * 통화 시작 (미디어 및 구독)
303
- * @param {Object} options
304
- * @param {string} options.roomId - 통화방 ID
305
- * @param {boolean} [options.isGroup=false] - 그룹 통화 여부
306
- * @param {Object} [options.mediaConstraints] - 미디어 제약조건
307
- * @returns {Promise<MediaStream>}
308
- */
309
- async startCall(options) {
310
- const { roomId, isGroup = false, mediaConstraints = { video: true, audio: true } } = options;
311
- if (this._pendingStart) throw new Error('A call is already starting');
312
- // 조기 offer의 peer 존재만으로 막지 않는다. 이미 소유한 통화만 보호한다.
313
- if (this.currentRoom !== null || this._callActive || this._waitingForCallAccept) {
314
- throw new Error('A call is already active');
315
- }
316
- this._callGeneration++;
317
- this._cancelGroupRecoveryRetry();
318
- this._groupRecoveryAttempts = 0;
319
- this._groupRecovery = null;
320
- const start = { cancelled: false };
321
- this._pendingStart = start;
322
- const checkCancelled = () => { if (start.cancelled) throw new Error('Call start cancelled'); };
323
-
324
- try {
325
- this.currentRoom = roomId;
326
- this.isGroupCall = isGroup;
327
- for (const deferredRoom of new Set(this._deferredGroupPeers.values())) {
328
- if (!isGroup || deferredRoom !== roomId) this._clearDeferredGroup(deferredRoom);
329
- }
330
- // 그룹 참가 임대를 먼저 연결한다. 권한 팝업/TURN 조회가 90초를 넘어도 갱신된다.
331
- await this._subscribeToSignaling(roomId, isGroup);
332
- checkCancelled();
333
- await this.initializeIceServers();
334
- checkCancelled();
335
- const localStream = await this.mediaManager.getUserMedia(mediaConstraints);
336
- checkCancelled();
337
- // 먼저 수신한 offer의 peer에도 트랙을 추가하고 재협상한다.
338
- // 권한 대기 중 시그널을 버리면 상대의 기존 peer가 offer를 다시 보내지 않을 수 있다.
339
- this.peerManager.setLocalStream(localStream);
340
- for (const peerId of this._deferredGroupPeers.keys()) {
341
- await this._resumeGroupNegotiation(peerId);
342
- checkCancelled();
343
- }
344
- this._callActive = true;
345
-
346
- // 입장 시그널 전송
347
- this._sendSignal({
348
- type: SignalTypes.JOIN_ROOM,
349
- roomId
350
- });
351
-
352
- this.logger.info(`Call started in room: ${roomId}`);
353
- this.emit('callStarted', { roomId, isGroup, localStream });
354
-
355
- return localStream;
356
-
357
- } catch (error) {
358
- if (isGroup) this.connectionManager.unsubscribe(WebSocketPaths.getWebRTCDestination(roomId));
359
- this._unsubscribeFromSignaling();
360
- this.mediaManager.stopAll();
361
- this.peerManager.closeAllPeerConnections();
362
- this.peerManager.setLocalStream(null);
363
- this.participants.clear();
364
- this._pendingIceCandidates.clear();
365
- this._deferredGroupPeers.clear();
366
- this._callActive = false;
367
- this.currentRoom = null;
368
- this.isGroupCall = false;
369
- this._joinedGroupRoom = null;
370
- this._groupJoin = null;
371
- this.logger.error('Failed to start call:', error);
372
- this.emit('error', {
373
- type: ErrorTypes.PEER_CONNECTION_FAILED,
374
- message: error.message,
375
- error
376
- });
377
- throw error;
378
- } finally {
379
- this._pendingStart = null;
261
+ /** 모두의 통화를 끝낸다(방장). 방을 만드는 중이면 만든 뒤 끝낸다. */
262
+ async endCallForAll() {
263
+ const call = this._requireCall();
264
+ if (!call.callRoomId) {
265
+ return this._endWhileCreating(call, 'ended_by_host', (id) => this.apiClient.delete(`${ROOMS}/${id}`));
380
266
  }
267
+ await this._trackExit(call.callRoomId, this._send(call.identity, () => this.apiClient.delete(`${ROOMS}/${call.callRoomId}`)));
268
+ await this._finish(call, 'ended_by_host', { callOver: true });
381
269
  }
382
270
 
383
- /**
384
- * 1:1 통화 요청
385
- * @param {string} targetUserId - 상대방 사용자 ID
386
- * @param {Object} [mediaConstraints] - 미디어 제약조건
387
- */
388
- async callUser(targetUserId, mediaConstraints = { video: true, audio: true }) {
389
- try {
390
- // TURN 서버 설정 초기화
391
- await this.initializeIceServers();
392
-
393
- // 로컬 미디어 획득
394
- const localStream = await this.mediaManager.getUserMedia(mediaConstraints);
395
- this.peerManager.setLocalStream(localStream);
396
-
397
- // 1:1 통화 구독
398
- await this._subscribeToDirectSignaling();
399
-
400
- // CALL_ACCEPT 대기 상태 설정
401
- // PeerConnection은 CALL_ACCEPT를 받은 후에 생성하여 자동 negotiation 방지
402
- this._callActive = true;
403
- this._waitingForCallAccept = true;
404
- this._pendingCallTarget = targetUserId;
405
- this._incomingCallPeer = null;
271
+ // ==================== 미디어 ====================
406
272
 
407
- // 통화 요청 시그널
408
- this._sendSignal({
409
- type: SignalTypes.CALL_REQUEST,
410
- receiverId: targetUserId
411
- });
412
-
413
- this.currentRoom = null;
414
- this.isGroupCall = false;
415
-
416
- this.emit('callRequested', { targetUserId, localStream });
273
+ /** @param {boolean} enabled */
274
+ async setCameraEnabled(enabled) {
275
+ await this._changeDevice('camera', 'video', enabled, session => session.setCameraEnabled(enabled));
276
+ }
417
277
 
418
- } catch (error) {
419
- this.logger.error('Failed to call user:', error);
420
- this._waitingForCallAccept = false;
421
- this._pendingCallTarget = null;
422
- throw error;
423
- }
278
+ /** @param {boolean} enabled */
279
+ async setMicrophoneEnabled(enabled) {
280
+ await this._changeDevice('microphone', 'audio', enabled, session => session.setMicrophoneEnabled(enabled));
424
281
  }
425
282
 
426
283
  /**
427
- * 통화 수락
428
- * @param {string} callerId - 발신자 ID
429
- * @param {Object} [mediaConstraints] - 미디어 제약조건
284
+ * 카메라·마이크를 켜고 끈다 — 통화가 있으면 언제든(방을 만드는 중·입장 중·다시 입장 중 포함). 의도를 «먼저» 기억한다 — 미디어 연결이
285
+ * 아직 없으면 의도만 적고 끝나며, 다음 연결이 이 의도로 붙는다(접속 중인 연결은 세션이 마지막 의도로 맞춘다). 연결에서 못 바꿨고 아직
286
+ * 그 연결이면 그 연결이 실제로 쓰는 상태로 되돌린다 — 앞선 호출도 실패했으면 그 의도(한 번도 적용되지 않은 값)가 아니라(그 사이 다시
287
+ * 붙었으면 새 연결이 이 의도로 붙었다).
430
288
  */
431
- async acceptCall(callerId, mediaConstraints = { video: true, audio: true }) {
289
+ async _changeDevice(source, key, enabled, action) {
290
+ const call = this._requireCall();
291
+ call.media[key] = enabled;
292
+ // 내 호출 차례 — 실패해 되돌릴 때 그 뒤의 호출(같은 값을 다시 누른 것 포함)을 덮지 않게.
293
+ const turn = ++call.mediaTurn[key];
294
+ const session = call.session;
295
+ if (!session) {
296
+ return;
297
+ }
432
298
  try {
433
- // TURN 서버 설정 초기화
434
- await this.initializeIceServers();
435
-
436
- // 로컬 미디어 획득 (수신자도 미디어 필요)
437
- const localStream = await this.mediaManager.getUserMedia(mediaConstraints);
438
- this.peerManager.setLocalStream(localStream);
439
-
440
- // 피어 연결 생성 (Polite - 수신자)
441
- // skipAutoNegotiation: true - 수신자는 offer를 받아서 answer를 보내므로 자동 negotiation 불필요
442
- this.peerManager.createPeerConnection(callerId, true, { skipAutoNegotiation: true });
443
- this._directCallPeer = callerId;
444
- this._incomingCallPeer = null;
445
-
446
- // 수락 시그널
447
- this._sendSignal({
448
- type: SignalTypes.CALL_ACCEPT,
449
- receiverId: callerId
450
- });
451
-
452
- this._callActive = true;
453
- this.emit('callAccepted', { callerId });
454
-
299
+ await this._mediaCall(source, () => action(session));
455
300
  } catch (error) {
456
- this.logger.error('Failed to accept call:', error);
301
+ if (call.session === session && call.mediaTurn[key] === turn) {
302
+ call.media[key] = session.appliedMedia(source);
303
+ }
457
304
  throw error;
458
305
  }
459
306
  }
460
307
 
461
- /**
462
- * 통화 거절
463
- * @param {string} callerId - 발신자 ID
464
- */
465
- rejectCall(callerId) {
466
- if (this._incomingCallPeer === callerId) this._incomingCallPeer = null;
467
- this._sendSignal({
468
- type: SignalTypes.CALL_REJECT,
469
- receiverId: callerId
470
- });
308
+ async startScreenShare() {
309
+ await this._mediaCall('screen', session => session.setScreenShareEnabled(true));
310
+ }
471
311
 
472
- this.emit('callRejected', { callerId });
312
+ async stopScreenShare() {
313
+ await this._mediaCall('screen', session => session.setScreenShareEnabled(false));
473
314
  }
474
315
 
475
316
  /**
476
- * 통화 취소 (발신자가 수신자 응답 전 취소)
317
+ * 장치 목록 — 통화 전에도 부를 수 있다.
318
+ * @param {MediaDeviceKind} [kind]
319
+ * @returns {Promise<MediaDeviceInfo[]>}
477
320
  */
478
- cancelCall() {
479
- if (this._waitingForCallAccept && this._pendingCallTarget) {
480
- this._sendSignal({
481
- type: SignalTypes.CALL_CANCEL,
482
- receiverId: this._pendingCallTarget
483
- });
484
-
485
- this.emit('callCancelled', { targetUserId: this._pendingCallTarget });
486
-
487
- // 대기 상태 초기화
488
- this._waitingForCallAccept = false;
489
- this._pendingCallTarget = null;
490
-
491
- // 미디어 정리
492
- this._callActive = false;
493
- this.mediaManager.stopAll();
321
+ async getDevices(kind) {
322
+ const session = this._call?.session ?? this._createMediaSession();
323
+ try {
324
+ return await session.getDevices(kind);
325
+ } catch (error) {
326
+ throw callError(ErrorTypes.ENUMERATE_DEVICES_FAILED, 'Failed to enumerate devices', error);
494
327
  }
495
328
  }
496
329
 
497
330
  /**
498
- * 통화 종료
331
+ * 장치를 바꾼다 — 통화가 있으면 언제든. 고른 장치를 «먼저» 기억한다 — 미디어 연결이 아직 없으면 기억만 하고 끝나며, 다시 입장(제거됨·
332
+ * 첫 접속 실패)해도 새 연결이 그 장치로 붙는다(livekit 은 고른 장치를 그 Room 에만 적는다). 그 연결에서 못 바꿨고 아직 그 연결이면
333
+ * 그 연결이 지금 쓰는 장치로 기억을 되돌린다(모르면 기본 장치) — 한 번도 적용되지 않은 앞선 선택을 기억하지 않게. 스피커를 일부만
334
+ * 바꾸고 쓰던 출력으로 되돌리지도 못하면 그 오류(`DEVICE_SWITCH_FAILED`)에 outputMismatch 가 붙는다 — 일부 상대 소리가 고른 출력에
335
+ * 남았고 기억은 쓰던 출력이다(다시 고르면 모두 그 출력으로 맞춘다).
336
+ * @param {MediaDeviceKind} kind - audioinput·videoinput·audiooutput
337
+ * @param {string} deviceId
499
338
  */
500
- endCall() {
501
- this._endCall(true);
502
- }
503
-
504
- _endCall(notifyPeers) {
505
- this._callGeneration++;
506
- this._cancelGroupRecoveryRetry();
507
- this._groupRecoveryAttempts = 0;
508
- this._groupRecovery = null;
509
- this._callActive = false;
510
- this._deferredGroupPeers.clear();
511
- this._joinedGroupRoom = null;
512
- this._groupJoin = null;
513
- this._incomingCallPeer = null;
514
- if (this._pendingStart) this._pendingStart.cancelled = true;
515
- // 대기 중인 통화가 있으면 취소 처리
516
- if (notifyPeers && this._waitingForCallAccept && this._pendingCallTarget) {
517
- this._sendSignal({
518
- type: SignalTypes.CALL_CANCEL,
519
- receiverId: this._pendingCallTarget
520
- });
521
- }
522
-
523
- // 대기 상태 초기화
524
- this._waitingForCallAccept = false;
525
- this._pendingCallTarget = null;
526
-
527
- if (this.currentRoom) {
528
- // 퇴장 시그널 전송
529
- if (notifyPeers) this._sendSignal({
530
- type: SignalTypes.LEAVE_ROOM,
531
- roomId: this.currentRoom
532
- });
533
-
534
- // 구독 해제
535
- this._unsubscribeFromSignaling();
536
- }
537
-
538
- // 모든 피어에게 종료 시그널
539
- if (notifyPeers) this.peerManager.getPeerIds().forEach(peerId => {
540
- this._sendSignal({
541
- type: SignalTypes.CALL_END,
542
- receiverId: peerId
543
- });
544
- });
545
- // 1:1 상대에게 가는 CALL_END 에 그룹 roomId 가 붙지 않도록 보낸 뒤에 지운다(_groupRoomFor).
546
- this._directCallPeer = null;
547
-
548
- // 정리
549
- this.peerManager.closeAllPeerConnections();
550
- this.peerManager.setLocalStream(null);
551
- this.mediaManager.stopAll();
552
- this.participants.clear();
553
- this._pendingIceCandidates.clear();
554
-
555
- const roomId = this.currentRoom;
556
- this.currentRoom = null;
557
- this.isGroupCall = false;
558
-
559
- this.emit('callEnded', { roomId });
560
- this.logger.info('Call ended');
339
+ async switchDevice(kind, deviceId) {
340
+ const call = this._requireCall();
341
+ call.devices[kind] = deviceId;
342
+ const turn = call.deviceTurn[kind] = (call.deviceTurn[kind] ?? 0) + 1;
343
+ const session = call.session;
344
+ if (!session) {
345
+ return true;
346
+ }
347
+ let switched = false;
348
+ try {
349
+ switched = await session.switchDevice(kind, deviceId);
350
+ return switched;
351
+ } catch (error) {
352
+ if (error?.code === ErrorTypes.INVALID_STATE) {
353
+ throw error;
354
+ }
355
+ const failure = callError(ErrorTypes.DEVICE_SWITCH_FAILED, 'Failed to switch device', error);
356
+ // 스피커를 일부만 바꾸고 되돌리지 못했다 — 일부 상대 소리가 고른 출력에 남았다(기억은 쓰던 출력). 다시 고르면 모두 맞춘다.
357
+ if (error?.outputMismatch) {
358
+ failure.outputMismatch = true;
359
+ }
360
+ throw failure;
361
+ } finally {
362
+ if (!switched && call.session === session && call.deviceTurn[kind] === turn) {
363
+ const inUse = session.deviceInUse(kind);
364
+ if (inUse === undefined) {
365
+ delete call.devices[kind];
366
+ } else {
367
+ call.devices[kind] = inUse;
368
+ }
369
+ }
370
+ }
561
371
  }
562
372
 
563
- // ==================== 미디어 제어 ====================
373
+ // ==================== 조회 ====================
564
374
 
565
- /**
566
- * 비디오 토글
567
- * @returns {boolean}
568
- */
569
- toggleVideo() {
570
- return this.mediaManager.toggleVideo();
375
+ /** @param {string} callRoomId */
376
+ async getCallRoom(callRoomId) {
377
+ requireCallRoomId(callRoomId);
378
+ return unwrap(await this._send(this._identity, () => this.apiClient.get(`${ROOMS}/${callRoomId}`)));
571
379
  }
572
380
 
573
381
  /**
574
- * 오디오 토글
575
- * @returns {boolean}
382
+ * 내 참가 상태 — {@code {callRoomId, participating, ring}}. ring 은 초대 대상의 울림 상태(RINGING 이면 아직 울리는 중).
383
+ * @param {string} callRoomId
576
384
  */
577
- toggleAudio() {
578
- return this.mediaManager.toggleAudio();
385
+ async getParticipation(callRoomId) {
386
+ requireCallRoomId(callRoomId);
387
+ return unwrap(await this._send(this._identity, () => this.apiClient.get(`${ROOMS}/${callRoomId}/participation`)));
579
388
  }
580
389
 
581
- /**
582
- * 화면 공유 시작
583
- * @returns {Promise<MediaStream>}
584
- */
585
- async startScreenShare() {
586
- const screenStream = await this.mediaManager.getDisplayMedia();
587
-
588
- // 피어들에게 화면 공유 트랙 전송
589
- const videoTrack = screenStream.getVideoTracks()[0];
590
- const localVideoTrack = this.mediaManager.getTrack('video');
390
+ /** @returns {boolean} */
391
+ isInCall() {
392
+ return this._call !== null;
393
+ }
591
394
 
592
- if (localVideoTrack && videoTrack) {
593
- await this.peerManager.replaceTrack(localVideoTrack, videoTrack);
395
+ /** 지금 통화 — 없으면 null. 방을 만드는 중이면 callRoomId 가 null 이다. */
396
+ getCurrentCall() {
397
+ const call = this._call;
398
+ if (!call) {
399
+ return null;
594
400
  }
595
-
596
- // 화면 공유 종료 시 원복 — 복원 시점의 현재 카메라 트랙 사용 (공유 중 switchDevice 대응)
597
- videoTrack.onended = async () => {
598
- const currentVideoTrack = this.mediaManager.getTrack('video');
599
- if (currentVideoTrack) {
600
- await this.peerManager.replaceTrack(videoTrack, currentVideoTrack);
601
- }
602
- this.emit('screenShareEnded', {});
401
+ return {
402
+ callRoomId: call.callRoomId,
403
+ kind: call.kind,
404
+ state: call.state,
405
+ participants: call.session?.participants() ?? [],
406
+ localMedia: call.session?.getLocalMediaState() ?? { camera: false, microphone: false, screen: false }
603
407
  };
408
+ }
604
409
 
605
- return screenStream;
410
+ /** 울리는 통화들. */
411
+ getRingingCalls() {
412
+ return [...this._ringing.values()].map(({ callRoomId, kind, callerId, title }) => ({ callRoomId, kind, callerId, title }));
606
413
  }
607
414
 
608
- /**
609
- * 화면 공유 중지
610
- */
611
- stopScreenShare() {
612
- const screenStream = this.mediaManager.getScreenStream();
613
- if (screenStream) {
614
- screenStream.getTracks().forEach(track => track.stop());
615
- }
415
+ /** @param {number} level */
416
+ setLogLevel(level) {
417
+ this.logger.setLevel(level);
616
418
  }
617
419
 
618
420
  /**
619
- * 로컬 스트림 가져오기
620
- * @public
621
- * @returns {MediaStream|null}
421
+ * 통화를 건 로그인 신원이 끝났다(로그아웃·사용자·회사 전환·세션 지워짐) — TalkFlowClient 가 부른다. 이 기기에서만 정리하고, 그
422
+ * 신원으로 시작한 요청은 더 보내지 않는다(진행 중인 재시도·만들기·입장 응답 뒤의 정리 포함). 로그인 세션은 클라이언트에 알리기 전에
423
+ * 토큰을 바꾸므로(AuthSession._accept) 그 뒤에 보내는 요청은 새 신원으로 간다. 서버는 미디어가 끊긴 뒤 끊김 유예가 지나면 참가를
424
+ * 닫는다. 같은 인스턴스는 다음 신원으로 계속 쓴다(토큰 직접 방식의 로그아웃 뒤 setToken).
622
425
  */
623
- // noinspection JSUnusedGlobalSymbols
624
- getLocalStream() {
625
- return this.mediaManager.getLocalStream();
426
+ async _resetIdentity() {
427
+ this._identity += 1;
428
+ // 새 사용자가 반영될 때까지(setToken 이 userId 를 넣을 때까지, 로그인 세션은 이 인스턴스를 버릴 때까지) 통화 요청을 받지 않는다 —
429
+ // 그 사이 자격은 이미 새 신원일 수 있어(AuthSession._accept) 옛 사용자로 보이는 요청이 새 신원으로 나간다.
430
+ this.userId = null;
431
+ for (const callRoomId of [...this._ringing.keys()]) {
432
+ this._stopRinging(callRoomId, 'ended');
433
+ }
434
+ // 장부(묘비·울림 정지 표식)도 그 신원의 것이다 — 다음 신원이 같은 통화에 초대돼도 막지 않게 비운다.
435
+ this._ledger = new CallLedger();
436
+ const call = this._call;
437
+ if (!call) {
438
+ return;
439
+ }
440
+ if (!call.callRoomId) {
441
+ // 방을 만드는 중 — 알릴 방 id 가 아직 없다. 만들기를 기다리지 않는다(신원 전환을 막지 않게). 만든 뒤 _startHosted 가
442
+ // 아무것도 보내지 않고 callUser 를 NO_ACTIVE_CALL 로 끝낸다.
443
+ this._call = null;
444
+ return;
445
+ }
446
+ await this._finish(call, 'left');
626
447
  }
627
448
 
628
449
  /**
629
- * 디바이스 목록 가져오기
630
- * @returns {Promise<Object>}
450
+ * 자원 정리 — 서버에 아무것도 보내지 않고, 뒤에 부르는 통화 시작도 막는다. 로그아웃·사용자 전환은 TalkFlowClient 가 그 전에
451
+ * 나가기를 보내거나(자격이 아직 지금 신원일 때) 이 기기에서만 끝낸다(_resetIdentity).
631
452
  */
632
- async getDevices() {
633
- return this.mediaManager.getDevices();
453
+ destroy() {
454
+ this._closed = true;
455
+ this.connectionManager.off?.('reconnected', this._onConnectionRestored);
456
+ this.connectionManager.off?.('resumed', this._onConnectionRestored);
457
+ for (const ring of this._ringing.values()) {
458
+ clearTimeout(ring.timer);
459
+ }
460
+ this._ringing.clear();
461
+ const call = this._call;
462
+ this._call = null;
463
+ if (call) {
464
+ call.endReason = 'destroyed';
465
+ if (call.callRoomId) {
466
+ this.connectionManager.unsubscribe(WebSocketPaths.getWebRTCDestination(call.callRoomId));
467
+ }
468
+ call.session?.disconnect().catch(error => this.logger.warn('Media disconnect failed:', error));
469
+ }
470
+ this.disableIncomingCalls();
471
+ this.removeAllListeners();
634
472
  }
635
473
 
636
- /**
637
- * 디바이스 전환
638
- * @param {string} deviceId
639
- * @param {string} kind - 'video' or 'audio'
640
- */
641
- async switchDevice(deviceId, kind) {
642
- // switchDevice가 localStream을 교체하므로, 교체 전 기존 트랙을 먼저 저장
643
- const oldTrack = this.mediaManager.getTrack(kind);
644
- const newTrack = await this.mediaManager.switchDevice(deviceId, kind);
474
+ // ==================== 내부: 통화 진행 ====================
645
475
 
646
- // 피어들에게 트랙 교체
647
- if (oldTrack) {
648
- await this.peerManager.replaceTrack(oldTrack, newTrack);
476
+ _beginCall({ kind, media, host, callRoomId = null }) {
477
+ if (this._closed) {
478
+ throw callError(ErrorTypes.INVALID_STATE, 'WebRTC client is destroyed');
649
479
  }
650
-
651
- return newTrack;
480
+ if (!this.userId) {
481
+ throw callError(ErrorTypes.INVALID_STATE, 'No signed-in user for calls (the signed-in user is changing)');
482
+ }
483
+ if (this._call) {
484
+ throw callError(ErrorTypes.CALL_ALREADY_ACTIVE, `Another call is active: ${this._call.callRoomId ?? '(creating)'}`);
485
+ }
486
+ const call = {
487
+ callRoomId,
488
+ kind,
489
+ host,
490
+ identity: this._identity,
491
+ state: null,
492
+ session: null,
493
+ media: { video: media?.video !== false, audio: media?.audio !== false },
494
+ /** 고른 장치(종류별 deviceId) — 다시 입장해도 새 연결이 이 장치로 붙는다. */
495
+ devices: {},
496
+ /** 카메라·마이크 바꾸기·장치 바꾸기의 호출 차례 — 실패한 호출은 마지막 호출일 때만 되돌린다. */
497
+ mediaTurn: { video: 0, audio: 0 },
498
+ deviceTurn: {},
499
+ created: null,
500
+ cancelRequested: false,
501
+ ending: null,
502
+ fromRing: false,
503
+ joinAttempted: false,
504
+ removedRejoinUsed: false,
505
+ endReason: null,
506
+ callOver: false
507
+ };
508
+ this._call = call;
509
+ return call;
652
510
  }
653
511
 
654
- /**
655
- * 비디오 상태 직접 설정
656
- * @public
657
- * @param {boolean} enabled
658
- */
659
- // noinspection JSUnusedGlobalSymbols
660
- setVideoEnabled(enabled) {
661
- this.mediaManager.setVideoEnabled(enabled);
662
- this._sendMediaState();
512
+ /** 방장 — 방을 만들고 입장한다. 만드는 동안 «취소»·«나가기» 가 오면 만든 뒤 그 메서드가 끝낸다. */
513
+ async _startHosted(call, body) {
514
+ let markCreated;
515
+ call.created = new Promise((resolve) => { markCreated = resolve; });
516
+ let room;
517
+ try {
518
+ room = await this._createRoom(call, body);
519
+ } catch (error) {
520
+ markCreated();
521
+ if (this._call === call) {
522
+ this._call = null;
523
+ }
524
+ throw error;
525
+ }
526
+ call.callRoomId = room.callRoomId;
527
+ markCreated();
528
+ if (this._call !== call) {
529
+ // 만드는 동안 이 기기에서만 끝났다(destroy·로그인 신원 끝남) — 서버에 아무것도 보내지 않는다. 알릴 방 id 가 없던 통화라 결과는
530
+ // 여기로만 알린다. 나가기·취소·모두 끝내기는 만들기를 기다려 아래 cancelRequested 로 온다.
531
+ throw callError(ErrorTypes.NO_ACTIVE_CALL, 'The call ended on this device before the room was created');
532
+ }
533
+ if (call.cancelRequested) {
534
+ // 만드는 동안 나가기·취소·모두 끝내기를 눌렀다 — 그 메서드가 이어서 끝낸다.
535
+ return call.callRoomId;
536
+ }
537
+ return this._enter(call);
663
538
  }
664
539
 
665
- /**
666
- * 오디오 상태 직접 설정
667
- * @public
668
- * @param {boolean} enabled
669
- */
670
- // noinspection JSUnusedGlobalSymbols
671
- setAudioEnabled(enabled) {
672
- this.mediaManager.setAudioEnabled(enabled);
673
- this._sendMediaState();
540
+ async _enter(call) {
541
+ const callRoomId = call.callRoomId;
542
+ this._setState(call, CallState.CONNECTING);
543
+ this._subscribeCallTopic(call);
544
+ try {
545
+ // 서버가 이미 끝냈다고 알린 통화(방 생성 응답보다 먼저 온 1:1 거절 등)에는 입장하지 않는다.
546
+ const endedBefore = this._ledger.endedReason(callRoomId);
547
+ if (endedBefore) {
548
+ await this._finish(call, endedBefore, { callOver: true });
549
+ return callRoomId;
550
+ }
551
+ const grant = await this._join(call);
552
+ if (this._call !== call) {
553
+ this._afterLateJoin(call);
554
+ return callRoomId;
555
+ }
556
+ call.kind = grant.kind ?? call.kind;
557
+ await this._connectMedia(call, grant.media, true);
558
+ return callRoomId;
559
+ } catch (error) {
560
+ if (this._call !== call) {
561
+ // 그 사이 끝났다(거절·취소·종료·나가기) — 결과는 callEnded 로 이미 알렸다.
562
+ return callRoomId;
563
+ }
564
+ await this._finish(call, 'failed', { error, abandon: 'start' });
565
+ throw error;
566
+ }
674
567
  }
675
568
 
676
569
  /**
677
- * 디바이스 변경 감지 시작
678
- * @public
570
+ * 통화방 토픽(종료 알림)을 구독한다 — 기다리지 않는다. STOMP 가 끊겨 있으면 구독은 다시 붙을 때까지 큐에 서는데, 미디어에는
571
+ * STOMP 가 필요 없고 놓친 종료는 미디어 방 삭제(room_deleted)로도 온다. 큐에 선 동안 통화가 끝나면 _finish 의 구독 해제가 큐에
572
+ * 닿지 않는다(ConnectionManager.unsubscribe 는 붙은 구독만 푼다) — 붙은 뒤에 그 통화가 지금 통화가 아니면 이 구독만 푼다. 끊긴 사이
573
+ * 같은 방 구독이 큐에 여럿 섰다가 함께 붙으면 맵에는 마지막 것만 있어, 목적지로 풀면 남의 구독을 풀고 이 구독은 남는다.
679
574
  */
680
- // noinspection JSUnusedGlobalSymbols
681
- startDeviceChangeDetection() {
682
- this.mediaManager.startDeviceChangeDetection();
683
- this.mediaManager.on('deviceChange', (devices) => {
684
- this.emit('deviceChange', devices);
685
- });
575
+ _subscribeCallTopic(call) {
576
+ const topic = WebSocketPaths.getWebRTCDestination(call.callRoomId);
577
+ Promise.resolve(this.connectionManager.subscribe(topic, (message) => this._handleEvent(message)))
578
+ .then((subscription) => {
579
+ if (this._call !== call) {
580
+ this.connectionManager.unsubscribe(topic, subscription);
581
+ }
582
+ })
583
+ .catch(error => this.logger.warn('Call topic subscription failed:', error));
686
584
  }
687
585
 
688
586
  /**
689
- * 디바이스 변경 감지 중지
690
- * @public
587
+ * 이 기기가 통화를 끝낸 뒤에 입장 응답이 왔다 — 서버에는 참가가 열렸다. 서버가 끝낸 통화가 아니면 나가기를 다시 보낸다(안 보내면
588
+ * 서버에 붙지 않는 참가가 접속 유예 동안 정원을 차지한다). 그 통화의 신원이 끝났으면 보내지 않는다. 그 사이 같은 통화에 다시
589
+ * 들어갔으면 보내지 않는다 — 서버 참가는 사용자마다 하나라 이 나가기가 새 참가를 닫는다.
691
590
  */
692
- // noinspection JSUnusedGlobalSymbols
693
- stopDeviceChangeDetection() {
694
- this.mediaManager.stopDeviceChangeDetection();
591
+ _afterLateJoin(call) {
592
+ if (call.callOver || this._closed || call.identity !== this._identity || this._call?.callRoomId === call.callRoomId) {
593
+ return;
594
+ }
595
+ this._trackExit(call.callRoomId, this.apiClient.post(`${ROOMS}/${call.callRoomId}/leave`))
596
+ .catch(error => this.logger.warn('Leave after a late join failed:', error));
695
597
  }
696
598
 
697
- /**
698
- * 비디오 해상도/프레임레이트 변경
699
- * @public
700
- * @param {Object} constraints - { width, height, frameRate }
701
- * @returns {Promise<void>}
702
- */
703
- // noinspection JSUnusedGlobalSymbols
704
- async applyVideoConstraints(constraints) {
705
- return this.mediaManager.applyVideoConstraints(constraints);
599
+ async _connectMedia(call, media, allowRejoin) {
600
+ const session = this._createMediaSession();
601
+ call.session = session;
602
+ this._wireSession(call, session);
603
+ try {
604
+ await session.connect(media.url, media.token, { video: call.media.video, audio: call.media.audio, devices: { ...call.devices } });
605
+ } catch (error) {
606
+ if (call.session === session) {
607
+ call.session = null;
608
+ }
609
+ if (this._call !== call) {
610
+ return;
611
+ }
612
+ // 접속 중에 같은 사람이 다른 기기로 들어왔거나(replaced) 미디어 방이 지워졌다 — 다시 입장하지 않고 그 사유로 끝낸다(나가기
613
+ // 없음). 다시 입장하면 방금 들어온 다른 기기를 밀어낸다.
614
+ const reason = error?.disconnectReason;
615
+ if (reason === 'replaced' || reason === 'room_deleted' || reason === 'room_closed') {
616
+ await this._finish(call, reason);
617
+ return;
618
+ }
619
+ if (!allowRejoin) {
620
+ throw callError(ErrorTypes.MEDIA_CONNECTION_FAILED, 'Failed to connect to the media server', error);
621
+ }
622
+ // 첫 접속 실패(방이 지워졌거나 토큰 마감 등) — 입장을 한 번 다시 부르면 서버가 방과 토큰을 다시 만든다(§5.3).
623
+ this.logger.warn('Media connect failed, joining again once:', error);
624
+ const grant = await this._join(call);
625
+ if (this._call === call) {
626
+ await this._connectMedia(call, grant.media, false);
627
+ } else {
628
+ this._afterLateJoin(call);
629
+ }
630
+ return;
631
+ }
632
+ if (this._call !== call || call.session !== session) {
633
+ await session.disconnect();
634
+ return;
635
+ }
636
+ // 접속이 끝날 때 LiveKit 이 재접속 중이면 아직 붙은 것이 아니다 — 다시 붙으면 세션의 reconnected 가 connected 로 바꾼다.
637
+ if (session.isReconnecting()) {
638
+ if (call.state !== CallState.RECONNECTING) {
639
+ this._setState(call, CallState.RECONNECTING);
640
+ }
641
+ return;
642
+ }
643
+ // 접속이 끝난 직후 끼어든 재접속 시작·끝 알림이 이미 connected 로 바꿨으면 다시 내지 않는다 — 상태 알림은 바뀔 때만.
644
+ if (call.state !== CallState.CONNECTED) {
645
+ this._setState(call, CallState.CONNECTED);
646
+ }
706
647
  }
707
648
 
708
- /**
709
- * 현재 비디오 설정 가져오기
710
- * @public
711
- * @returns {MediaTrackSettings|null}
712
- */
713
- // noinspection JSUnusedGlobalSymbols
714
- getVideoSettings() {
715
- return this.mediaManager.getVideoSettings();
649
+ _wireSession(call, session) {
650
+ const live = () => this._call === call && call.session === session;
651
+ const forward = (event) => session.on(event, (payload) => {
652
+ if (live()) this.emit(event, { callRoomId: call.callRoomId, ...payload });
653
+ });
654
+ ['participantJoined', 'participantLeft', 'trackSubscribed', 'trackUnsubscribed', 'localTrackPublished',
655
+ 'localTrackUnpublished', 'participantMediaChanged'].forEach(forward);
656
+ session.on('mediaError', ({ source, error }) => {
657
+ if (!live()) return;
658
+ // 통화에 붙을 때 켜던 장치가 실패했다(사용자가 그 뒤에 바꾸지 않았다) — 꺼진 것으로 기억한다. 다시 입장해도 누르지 않은 장치를
659
+ // 다시 켜지 않는다(다시 누르면 다시 시도).
660
+ const key = source === 'camera' ? 'video' : source === 'microphone' ? 'audio' : null;
661
+ if (key) {
662
+ call.media[key] = false;
663
+ call.mediaTurn[key] += 1;
664
+ }
665
+ const code = source === 'screen' ? ErrorTypes.SCREEN_SHARE_DENIED : ErrorTypes.MEDIA_ACCESS_DENIED;
666
+ this.emit('callError', { callRoomId: call.callRoomId, code, message: `Failed to enable ${source}`, error });
667
+ });
668
+ // LiveKit 이 스스로 옮긴 장치(빠진 장치를 다른 장치로 다시 열기·출력 전환)를 기억한다 — 다시 입장해도 없는 장치로 붙지 않게
669
+ // (undefined 면 기본 장치). 그 종류의 장치 바꾸기가 진행 중이면 세션이 알리지 않는다.
670
+ session.on('deviceInUseChanged', ({ kind, deviceId }) => {
671
+ if (!live()) return;
672
+ if (deviceId === undefined) {
673
+ delete call.devices[kind];
674
+ } else {
675
+ call.devices[kind] = deviceId;
676
+ }
677
+ });
678
+ // 상태 알림은 바뀔 때만 — reconnected 는 재접속 중일 때만 connected 로 바꾼다(접속 직후 한 칸에 끼어도 두 번 내지 않게).
679
+ session.on('reconnecting', () => {
680
+ if (live() && call.state !== CallState.RECONNECTING) this._setState(call, CallState.RECONNECTING);
681
+ });
682
+ session.on('reconnected', () => {
683
+ if (live() && call.state === CallState.RECONNECTING) this._setState(call, CallState.CONNECTED);
684
+ });
685
+ session.on('disconnected', ({ reason }) => {
686
+ if (!live()) return;
687
+ this._onMediaDisconnected(call, reason).catch(error => this.logger.error('Media disconnect handling failed:', error));
688
+ });
716
689
  }
717
690
 
718
- /**
719
- * 현재 오디오 설정 가져오기
720
- * @public
721
- * @returns {MediaTrackSettings|null}
722
- */
723
- // noinspection JSUnusedGlobalSymbols
724
- getAudioSettings() {
725
- return this.mediaManager.getAudioSettings();
691
+ async _onMediaDisconnected(call, reason) {
692
+ call.session = null;
693
+ switch (reason) {
694
+ case 'client':
695
+ // SDK 가 끊은 것만 client 로 온다 — 그때는 이미 live 가 아니라 여기 오지 않는다. 혹시 오면 끝난 것으로 본다.
696
+ return this._finish(call, 'left');
697
+ case 'replaced':
698
+ // 같은 사람이 다른 기기로 들어왔다 — 이 기기만 끝낸다. 나가기를 보내면 새 기기의 참가가 닫힌다.
699
+ return this._finish(call, 'replaced');
700
+ case 'room_deleted':
701
+ case 'room_closed':
702
+ return this._finish(call, reason);
703
+ case 'removed':
704
+ return this._rejoinAfterRemoval(call);
705
+ default:
706
+ this.emit('callRecoveryFailed', { callRoomId: call.callRoomId, reason });
707
+ return this._finish(call, 'connection_lost');
708
+ }
726
709
  }
727
710
 
728
- // ==================== 시그널링 ====================
729
-
730
711
  /**
731
- * 시그널링 구독
732
- * @private
712
+ * 서버가 연결을 뺐다 — 참가가 열려 있는데 «확인 → 제거» 창에 걸렸을 수 있다(설계 L6). 입장을 한 번 다시 부른다: 성공하면 새
713
+ * 토큰으로 다시 붙고(사용자가 끈 장치는 끈 채), 거부되면(참가가 닫혔거나 통화가 끝났다) 끝낸다 — 나가기는 보내지 않는다(닫을 참가가
714
+ * 없거나, 같은 사람의 다른 기기 참가다). 다시 연 참가에 미디어로 못 붙었으면 그 참가는 이 기기가 연 것이라 나가기로 닫는다 — 안
715
+ * 닫으면 접속 유예(admitGrace) 동안 1:1 상대가 기다리고 그룹 정원을 차지한다.
733
716
  */
734
- async _subscribeToSignaling(roomId, isGroup) {
735
- if (isGroup) {
736
- // 그룹 통화: /topic/webrtc/{roomId}
737
- const destination = WebSocketPaths.getWebRTCDestination(roomId);
738
- await this.connectionManager.subscribe(destination, (message) => {
739
- this._handleSignal(message);
740
- });
717
+ async _rejoinAfterRemoval(call) {
718
+ if (call.removedRejoinUsed) {
719
+ return this._finish(call, 'removed');
720
+ }
721
+ call.removedRejoinUsed = true;
722
+ this._setState(call, CallState.RECONNECTING, 'removed');
723
+ let grant;
724
+ try {
725
+ grant = await this._join(call);
726
+ } catch (error) {
727
+ if (this._call === call) {
728
+ await this._finish(call, 'removed', { error });
729
+ }
730
+ return;
731
+ }
732
+ if (this._call !== call) {
733
+ this._afterLateJoin(call);
734
+ return;
735
+ }
736
+ try {
737
+ await this._connectMedia(call, grant.media, false);
738
+ } catch (error) {
739
+ if (this._call === call) {
740
+ await this._finish(call, 'removed', { error, abandon: 'leave' });
741
+ }
741
742
  }
742
-
743
- // 1:1 또는 그룹 모두에서 개인 큐 구독
744
- await this._subscribeToDirectSignaling();
745
743
  }
746
744
 
747
745
  /**
748
- * 1:1 시그널링 구독
749
- * @private
746
+ * 이 기기의 통화를 끝낸다.
747
+ * @param {Object} call
748
+ * @param {string} reason
749
+ * @param {{error?: Error, callOver?: boolean, abandon?: 'start'|'leave'}} [options]
750
+ * callOver — 서버에서 통화가 끝났다(묘비를 남겨 늦은 초대가 다시 울리지 않게). 이 기기만 나간 것이면 남기지 않는다 — 다시
751
+ * 들어갈 수 있다. abandon — 이 기기의 실패를 서버에 알린다({@link #_abandon}).
750
752
  */
751
- async _subscribeToDirectSignaling() {
752
- const destination = WebSocketPaths.getWebRTCUserDestination();
753
- await this.connectionManager.subscribe(destination, (message) => {
754
- this._handleSignal(message);
755
- });
753
+ async _finish(call, reason, { error, callOver = false, abandon = null } = {}) {
754
+ if (this._call !== call) {
755
+ return;
756
+ }
757
+ this._call = null;
758
+ call.endReason = reason;
759
+ call.callOver = callOver;
760
+ if (callOver && call.callRoomId) {
761
+ this._ledger.markEnded(call.callRoomId, reason);
762
+ }
763
+ if (call.callRoomId) {
764
+ this.connectionManager.unsubscribe(WebSocketPaths.getWebRTCDestination(call.callRoomId));
765
+ }
766
+ // 미디어 끊기를 시작하되 끝남은 끊기(수백 ms)를 기다리지 않고 지금 알린다 — 그 사이 같은 통화에 다시 들어가도 이 알림이 새 통화의
767
+ // 알림보다 먼저 간다(늦게 가면 앱이 새 통화 화면을 지운다). 끊는 동안 옛 세션의 이벤트는 _wireSession 이 거른다.
768
+ const session = call.session;
769
+ call.session = null;
770
+ // 실패 정리(취소·나가기)는 끝남 알림보다 먼저 시작해 방별 추적에 올린다 — 알림 안에서 같은 통화에 다시 들어가도 새 입장은 이 정리
771
+ // 뒤에 나간다.
772
+ if (abandon) {
773
+ this._trackExit(call.callRoomId, this._abandon(call, abandon)).catch(cleanupError => this.logger.warn('Call cleanup failed:', cleanupError));
774
+ }
775
+ const disconnecting = session
776
+ ? session.disconnect().catch(disconnectError => this.logger.warn('Media disconnect failed:', disconnectError))
777
+ : null;
778
+ this._setState(call, CallState.ENDED, reason);
779
+ this.emit('callEnded', { callRoomId: call.callRoomId, reason });
780
+ if (error) {
781
+ this.emit('callError', { callRoomId: call.callRoomId, code: error.code ?? ErrorTypes.UNKNOWN_ERROR, message: error.message, error });
782
+ }
783
+ await disconnecting;
756
784
  }
757
785
 
758
786
  /**
759
- * 시그널링 구독 해제
760
- * @private
787
+ * 이 기기의 실패를 서버에 알린다 — 안 알리면 상대 폰이 울림 상한까지 울리거나(거는 쪽 실패), 상대가 접속 유예 동안 기다린다(받는
788
+ * 쪽 실패). 통화를 시작하다 실패했으면('start') 방장은 취소(아무도 안 들어왔을 때만 된다 — 409 면 나가기), 통화 중에 실패했으면
789
+ * ('leave') 나가기만 — 그룹 통화는 다른 사람에게 이어지고 1:1 은 서버가 끝낸다. 받는 쪽은 입장을 시도했으면 나가기. 실패해도 서버가
790
+ * 유예 뒤에 닫는다.
791
+ * @param {Object} call
792
+ * @param {'start'|'leave'} mode
761
793
  */
762
- _unsubscribeFromSignaling() {
763
- if (this.currentRoom && this.isGroupCall) {
764
- const destination = WebSocketPaths.getWebRTCDestination(this.currentRoom);
765
- this.connectionManager.unsubscribe(destination);
794
+ async _abandon(call, mode) {
795
+ if (!call.callRoomId) {
796
+ return;
766
797
  }
767
-
768
- // 수신 대기가 활성화된 경우 direct 채널 구독 유지 (그룹 통화 종료 후에도 수신 가능)
769
- if (!this._incomingCallsEnabled) {
770
- const directDestination = WebSocketPaths.getWebRTCUserDestination();
771
- this.connectionManager.unsubscribe(directDestination);
798
+ if (call.host && mode === 'start') {
799
+ try {
800
+ await this._send(call.identity, () => this.apiClient.post(`${ROOMS}/${call.callRoomId}/cancel`));
801
+ return;
802
+ } catch (error) {
803
+ if (statusOf(error) !== 409) {
804
+ throw error;
805
+ }
806
+ }
807
+ }
808
+ if (call.host || call.joinAttempted) {
809
+ await this._send(call.identity, () => this.apiClient.post(`${ROOMS}/${call.callRoomId}/leave`));
772
810
  }
773
811
  }
774
812
 
775
- /**
776
- * 시그널 전송
777
- * @private
778
- */
779
- _sendSignal(signal) {
780
- const payload = {
781
- type: signal.type,
782
- roomId: signal.roomId || this._groupRoomFor(signal),
783
- receiverId: signal.receiverId,
784
- data: signal.data
785
- };
786
-
787
- this.connectionManager.send(WebSocketPaths.WEBRTC_SIGNAL, payload);
788
- this.logger.debug('Signal sent:', payload);
813
+ _setState(call, state, reason) {
814
+ call.state = state;
815
+ this.emit('callStateChanged', reason ? { callRoomId: call.callRoomId, state, reason } : { callRoomId: call.callRoomId, state });
789
816
  }
790
817
 
791
- /**
792
- * 그룹 roomId 는 그룹 상대·그룹 방 신호에만 붙인다.
793
- * 1:1 신호에 붙으면 상대가 방 신호로 보고 버린다(그룹 통화 중 자동 BUSY 유실).
794
- * @private
795
- */
796
- _groupRoomFor({ type, receiverId }) {
797
- if (DIRECT_CONTROL_SIGNALS.has(type)) return undefined;
798
- if (receiverId && receiverId === this._directCallPeer) return undefined;
799
- return this.currentRoom || this._deferredGroupPeers.get(receiverId);
818
+ _whenCreated(call) {
819
+ if (call.callRoomId || !call.created) {
820
+ return Promise.resolve();
821
+ }
822
+ call.cancelRequested = true;
823
+ return call.created;
800
824
  }
801
825
 
802
826
  /**
803
- * 미디어 상태 전송
804
- * @private
827
+ * 방을 만드는 중의 끝내기(나가기·취소·모두 끝내기) — 처음 누른 것이 정하고 뒤에 누른 것은 그것을 기다린다. 방이 만들어지면 그
828
+ * 요청을 보내고(나가기면 이 기기에서 끝낸 뒤 취소), 성공하면 서버가 끝낸 통화로 끝낸다. 서버 요청이 실패해도 이 기기에서는 끝내고
829
+ * 정리(취소 → 409 면 나가기)를 보낸다 — 입장·미디어 없이 남아 다음 통화를 막지 않게. 응답을 기다리는 사이 다른 경로(나가기 등)로
830
+ * 이미 끝났으면 정리는 그 경로가 했다 — 다시 보내지 않는다(그 사이 같은 통화에 다시 들어갔으면 새 참가를 닫는다).
831
+ * @param {Object} call
832
+ * @param {string} reason
833
+ * @param {((callRoomId: string) => Promise<*>)|null} request - 나가기면 null
805
834
  */
806
- _sendMediaState() {
807
- if (!this.currentRoom && this.peerManager.getPeerIds().length === 0) {
808
- return;
835
+ _endWhileCreating(call, reason, request) {
836
+ if (!call.ending) {
837
+ call.ending = (async () => {
838
+ await this._whenCreated(call);
839
+ if (this._call !== call || !call.callRoomId) {
840
+ // 만들기가 실패했거나(callUser 가 그 오류로 끝난다) 그 사이 이 기기에서만 끝났다.
841
+ return;
842
+ }
843
+ const cleanup = async () => {
844
+ // 정리를 끝남 알림보다 먼저 시작해 방별 추적에 올린다(_finish 와 같은 이유).
845
+ const abandoning = this._trackExit(call.callRoomId, this._abandon(call, 'start'));
846
+ await this._finish(call, reason);
847
+ await abandoning.catch(error => this.logger.warn('Call cleanup failed:', error));
848
+ };
849
+ if (!request) {
850
+ await cleanup();
851
+ return;
852
+ }
853
+ try {
854
+ // 방별 추적에 올린다 — 응답 전에 나가고 같은 통화에 다시 들어가면 새 입장은 이 요청 뒤에 간다.
855
+ await this._trackExit(call.callRoomId, this._send(call.identity, () => request(call.callRoomId)));
856
+ } catch (error) {
857
+ if (this._call === call) {
858
+ await cleanup();
859
+ }
860
+ throw error;
861
+ }
862
+ await this._finish(call, reason, { callOver: true });
863
+ })();
809
864
  }
810
-
811
- const mediaState = {
812
- videoEnabled: this.mediaManager.isVideoEnabled(),
813
- audioEnabled: this.mediaManager.isAudioEnabled()
814
- };
815
-
816
- this._sendSignal({
817
- type: SignalTypes.VIDEO_STATE_CHANGED,
818
- data: mediaState
819
- });
820
-
821
- this.emit('mediaStateChanged', mediaState);
865
+ return call.ending;
822
866
  }
823
867
 
824
- /**
825
- * 시그널 처리
826
- * @private
827
- */
828
- async _handleSignal(message) {
829
- const { type, senderId, data } = message;
868
+ // ==================== 내부: 서버 이벤트 ====================
830
869
 
831
- // 자신의 시그널 무시
832
- if (senderId === this.userId) {
870
+ _handleEvent(message) {
871
+ const type = message?.type;
872
+ const data = message?.data ?? {};
873
+ const callRoomId = data.callRoomId ?? message?.roomId;
874
+ // 로그인 신원이 바뀌는 중(userId 없음) — 옛 연결로 온 알림은 버린다.
875
+ if (!type || !callRoomId || !this.userId) {
876
+ return;
877
+ }
878
+ const version = Number(data.version);
879
+ // 서버가 끝낸 통화(묘비)는 상태를 되살리지 않는다 — 초대는 버리고 끝내기는 다시 하지 않는다. 아직 알리지 않은 «일어난 일»(거절)은
880
+ // 알린다 — 같은 version 의 1:1 CALL_END 가 CALL_REJECT 보다 먼저 와도.
881
+ const ended = this._ledger.endedReason(callRoomId) !== null;
882
+ // 초대만 «지금 상태» 다 — 통화가 그 뒤로 나아갔으면(낡은 version) 울리지 않는다. 다른 이벤트는 «일어난 일» 이라 늦어도 받는다.
883
+ if (type === CallEventTypes.INVITATION && (ended || this._ledger.isStale(callRoomId, version))) {
884
+ return;
885
+ }
886
+ if (!this._ledger.record(callRoomId, version, type)) {
887
+ this.logger.debug('Duplicate call event ignored:', type, callRoomId, data.version);
833
888
  return;
834
889
  }
835
-
836
- if (this._holdsEarlyRoomReject(message)) return;
837
- if (!this._acceptsSignal(message)) return;
838
-
839
- this.logger.debug(`Signal received: ${type} from ${senderId}`);
840
-
841
890
  switch (type) {
842
- case SignalTypes.JOIN_ROOM:
843
- case SignalTypes.PEER_JOINED:
844
- await this._handleUserJoined(senderId, message.roomId);
845
- break;
846
-
847
- case SignalTypes.LEAVE_ROOM:
848
- case SignalTypes.PEER_LEFT:
849
- this._handleUserLeft(senderId);
850
- break;
851
-
852
- case SignalTypes.CALL_OFFER:
853
- await this._handleOffer(senderId, data.sdp, message.roomId);
854
- break;
855
-
856
- case SignalTypes.CALL_ANSWER:
857
- await this._handleAnswer(senderId, data.sdp);
858
- break;
859
-
860
- case SignalTypes.ICE_CANDIDATE:
861
- await this._handleIceCandidate(senderId, data.candidate);
862
- break;
863
-
864
- case SignalTypes.VIDEO_STATE_CHANGED:
865
- case SignalTypes.AUDIO_STATE_CHANGED:
866
- this._handleMediaState(senderId, data);
867
- break;
868
-
869
- case SignalTypes.CALL_REQUEST:
870
- // 통화 중이거나 통화 요청 대기 중이면 BUSY 응답
871
- if (this.isInCall() || this._waitingForCallAccept) {
872
- this._sendSignal({
873
- type: SignalTypes.CALL_BUSY,
874
- receiverId: senderId
875
- });
876
- this.emit('incomingCallWhileBusy', { callerId: senderId });
877
- } else {
878
- this._incomingCallPeer = senderId;
879
- this.emit('incomingCall', { callerId: senderId });
880
- }
881
- break;
882
-
883
- case SignalTypes.CALL_BUSY:
884
- // 상대방이 통화 중
885
- this._waitingForCallAccept = false;
886
- this._pendingCallTarget = null;
887
- this._callActive = false;
888
- this.mediaManager.stopAll();
889
- this.emit('callBusy', { userId: senderId });
890
- break;
891
-
892
- case SignalTypes.CALL_INVITATION: {
893
- // 그룹 통화 초대 (createCallRoom으로 초대받은 경우)
894
- const callRoomId = data?.callRoomId || message.roomId;
895
- // 취소 수락 기준은 서버가 채운 roomId·senderId(host)다. data 는 보낸 쪽이 정한 값이라 기억 키로 쓰지 않는다.
896
- if (message.roomId) this._rememberCallRoom(this._receivedInvitations, message.roomId, senderId);
897
- this.emit('callInvitation', {
898
- callRoomId,
899
- title: data?.title,
900
- hostUserId: data?.hostUserId || senderId,
901
- maxParticipants: data?.maxParticipants,
902
- createdAt: data?.createdAt
903
- });
904
- break;
905
- }
906
-
907
- case SignalTypes.CALL_ACCEPT:
908
- await this._handleCallAccepted(senderId);
909
- break;
910
-
911
- case SignalTypes.CALL_REJECT:
912
- if (message.roomId) {
913
- // 그룹 초대 거절(서버 REST 발신). host 의 통화·미디어는 그대로 둔다.
914
- this.emit('callRejected', { userId: senderId, roomId: message.roomId });
915
- break;
916
- }
917
- // 대기 상태 초기화
918
- this._waitingForCallAccept = false;
919
- this._pendingCallTarget = null;
920
- this._callActive = false;
921
- // 발신자 미디어 정리 (거절당했으므로 통화 불가)
922
- this.mediaManager.stopAll();
923
- this.emit('callRejected', { userId: senderId });
924
- break;
925
-
926
- case SignalTypes.CALL_CANCEL:
927
- if (message.roomId) {
928
- // 그룹 초대 취소(서버 REST 발신). 1:1 대기 상태는 건드리지 않는다.
929
- this._receivedInvitations.delete(message.roomId);
930
- this.emit('callCancelled', { userId: senderId, roomId: message.roomId });
931
- break;
932
- }
933
- this._incomingCallPeer = null;
934
- // 대기 상태 초기화
935
- this._waitingForCallAccept = false;
936
- this._pendingCallTarget = null;
937
- this.emit('callCancelled', { userId: senderId });
938
- break;
939
-
940
- case SignalTypes.CALL_END:
941
- if (data?.callEndScope === 'ROOM') {
942
- this._endCall(false); // _acceptsSignal에서 현재/참가 요청한 그룹 방 일치를 확인했다.
943
- } else {
944
- this._handleCallEnded(senderId);
945
- }
946
- break;
947
-
891
+ case CallEventTypes.INVITATION:
892
+ return this._onInvitation(callRoomId, message, data);
893
+ case CallEventTypes.ANSWERED:
894
+ // 그 사용자가 다른 기기에서 받았거나(그룹이면 거절도) 응답했다 — 이 기기 울림을 끈다.
895
+ if (!data.userId || data.userId === this.userId) this._stopRinging(callRoomId, 'answered_elsewhere');
896
+ return;
897
+ case CallEventTypes.REJECT:
898
+ this.emit('callRejected', { callRoomId, userId: data.rejectedBy ?? null, reason: data.reason ?? null });
899
+ if (data.kind === CallKind.DIRECT && !ended) this._endCall(callRoomId, data.reason ?? 'declined');
900
+ return;
901
+ case CallEventTypes.CANCEL:
902
+ if (ended) return;
903
+ // 1:1 취소는 통화가 끝난 것이다 — 받는 중(입장 진행 중)이어도 끝낸다. 그룹 취소는 그 사람의 울림만.
904
+ if (data.kind === CallKind.DIRECT) this._endCall(callRoomId, data.reason ?? 'cancelled');
905
+ else this._stopRinging(callRoomId, data.reason ?? 'cancelled');
906
+ return;
907
+ case CallEventTypes.END:
908
+ if (!ended) this._endCall(callRoomId, data.reason ?? 'ended');
909
+ return;
948
910
  default:
949
- this.logger.warn(`Unknown signal type: ${type}`);
911
+ this.logger.debug('Unknown call event ignored:', type);
950
912
  }
951
913
  }
952
914
 
953
- /** 미디어를 공유할 상대/방은 로컬 참가·발신·수락으로만 결정한다. */
954
- _acceptsSignal({ type, senderId, roomId }) {
955
- if (!senderId) return false;
956
- if (type === SignalTypes.CALL_INVITATION) return true;
957
- if (type === SignalTypes.CALL_REQUEST) return !roomId;
958
- // 그룹 초대 거절·취소는 서버가 roomId 를 붙여 보낸다. 내가 연 방·받은 초대의 host 것만 받는다.
959
- if (roomId && type === SignalTypes.CALL_REJECT) return this._hostedCallRooms.has(roomId);
960
- if (roomId && type === SignalTypes.CALL_CANCEL) return this._receivedInvitations.get(roomId) === senderId;
961
- if ([SignalTypes.CALL_ACCEPT, SignalTypes.CALL_BUSY, SignalTypes.CALL_REJECT].includes(type)) {
962
- return !roomId && this._waitingForCallAccept && senderId === this._pendingCallTarget;
963
- }
964
- if (type === SignalTypes.CALL_CANCEL) return !roomId && senderId === this._incomingCallPeer;
965
- // 수락한 1:1 상대의 신호는 roomId 없이 온다. 그룹 통화 중에 수락했어도 받는다.
966
- if (!roomId && senderId === this._directCallPeer) return true;
967
- const groupRoom = this.isGroupCall ? this.currentRoom
968
- : (!this._callActive && !this._waitingForCallAccept ? this._groupJoin?.roomId || this._joinedGroupRoom : null);
969
- if (groupRoom) return roomId === groupRoom;
970
- return !roomId && senderId === this._pendingCallTarget;
971
- }
972
-
973
- /** 방 생성 응답 전에 온 그룹 거절은 응답의 방 ID 로 판정할 수 있을 때까지 보관한다. */
974
- _holdsEarlyRoomReject(message) {
975
- const { type, roomId } = message;
976
- if (type !== SignalTypes.CALL_REJECT || !roomId || this._pendingRoomCreations === 0
977
- || this._hostedCallRooms.has(roomId)) return false;
978
- this._earlyRoomRejects.push(message);
979
- if (this._earlyRoomRejects.length > MAX_REMEMBERED_CALL_ROOMS) this._earlyRoomRejects.shift();
980
- return true;
981
- }
982
-
983
- _rememberCallRoom(rooms, roomId, userId) {
984
- rooms.delete(roomId);
985
- rooms.set(roomId, userId);
986
- if (rooms.size > MAX_REMEMBERED_CALL_ROOMS) rooms.delete(rooms.keys().next().value);
987
- }
988
-
989
- _clearDeferredGroup(roomId) {
990
- for (const [peerId, peerRoom] of this._deferredGroupPeers) {
991
- if (peerRoom !== roomId) continue;
992
- this.peerManager.closePeerConnection(peerId);
993
- this.participants.delete(peerId);
994
- this._pendingIceCandidates.delete(peerId);
995
- this._deferredGroupPeers.delete(peerId);
915
+ _onInvitation(callRoomId, message, data) {
916
+ if (this._ledger.isRingStopped(callRoomId) || this._ringing.has(callRoomId) || this._call?.callRoomId === callRoomId) {
917
+ return;
918
+ }
919
+ const ring = {
920
+ callRoomId,
921
+ kind: data.kind ?? null,
922
+ callerId: data.hostUserId ?? message.senderId ?? null,
923
+ title: data.title ?? null,
924
+ timer: setTimeout(() => this._stopRinging(callRoomId, 'timeout'), this.ringTimeoutMs)
925
+ };
926
+ ring.timer.unref?.();
927
+ this._ringing.set(callRoomId, ring);
928
+ this.emit('incomingCall', {
929
+ callRoomId,
930
+ kind: ring.kind,
931
+ callerId: ring.callerId,
932
+ callerName: message.nickName ?? null,
933
+ callerProfileUrl: message.profileUrl ?? null,
934
+ title: ring.title
935
+ });
936
+ // incomingCall 안에서 바로 거절·받기를 했으면(통화 중 BUSY 거절·자동 응답) 울림은 이미 끝났다 — ringing 을 내지 않는다(내면 끝난
937
+ // 통화의 마지막 상태가 ringing 이 된다).
938
+ if (this._ringing.get(callRoomId) === ring) {
939
+ this.emit('callStateChanged', { callRoomId, state: CallState.RINGING });
996
940
  }
997
941
  }
998
942
 
999
- /**
1000
- * 사용자 입장 처리
1001
- * @private
1002
- */
1003
- async _handleUserJoined(userId, roomId) {
1004
- this.participants.set(userId, { joinedAt: new Date() });
1005
-
1006
- // userId 비교로 Polite/Impolite 결정 (작은 쪽이 Polite)
1007
- // Perfect Negotiation에서 충돌 시 한 쪽만 양보하도록 보장
1008
- const polite = this.userId < userId;
1009
- const defer = !!roomId && !this.peerManager.localStream;
1010
- this.peerManager.createPeerConnection(userId, polite, { skipAutoNegotiation: defer });
1011
- if (defer) this._deferredGroupPeers.set(userId, roomId);
1012
-
1013
- this.emit('userJoined', { userId });
1014
- this.logger.info(`User joined: ${userId} (I am ${polite ? 'polite' : 'impolite'})`);
1015
- }
1016
-
1017
- /**
1018
- * 사용자 퇴장 처리
1019
- * @private
1020
- */
1021
- _handleUserLeft(userId) {
1022
- this.participants.delete(userId);
1023
- this.peerManager.closePeerConnection(userId);
1024
-
1025
- this.emit('userLeft', { userId });
1026
- this.logger.info(`User left: ${userId}`);
943
+ /** 서버가 통화를 끝냈다 — 묘비를 남기고, 울리던 것이면 울림을 끄고, 이 기기가 그 통화에 있으면(입장 진행 중 포함) 끝낸다. */
944
+ _endCall(callRoomId, reason) {
945
+ this._ledger.markEnded(callRoomId, reason);
946
+ this._stopRinging(callRoomId, reason);
947
+ if (this._call?.callRoomId === callRoomId) {
948
+ this._finish(this._call, reason, { callOver: true }).catch(error => this.logger.error('Call finish failed:', error));
949
+ }
1027
950
  }
1028
951
 
1029
952
  /**
1030
- * Offer 처리
1031
- * @private
953
+ * 울림을 끈다. 받아서 끈 게 아니면 이 기기에서 그 통화가 끝난 것이다 — callStateChanged(ended)·callEnded 도 낸다(울림 시작에
954
+ * ringing 을 냈으니 짝을 맞춘다).
1032
955
  */
1033
- async _handleOffer(senderId, sdp, roomId = this.isGroupCall ? this.currentRoom : null) {
1034
- // 피어 연결이 없으면 생성
1035
- if (!this.peerManager.getPeerConnection(senderId)) {
1036
- // userId 비교로 Polite/Impolite 결정
1037
- const polite = this.userId < senderId;
1038
- // skipAutoNegotiation: true - offer를 먼저 처리한 후 answer 전송
1039
- // 트랙 추가 시 자동 negotiation이 발생하면 offer 충돌 발생
1040
- this.peerManager.createPeerConnection(senderId, polite, { skipAutoNegotiation: true });
1041
- // REST join의 PEER_JOINED는 startCall보다 먼저 올 수 있다.
1042
- // 메시지의 roomId로 그룹을 식별하되, 1:1 수동 협상에는 적용하지 않는다.
1043
- if (roomId && !this.peerManager.localStream) this._deferredGroupPeers.set(senderId, roomId);
1044
- }
1045
-
1046
- await this.peerManager.handleRemoteDescription(senderId, sdp);
1047
- await this._resumeGroupNegotiation(senderId);
1048
-
1049
- // remoteDescription 설정 후 대기 중인 ICE candidates 처리
1050
- await this._processPendingIceCandidates(senderId);
1051
- }
1052
-
1053
- async _resumeGroupNegotiation(peerId) {
1054
- if (!this.isGroupCall || this._deferredGroupPeers.get(peerId) !== this.currentRoom
1055
- || !this.peerManager.localStream) return;
1056
- if (await this.peerManager.resumeAutoNegotiation(peerId)) this._deferredGroupPeers.delete(peerId);
956
+ _stopRinging(callRoomId, reason, accepted = false) {
957
+ this._ledger.stopRinging(callRoomId);
958
+ const ring = this._ringing.get(callRoomId);
959
+ if (!ring) {
960
+ return;
961
+ }
962
+ clearTimeout(ring.timer);
963
+ this._ringing.delete(callRoomId);
964
+ this.emit('ringStopped', { callRoomId, reason });
965
+ if (!accepted) {
966
+ this.emit('callStateChanged', { callRoomId, state: CallState.ENDED, reason });
967
+ this.emit('callEnded', { callRoomId, reason });
968
+ }
1057
969
  }
1058
970
 
1059
- /** 재구독은 참가 이력을 복구하지 않는다. 동일 사용자의 다른 기기 참가도 이 조회에 반영된다. */
1060
- async _recoverGroupParticipation() {
1061
- if (!this.isGroupCall || !this.currentRoom || this._groupRecovery) return;
1062
- this._cancelGroupRecoveryRetry();
1063
- const roomId = this.currentRoom;
1064
- const generation = this._callGeneration;
1065
- const stillCurrent = () => this.currentRoom === roomId && this._callGeneration === generation;
1066
- const recovery = (async () => {
971
+ async _recheckRinging() {
972
+ const identity = this._identity;
973
+ for (const callRoomId of [...this._ringing.keys()]) {
1067
974
  try {
1068
- const response = await this.apiClient.get(`/api/v1/webrtc/rooms/${roomId}/participation`);
1069
- if (!stillCurrent()) return;
1070
- if (!response.data.participating) {
1071
- await this.joinCallRoomApi(roomId);
1072
- if (!stillCurrent()) return;
1073
- this._sendSignal({ type: SignalTypes.JOIN_ROOM, roomId });
975
+ const participation = await this.getParticipation(callRoomId);
976
+ if (identity !== this._identity) {
977
+ return;
978
+ }
979
+ if (participation?.ring !== 'RINGING') {
980
+ this._stopRinging(callRoomId, 'stale');
1074
981
  }
1075
- this._groupRecoveryAttempts = 0;
1076
982
  } catch (error) {
1077
- if (!stillCurrent()) return;
1078
- if ([403, 404, 409].includes(error?.status ?? error?.response?.status)) {
1079
- // 확정 실패만 로컬 종료한다. 다른 기기 참가를 지우는 LEAVE는 보내지 않는다.
1080
- this._endCall(false);
1081
- this.emit('callRecoveryFailed', { roomId, error });
983
+ if (identity !== this._identity) {
984
+ return;
985
+ }
986
+ if (statusOf(error) === 404) {
987
+ this._stopRinging(callRoomId, 'ended');
1082
988
  } else {
1083
- // 일시 오류 동안 미디어·peer·구독을 유지하고 1s→2s→…→30s 간격으로 재시도한다.
1084
- const delay = Math.min(30000, 1000 * 2 ** Math.min(this._groupRecoveryAttempts++, 5));
1085
- this._groupRecoveryTimer = setTimeout(() => {
1086
- this._groupRecoveryTimer = null;
1087
- if (stillCurrent()) this._onReconnected();
1088
- }, delay);
1089
- this._groupRecoveryTimer.unref?.();
1090
- this.logger.warn('Call participation recovery will retry:', error);
989
+ this.logger.warn('Ring re-check failed, keeping the ring until timeout:', error);
1091
990
  }
1092
991
  }
1093
- })();
1094
- this._groupRecovery = recovery;
1095
- try { await recovery; }
1096
- finally { if (this._groupRecovery === recovery) this._groupRecovery = null; }
1097
- }
1098
-
1099
- _cancelGroupRecoveryRetry() {
1100
- clearTimeout(this._groupRecoveryTimer);
1101
- this._groupRecoveryTimer = null;
1102
- }
1103
-
1104
- /**
1105
- * Answer 처리
1106
- * @private
1107
- */
1108
- async _handleAnswer(senderId, sdp) {
1109
- await this.peerManager.handleRemoteDescription(senderId, sdp);
1110
- // remoteDescription 설정 후 대기 중인 ICE candidates 처리
1111
- await this._processPendingIceCandidates(senderId);
992
+ }
1112
993
  }
1113
994
 
1114
- /**
1115
- * ICE Candidate 처리
1116
- * @private
1117
- */
1118
- async _handleIceCandidate(senderId, candidate) {
1119
- // peer connection이 아직 없으면 큐에 저장
1120
- if (!this.peerManager.getPeerConnection(senderId)) {
1121
- this._queueIceCandidate(senderId, candidate);
1122
- return;
1123
- }
995
+ // ==================== 내부: REST ====================
1124
996
 
1125
- // addIceCandidate가 false 반환하면 (remoteDescription 미설정) 큐에 저장
1126
- const added = await this.peerManager.addIceCandidate(senderId, new RTCIceCandidate(candidate));
1127
- if (!added) {
1128
- this._queueIceCandidate(senderId, candidate);
997
+ async _createRoom(call, body) {
998
+ // 만들기는 멱등이 아니다(보낼 때마다 새 방) — 다시 보내지 않는다. 서버도 만들기에는 CALL_CONCURRENT_UPDATE 를 내지 않는다(insert).
999
+ this._requireIdentity(call.identity);
1000
+ const room = unwrap(await this.apiClient.post(ROOMS, body));
1001
+ if (!room?.callRoomId) {
1002
+ throw callError(ErrorTypes.API_ERROR, 'Call room creation returned no callRoomId');
1129
1003
  }
1130
- }
1131
-
1132
- /**
1133
- * ICE candidate를 큐에 저장
1134
- * @private
1135
- */
1136
- _queueIceCandidate(peerId, candidate) {
1137
- if (!this._pendingIceCandidates.has(peerId)) {
1138
- this._pendingIceCandidates.set(peerId, []);
1004
+ this._ledger.observe(room.callRoomId, Number(room.version));
1005
+ return room;
1006
+ }
1007
+
1008
+ async _join(call) {
1009
+ // 같은 방의 나가기·취소·거절·모두 끝내기·정리 요청이 하나도 남지 않을 때까지 기다린다(this._exits) — 기다리는 사이 새로 오른
1010
+ // 요청(지금 통화의 모두 끝내기 등)도 기다린다.
1011
+ for (let pending = this._exits.get(call.callRoomId); pending;) {
1012
+ await pending;
1013
+ if (this._call !== call) {
1014
+ // 기다리는 사이 끝났다 — 입장을 보내지 않는다(보내면 나간 참가를 되살린다).
1015
+ throw callError(ErrorTypes.NO_ACTIVE_CALL, 'The call ended before joining');
1016
+ }
1017
+ const next = this._exits.get(call.callRoomId);
1018
+ pending = next === pending ? null : next;
1139
1019
  }
1140
- this._pendingIceCandidates.get(peerId).push(candidate);
1141
- this.logger.debug(`ICE candidate queued for ${peerId}`);
1142
- }
1143
-
1144
- /**
1145
- * 대기 중인 ICE candidate 처리
1146
- * @private
1147
- */
1148
- async _processPendingIceCandidates(peerId) {
1149
- const candidates = this._pendingIceCandidates.get(peerId);
1150
- if (!candidates || candidates.length === 0) {
1151
- return;
1020
+ if (this._call !== call) {
1021
+ throw callError(ErrorTypes.NO_ACTIVE_CALL, 'The call ended before joining');
1152
1022
  }
1153
-
1154
- this.logger.debug(`Processing ${candidates.length} pending ICE candidates for ${peerId}`);
1155
-
1156
- for (const candidate of candidates) {
1157
- await this.peerManager.addIceCandidate(peerId, new RTCIceCandidate(candidate));
1023
+ call.joinAttempted = true;
1024
+ // 그 사이 이 기기가 통화를 끝냈으면 409 재시도를 멈춘다 — 다시 보내면 나간 참가를 되살린다.
1025
+ const grant = unwrap(await this._send(call.identity, () => this.apiClient.post(`${ROOMS}/${call.callRoomId}/join`),
1026
+ () => this._call === call));
1027
+ if (!grant?.media?.url || !grant?.media?.token) {
1028
+ throw callError(ErrorTypes.API_ERROR, 'Join returned no media grant');
1158
1029
  }
1159
-
1160
- this._pendingIceCandidates.delete(peerId);
1030
+ this._ledger.observe(call.callRoomId, Number(grant.version));
1031
+ return grant;
1161
1032
  }
1162
1033
 
1163
1034
  /**
1164
- * 미디어 상태 처리
1165
- * @private
1035
+ * 서버 요청 — 그 요청을 시작한 신원이 지금 신원일 때만 보낸다(다시 보낼 때마다 다시 본다). 409 CALL_CONCURRENT_UPDATE(서버가 이미
1036
+ * 16번 시도한 뒤)는 같은 요청을 다시 보낸다(§5.3).
1037
+ * @param {number} identity - 요청을 시작한 신원(통화면 call.identity)
1038
+ * @param {() => Promise<*>} request
1039
+ * @param {() => boolean} [stillWanted] - 다시 보내기 전에 확인 — false 면 마지막 오류를 던진다
1166
1040
  */
1167
- _handleMediaState(userId, state) {
1168
- this.emit('participantMediaState', {
1169
- userId,
1170
- videoEnabled: state.videoEnabled,
1171
- audioEnabled: state.audioEnabled
1172
- });
1041
+ async _send(identity, request, stillWanted = () => true) {
1042
+ for (let attempt = 1; ; attempt++) {
1043
+ this._requireIdentity(identity);
1044
+ try {
1045
+ return await request();
1046
+ } catch (error) {
1047
+ if (attempt >= CONFLICT_ATTEMPTS || statusOf(error) !== 409 || error?.response?.code !== CONFLICT_CODE) {
1048
+ throw error;
1049
+ }
1050
+ await new Promise(resolve => setTimeout(resolve, 50 + Math.floor(Math.random() * 150)));
1051
+ if (!stillWanted()) {
1052
+ throw error;
1053
+ }
1054
+ }
1055
+ }
1173
1056
  }
1174
1057
 
1175
1058
  /**
1176
- * 통화 수락 처리 (발신자 측에서 CALL_ACCEPT 수신 시)
1177
- * @private
1059
+ * 그 방의 나가기·취소·거절·정리 요청을 추적에 올린다 — 끝나면(성공·실패) 지운다. 받은 요청을 그대로 돌려준다(오류도 그대로).
1060
+ * @param {string|null} callRoomId
1061
+ * @param {Promise<*>} request
1178
1062
  */
1179
- async _handleCallAccepted(userId) {
1180
- this._directCallPeer = userId;
1181
- // 대기 상태 초기화
1182
- this._waitingForCallAccept = false;
1183
- this._pendingCallTarget = null;
1184
-
1185
- // 이제 PeerConnection 생성 (Impolite - 발신자)
1186
- // skipAutoNegotiation: true - 트랙 추가 시 자동 negotiation 건너뛰고 수동으로 offer 전송
1187
- this.peerManager.createPeerConnection(userId, false, { skipAutoNegotiation: true });
1188
-
1189
- // Offer 생성 및 전송
1190
- const offer = await this.peerManager.createOffer(userId);
1191
- this._sendSignal({
1192
- type: SignalTypes.CALL_OFFER,
1193
- receiverId: userId,
1194
- data: { sdp: offer }
1063
+ _trackExit(callRoomId, request) {
1064
+ if (!callRoomId) {
1065
+ return request;
1066
+ }
1067
+ const settled = request.then(() => {}, () => {});
1068
+ const previous = this._exits.get(callRoomId);
1069
+ const all = previous ? Promise.all([previous, settled]).then(() => {}) : settled;
1070
+ this._exits.set(callRoomId, all);
1071
+ all.then(() => {
1072
+ if (this._exits.get(callRoomId) === all) {
1073
+ this._exits.delete(callRoomId);
1074
+ }
1195
1075
  });
1196
-
1197
- this.emit('callAccepted', { userId });
1076
+ return request;
1198
1077
  }
1199
1078
 
1200
- /**
1201
- * 통화 종료 처리
1202
- * @private
1203
- */
1204
- _handleCallEnded(userId) {
1205
- this.peerManager.closePeerConnection(userId);
1206
- this.participants.delete(userId);
1207
- // 끝난 1:1 상대는 더 이상 선택한 상대가 아니다. 그룹 통화가 남아도 그의 roomId 없는 신호를 받지 않는다.
1208
- if (userId === this._directCallPeer) this._directCallPeer = null;
1209
-
1210
- this.emit('participantLeft', { userId });
1211
-
1212
- // 모든 참여자가 나갔으면 통화 종료
1213
- if (this.peerManager.getPeerIds().length === 0) {
1214
- this.endCall();
1079
+ _requireIdentity(identity) {
1080
+ if (this._closed || identity !== this._identity || !this.userId) {
1081
+ throw callError(ErrorTypes.INVALID_STATE,
1082
+ 'Call request dropped: the signed-in identity changed or is changing, or the client is destroyed');
1215
1083
  }
1216
1084
  }
1217
1085
 
1218
- // ==================== 유틸리티 ====================
1086
+ // ==================== 내부: 확인 ====================
1219
1087
 
1220
- /**
1221
- * 현재 통화 중인지 확인
1222
- * @returns {boolean}
1223
- */
1224
- isInCall() {
1225
- return this.currentRoom !== null || this.peerManager.getPeerIds().length > 0;
1226
- }
1227
-
1228
- /**
1229
- * 현재 통화방 ID
1230
- * @returns {string|null}
1231
- */
1232
- getCurrentRoom() {
1233
- return this.currentRoom;
1088
+ _requireCall() {
1089
+ if (!this._call) {
1090
+ throw callError(ErrorTypes.NO_ACTIVE_CALL, 'No active call');
1091
+ }
1092
+ return this._call;
1234
1093
  }
1235
1094
 
1236
- /**
1237
- * 참여자 목록
1238
- * @returns {string[]}
1239
- */
1240
- getParticipants() {
1241
- return Array.from(this.participants.keys());
1095
+ _requireSession() {
1096
+ const session = this._requireCall().session;
1097
+ if (!session) {
1098
+ throw callError(ErrorTypes.INVALID_STATE, 'Media is not connected');
1099
+ }
1100
+ return session;
1242
1101
  }
1243
1102
 
1244
- /**
1245
- * 연결 상태 요약
1246
- * @public
1247
- * @returns {Object}
1248
- */
1249
- // noinspection JSUnusedGlobalSymbols
1250
- getConnectionSummary() {
1251
- return this.peerManager.getConnectionSummary();
1103
+ async _mediaCall(source, action) {
1104
+ const session = this._requireSession();
1105
+ try {
1106
+ return await action(session);
1107
+ } catch (error) {
1108
+ // 미디어가 아직 붙지 않았다(화면 공유는 접속이 끝난 뒤에만) — 권한 거부로 보이지 않게 그대로 던진다.
1109
+ if (error?.code === ErrorTypes.INVALID_STATE) {
1110
+ throw error;
1111
+ }
1112
+ const code = source === 'screen' ? ErrorTypes.SCREEN_SHARE_DENIED : ErrorTypes.MEDIA_ACCESS_DENIED;
1113
+ throw callError(code, `Failed to change ${source}`, error);
1114
+ }
1252
1115
  }
1116
+ }
1253
1117
 
1254
- /**
1255
- * 특정 피어의 연결 통계 가져오기 (디버깅/모니터링용)
1256
- * @public
1257
- * @param {string} peerId - 피어 ID
1258
- * @returns {Promise<RTCStatsReport|null>}
1259
- */
1260
- // noinspection JSUnusedGlobalSymbols
1261
- async getStats(peerId) {
1262
- return this.peerManager.getStats(peerId);
1118
+ function requireCallRoomId(callRoomId) {
1119
+ if (!callRoomId) {
1120
+ throw new Error('callRoomId is required');
1263
1121
  }
1122
+ }
1264
1123
 
1265
- /**
1266
- * 미디어 상태
1267
- * @returns {Object}
1268
- */
1269
- getMediaState() {
1270
- return {
1271
- videoEnabled: this.mediaManager.isVideoEnabled(),
1272
- audioEnabled: this.mediaManager.isAudioEnabled(),
1273
- streamInfo: this.mediaManager.getStreamInfo()
1274
- };
1275
- }
1124
+ function unwrap(response) {
1125
+ return response && typeof response === 'object' && 'data' in response ? response.data : response;
1126
+ }
1276
1127
 
1277
- /**
1278
- * 로그 레벨 설정
1279
- * @param {number} level
1280
- */
1281
- setLogLevel(level) {
1282
- this.logger.setLevel(level);
1283
- this.mediaManager.setLogLevel(level);
1284
- this.peerManager.setLogLevel(level);
1285
- }
1128
+ function statusOf(error) {
1129
+ return error?.status ?? error?.response?.status;
1130
+ }
1286
1131
 
1287
- /**
1288
- * 리소스 정리
1289
- */
1290
- destroy() {
1291
- this.connectionManager.off?.('reconnected', this._onReconnected);
1292
- this.endCall();
1293
- this.mediaManager.destroy();
1294
- this.peerManager.destroy();
1295
- this.removeAllListeners();
1296
- this.logger.info('WebRTCClient destroyed');
1132
+ function callError(code, message, cause) {
1133
+ const error = new Error(message);
1134
+ error.code = code;
1135
+ if (cause) {
1136
+ error.cause = cause;
1297
1137
  }
1138
+ return error;
1298
1139
  }
1299
1140
 
1300
1141
  export default WebRTCClient;