@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.
- package/README.md +3 -2
- package/dist/index.d.ts +775 -155
- package/dist/talkflow-sdk.esm.js +1 -1
- package/dist/talkflow-sdk.esm.js.map +1 -1
- package/dist/talkflow-sdk.standalone.js +1 -1
- package/dist/talkflow-sdk.standalone.js.map +1 -1
- package/dist/talkflow-sdk.umd.js +1 -1
- package/dist/talkflow-sdk.umd.js.map +1 -1
- package/package.json +4 -2
- package/src/TalkFlowClient.js +13 -5
- package/src/chat/ChatClient.js +192 -0
- package/src/constants.js +39 -41
- package/src/core/ConnectionManager.js +11 -1
- package/src/index.js +4 -4
- package/src/talkflow/delegates.js +22 -23
- package/src/talkflow/eventForwarding.js +11 -23
- package/src/talkflow/session.js +37 -4
- package/src/utils/ApiClient.js +45 -41
- package/src/webrtc/CallLedger.js +135 -0
- package/src/webrtc/LiveKitMediaSession.js +1568 -0
- package/src/webrtc/WebRTCClient.js +909 -1068
- package/types/__typetests__/call.types.ts +101 -0
- package/types/__typetests__/pmReports.types.ts +55 -0
- package/types/__typetests__/scheduledReports.types.ts +138 -0
- package/types/index.d.ts +775 -155
- package/src/webrtc/MediaStreamManager.js +0 -478
- package/src/webrtc/PeerConnectionManager.js +0 -476
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WebRTCClient
|
|
3
|
-
*
|
|
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
|
|
9
|
-
import
|
|
10
|
-
import { WebSocketPaths,
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
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
|
|
37
|
-
|
|
52
|
+
this.logLevel = options.logLevel ?? LogLevel.WARN;
|
|
38
53
|
this.logger = new Logger(this.logLevel, 'WebRTCClient');
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
this.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
this.
|
|
50
|
-
|
|
51
|
-
this.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.
|
|
67
|
-
this.
|
|
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.
|
|
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
|
-
*
|
|
87
|
-
* 통화 시작 전 호출하면 더 빠른 연결 가능
|
|
91
|
+
* 통화 알림 수신 — 개인 큐({@code /user/queue/webrtc})를 구독한다. connect() 가 부른다.
|
|
88
92
|
* @returns {Promise<void>}
|
|
89
93
|
*/
|
|
90
|
-
async
|
|
91
|
-
if (this.
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
// ====================
|
|
134
|
+
// ==================== 통화 시작·응답 ====================
|
|
169
135
|
|
|
170
136
|
/**
|
|
171
|
-
*
|
|
172
|
-
* @
|
|
137
|
+
* 1:1 통화를 건다 — 방을 만들고 입장해 미디어에 붙은 채 상대를 기다린다.
|
|
138
|
+
* @param {string} targetUserId
|
|
139
|
+
* @param {{video?: boolean, audio?: boolean}} [media]
|
|
140
|
+
* @returns {Promise<string>} callRoomId
|
|
173
141
|
*/
|
|
174
|
-
async
|
|
175
|
-
const
|
|
176
|
-
return
|
|
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
|
-
* @
|
|
182
|
-
* @
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
this.
|
|
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
|
-
* @
|
|
208
|
-
* @param {
|
|
209
|
-
* @returns {Promise<
|
|
158
|
+
* 울리는 통화를 받는다. 이 기기의 울림을 끄고 입장한다 — 다른 기기는 서버 «응답함» 으로 울림을 끈다.
|
|
159
|
+
* @param {string} callRoomId
|
|
160
|
+
* @param {{video?: boolean, audio?: boolean}} [media]
|
|
161
|
+
* @returns {Promise<string>}
|
|
210
162
|
*/
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
* @
|
|
219
|
-
* @param {
|
|
220
|
-
* @returns {Promise<
|
|
181
|
+
* 그룹 통화에 들어가거나 다시 들어간다(초대 수락·재참가·이 기기에서 끊긴 뒤 복귀).
|
|
182
|
+
* @param {string} callRoomId
|
|
183
|
+
* @param {{video?: boolean, audio?: boolean}} [media]
|
|
184
|
+
* @returns {Promise<string>}
|
|
221
185
|
*/
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
* @
|
|
243
|
-
* @param {
|
|
244
|
-
* @returns {Promise<Object>}
|
|
191
|
+
* 울리는 통화를 거절한다.
|
|
192
|
+
* @param {string} callRoomId
|
|
193
|
+
* @param {{reason?: 'DECLINED'|'BUSY'}} [options]
|
|
245
194
|
*/
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
*
|
|
260
|
-
* 개인 WebRTC 채널(/user/queue/webrtc)을 구독하여 초대 알림 수신
|
|
261
|
-
* @public
|
|
262
|
-
* @returns {Promise<void>}
|
|
205
|
+
* 건 통화를 취소한다(방장, 상대가 입장하기 전). 방을 만드는 중이면 만든 뒤 바로 취소한다.
|
|
206
|
+
* @param {string} [callRoomId] - 생략하면 지금 통화
|
|
263
207
|
*/
|
|
264
|
-
async
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
this.
|
|
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
|
-
*
|
|
226
|
+
* 지금 통화에서 나간다 — 이 기기의 미디어를 먼저 끊기 시작하고 같은 틱에 서버에 나가기를 보낸다. 서버 요청이 실패하면 던지지만
|
|
227
|
+
* 이 기기의 통화는 이미 끝났다(서버는 끊김 유예 뒤 닫는다). 방을 만드는 중이면 만든 뒤 끝내고 취소를 보낸다.
|
|
278
228
|
*/
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
*
|
|
293
|
-
*
|
|
246
|
+
* 나가기에 보낼 서버 요청. 방장이거나 입장을 보냈으면 나가기. 받는 쪽이 입장을 보내기 전에 나가면 서버 참가가 없어 나가기는 아무것도
|
|
247
|
+
* 하지 않는다 — 울리던 초대를 받은 것이면 거절(DECLINED)로 서버 초대를 정리하고(안 하면 다른 기기가 울림 상한까지 울리고 방장이
|
|
248
|
+
* 기다린다), 울림 없이 다시 들어가던 중이면 보낼 것이 없다.
|
|
249
|
+
* @returns {(() => Promise<*>)|null}
|
|
294
250
|
*/
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
*
|
|
429
|
-
*
|
|
284
|
+
* 카메라·마이크를 켜고 끈다 — 통화가 있으면 언제든(방을 만드는 중·입장 중·다시 입장 중 포함). 의도를 «먼저» 기억한다 — 미디어 연결이
|
|
285
|
+
* 아직 없으면 의도만 적고 끝나며, 다음 연결이 이 의도로 붙는다(접속 중인 연결은 세션이 마지막 의도로 맞춘다). 연결에서 못 바꿨고 아직
|
|
286
|
+
* 그 연결이면 그 연결이 실제로 쓰는 상태로 되돌린다 — 앞선 호출도 실패했으면 그 의도(한 번도 적용되지 않은 값)가 아니라(그 사이 다시
|
|
287
|
+
* 붙었으면 새 연결이 이 의도로 붙었다).
|
|
430
288
|
*/
|
|
431
|
-
async
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
501
|
-
this.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
382
|
+
* 내 참가 상태 — {@code {callRoomId, participating, ring}}. ring 은 초대 대상의 울림 상태(RINGING 이면 아직 울리는 중).
|
|
383
|
+
* @param {string} callRoomId
|
|
576
384
|
*/
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
395
|
+
/** 지금 통화 — 없으면 null. 방을 만드는 중이면 callRoomId 가 null 이다. */
|
|
396
|
+
getCurrentCall() {
|
|
397
|
+
const call = this._call;
|
|
398
|
+
if (!call) {
|
|
399
|
+
return null;
|
|
594
400
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
621
|
-
*
|
|
421
|
+
* 통화를 건 로그인 신원이 끝났다(로그아웃·사용자·회사 전환·세션 지워짐) — TalkFlowClient 가 부른다. 이 기기에서만 정리하고, 그
|
|
422
|
+
* 신원으로 시작한 요청은 더 보내지 않는다(진행 중인 재시도·만들기·입장 응답 뒤의 정리 포함). 로그인 세션은 클라이언트에 알리기 전에
|
|
423
|
+
* 토큰을 바꾸므로(AuthSession._accept) 그 뒤에 보내는 요청은 새 신원으로 간다. 서버는 미디어가 끊긴 뒤 끊김 유예가 지나면 참가를
|
|
424
|
+
* 닫는다. 같은 인스턴스는 다음 신원으로 계속 쓴다(토큰 직접 방식의 로그아웃 뒤 setToken).
|
|
622
425
|
*/
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
*
|
|
450
|
+
* 자원 정리 — 서버에 아무것도 보내지 않고, 뒤에 부르는 통화 시작도 막는다. 로그아웃·사용자 전환은 TalkFlowClient 가 그 전에
|
|
451
|
+
* 나가기를 보내거나(자격이 아직 지금 신원일 때) 이 기기에서만 끝낸다(_resetIdentity).
|
|
631
452
|
*/
|
|
632
|
-
|
|
633
|
-
|
|
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 (
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
*
|
|
570
|
+
* 통화방 토픽(종료 알림)을 구독한다 — 기다리지 않는다. STOMP 가 끊겨 있으면 구독은 다시 붙을 때까지 큐에 서는데, 미디어에는
|
|
571
|
+
* STOMP 가 필요 없고 놓친 종료는 미디어 방 삭제(room_deleted)로도 온다. 큐에 선 동안 통화가 끝나면 _finish 의 구독 해제가 큐에
|
|
572
|
+
* 닿지 않는다(ConnectionManager.unsubscribe 는 붙은 구독만 푼다) — 붙은 뒤에 그 통화가 지금 통화가 아니면 이 구독만 푼다. 끊긴 사이
|
|
573
|
+
* 같은 방 구독이 큐에 여럿 섰다가 함께 붙으면 맵에는 마지막 것만 있어, 목적지로 풀면 남의 구독을 풀고 이 구독은 남는다.
|
|
679
574
|
*/
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
this.
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
*
|
|
587
|
+
* 이 기기가 통화를 끝낸 뒤에 입장 응답이 왔다 — 서버에는 참가가 열렸다. 서버가 끝낸 통화가 아니면 나가기를 다시 보낸다(안 보내면
|
|
588
|
+
* 서버에 붙지 않는 참가가 접속 유예 동안 정원을 차지한다). 그 통화의 신원이 끝났으면 보내지 않는다. 그 사이 같은 통화에 다시
|
|
589
|
+
* 들어갔으면 보내지 않는다 — 서버 참가는 사용자마다 하나라 이 나가기가 새 참가를 닫는다.
|
|
691
590
|
*/
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
*
|
|
712
|
+
* 서버가 연결을 뺐다 — 참가가 열려 있는데 «확인 → 제거» 창에 걸렸을 수 있다(설계 L6). 입장을 한 번 다시 부른다: 성공하면 새
|
|
713
|
+
* 토큰으로 다시 붙고(사용자가 끈 장치는 끈 채), 거부되면(참가가 닫혔거나 통화가 끝났다) 끝낸다 — 나가기는 보내지 않는다(닫을 참가가
|
|
714
|
+
* 없거나, 같은 사람의 다른 기기 참가다). 다시 연 참가에 미디어로 못 붙었으면 그 참가는 이 기기가 연 것이라 나가기로 닫는다 — 안
|
|
715
|
+
* 닫으면 접속 유예(admitGrace) 동안 1:1 상대가 기다리고 그룹 정원을 차지한다.
|
|
733
716
|
*/
|
|
734
|
-
async
|
|
735
|
-
if (
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
*
|
|
749
|
-
* @
|
|
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
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
*
|
|
787
|
+
* 이 기기의 실패를 서버에 알린다 — 안 알리면 상대 폰이 울림 상한까지 울리거나(거는 쪽 실패), 상대가 접속 유예 동안 기다린다(받는
|
|
788
|
+
* 쪽 실패). 통화를 시작하다 실패했으면('start') 방장은 취소(아무도 안 들어왔을 때만 된다 — 409 면 나가기), 통화 중에 실패했으면
|
|
789
|
+
* ('leave') 나가기만 — 그룹 통화는 다른 사람에게 이어지고 1:1 은 서버가 끝낸다. 받는 쪽은 입장을 시도했으면 나가기. 실패해도 서버가
|
|
790
|
+
* 유예 뒤에 닫는다.
|
|
791
|
+
* @param {Object} call
|
|
792
|
+
* @param {'start'|'leave'} mode
|
|
761
793
|
*/
|
|
762
|
-
|
|
763
|
-
if (
|
|
764
|
-
|
|
765
|
-
this.connectionManager.unsubscribe(destination);
|
|
794
|
+
async _abandon(call, mode) {
|
|
795
|
+
if (!call.callRoomId) {
|
|
796
|
+
return;
|
|
766
797
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
*
|
|
827
|
+
* 방을 만드는 중의 끝내기(나가기·취소·모두 끝내기) — 처음 누른 것이 정하고 뒤에 누른 것은 그것을 기다린다. 방이 만들어지면 그
|
|
828
|
+
* 요청을 보내고(나가기면 이 기기에서 끝낸 뒤 취소), 성공하면 서버가 끝낸 통화로 끝낸다. 서버 요청이 실패해도 이 기기에서는 끝내고
|
|
829
|
+
* 정리(취소 → 409 면 나가기)를 보낸다 — 입장·미디어 없이 남아 다음 통화를 막지 않게. 응답을 기다리는 사이 다른 경로(나가기 등)로
|
|
830
|
+
* 이미 끝났으면 정리는 그 경로가 했다 — 다시 보내지 않는다(그 사이 같은 통화에 다시 들어갔으면 새 참가를 닫는다).
|
|
831
|
+
* @param {Object} call
|
|
832
|
+
* @param {string} reason
|
|
833
|
+
* @param {((callRoomId: string) => Promise<*>)|null} request - 나가기면 null
|
|
805
834
|
*/
|
|
806
|
-
|
|
807
|
-
if (!
|
|
808
|
-
|
|
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
|
-
|
|
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
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
case
|
|
849
|
-
this.
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
case
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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.
|
|
911
|
+
this.logger.debug('Unknown call event ignored:', type);
|
|
950
912
|
}
|
|
951
913
|
}
|
|
952
914
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
:
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
if (
|
|
977
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
*
|
|
1031
|
-
*
|
|
953
|
+
* 울림을 끈다. 받아서 끈 게 아니면 이 기기에서 그 통화가 끝난 것이다 — callStateChanged(ended)·callEnded 도 낸다(울림 시작에
|
|
954
|
+
* ringing 을 냈으니 짝을 맞춘다).
|
|
1032
955
|
*/
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
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
|
|
1069
|
-
if (
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
this.
|
|
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 (
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
this.
|
|
983
|
+
if (identity !== this._identity) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (statusOf(error) === 404) {
|
|
987
|
+
this._stopRinging(callRoomId, 'ended');
|
|
1082
988
|
} else {
|
|
1083
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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.
|
|
1141
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1030
|
+
this._ledger.observe(call.callRoomId, Number(grant.version));
|
|
1031
|
+
return grant;
|
|
1161
1032
|
}
|
|
1162
1033
|
|
|
1163
1034
|
/**
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
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
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
*
|
|
1177
|
-
* @
|
|
1059
|
+
* 그 방의 나가기·취소·거절·정리 요청을 추적에 올린다 — 끝나면(성공·실패) 지운다. 받은 요청을 그대로 돌려준다(오류도 그대로).
|
|
1060
|
+
* @param {string|null} callRoomId
|
|
1061
|
+
* @param {Promise<*>} request
|
|
1178
1062
|
*/
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
return
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
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;
|