@vibexnpm/talkx 8.13.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.
@@ -0,0 +1,1568 @@
1
+ /**
2
+ * LiveKitMediaSession
3
+ * LiveKit Room 하나를 감싸 SDK 이벤트로 바꾼다(화상통화 SFU 설계 v0.7 §5.3·§8). 통화 제어(입장·나가기)는 하지 않는다 — 그것은
4
+ * {@link WebRTCClient} 가 서버 REST 로 한다.
5
+ *
6
+ * <p>livekit-client 는 통화를 시작할 때 불러온다 — 채팅만 쓰는 화면의 번들에 넣지 않으려고. ESM 빌드는 이 모듈을 외부 의존으로 두고
7
+ * 동적 import 로 남겨, 앱 번들러가 따로 떼어 낸다.</p>
8
+ *
9
+ * 내보내는 이벤트:
10
+ * <ul>
11
+ * <li>{@code participantJoined} / {@code participantLeft} — {@code {userId}}. Room 이 끊기면(서버) 남은 사람마다 participantLeft
12
+ * 를 낸 뒤 disconnected 를 낸다 — livekit 은 원격 참가자를 이벤트 없이 비운다. livekit 은 재접속 중의 입장·트랙 구독·음소거
13
+ * 알림을 모아 두었다가 다시 붙은 뒤 내고 퇴장·트랙 해제는 바로 낸다 — 늦게 온 알림은 그 사람(그 연결)이 아직 Room 에 있고 그
14
+ * 구독이 아직 붙어 있을 때만 낸다(그 사이 나간 사람·해제된 트랙을 되살리지 않는다).</li>
15
+ * <li>{@code trackSubscribed} / {@code trackUnsubscribed} — {@code {userId, source, track}}</li>
16
+ * <li>{@code localTrackPublished} / {@code localTrackUnpublished} — {@code {source, track}}. 카메라·마이크의 게시는 그 장치의 지금
17
+ * 트랙만 낸다 — 새 트랙으로 바뀐 옛 트랙이 늦게 게시되는 것(전체 재접속의 다시 게시)은 내지 않는다. 게시를 낸 트랙은 내려질 때
18
+ * (그 사이 바뀐 트랙이 됐어도) 짝으로 내림을 낸다.</li>
19
+ * <li>{@code participantMediaChanged} — {@code {userId, camera, microphone, screen}} (내 것이면 userId 는 null)</li>
20
+ * <li>{@code reconnecting} / {@code reconnected} — LiveKit 전체 재접속(Reconnecting → Reconnected). Room 접속이 끝난 뒤에만 낸다
21
+ * (장치를 켜는 동안에도 낸다) — Room 접속 중에 재접속을 시작했으면 connect 가 끝날 때 {@link #isReconnecting} 이 true 고, 다시
22
+ * 붙으면 reconnected 가 온다. 시그널만 다시 붙는 것(SignalReconnecting)은 둘 다 내지 않는다.</li>
23
+ * <li>{@code disconnected} — {@code {reason}}: client·replaced·removed·room_deleted·room_closed·server_shutdown·connection_lost.
24
+ * <b>접속에 성공한 뒤에만</b> 낸다 — 접속 실패는 {@link #connect} 가 던진다(livekit-client 는 실패한 접속에도 Disconnected 를
25
+ * 먼저 낸다, 실제 LiveKit 으로 확인). client 는 {@link #disconnect} 로 끊은 것만이다 — livekit-client 가 스스로 끊은 것(페이지
26
+ * 떠남·탭 얼림 때 CLIENT_INITIATED)은 connection_lost 다.</li>
27
+ * <li>{@code mediaError} — {@code {source, error}} (장치 권한 거부 등, 연결은 유지 — livekit 이 스스로 끈 잃은 장치도)</li>
28
+ * <li>{@code devicesChanged}</li>
29
+ * <li>{@code deviceInUseChanged} — {@code {kind, deviceId}}: livekit 이 스스로 옮긴 장치(끝난 트랙을 다른 장치로 다시 열기·기기
30
+ * 변화로 입력·출력 전환 — 꺼진 카메라·음소거한 마이크는 다음 켜기 때 열 장치). deviceId 가 undefined 면 기본 장치 — 부른 쪽(통화)이
31
+ * 고른 장치 기억을 맞춘다.</li>
32
+ * </ul>
33
+ */
34
+
35
+ import EventEmitter from '../utils/EventEmitter.js';
36
+ import Logger from '../utils/Logger.js';
37
+ import { ErrorTypes, LogLevel } from '../constants.js';
38
+
39
+ /** LiveKit DisconnectReason(숫자) → SDK 사유. 표에 없는 값(신호 끊김·시간 초과·미디어 실패 등)은 connection_lost. */
40
+ const DISCONNECT_REASONS = {
41
+ 1: 'client', // CLIENT_INITIATED
42
+ 2: 'replaced', // DUPLICATE_IDENTITY — 같은 사람이 다른 기기로 들어왔다
43
+ 3: 'server_shutdown', // SERVER_SHUTDOWN
44
+ 4: 'removed', // PARTICIPANT_REMOVED — 서버가 연결을 뺐다
45
+ 5: 'room_deleted', // ROOM_DELETED
46
+ 10: 'room_closed' // ROOM_CLOSED
47
+ };
48
+
49
+ /** LiveKit Track.Source → SDK source. */
50
+ const SOURCES = {
51
+ camera: 'camera',
52
+ microphone: 'microphone',
53
+ screen_share: 'screen',
54
+ screen_share_audio: 'screen_audio'
55
+ };
56
+
57
+ /** 접속 때 장치를 켜는 순서 — 마이크 권한 창이 먼저 뜬다. */
58
+ const DEVICES = ['microphone', 'camera'];
59
+
60
+ const defaultLoader = () => import('livekit-client');
61
+
62
+ class LiveKitMediaSession extends EventEmitter {
63
+ /**
64
+ * @param {Object} [options]
65
+ * @param {number} [options.logLevel]
66
+ * @param {() => Promise<Object>} [options.loadLiveKit] - livekit-client 모듈을 돌려준다(시험은 가짜 Room 을 넣는다)
67
+ */
68
+ constructor({ logLevel = LogLevel.WARN, loadLiveKit = defaultLoader } = {}) {
69
+ super();
70
+ this.logger = new Logger(logLevel, 'LiveKitMediaSession');
71
+ this._loadLiveKit = loadLiveKit;
72
+ this._lk = null;
73
+ this._room = null;
74
+ /** 이 세션이 disconnect() 로 끊은 Room — 그 Room 의 CLIENT_INITIATED 만 «내가 끊음» 이다. */
75
+ this._closing = new WeakSet();
76
+ /** disconnect() 뒤 — 세션은 한 번 쓰고 버린다. 접속 중에 끊으라고 했으면 그 뒤로 접속·장치 켜기를 하지 않는다. */
77
+ this._disposed = false;
78
+ /**
79
+ * Room 접속이 끝나기 전(livekit-client 불러오기·room.connect) — 그동안 카메라·마이크 켜고 끄기는 의도만 적고 접속이 끝나면
80
+ * 반영한다. 화면 공유는 INVALID_STATE, 재접속 알림은 내지 않는다.
81
+ */
82
+ this._connecting = false;
83
+ /**
84
+ * 장치마다 의도와 처리 결과 — version 은 의도가 바뀔 때마다 오르고, done 은 마지막으로 처리한 의도(version·값·성공)다. 장치마다
85
+ * 반영 고리가 따로 돌고(_applyLoop), 한 장치는 한 번에 하나씩 반영한다 — 반영하는 동안 바뀐 의도는 그 반영이 끝난 뒤 마지막
86
+ * 것으로 맞춘다(권한 창이 떠 있는 동안 끈 장치가 응답 뒤에 켜진 채 남지 않게). 실패한 의도는 새 의도가 올 때만 다시 반영한다.
87
+ * 장치 잠금(lock)은 그 장치의 livekit 호출(켜고 끄기·장치 바꾸기)을 부른 순서대로 한 번에 하나씩 돌린다 — livekit 의 끄기(muteLock)와
88
+ * 장치 옮기기(trackChangeLock)는 잠금이 달라, 옮기는 중에 끈 카메라의 새 트랙이 켜진 채 남는다. 고리가 livekit 을 부르는 동안 바뀐
89
+ * 의도는 그 순간 다음 반영의 자리를 잡는다(그 뒤에 부른 장치 바꾸기가 먼저 돌지 않게). 마이크 끄기만 잠금 밖이다 — livekit 은
90
+ * 마이크를 음소거해도 멈추지 않고, 옮기는 중의 음소거를 새 트랙에 이어 준다(잠그면 새 마이크가 음소거 전에 소리를 낸다).
91
+ * <p>장치마다 이 세션의 트랙 전부(tracks — livekit 이 연 순서)와 그 장치의 지금 트랙(current — 게시된 것 중 가장 늦게 연 트랙)을
92
+ * 가진다. livekit 이 트랙을 여는 순간(켜기의 처음 획득 — 이 세션의 createTracks) 소유로 적어, 게시를 기다리는 중(다시 게시·재접속
93
+ * 대기)에 세션이 끝나도 그 자리에서 멈춘다. livekit 은 같은 장치의 트랙을 둘 만들 수 있다 — 전체 재접속의 다시 게시가 옛 트랙을
94
+ * 다시 여는 사이(다른 트랙의 실패로 다시 게시가 먼저 끝났다) 켜면 게시가 없어 새 트랙을 만들고, 옛 트랙은 그 뒤에 다시 게시된다.
95
+ * 지금 트랙보다 먼저 연 트랙(바뀐 트랙)은 보이는 대로(새 트랙이 지금 트랙이 될 때·늦게 다시 열리거나 게시될 때·켜고 끄기 전후)
96
+ * 멈추고 게시를 내린다(_collect) — livekit 의 켜고 끄기는 소스로 게시 하나만 찾아, 남겨 두면 끈 뒤에도 바뀐 트랙이 보낸다.</p>
97
+ */
98
+ this._devices = { microphone: deviceState(), camera: deviceState() };
99
+ /**
100
+ * 처음 획득(이 Room 에 그 장치 게시가 없는 켜기) 줄 — 권한 창을 한 번에 하나씩 띄운다(접속 때는 마이크 먼저). 끄기·게시된 장치
101
+ * 다시 켜기(livekit 의 음소거 풀기)는 줄에 서지 않는다 — 다른 장치의 권한 창이 떠 있어도 음소거·음소거 풀기·카메라 끄기는 바로
102
+ * 간다. 줄에서 기다리는 사이 그 장치를 끄면 줄에서 빠지고, 차례가 오면 의도를 다시 읽는다(그 사이 끈 장치는 획득하지 않는다).
103
+ */
104
+ this._acquireTail = Promise.resolve();
105
+ /**
106
+ * 스피커의 장치 잠금 — 스피커 바꾸기를 부른 순서대로 하나씩 돌린다(앞선 호출의 늦은 결과가 뒤에 고른 출력을 덮지 않게). 출력은
107
+ * SDK 가 참가자마다 바꾸고 모두 끝난 뒤 판정한다(_switchSpeaker).
108
+ */
109
+ this._speaker = { lock: Promise.resolve() };
110
+ /**
111
+ * 화면 공유 — 마지막 의도(wanted·version), 떠 있는 공유 창(picking), 마지막에 고른 화면(latest), 이 세션이 잡은 공유 화면
112
+ * 트랙 전부(owned — 공유 창에서 받은 순간부터 세션이 끝날 때까지: 게시가 잠깐 없어도, 멈춘 뒤에도 — livekit 의 전체 재접속은
113
+ * 게시를 내렸다 멈춘 트랙까지 다시 올린다), 정리 줄(turn). 무엇이 일어나든(켜기·끄기·공유 창 결과·게시 끝·다시 게시·재접속 끝)
114
+ * 같은 정리(_reconcileScreen)로 «켜짐이면 latest 만 게시, 나머지 잡은 화면은 내리고 멈춤» 에 맞춘다 — 세션이 끝나면 잡은 화면을
115
+ * 모두 그 자리에서 멈춘다. 공유 창은 사용자 조작 안에서만 열려 부른 자리에서 바로 연다 — livekit 의 끄기는 공유 창을 10초까지만
116
+ * 기다리고 그 뒤 고른 화면은 그대로 게시되므로, 공유 창과 게시는 SDK 가 따로 부른다. shared 는 SDK 가 게시를 마친 마지막 화면
117
+ * (재접속 중에는 livekit 이 다시 게시한다), failure 는 마지막 화면의 게시 실패(게시를 시도한 쪽이 앞선 호출·사건이어도 마지막
118
+ * 켜기가 받는다), waiting 은 결과를 기다리는 마지막 켜기의 의도 번호(없으면 실패를 mediaError 로 알린다). publishing 은 게시 줄의
119
+ * 마지막 일({latest, done}) — 정리 줄은 게시를 기다리지 않는다(livekit 의 게시는 재접속·다시 게시·협상을 기다린다 ≈32279–32282).
120
+ */
121
+ this._screen = { wanted: false, version: 0, picking: null, latest: null, owned: new Set(), turn: Promise.resolve(), shared: null,
122
+ failure: null, waiting: null, publishing: null };
123
+ /** 고른 장치(종류별 deviceId) — Room 을 만들 때 기본 장치로 쓴다(livekit 은 고른 장치를 그 Room 의 옵션에만 적는다). */
124
+ this._chosen = {};
125
+ /** 종류별 고른 차례 — 못 바꾼 선택을 되돌릴 때 그 뒤에 고른 장치를 덮지 않게. */
126
+ this._chosenSeq = {};
127
+ /** 종류별 진행 중인 장치 바꾸기 수 — 그동안 livekit 의 쓰는 장치 알림은 기억에 들이지 않는다(그 바꾸기의 결과가 맞춘다). */
128
+ this._switching = {};
129
+ /** 앱에 게시를 알린(localTrackPublished) 카메라·마이크 트랙 — 내려질 때 짝으로 localTrackUnpublished 를 낸다. */
130
+ this._shownTracks = new WeakSet();
131
+ /** LiveKit 이 재접속 중(Reconnecting 뒤, Reconnected·Disconnected 전). */
132
+ this._reconnecting = false;
133
+ /** 지금 재접속이 끝나면(Reconnected·세션 끝) 풀린다 — 재접속 중의 화면 공유 켜기·장치 바꾸기가 다시 붙은 뒤에 돌게. */
134
+ this._reconnection = Promise.resolve();
135
+ this._reconnectionDone = null;
136
+ /** 이 세션이 알린 원격 참가자 — Room 이 끊기면 남은 사람마다 participantLeft 를 낸다(livekit 은 내지 않고 비운다). */
137
+ this._announced = new Set();
138
+ /** Room 접속 중에 끊긴 사유(같은 사람의 다른 기기·방 삭제) — connect 가 던지는 오류에 싣는다. */
139
+ this._failedReason = null;
140
+ /**
141
+ * 세션이 끝나면 풀린다 — 기다리던 켜고 끄기·획득 차례·장치 잠금, 진행 중인 장치 바꾸기·화면 공유를 끝낸다(늦게 끝나는 livekit
142
+ * 호출의 결과는 버린다).
143
+ */
144
+ this._ended = new Promise((resolve) => { this._markEnded = resolve; });
145
+ }
146
+
147
+ /**
148
+ * 미디어 서버에 붙는다 — Room 접속이 끝나면 돌아온다. 카메라·마이크는 그 뒤에 의도대로 켠다(장치 권한 창이 오래 떠 있어도 접속은
149
+ * 끝난다). 장치 권한이 거부돼도 연결은 유지하고 {@code mediaError} 를 낸다. 접속 중에 바꾼 의도·고른 장치도 반영한다.
150
+ * @param {string} url - 서버가 입장 응답에 실은 미디어 서버 주소
151
+ * @param {string} token - 입장 토큰
152
+ * @param {{video?: boolean, audio?: boolean, devices?: Object<string, string>}} [media] - devices: 종류별(audioinput·videoinput·audiooutput)
153
+ * 고른 장치 id
154
+ * @throws 미디어 서버 접속 실패(livekit-client ConnectionError)
155
+ */
156
+ async connect(url, token, { video = true, audio = true, devices = {} } = {}) {
157
+ this._intend('microphone', audio);
158
+ this._intend('camera', video);
159
+ this._chosen = { ...devices };
160
+ this._connecting = true;
161
+ let room;
162
+ let connected = false;
163
+ try {
164
+ // 첫 통화는 livekit-client 청크를 내려받는 동안 기다린다 — 그 사이 끊으라고 했으면(나가기·로그아웃) 청크를 기다리지 않고 바로
165
+ // 끝내고 Room 을 만들지 않는다(망이 멈춰도 입장 호출이 묶이지 않게 — 청크는 다음 통화를 위해 계속 받는다).
166
+ const lk = await untilEnded(this._module(), this._ended, null);
167
+ this._requireNotDisposed();
168
+ room = new lk.Room({ adaptiveStream: true, dynacast: true, ...captureOptions(this._chosen) });
169
+ this._room = room;
170
+ this._wire(room, lk, () => connected);
171
+ try {
172
+ await room.connect(url, token);
173
+ // 접속 중에 끊겼으면(disconnect() 나 서버) 장치를 켜지 않는다.
174
+ if (!this._isCurrent(room)) {
175
+ throw new Error('Media session was disconnected while connecting');
176
+ }
177
+ } catch (error) {
178
+ if (this._room === room) {
179
+ this._room = null;
180
+ }
181
+ // 접속 중 트랙 알림으로 알린 사람의 짝 — 접속이 끝나는 순간 끊으면 livekit 의 Disconnected 는 리스너를 지운 뒤에 온다.
182
+ this._leaveAnnounced();
183
+ room.removeAllListeners?.();
184
+ // 접속 중에 끊긴 사유(같은 사람의 다른 기기·방 삭제)를 오류에 싣는다 — 부른 쪽이 «첫 접속 실패 → 입장 한 번 더» 와 구별한다.
185
+ if (this._failedReason && error && typeof error === 'object') {
186
+ error.disconnectReason = this._failedReason;
187
+ }
188
+ throw error;
189
+ }
190
+ connected = true;
191
+ } finally {
192
+ this._connecting = false;
193
+ }
194
+ // 먼저 와 있던 사람은 ParticipantConnected 가 오지 않는다 — 여기서 알린다(접속 중 트랙 알림으로 이미 알렸으면 건너뛴다). 접속은
195
+ // 끝났으니 이 알림 안에서 부른 화면 공유도 된다. 그 사람들의 트랙은 구독이 끝나면 TrackSubscribed 로 온다.
196
+ for (const participant of room.remoteParticipants.values()) {
197
+ if (!this._isCurrent(room)) {
198
+ return;
199
+ }
200
+ if (this._announce(participant)) {
201
+ this._emitMedia(participant);
202
+ }
203
+ }
204
+ // 장치는 접속 뒤에 장치마다 맞춘다 — 권한 창이 오래 떠 있어도 connected·통화 시작 응답을 막지 않는다(접속 중에 바꾼 의도도 여기서).
205
+ this._kickDevices();
206
+ }
207
+
208
+ /** 미디어 서버에서 나간다(로컬 트랙도 멈춘다). 접속 중이면 접속을 멈춘다. 이미 나갔으면 할 일이 없다. */
209
+ async disconnect() {
210
+ this._disposed = true;
211
+ this._end();
212
+ const room = this._room;
213
+ this._room = null;
214
+ if (!room) {
215
+ return;
216
+ }
217
+ this._closing.add(room);
218
+ try {
219
+ await room.disconnect(true);
220
+ } finally {
221
+ room.removeAllListeners?.();
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 카메라를 켜고 끈다. Room 접속이 끝나기 전이면 의도만 적고 바로 끝난다 — 접속 뒤 그 의도로 맞춘다. 접속 뒤에는 그 의도를 반영하면
227
+ * 끝난다(켜기는 권한 응답 뒤) — 그 사이 다시 부르면 마지막 의도로 맞추고 앞선 호출은 그대로 끝난다. 그 의도의 반영이 실패하면 던진다.
228
+ * 끊긴 세션은 의도만 적는다(다음 연결은 부른 쪽이 가진 의도로 붙는다) — 기다리던 호출도 그때 끝난다.
229
+ * @param {boolean} enabled
230
+ */
231
+ async setCameraEnabled(enabled) {
232
+ await this._setDevice('camera', enabled);
233
+ }
234
+
235
+ /**
236
+ * 마이크를 켜고 끈다 — {@link #setCameraEnabled} 와 같은 규칙.
237
+ * @param {boolean} enabled
238
+ */
239
+ async setMicrophoneEnabled(enabled) {
240
+ await this._setDevice('microphone', enabled);
241
+ }
242
+
243
+ /**
244
+ * 화면 공유 — Room 접속이 끝난 뒤에만(공유 창은 사용자 조작 안에서만 열려 나중으로 미룰 수 없다). 그 전이면 INVALID_STATE. 마지막
245
+ * 의도로 맞춘다. 켜기는 공유 중이 아니면 부른 자리에서 공유 창을 열고, 고른 화면을 그때의 의도로 게시한 뒤 끝난다. 공유 창이 떠
246
+ * 있는 동안 다시 켜면 그 창의 결과를 같이 기다린다(창은 하나). 이미 공유 중이면(전체 재접속이 게시를 잠깐 내렸어도) 새 창을 열지
247
+ * 않는다. 끄기는 잡은 화면을 그 자리에서 멈추고(공유 표시가 바로 꺼진다) 게시를 내린 뒤 끝난다 — 공유 창이 떠 있으면 바로 끝나고
248
+ * 그 뒤 고른 화면은 게시하지 않고 멈춘다. 끈 화면의 늦은 게시 실패는 알리지 않는다(그 뒤 새 공유 창을 취소해도). 다시 켜면 새 공유 창을 연다. 시작이 실패하면 그 사이 다시 부르지 않은 호출만 던진다(켜고
249
+ * 끄기와 같은 규칙) — 게시를 시도한 쪽이 앞선 호출·재접속 뒤 정리여도 마지막 켜기가 그 실패를 받는다. 전체 재접속 중이면(livekit 이
250
+ * 다시 게시하는 동안 SDK 는 게시하지 않는다) 다시 붙은 뒤 마지막 화면을 게시하고 끝난다 — 이미 공유 중이던 화면이면 livekit 이 다시
251
+ * 게시하니 바로 끝난다(다시 붙었어도 livekit 이 다시 게시하는 중이면 그 끝을 기다려 게시한다). 켜는 사이 전체 재접속이 끼었으면(이미
252
+ * 다시 붙었어도) 그 사이의 게시 실패는 재접속 탓일 수 있어 다시 붙은 뒤의 정리까지 보고 끝난다. 세션이 끝나면 기다리지 않는다 — 마지막 시작은 INVALID_STATE(화면 공유는 다음 연결로 넘어가지 않는다 —
253
+ * 카메라·마이크와 달리 부른 쪽이 기억하지 않는다), 끄기는 그대로 끝난다. 잡은 화면은 그때 모두 멈춘다.
254
+ * @param {boolean} enabled
255
+ */
256
+ async setScreenShareEnabled(enabled) {
257
+ if (this._connecting) {
258
+ throw notConnected();
259
+ }
260
+ const room = this._requireRoom();
261
+ const screen = this._screen;
262
+ screen.wanted = enabled;
263
+ const version = ++screen.version;
264
+ // 이 호출이 본 재접속 — 그 뒤에 전체 재접속이 끼었으면 다시 붙은 뒤의 정리까지 보고 끝낸다.
265
+ let seen = this._reconnection;
266
+ let work;
267
+ if (!enabled) {
268
+ stopAll(screen.owned);
269
+ screen.latest = null;
270
+ work = this._reconcileScreen(room);
271
+ } else if (screen.picking) {
272
+ work = screen.picking.then(() => this._reconcileScreen(room));
273
+ } else if (screen.latest?.some(track => track.source === SCREEN_VIDEO && live(track))) {
274
+ work = this._reconcileScreen(room);
275
+ } else {
276
+ work = this._captureScreen(room);
277
+ }
278
+ if (enabled) {
279
+ screen.waiting = version;
280
+ }
281
+ try {
282
+ let ended = await untilEnded(work.then(() => false), this._ended, true);
283
+ // 게시 줄의 게시가 남았거나 재접속·다시 게시가 끼었으면(아직 붙는 중, 다시 붙었어도 livekit 이 다시 게시하는 중, 이 켜기를
284
+ // 기다리는 사이 끝남) 그 끝 뒤의 정리까지 보고 끝낸다 — 정리는 게시를 기다리지 않고, 그동안 게시를 미루며, 그 사이의 게시 실패는
285
+ // 재접속 탓일 수 있다. 게시 줄이 남았으면 «공유 중» 보다 그 끝을 먼저 본다 — 영상이 먼저 붙어도 소리 게시가 실패하면 시작이
286
+ // 실패해야 한다(«공유 중» 은 영상으로 본다).
287
+ for (;;) {
288
+ if (ended || !enabled || screen.version !== version) {
289
+ break;
290
+ }
291
+ const publishing = screen.publishing?.done;
292
+ if (!publishing && this._screenShared()) {
293
+ break;
294
+ }
295
+ const settling = this._settling(room);
296
+ if (!publishing && !settling && this._reconnection === seen) {
297
+ break;
298
+ }
299
+ seen = this._reconnection;
300
+ ended = await untilEnded(Promise.resolve(publishing ?? settling).then(() => this._reconcileScreen(room)).then(() => false),
301
+ this._ended, true);
302
+ }
303
+ if (ended) {
304
+ if (enabled) {
305
+ throw notConnected();
306
+ }
307
+ return;
308
+ }
309
+ // 마지막 켜기는 마지막 화면이 공유돼야 끝난다 — 게시가 실패했으면 그 실패를, 그 전에 멈췄으면(브라우저 «공유 중지») 그것을 던진다.
310
+ if (enabled && screen.version === version && !this._screenShared()) {
311
+ throw screen.failure?.tracks === screen.latest ? screen.failure.error : screenNotShared();
312
+ }
313
+ } catch (error) {
314
+ // 그 사이 다시 불렀으면(끄기·다시 켜기) 그 호출이 맞춘다 — 대체된 호출은 그대로 끝난다.
315
+ if (screen.version === version) {
316
+ throw error;
317
+ }
318
+ } finally {
319
+ if (screen.waiting === version) {
320
+ screen.waiting = null;
321
+ }
322
+ }
323
+ }
324
+
325
+ /** LiveKit 이 재접속 중인가 — connect() 가 끝날 때 true 면 reconnected 가 올 때까지 아직 다시 붙지 않았다. */
326
+ isReconnecting() {
327
+ return this._reconnecting;
328
+ }
329
+
330
+ /** @returns {{camera: boolean, microphone: boolean, screen: boolean}} */
331
+ getLocalMediaState() {
332
+ const local = this._room?.localParticipant;
333
+ return {
334
+ camera: Boolean(local?.isCameraEnabled),
335
+ microphone: Boolean(local?.isMicrophoneEnabled),
336
+ screen: Boolean(local?.isScreenShareEnabled)
337
+ };
338
+ }
339
+
340
+ /** @returns {Array<{userId: string, camera: boolean, microphone: boolean, screen: boolean}>} */
341
+ participants() {
342
+ return [...(this._room?.remoteParticipants?.values() ?? [])].map(p => mediaOf(p, p.identity));
343
+ }
344
+
345
+ /**
346
+ * 장치 목록 — 통화 전에도 부를 수 있다.
347
+ * @param {MediaDeviceKind} [kind]
348
+ * @returns {Promise<MediaDeviceInfo[]>}
349
+ */
350
+ async getDevices(kind) {
351
+ const lk = await this._module();
352
+ return lk.Room.getLocalDevices(kind);
353
+ }
354
+
355
+ /**
356
+ * 장치를 바꾼다 — 고른 장치는 이 세션의 Room 기본값이 된다(livekit-client 를 불러오는 중이면 Room 을 만들 때 쓴다). 카메라·마이크는
357
+ * 그 장치의 다른 livekit 호출(켜기·끄기·앞선 바꾸기)이 끝난 뒤 부른 순서대로 옮긴다 — 켜는 중(권한 창·획득)이면 그 획득이 끝난 뒤
358
+ * 게시된 트랙을 옮기고(획득은 부를 때의 기본값으로 끝난다), 옮기는 중에 끈 카메라는 옮긴 뒤 끈다. 못 옮겼으면(같은 장치를 다시 골라도
359
+ * livekit 은 다시 연다) 쓰던 장치로 한 번 다시 열고(livekit 은 옛 트랙을 먼저 멈춘 뒤 새 장치를 연다 — 못 열면 그 장치가 꺼진 채
360
+ * 남는다) 기억은 지금 쓰는 장치로 되돌린다 — 앞선 선택도 실패했으면 그것이 아니라 실제로 쓰는 장치로(그 뒤에 고른 장치는 덮지
361
+ * 않는다). livekit 이 미디어를 다시 맞추는 중(전체 재접속·다시
362
+ * 붙은 뒤의 다시 게시)이면 그 끝을 기다린 뒤 줄에 선다 — 기다리는 동안 그 장치의 켜고 끄기는 막지 않는다(_lockSettled). 스피커도 부른
363
+ * 순서대로 하나씩 바꾸고, 소리 요소 일부만 바뀌면 모두 쓰던 출력으로 되돌린 뒤 실패한다 — 되돌리지 못하면 그 오류에 outputMismatch 를
364
+ * 단다(_switchSpeaker). 끊긴 세션은 기억만 한다(다음 연결은 부른 쪽이 넘긴다) — 장치를 여는 중·다시 맞추기를 기다리는 중에 세션이
365
+ * 끝나도 기다리지 않고 끝난다. 바꾸는 동안(줄을 기다리는 동안 포함) livekit 의 쓰는 장치 알림은 기억에 들이지 않는다 — 이 바꾸기의
366
+ * 결과가 기억을 맞춘다.
367
+ * @param {MediaDeviceKind} kind - audioinput·videoinput·audiooutput
368
+ * @param {string} deviceId
369
+ * @returns {Promise<boolean>}
370
+ */
371
+ async switchDevice(kind, deviceId) {
372
+ this._switching[kind] = (this._switching[kind] ?? 0) + 1;
373
+ try {
374
+ return await this._switchDevice(kind, deviceId);
375
+ } finally {
376
+ this._switching[kind] -= 1;
377
+ }
378
+ }
379
+
380
+ async _switchDevice(kind, deviceId) {
381
+ const seq = (this._chosenSeq[kind] ?? 0) + 1;
382
+ this._chosenSeq[kind] = seq;
383
+ this._chosen[kind] = deviceId;
384
+ const room = this._room;
385
+ const line = this._lineOf(kind);
386
+ const unlock = room && line ? await this._lockSettled(room, line, () => this._chosenSeq[kind] !== seq) : NO_LOCK;
387
+ // 다시 맞추기를 기다리는 사이 이 종류를 또 골랐으면 그 바꾸기가 마지막 선택으로 옮긴다 — 기다림이 풀리는 순서는 부른 순서가 아닐
388
+ // 수 있다(줄을 잡았다 놓고 다시 기다리면 뒤에 선다).
389
+ if (unlock === SKIP) {
390
+ return true;
391
+ }
392
+ // Room 이 아직 없거나(불러오는 중) 기다리는 사이 세션이 끝났으면 기억만 한다 — 끝나지 않는 권한 창에 묶이지 않는다.
393
+ if (!room || !unlock || !this._isCurrent(room)) {
394
+ unlock?.();
395
+ return true;
396
+ }
397
+ let switched = false;
398
+ try {
399
+ // 장치를 여는 중(권한 창·느린 장치)에 세션이 끝나면 기다리지 않는다 — 고른 장치는 기억하고(다음 연결이 쓴다) 늦게 끝나는
400
+ // livekit 의 결과는 버린다(되돌리기·끄기는 살아 있는 Room 에만 한다).
401
+ switched = await untilEnded(this._switchActive(room, kind, deviceId), this._ended, true);
402
+ return switched;
403
+ } finally {
404
+ unlock();
405
+ if (!switched && this._chosenSeq[kind] === seq) {
406
+ const inUse = this.deviceInUse(kind);
407
+ if (inUse === undefined) {
408
+ delete this._chosen[kind];
409
+ } else {
410
+ this._chosen[kind] = inUse;
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * 장치 줄을 잡는다 — 카메라·마이크는 livekit 이 미디어를 다시 맞추는 중이면 그 끝을 기다린 뒤에 잡는다(스피커는 바로). 그동안 옮기면
418
+ * livekit 은 게시가 없다고 보고 기본값만 바꾼 채 «성공» 하고, 다시 게시는 옛 장치로 연다(기억은 새 장치, 실제는 옛 장치). 줄을 잡기
419
+ * 전에 기다린다 — 잡은 채 기다리면 그동안의 카메라 끄기가 livekit 에 못 가 다시 게시가 끈 카메라를 연다. 줄을 기다리는 사이 다시
420
+ * 맞추기가 시작됐으면 줄을 놓고 다시 기다린다. 기다린 뒤 대체됐으면(superseded) SKIP, 세션이 끝나면 null.
421
+ */
422
+ async _lockSettled(room, line, superseded) {
423
+ let waited = false;
424
+ for (;;) {
425
+ const settling = line === this._speaker ? null : this._settling(room);
426
+ if (settling) {
427
+ if (await untilEnded(settling.then(() => false), this._ended, true)) {
428
+ return null;
429
+ }
430
+ waited = true;
431
+ continue;
432
+ }
433
+ if (waited && superseded()) {
434
+ return SKIP;
435
+ }
436
+ const unlock = await this._lockDevice(line);
437
+ if (!unlock || line === this._speaker || !this._settling(room)) {
438
+ return unlock;
439
+ }
440
+ unlock();
441
+ waited = true;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * livekit 이 아직 미디어를 다시 맞추는 중이면 그 끝(실패도 끝)을 기다리는 약속, 아니면 null — 전체 재접속 중이면 다시 붙기, 다시
447
+ * 붙었어도 다시 게시 중이면 그 다시 게시. 여러 번 시도하는 재접속은 Reconnected 를 다음 시도의 다시 게시보다 먼저 낸다(첫 시도의
448
+ * 기다림이 다음 시도의 Restarted 에 풀린다 ≈34458·≈24886). 다시 게시 약속은 livekit-client 2.22.4(고정) LocalParticipant 의 내부
449
+ * 필드다(≈33013 에서 세우고 끝나면 비운다 ≈33045) — 없으면 기다리지 않는다.
450
+ */
451
+ _settling(room) {
452
+ if (this._reconnecting) {
453
+ return this._reconnection;
454
+ }
455
+ const republishing = room.localParticipant.republishPromise;
456
+ return republishing ? republishing.then(() => {}, () => {}) : null;
457
+ }
458
+
459
+ /**
460
+ * 그 종류에서 지금 쓰는 장치 id — 켜진(음소거가 아닌) 카메라·마이크 트랙이 실제로 연 장치, 음소거한 트랙이면 livekit 이 다음 켜기
461
+ * 때 열 장치(그 트랙의 제약 — livekit 의 다시 열기는 트랙의 제약으로 열고 ≈22918, 음소거한 트랙의 장치 바꾸기(기기 변화 자동 선택
462
+ * 포함)는 제약에만 적는다 ≈22829–22834), 그 밖에는 livekit 이 쓰는 장치로 적은 것(`getActiveDevice` — 성공한 바꾸기·게시·재시작 때
463
+ * 적는다 ≈35536·≈34797·≈34811, 스피커도). 음소거한 트랙은 열린 채여도(마이크는 음소거해도 멈추지 않는다) 그 장치가 아니고, 쓰는 장치
464
+ * 표도 믿지 않는다 — livekit 은 게시 때 열린 트랙의 장치로 표를 덮어(전체 재접속의 다시 게시 ≈34797) 음소거 중 바꾼 장치를 옛 장치로
465
+ * 되돌려 적는다. 제약에 장치가 없으면(기본 장치) 표를 본다. 기본 장치(livekit 은 처음에 'default' 로 채운다 ≈31825)·끊긴 세션은
466
+ * undefined — 기억 없음이 곧 기본 장치다(카메라에는 'default' 라는 장치가 없어 그 id 로 열면 정확 일치가 한 번 실패한다). 못 바꾼
467
+ * 장치 바꾸기의 기억을 되돌릴 때·livekit 이 스스로 옮긴 장치를 기억에 들일 때 쓴다.
468
+ * @param {MediaDeviceKind} kind
469
+ * @returns {string|undefined}
470
+ */
471
+ deviceInUse(kind) {
472
+ const room = this._room;
473
+ if (!room) {
474
+ return undefined;
475
+ }
476
+ const source = SOURCE_OF_KIND[kind];
477
+ const publication = source ? this._publicationOf(room, source) : undefined;
478
+ const capturing = publication && !publication.isMuted ? liveDeviceId(publication) : undefined;
479
+ const next = publication?.isMuted ? nextDeviceId(publication.track) : undefined;
480
+ const inUse = capturing ?? next ?? room.getActiveDevice(kind);
481
+ return inUse === DEFAULT_DEVICE ? undefined : inUse;
482
+ }
483
+
484
+ /**
485
+ * 그 장치가 지금 켜져 있는가 — livekit 이 마지막으로 성공한 켜고 끄기(잃은 장치 끄기 포함). 못 바꾼 켜고 끄기의 기억을 되돌릴 때
486
+ * 쓴다 — 앞선 의도도 실패했으면 그 의도가 아니라 실제 상태로.
487
+ * @param {'camera'|'microphone'} source
488
+ * @returns {boolean}
489
+ */
490
+ appliedMedia(source) {
491
+ return this._devices[source].applied;
492
+ }
493
+
494
+ async _module() {
495
+ if (!this._lk) {
496
+ this._lk = await this._loadLiveKit();
497
+ }
498
+ return this._lk;
499
+ }
500
+
501
+ _requireRoom() {
502
+ if (!this._room) {
503
+ throw notConnected();
504
+ }
505
+ return this._room;
506
+ }
507
+
508
+ /** 그 종류의 장치 잠금을 가진 것 — 카메라·마이크는 반영 고리의 장치, 스피커는 잠금만 있다. */
509
+ _lineOf(kind) {
510
+ const source = SOURCE_OF_KIND[kind];
511
+ if (source) {
512
+ return this._devices[source];
513
+ }
514
+ return kind === 'audiooutput' ? this._speaker : null;
515
+ }
516
+
517
+ /**
518
+ * 그 장치의 게시 — 지금 트랙이 있으면 그 트랙의 게시(바뀐 트랙의 게시가 잠깐 남아 있어도 그것이 아니다), 아직 없으면(livekit 이
519
+ * 게시를 알리기 전) 소스로 찾은 게시.
520
+ */
521
+ _publicationOf(room, source) {
522
+ const track = this._devices[source].current;
523
+ const local = room.localParticipant;
524
+ return track ? local.getPublicationForTrack(track) : local.getTrackPublication(source);
525
+ }
526
+
527
+ /** 그 장치가 게시돼 있나 — 게시돼 있으면 켜기는 livekit 의 음소거 풀기라 처음 획득(권한 창)이 아니다. */
528
+ _published(room, source) {
529
+ return Boolean(this._publicationOf(room, source));
530
+ }
531
+
532
+ async _setDevice(source, enabled) {
533
+ const version = this._intend(source, enabled);
534
+ const room = this._room;
535
+ if (this._connecting || !room || !this._isCurrent(room)) {
536
+ return;
537
+ }
538
+ const settled = new Promise((resolve, reject) => this._devices[source].waiters.push({ version, resolve, reject }));
539
+ this._kickDevices(source);
540
+ await settled;
541
+ }
542
+
543
+ _intend(source, enabled) {
544
+ const device = this._devices[source];
545
+ device.wanted = enabled;
546
+ device.version += 1;
547
+ return device.version;
548
+ }
549
+
550
+ /**
551
+ * 장치마다 반영 고리를 돌린다 — 이미 돌고 있으면 그 고리가 새 의도까지 맞춘다(획득 줄에서 기다리는 중이면 깨워 새 의도를 보게
552
+ * 한다 — 그 사이 끈 장치는 줄에서 빠진다). 접속 때는 마이크부터 깨운다(획득 줄 순서).
553
+ */
554
+ _kickDevices(source) {
555
+ for (const each of source ? [source] : DEVICES) {
556
+ const device = this._devices[each];
557
+ if (device.applying) {
558
+ device.wake?.();
559
+ // livekit 을 부르는 중인 고리는 다음 반영의 장치 잠금 자리를 지금 잡는다 — 그 뒤에 부른 장치 바꾸기가 먼저 돌지 않게.
560
+ if (device.holding && !device.reserved) {
561
+ device.reserved = joinLine(device, 'lock');
562
+ }
563
+ continue;
564
+ }
565
+ device.applying = true;
566
+ this._applyLoop(each).catch(error => this.logger.error('Device apply loop failed:', error));
567
+ }
568
+ }
569
+
570
+ /**
571
+ * 그 장치를 마지막 의도로 맞춘다 — 한 번에 하나씩(장치 바꾸기와도 — 장치 잠금). 처음 획득(이 Room 에 그 장치 게시가 없는 켜기)만
572
+ * 획득 줄에서 차례를 기다린다 — 끄기·게시된 장치 다시 켜기는 다른 장치의 권한 창을 기다리지 않는다. 차례·잠금을 기다리는 사이 바뀐
573
+ * 의도는 다시 읽는다. 끊겼으면(나가기·방 삭제) 더 반영하지 않는다 — 통화가 끝난 뒤 장치 획득을 시작하지 않는다. 마지막 확인과 같은
574
+ * 틱에 «도는 중» 을 내리므로 그 뒤의 의도는 새 고리가 맡는다(잃는 의도가 없다).
575
+ */
576
+ async _applyLoop(source) {
577
+ const device = this._devices[source];
578
+ try {
579
+ for (;;) {
580
+ const room = this._room;
581
+ if (!room || !this._isCurrent(room) || !this._needsApply(source)) {
582
+ dropReserved(device);
583
+ return;
584
+ }
585
+ const acquiring = device.wanted && !this._published(room, source);
586
+ // 마이크 끄기는 장치 잠금 밖이다(livekit 이 옮기는 중의 음소거를 새 트랙에 이어 준다). 처음 획득은 획득 줄에서 기다린다 —
587
+ // 잡아 둔 잠금 자리를 쥔 채 기다리면 그동안의 장치 바꾸기가 묶이니 놓고 기다린 뒤 다시 선다.
588
+ const lockFree = source === 'microphone' && !device.wanted;
589
+ if (acquiring || lockFree) {
590
+ dropReserved(device);
591
+ }
592
+ const release = acquiring ? await this._takeAcquireTurn(device) : NO_LOCK;
593
+ if (!release) {
594
+ return;
595
+ }
596
+ if (release === SKIP) {
597
+ continue;
598
+ }
599
+ const slot = lockFree ? null : (device.reserved ?? joinLine(device, 'lock'));
600
+ device.reserved = null;
601
+ const unlock = slot ? await this._lockDevice(device, slot) : NO_LOCK;
602
+ if (!unlock) {
603
+ release();
604
+ return;
605
+ }
606
+ try {
607
+ // 기다리는 사이 끝났거나, 맞출 것이 없어졌거나(꺼진 장치를 끄는 의도는 livekit 없이 끝낸다), 차례 없이 처음 획득이
608
+ // 필요해졌으면 처음부터 다시 본다.
609
+ if (!this._isCurrent(room) || !this._needsApply(source)
610
+ || (device.wanted && !acquiring && !this._published(room, source))) {
611
+ continue;
612
+ }
613
+ const { version, wanted } = device;
614
+ device.holding = true;
615
+ try {
616
+ if (wanted) {
617
+ await reopenEnded(source, this._publicationOf(room, source));
618
+ // 다시 여는 사이 세션이 끝났거나 의도가 바뀌었으면 처음부터 다시 본다 — 끝난 Room 에서 장치를 새로 열지 않는다
619
+ // (게시가 없는 켜기는 livekit 의 처음 획득이고, 끊긴 엔진에 게시를 미루다 15초 뒤에야 멈춘다 ≈32371–32378).
620
+ if (!this._isCurrent(room) || device.version !== version) {
621
+ continue;
622
+ }
623
+ }
624
+ // livekit 의 켜고 끄기는 소스로 게시 하나를 찾는다 — 바뀐 트랙의 게시는 먼저 내린다. 게시가 없는 켜기는 livekit 이
625
+ // 새로 열고, 그 트랙이 게시되면 지금 트랙이 되어 그 전 트랙(게시 없이 열린 채 남은 것 포함)은 멈춘다.
626
+ this._collect(room, source);
627
+ await invoke(() => setEnabled(room, source, wanted));
628
+ // 끝나는 순간 협상 중이던 게시는 livekit 이 끝난 Room 에 붙이고 멈추지 않는다(Room 정리는 그 전에 그때의 게시만
629
+ // 멈춘다) — 세션이 끝났으면 SDK 가 멈춘다(카메라 표시등·마이크가 켜진 채 남지 않게).
630
+ if (wanted && !this._isCurrent(room)) {
631
+ stopPublished(room, source);
632
+ }
633
+ this._settleDevice(source, { version, value: wanted, ok: true });
634
+ } catch (error) {
635
+ this._settleDevice(source, { version, value: wanted, ok: false, error });
636
+ } finally {
637
+ device.holding = false;
638
+ }
639
+ // 확정 상태를 이 세션의 트랙에 맞춘다 — 바뀐 트랙은 멈추고 내리고, 꺼짐이면 지금 트랙도 보내지 않게(게시 없이 열린
640
+ // 트랙은 멈춘다 — livekit 의 끄기는 게시가 없으면 아무것도 하지 않는다 ≈32088–32110).
641
+ if (this._isCurrent(room)) {
642
+ await this._enforce(room, source);
643
+ }
644
+ } finally {
645
+ unlock();
646
+ release();
647
+ }
648
+ }
649
+ } finally {
650
+ device.applying = false;
651
+ }
652
+ }
653
+
654
+ /**
655
+ * 획득 줄의 차례를 기다린다 — 차례가 오면 놓는 함수를, 그 전에 세션이 끝나면 null 을, 기다리는 사이 그 장치를 끄면 SKIP 을 돌려준다
656
+ * (줄에서 빠진다 — 뒤에 선 장치는 그 앞 장치만 기다린다). 같은 값으로 다시 켜면 차례를 그대로 기다린다.
657
+ */
658
+ async _takeAcquireTurn(device) {
659
+ const { previous, release } = joinLine(this, '_acquireTail');
660
+ for (;;) {
661
+ const woken = new Promise((resolve) => { device.wake = resolve; });
662
+ const outcome = await Promise.race([previous.then(() => 'turn'), this._ended.then(() => 'ended'), woken.then(() => 'woken')]);
663
+ device.wake = null;
664
+ if (outcome === 'ended' || this._disposed) {
665
+ release();
666
+ return null;
667
+ }
668
+ if (outcome === 'turn') {
669
+ return release;
670
+ }
671
+ if (!device.wanted) {
672
+ release();
673
+ return SKIP;
674
+ }
675
+ }
676
+ }
677
+
678
+ /**
679
+ * 그 장치의 livekit 호출 차례를 기다린다(켜고 끄기·장치 바꾸기를 부른 순서대로 한 번에 하나씩) — 세션이 끝나면 null. slot 은 미리
680
+ * 잡아 둔 자리(없으면 지금 줄 끝에 선다).
681
+ */
682
+ async _lockDevice(device, slot = joinLine(device, 'lock')) {
683
+ const { previous, release } = slot;
684
+ const ended = await Promise.race([previous.then(() => false), this._ended.then(() => true)]);
685
+ if (ended || this._disposed) {
686
+ release();
687
+ return null;
688
+ }
689
+ return release;
690
+ }
691
+
692
+ /**
693
+ * 장치를 옮긴다 — 못 열면 쓰던 장치로 한 번 다시 열고 원래 오류를 던진다. livekit 은 게시된 트랙의 옛 장치를 먼저 멈춘 뒤 새 장치를
694
+ * 연다(Safari 때문) — 같은 장치를 다시 골라도 다시 연다(매번 새 {exact} 제약이라 같은 장치 건너뛰기가 맞지 않는다). 그대로 두면
695
+ * 쓰는 중인 카메라·빠진 장치를 고른 순간 그 연결의 카메라·마이크가 멈춘 트랙으로 «켜짐» 인 채 남는다. 다시 열기도 못 하거나 쓰던
696
+ * 장치를 모르면 꺼짐으로 맞추고 mediaError 로 알린다(다시 켜면 다시 연다). 꺼진(음소거한) 트랙이면 livekit 은 장치를 열지 않고 다음
697
+ * 켜기 때 연다 — 그때 못 열면 그 켜기가 실패한다.
698
+ */
699
+ async _switchActive(room, kind, deviceId) {
700
+ if (kind === 'audiooutput') {
701
+ return this._switchSpeaker(room, deviceId);
702
+ }
703
+ const source = SOURCE_OF_KIND[kind];
704
+ const before = source ? liveDeviceId(this._publicationOf(room, source)) : undefined;
705
+ try {
706
+ return await room.switchActiveDevice(kind, deviceId);
707
+ } catch (error) {
708
+ if (source && this._isCurrent(room)) {
709
+ const lost = before ? await this._reopen(room, kind, before) : error;
710
+ if (lost && this._isCurrent(room)) {
711
+ await this._turnOffLost(room, source, lost);
712
+ }
713
+ }
714
+ throw error;
715
+ }
716
+ }
717
+
718
+ /**
719
+ * 스피커를 바꾼다 — 원격 소리 요소마다 SDK 가 출력을 바꾸고 모두 끝난 뒤 판정한다. livekit 의 바꾸기는 참가자·트랙마다 Promise.all
720
+ * 이라 첫 실패에서 끝나고 다른 요소는 아직 바꾸는 중일 수 있다(RemoteParticipant.setAudioOutput·RemoteAudioTrack.setSinkId) — 그
721
+ * 뒤에 되돌리면 setSinkId 는 지금 출력과 같은 값을 바로 끝내(Audio Output Devices 규격의 같은 값 단계) 늦게 끝난 바꾸기가 새 출력으로
722
+ * 남는다. 하나라도 실패하면 바꾼 요소를 쓰던 출력으로 되돌리고(역시 모두 끝날 때까지) 확인한 뒤 던진다 — 되돌리지 못했으면(되돌리기
723
+ * 실패·아직 다른 출력인 요소) outputMismatch 를 단 오류다(일부 소리가 새 출력에 남았다 — 기억·Room 기본값은 쓰던 출력). 실패하면
724
+ * livekit 의 기록(Room 기본값·참가자·트랙의 출력)은 바꾸지 않는다. 성공하면 그 기록을 새 출력으로 — 뒤에 들어오는 사람(≈35889)·뒤에
725
+ * 붙는 요소(RemoteAudioTrack.attach)가 그 출력을 쓴다(요소는 이미 그 출력이라 livekit 의 요소 바꾸기는 바로 끝난다) — 그리고 지금 쓰는
726
+ * 출력(≈35446)을 적는다. 바꾸는 동안 새로 붙은 요소는 livekit 이 붙일 때 그때의 기록으로 맞춘다(그 결과는 SDK 가 볼 수 없다).
727
+ * setSinkId 가 없는 브라우저는 livekit 처럼 바로 실패한다.
728
+ */
729
+ async _switchSpeaker(room, deviceId) {
730
+ if (!canSetSink()) {
731
+ throw new Error('cannot switch audio output, the current browser does not support it');
732
+ }
733
+ const before = room.getActiveDevice('audiooutput') ?? DEFAULT_DEVICE;
734
+ const elements = sinkElements(room);
735
+ const failed = await setSinks(elements, deviceId);
736
+ // 바꾸는 사이 세션이 끝났으면 끝난 Room 은 더 부르지 않는다(되돌리기·적기 없음 — 부른 쪽은 이미 끝났다).
737
+ if (!this._isCurrent(room)) {
738
+ return true;
739
+ }
740
+ if (failed) {
741
+ const restoreFailed = await setSinks(elements, before);
742
+ const attached = new Set(sinkElements(room));
743
+ const split = restoreFailed || elements.some(element => attached.has(element) && !sameSink(element.sinkId, before));
744
+ throw split ? outputSplit(failed, restoreFailed) : failed;
745
+ }
746
+ const output = room.options.audioOutput ?? (room.options.audioOutput = {});
747
+ output.deviceId = deviceId;
748
+ const late = await setOutput(room, deviceId);
749
+ if (late) {
750
+ this.logger.warn('Failed to switch an audio element attached while switching the output:', late);
751
+ }
752
+ room.localParticipant.activeDeviceMap.set('audiooutput', deviceId);
753
+ return true;
754
+ }
755
+
756
+ /**
757
+ * 잃은 장치(멈춘 트랙)를 그 자리에서 끈다 — 장치 잠금을 쥔 채 livekit 으로 끄고 결과를 «끔» 으로 적은 뒤 mediaError 로 알린다. 의도만
758
+ * 바꾸면 그 사이 들어온 켜기가 덮어 끄기가 나가지 않는다 — 음소거가 아닌 트랙의 켜기는 livekit 이 아무것도 하지 않아 멈춘 트랙이
759
+ * «켜짐» 으로 남는다. 꺼진 뒤의 켜기는 음소거 풀기라 livekit 이 장치를 다시 연다. 끄기도 못 하면 그 실패를 알린다. 끄는 사이 새
760
+ * 의도가 왔으면(끄기 알림을 받고 바로 다시 켬) 알리지 않는다 — 대체된 실패는 알리지 않는다(_settleDevice 와 같은 규칙): 알리면 통화가
761
+ * 그 새 켜기의 기억을 꺼짐으로 덮는다(다시 입장하면 카메라가 꺼진 채 붙는다).
762
+ */
763
+ async _turnOffLost(room, source, lostError) {
764
+ const version = this._intend(source, false);
765
+ try {
766
+ await invoke(() => setEnabled(room, source, false));
767
+ } catch (error) {
768
+ this._settleDevice(source, { version, value: false, ok: false, error });
769
+ return;
770
+ }
771
+ this._settleDevice(source, { version, value: false, ok: true });
772
+ if (!this._disposed && this._devices[source].version === version) {
773
+ this.emit('mediaError', { source, error: lostError });
774
+ }
775
+ if (this._isCurrent(room)) {
776
+ await this._enforce(room, source);
777
+ }
778
+ }
779
+
780
+ /** 쓰던 장치로 다시 연다 — 열었으면 null, 못 열었으면 그 오류. */
781
+ async _reopen(room, kind, deviceId) {
782
+ try {
783
+ await room.switchActiveDevice(kind, deviceId);
784
+ return null;
785
+ } catch (error) {
786
+ this.logger.warn(`Failed to restore ${kind} to the previous device:`, error);
787
+ return error;
788
+ }
789
+ }
790
+
791
+ /**
792
+ * livekit 이 스스로 바꾼 카메라·마이크 상태를 확정 상태로 들인다 — 장치 잠금 안에서(SDK 의 켜고 끄기·장치 바꾸기와 한 줄), 잠금을
793
+ * 기다린 뒤의 상태로 본다. 켜짐으로 확정된 켜짐 의도인데 지금 트랙이 livekit 에서 꺼져 있으면 잃은 장치로 끈다(_turnOffLost — 통화
794
+ * 기억도 꺼짐이라 다시 입장해도 저절로 켜지지 않는다): 게시된 트랙이 끝나고 음소거됐거나 — livekit 이 다시 열기를 포기한 신호다(끝난
795
+ * 트랙을 다시 열다 실패하면 스스로 음소거한다 ≈31815). 끝났어도 음소거가 아니면 livekit 이 다시 여는 중일 수 있다(재시작은 Restarted
796
+ * 를 낸 뒤 줄 선 다음 재시작에 잠금을 넘기고, 그 재시작은 옛 트랙을 먼저 멈춘다 ≈22965·≈22952) — 그 재시작이 끝나며 내는 알림에 다시
797
+ * 본다. 또는 다시 맞추기가 끝났는데 게시가 없고 트랙이 끝났다 — 전체 재접속의 다시 게시가 내린 트랙을 다시 열지 못했다(≈33026–
798
+ * 33032 — 다시 맞추는 중이면 게시가 잠깐 없으니 보지 않는다). 살아 있는데 게시가 없는 트랙은 판정하지 않는다 — livekit 은 다시 게시를
799
+ * 트랙마다 동시에 하다 첫 실패에서 끝을 알리고(≈33024–33040) 아직 게시 중인 트랙과 게시만 실패한 트랙을 가를 공개 신호가 없다(게시
800
+ * 대기 맵은 다시 열기가 끝난 뒤에야 든다 ≈32399) — 그 트랙은 새 트랙이 게시되면(바뀐 트랙)·끄면·세션이 끝나면 멈춘다. 그 밖에는
801
+ * 확정 상태를 이 세션의 트랙에 맞춘다(_enforce).
802
+ */
803
+ async _syncDevice(room, source) {
804
+ const device = this._devices[source];
805
+ const unlock = await this._lockDevice(device);
806
+ if (!unlock) {
807
+ return;
808
+ }
809
+ try {
810
+ if (!this._isCurrent(room)) {
811
+ return;
812
+ }
813
+ if (device.wanted && device.applied) {
814
+ const publication = this._publicationOf(room, source);
815
+ const track = device.current;
816
+ const off = publication ? publication.isMuted && !live(publication.track) : !this._settling(room) && Boolean(track) && !live(track);
817
+ if (off) {
818
+ await this._turnOffLost(room, source, deviceLost(source));
819
+ return;
820
+ }
821
+ }
822
+ await this._enforce(room, source);
823
+ } finally {
824
+ unlock();
825
+ }
826
+ }
827
+
828
+ /**
829
+ * 확정 상태를 이 세션의 트랙에 맞춘다 — 장치 잠금 안에서. 바뀐 트랙은 멈추고 게시를 내린다(_collect). 꺼짐으로 확정된 꺼짐 의도면
830
+ * 지금 트랙이 보내지 않게 한다: 게시가 음소거가 아니면(다시 게시가 늦게 올렸다·livekit 의 끄기가 바뀐 트랙의 게시를 골랐다) 음소거 —
831
+ * 지금 트랙의 게시를 직접(livekit 의 끄기와 같다, 소스로 찾지 않는다). 카메라가 음소거된 채 장치를 쥐고 있으면(livekit 이 스스로 다시
832
+ * 여는 사이 껐다 — 다시 열기와 음소거는 잠금이 다르다 ≈22917·≈24015) 멈춘다(표시등 — livekit 의 카메라 끄기도 트랙을 멈춘다 ≈24021,
833
+ * 마이크는 음소거해도 멈추지 않는 것이 livekit 의 동작이다). 게시 없이 열려 있으면(다시 게시가 다시 열고 게시만 실패·아직 게시 전)
834
+ * 멈춘다 — livekit 의 끄기는 게시가 없으면 아무것도 하지 않는다(≈32088–32110). 그 트랙이 뒤에 다시 열리거나 게시되면 그 알림에서
835
+ * 다시 맞춘다(게시되면 음소거, 켤 때 livekit 이 다시 연다). 지금 트랙을 모르면(livekit 이 게시를 알리기 전) 맞출 트랙이 없다.
836
+ */
837
+ async _enforce(room, source) {
838
+ this._collect(room, source);
839
+ const device = this._devices[source];
840
+ const track = device.current;
841
+ if (device.wanted || device.applied || !track) {
842
+ return;
843
+ }
844
+ const publication = room.localParticipant.getPublicationForTrack(track);
845
+ if (!publication) {
846
+ stopUnpublished(room, track);
847
+ return;
848
+ }
849
+ if (!publication.isMuted) {
850
+ await invoke(() => publication.mute()).catch(error => this.logger.warn(`Failed to turn off ${source}:`, error));
851
+ } else if (source === 'camera' && live(track)) {
852
+ track.mediaStreamTrack.stop();
853
+ }
854
+ }
855
+
856
+ /**
857
+ * 바뀐 트랙(지금 트랙보다 먼저 연 트랙)을 멈추고 게시가 있으면 내린다 — 몇 번 불러도 같다. 멈춤은 진행 중인 livekit 의 다시 열기에도
858
+ * 듣는다(다시 열기가 끝나면 livekit 이 새 트랙을 멈춘다 ≈22967). 게시를 기다리는 중이면(다시 게시) 게시가 생기지 않았으니 내릴 것이
859
+ * 없다 — 게시되면 그 알림에서 다시 부른다. 늦게 시작한 다시 열기는 멈춤 표시를 지운다(≈22916) — Restarted 알림에서 다시 부른다.
860
+ */
861
+ _collect(room, source) {
862
+ const device = this._devices[source];
863
+ const local = room.localParticipant;
864
+ for (const track of device.tracks) {
865
+ if (!device.current || track === device.current) {
866
+ return;
867
+ }
868
+ track.stop?.();
869
+ if (this._isCurrent(room) && local.getPublicationForTrack(track)) {
870
+ local.unpublishTrack(track).catch(error => this.logger.warn(`Failed to unpublish a replaced ${source} track:`, error));
871
+ }
872
+ }
873
+ }
874
+
875
+ /**
876
+ * 게시된 카메라·마이크 트랙을 지금 트랙으로 들인다 — 지금 트랙이 없거나 그보다 늦게 연 트랙이면 지금 트랙이 되고 true, 그보다 먼저 연
877
+ * 트랙(바뀐 트랙)이면 false.
878
+ */
879
+ _adopt(device, track) {
880
+ if (this._replaced(device, track)) {
881
+ return false;
882
+ }
883
+ device.current = track;
884
+ return true;
885
+ }
886
+
887
+ /** 바뀐 트랙인가 — 이 세션의 트랙이고 지금 트랙보다 먼저 열었다. */
888
+ _replaced(device, track) {
889
+ if (!device.current || track === device.current || !device.tracks.has(track)) {
890
+ return false;
891
+ }
892
+ for (const each of device.tracks) {
893
+ if (each === track) {
894
+ return true;
895
+ }
896
+ if (each === device.current) {
897
+ return false;
898
+ }
899
+ }
900
+ return false;
901
+ }
902
+
903
+ /**
904
+ * 공유 창을 연다(부른 자리에서 바로 — 사용자 조작 안이어야 열린다). 고른 화면은 받는 순간 소유 목록에 넣고, 그때 의도가 켜짐이고
905
+ * 세션이 살아 있으면 마지막에 고른 화면이 된다 — 아니면(고르는 사이 끄거나 끝났다) 게시하지 않고 멈춘다. 그 뒤 정리가 게시한다.
906
+ */
907
+ _captureScreen(room) {
908
+ const screen = this._screen;
909
+ const picking = invoke(() => room.localParticipant.createScreenTracks()).then((tracks) => {
910
+ tracks.forEach(track => screen.owned.add(track));
911
+ if (this._isCurrent(room) && screen.wanted) {
912
+ screen.latest = tracks;
913
+ } else {
914
+ stopAll(tracks);
915
+ }
916
+ });
917
+ const tracked = picking.finally(() => {
918
+ if (screen.picking === tracked) {
919
+ screen.picking = null;
920
+ }
921
+ });
922
+ screen.picking = tracked;
923
+ return tracked.then(() => this._reconcileScreen(room));
924
+ }
925
+
926
+ /**
927
+ * 화면 공유를 마지막 의도로 맞춘다 — 정리 줄에서 한 번에 하나씩(앞선 정리의 실패는 그 정리를 부른 쪽이 받는다). failed 는 게시 줄이
928
+ * 넘긴 게시 실패 — 정리 전에 그 화면 전체를 멈추고 적는다(_screenPublishFailed).
929
+ */
930
+ _reconcileScreen(room, failed = null) {
931
+ const screen = this._screen;
932
+ const run = screen.turn.catch(() => {}).then(() => {
933
+ if (failed) {
934
+ this._screenPublishFailed(room, failed);
935
+ }
936
+ return this._reconcileOnce(room);
937
+ });
938
+ screen.turn = run;
939
+ return run;
940
+ }
941
+
942
+ /**
943
+ * 켜짐이고 세션이 살아 있으면 마지막에 고른 화면만 게시하고, 이 세션이 잡은 나머지 화면은 내리고 멈춘다 — 게시가 잠깐 없어도(전체
944
+ * 재접속의 다시 게시) 멈추고, 다시 게시되면 그 게시 알림의 정리가 내린다. 세션이 끝났으면 livekit 을 부르지 않고 멈추기만 한다.
945
+ * 살아 있는 화면만 남긴다 — 끄기·세션 끝은 잡은 화면을 그 자리에서 멈추므로, 게시하는 사이 껐으면 남은 화면은 게시하지 않는다(멈춘
946
+ * 화면이 게시돼 있으면 내린다). 내리기의 협상 실패는 정리를 멈추지 않는다(livekit 은 게시를 지우고 트랙을 멈춘 뒤 협상한다).
947
+ * 전체 재접속 중에는 게시하지 않는다 — livekit 이 게시했던 화면을 다시 게시하고, 그동안의 게시는 그 다시 게시를 기다렸다가 다른
948
+ * 트랙(카메라 등)의 실패까지 받는다. 다시 붙으면(Reconnected) 정리가 빠진 것을 게시한다 — 다시 붙었어도 livekit 이 다시 게시하는
949
+ * 중이면(여러 번 시도한 재접속) 그 끝에 정리를 다시 줄 세우고 지금은 돌아간다(줄을 쥔 채 기다리면 그동안의 끄기·옛 화면 내리기가
950
+ * 막힌다). 게시는 게시 줄에 넘기고 기다리지 않는다(_publishScreen) — livekit 의 게시는 시작한 뒤에도 시그널 재개·다시 게시·협상을
951
+ * 기다려(≈32279–32282) 줄을 쥐면 그동안의 끄기가 밀린다. 게시가 끝나면 게시 줄이 정리를 다시 줄 세운다(실패면 그 실패와 함께).
952
+ */
953
+ async _reconcileOnce(room) {
954
+ const screen = this._screen;
955
+ const local = room.localParticipant;
956
+ const latest = screen.latest;
957
+ const keep = (this._isCurrent(room) && screen.wanted && latest ? latest : []).filter(live);
958
+ for (const track of [...screen.owned]) {
959
+ if (keep.includes(track)) {
960
+ continue;
961
+ }
962
+ if (this._isCurrent(room) && local.getPublicationForTrack(track)) {
963
+ await local.unpublishTrack(track).catch(error => this.logger.warn('Failed to unpublish screen share:', error));
964
+ }
965
+ track.stop();
966
+ }
967
+ if (keep.some(track => live(track) && !local.getPublicationForTrack(track))) {
968
+ if (this._reconnecting) {
969
+ return;
970
+ }
971
+ const settling = this._settling(room);
972
+ if (settling) {
973
+ settling.then(() => this._reconcileScreen(room)).catch(failure => this.logger.warn('Failed to reconcile screen share:', failure));
974
+ return;
975
+ }
976
+ this._publishScreen(room, latest, keep);
977
+ return;
978
+ }
979
+ if (keep.length && keep.every(track => local.getPublicationForTrack(track))) {
980
+ screen.shared = latest;
981
+ }
982
+ }
983
+
984
+ /**
985
+ * 고른 화면의 게시를 게시 줄에 넘긴다. 같은 화면을 이미 게시하는 중이면 할 일이 없다 — 그 게시가 끝나면 정리가 이어 맞춘다. 게시는
986
+ * 한 번에 하나 — 새 화면의 게시는 앞 게시가 끝난 뒤 시작한다(늦게 붙은 옛 화면은 그 뒤 정리가 내린다 — 공유 화면이 둘 게시되는
987
+ * 순간을 만들지 않는다).
988
+ */
989
+ _publishScreen(room, latest, keep) {
990
+ const screen = this._screen;
991
+ if (screen.publishing?.latest === latest) {
992
+ return;
993
+ }
994
+ const previous = screen.publishing?.done ?? Promise.resolve();
995
+ const job = { latest, done: null };
996
+ job.done = previous.then(() => this._publishTracks(room, job, keep));
997
+ screen.publishing = job;
998
+ }
999
+
1000
+ /**
1001
+ * 게시 줄의 일 — 고른 화면의 트랙을 하나씩 게시한다. 트랙마다 게시 전에 다시 본다: 끝났거나·껐거나·다른 화면을 골랐거나·다시 맞추는
1002
+ * 중이면 그친다(정리가 맡는다). 게시하는 사이 전체 재접속이 끼었으면(이미 다시 붙었어도 — livekit 은 다른 트랙의 다시 게시 실패를
1003
+ * 잡고 곧바로 Reconnected 를 내서, 그 실패가 이 게시에 늦게 닿을 수 있다) 그 게시 실패는 최종이 아니다 — 화면은 두고 Reconnected 가
1004
+ * 부른 정리가 다시 게시한다. 끝나면 이 일을 게시 줄에서 내리고 정리를 다시 줄 세운다 — 실패면 그 실패와 함께(그 정리가 이미 게시된
1005
+ * 나머지를 내린다 — 그 게시 알림의 정리는 이미 지나갔을 수 있고, 이 일이 Reconnected 의 정리에서 시작됐으면 뒤에 오는 정리가 없다).
1006
+ */
1007
+ async _publishTracks(room, job, keep) {
1008
+ const screen = this._screen;
1009
+ const local = room.localParticipant;
1010
+ let failed = null;
1011
+ for (const track of keep) {
1012
+ if (!this._isCurrent(room) || !screen.wanted || screen.latest !== job.latest || this._reconnecting || this._settling(room)) {
1013
+ break;
1014
+ }
1015
+ if (!live(track) || local.getPublicationForTrack(track)) {
1016
+ continue;
1017
+ }
1018
+ const reconnection = this._reconnection;
1019
+ try {
1020
+ await local.publishTrack(track);
1021
+ } catch (error) {
1022
+ if (!this._reconnecting && this._reconnection === reconnection) {
1023
+ failed = { error, latest: job.latest, keep };
1024
+ }
1025
+ break;
1026
+ }
1027
+ }
1028
+ if (screen.publishing === job) {
1029
+ screen.publishing = null;
1030
+ }
1031
+ await this._reconcileScreen(room, failed).catch(error => this.logger.warn('Failed to reconcile screen share:', error));
1032
+ }
1033
+
1034
+ /**
1035
+ * 게시 실패 — 그 공유 화면 전체(영상·소리)를 멈추고, 그 화면이 아직 마지막 화면이면 실패로 적는다(마지막 켜기가 받는다 — 기다리는
1036
+ * 켜기가 없으면 mediaError. 끄기로 버린 화면·다시 고른 화면의 실패는 알리지 않는다).
1037
+ */
1038
+ _screenPublishFailed(room, { error, latest, keep }) {
1039
+ const screen = this._screen;
1040
+ stopAll(keep);
1041
+ if (latest === screen.latest) {
1042
+ screen.failure = { tracks: latest, error };
1043
+ if (this._isCurrent(room) && screen.waiting !== screen.version) {
1044
+ this.emit('mediaError', { source: 'screen', error });
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * 마지막에 고른 화면이 공유 중인가 — 영상 트랙이 살아 있고 살아 있는 트랙(영상·소리)이 모두 게시돼 있거나, 전체 재접속 중이고 그 전에
1051
+ * SDK 가 게시를 마친 화면이다(livekit 이 다시 게시한다). 영상만 게시된 채 소리를 올리기 전이면 아니다 — 그 사이 재접속이 끼면 소리
1052
+ * 게시는 다시 붙은 뒤로 미뤄지고, 그 결과까지 봐야 켜기가 끝난다.
1053
+ */
1054
+ _screenShared() {
1055
+ const screen = this._screen;
1056
+ const tracks = (screen.latest ?? []).filter(live);
1057
+ if (!tracks.some(track => track.source === SCREEN_VIDEO)) {
1058
+ return false;
1059
+ }
1060
+ const local = this._room?.localParticipant;
1061
+ if (local && tracks.every(track => local.getPublicationForTrack(track))) {
1062
+ return true;
1063
+ }
1064
+ return this._reconnecting && screen.shared === screen.latest;
1065
+ }
1066
+
1067
+ /**
1068
+ * 그 장치에 반영할 의도가 있는가 — 마지막 처리 뒤 의도가 바뀌었나. 꺼져 있는 장치(마지막 처리가 성공한 «끔»)를 끄는 의도는 livekit 을
1069
+ * 부르지 않고 그 자리에서 끝낸다. 켜는 의도는 같은 값이어도 다시 부른다 — 서버 음소거·장치 빠짐으로 실제가 달라졌을 수 있다.
1070
+ */
1071
+ _needsApply(source) {
1072
+ const device = this._devices[source];
1073
+ if (device.done.version === device.version) {
1074
+ return false;
1075
+ }
1076
+ if (!device.wanted && device.done.ok && !device.done.value) {
1077
+ this._settleDevice(source, { version: device.version, value: false, ok: true });
1078
+ return false;
1079
+ }
1080
+ return true;
1081
+ }
1082
+
1083
+ /**
1084
+ * 처리 결과를 적고 기다리던 호출을 끝낸다 — 그 의도가 실패했으면 그 의도를 부른 호출만 던지고, 앞선(대체된) 호출은 그대로 끝낸다.
1085
+ * 실패한 의도를 기다리는 호출이 없으면(접속 때 켠 장치) mediaError 를 낸다 — 그 사이 새 의도가 왔으면 낼 것이 없다(사용자가 이미
1086
+ * 바꿨다).
1087
+ */
1088
+ _settleDevice(source, { version, value, ok, error }) {
1089
+ const device = this._devices[source];
1090
+ device.done = { version, value, ok };
1091
+ if (ok) {
1092
+ device.applied = value;
1093
+ }
1094
+ const waiting = [];
1095
+ let reported = false;
1096
+ for (const waiter of device.waiters) {
1097
+ if (waiter.version > version) {
1098
+ waiting.push(waiter);
1099
+ } else if (waiter.version === version && !ok) {
1100
+ waiter.reject(error);
1101
+ reported = true;
1102
+ } else {
1103
+ waiter.resolve();
1104
+ }
1105
+ }
1106
+ device.waiters = waiting;
1107
+ if (!ok && !reported && !this._disposed && version === device.version) {
1108
+ this.logger.warn(`Failed to turn ${value ? 'on' : 'off'} ${source}:`, error);
1109
+ this.emit('mediaError', { source, error });
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * 세션이 끝났다 — 기다리던 켜고 끄기를 끝내고, 획득 차례·장치 잠금·재접속 대기와 진행 중인 장치 바꾸기·화면 공유는 _ended 로
1115
+ * 끝난다(다음 연결은 부른 쪽의 의도·고른 장치로 붙는다).
1116
+ */
1117
+ _end() {
1118
+ for (const device of Object.values(this._devices)) {
1119
+ const waiters = device.waiters;
1120
+ device.waiters = [];
1121
+ waiters.forEach(waiter => waiter.resolve());
1122
+ }
1123
+ // 잡은 공유 화면은 어디서 기다리든(앞선 내리기·livekit 의 다시 게시·협상) 그 자리에서 멈춘다 — 공유 표시가 남지 않게.
1124
+ stopAll(this._screen.owned);
1125
+ // 이 세션의 카메라·마이크 트랙도 모두 그 자리에서 멈춘다 — livekit 은 Room 이 끊길 때 게시된 트랙만 멈추고(≈35740), 게시를
1126
+ // 기다리는 트랙(다시 게시·재접속 대기 뒤 15초 ≈32371–32378)·게시 없이 열린 트랙·바뀐 트랙은 그대로 둔다. 진행 중인 다시 열기는
1127
+ // 끝날 때 livekit 이 멈추고(≈22967), 끝난 뒤에 열리거나 다시 열린 트랙은 그 순간 멈춘다(_wire 의 소유 적기).
1128
+ Object.values(this._devices).forEach(device => device.tracks.forEach(track => track.stop?.()));
1129
+ this._markEnded();
1130
+ }
1131
+
1132
+ /** 아직 알리지 않은 사람이면 participantJoined 를 낸다 — 이번에 알렸으면 true. */
1133
+ _announce(participant) {
1134
+ const userId = participant.identity;
1135
+ if (this._announced.has(userId)) {
1136
+ return false;
1137
+ }
1138
+ this._announced.add(userId);
1139
+ this.emit('participantJoined', { userId });
1140
+ return true;
1141
+ }
1142
+
1143
+ _requireNotDisposed() {
1144
+ if (this._disposed) {
1145
+ throw new Error('Media session was disconnected while connecting');
1146
+ }
1147
+ }
1148
+
1149
+ /** 그 Room 이 아직 이 세션의 살아 있는 Room 인가 — 끊기(SDK·서버) 뒤면 false. */
1150
+ _isCurrent(room) {
1151
+ return !this._disposed && this._room === room;
1152
+ }
1153
+
1154
+ _wire(room, lk, isConnected) {
1155
+ const E = lk.RoomEvent;
1156
+ const current = () => this._room === room;
1157
+ // livekit 은 재접속·재개 중의 입장·트랙 구독·음소거 알림을 모아 두었다가 다시 붙은 뒤 내고, 퇴장·트랙 해제는 바로 낸다 — 늦게 온
1158
+ // 알림은 그 참가자 객체가 아직 Room 에 있는 그 연결일 때만 낸다(이미 떠난 사람을 알리면 짝인 participantLeft 가 오지 않는다).
1159
+ room.on(E.ParticipantConnected, (participant) => {
1160
+ if (current() && present(room, participant)) this._announce(participant);
1161
+ });
1162
+ // 알린 사람만 — 접속 중에 들어왔다 나간 사람에게 participantJoined 없이 participantLeft 만 가지 않게.
1163
+ room.on(E.ParticipantDisconnected, (participant) => {
1164
+ if (!current() || !this._announced.delete(participant.identity)) return;
1165
+ this.emit('participantLeft', { userId: participant.identity });
1166
+ });
1167
+ // 원격 트랙 알림은 그 사람의 participantJoined 뒤에 — livekit 은 먼저 와 있던 사람의 트랙을 room.connect 가 끝나기 전에 낸다. 늦게
1168
+ // 온 구독 알림은 그 구독이 아직 붙어 있을 때만(그 사이 해제됐으면 해제 알림이 먼저 갔다 — livekit 은 해제 때 publication.track 을
1169
+ // 비운다).
1170
+ room.on(E.TrackSubscribed, (track, publication, participant) => {
1171
+ if (!current() || !present(room, participant) || publication.track !== track) return;
1172
+ this._announce(participant);
1173
+ this.emit('trackSubscribed', { userId: participant.identity, source: sourceOf(track), track });
1174
+ this._emitMedia(participant);
1175
+ });
1176
+ room.on(E.TrackUnsubscribed, (track, _publication, participant) => {
1177
+ if (!current() || !this._announced.has(participant.identity)) return;
1178
+ this.emit('trackUnsubscribed', { userId: participant.identity, source: sourceOf(track), track });
1179
+ this._emitMedia(participant);
1180
+ });
1181
+ // 음소거는 내 트랙에도 온다 — 내 것이면 userId 를 null 로(다른 이벤트와 같은 약속). 다른 사람 것은 그 사람의 participantJoined 뒤에.
1182
+ const owner = (participant) => (participant === room.localParticipant ? null : participant.identity);
1183
+ const muteChanged = (participant) => {
1184
+ if (!current()) return;
1185
+ if (participant !== room.localParticipant) {
1186
+ if (!present(room, participant)) return;
1187
+ this._announce(participant);
1188
+ }
1189
+ this._emitMedia(participant, owner(participant));
1190
+ };
1191
+ // 내 카메라·마이크는 livekit 이 스스로 바꿀 수 있다(잃은 장치 음소거·다시 게시·다시 열기) — 그때마다 확정 상태로 들인다.
1192
+ const syncDevice = (source) => this._syncDevice(room, source).catch(error => this.logger.warn(`Failed to sync ${source}:`, error));
1193
+ const restarted = lk.TrackEvent?.Restarted ?? 'restarted';
1194
+ const localDevice = (publication, participant) => participant === room.localParticipant && DEVICES.includes(publication.source);
1195
+ // 이 세션의 카메라·마이크 트랙을 연 순서대로 소유로 적는다 — livekit 이 그 트랙을 다시 열 때마다(Restarted — 다시 게시·장치 빠짐·
1196
+ // 기기 변화·켜기) 바뀐 트랙을 정리하고 확정 상태로 들인다. 세션이 끝난 뒤에 열리거나 다시 열린 트랙은 그 자리에서 멈춘다(livekit
1197
+ // 은 다시 열기를 시작할 때 멈춤 표시를 지운다 ≈22916).
1198
+ const own = (track) => {
1199
+ const source = DEVICES.includes(track?.source) ? track.source : null;
1200
+ if (!source) return;
1201
+ const device = this._devices[source];
1202
+ if (!device.tracks.has(track)) {
1203
+ device.tracks.add(track);
1204
+ if (typeof track.on === 'function') {
1205
+ track.on(restarted, () => {
1206
+ if (!this._isCurrent(room)) {
1207
+ track.stop?.();
1208
+ return;
1209
+ }
1210
+ this._collect(room, source);
1211
+ syncDevice(source);
1212
+ });
1213
+ }
1214
+ }
1215
+ if (!this._isCurrent(room)) track.stop?.();
1216
+ };
1217
+ // livekit 이 카메라·마이크를 새로 여는 곳은 켜기의 처음 획득이다(이 LocalParticipant 의 createTracks ≈32035·≈32040 — livekit-client
1218
+ // 2.22.4 고정, 판을 올리면 다시 확인한다). 연 순간 소유로 적는다 — 게시를 기다리는 사이(다시 게시·재접속 대기 ≈32279–32282)
1219
+ // 세션이 끝나도 그 자리에서 멈추고, 같은 장치의 앞선 트랙은 이 트랙이 게시되면 바뀐 트랙이 된다. 경로가 바뀌어도 게시 알림에서 적는다.
1220
+ const local = room.localParticipant;
1221
+ const createTracks = local.createTracks;
1222
+ if (typeof createTracks === 'function') {
1223
+ local.createTracks = async (...args) => {
1224
+ const tracks = await createTracks.apply(local, args);
1225
+ tracks.forEach(track => own(track));
1226
+ return tracks;
1227
+ };
1228
+ }
1229
+ room.on(E.TrackMuted, (publication, participant) => {
1230
+ muteChanged(participant);
1231
+ if (current() && localDevice(publication, participant)) syncDevice(publication.source);
1232
+ });
1233
+ room.on(E.TrackUnmuted, (publication, participant) => {
1234
+ muteChanged(participant);
1235
+ if (current() && localDevice(publication, participant)) syncDevice(publication.source);
1236
+ });
1237
+ room.on(E.LocalTrackPublished, (publication, participant) => {
1238
+ if (!current()) return;
1239
+ // livekit 의 전체 재접속은 이 세션이 멈춘 공유 화면까지 다시 게시한다 — 공유 화면이 게시될 때마다 마지막 의도로 다시 맞춘다
1240
+ // (내 게시면 할 일이 없다).
1241
+ if (SCREEN_SOURCES.has(publication.source)) {
1242
+ this._reconcileScreen(room).catch(error => this.logger.warn('Failed to reconcile screen share:', error));
1243
+ }
1244
+ // 카메라·마이크 트랙은 게시되면 그 장치의 지금 트랙이 된다(그보다 먼저 연 트랙은 바뀐 트랙 — 멈추고 내린다). 바뀐 트랙이 늦게
1245
+ // 게시됐으면(전체 재접속의 다시 게시) 내리고 멈추며 알리지 않는다 — 앱에는 지금 트랙만 새로 보인다. 알린 게시는 적어 두고 그
1246
+ // 게시가 내려질 때 짝으로 알린다(_shownTracks).
1247
+ const track = publication.track;
1248
+ if (localDevice(publication, participant) && track) {
1249
+ own(track);
1250
+ const adopted = this._adopt(this._devices[publication.source], track);
1251
+ this._collect(room, publication.source);
1252
+ if (!adopted) return;
1253
+ this._shownTracks.add(track);
1254
+ syncDevice(publication.source);
1255
+ }
1256
+ this.emit('localTrackPublished', { source: sourceOf(publication.track ?? publication), track: publication.track });
1257
+ this._emitMedia(participant, null);
1258
+ });
1259
+ room.on(E.LocalTrackUnpublished, (publication, participant) => {
1260
+ if (!current()) return;
1261
+ // 카메라·마이크는 앱에 게시를 알린 트랙만 내림도 알린다 — 알린 뒤 바뀐 트랙이 되어 내려져도 짝을 맞추고(앱의 미리보기가 남지
1262
+ // 않게), 알리지 않은 늦은 게시(바뀐 트랙)의 내림은 알리지 않는다.
1263
+ if (localDevice(publication, participant) && !this._shownTracks.delete(publication.track)) return;
1264
+ this.emit('localTrackUnpublished', { source: sourceOf(publication.track ?? publication), track: publication.track });
1265
+ this._emitMedia(participant, null);
1266
+ });
1267
+ // Room 접속 중의 재접속은 알리지 않고 상태만 적는다 — 받는 쪽은 connect 가 끝날 때 isReconnecting() 으로 본다.
1268
+ // 다시 붙을 때까지 기다리는 호출(화면 공유 켜기·카메라·마이크 바꾸기)은 이 재접속의 약속 하나를 기다린다 — 다시 붙기 전에 또 와도
1269
+ // 새로 만들지 않는다(livekit 은 연결 상태가 바뀔 때만 내지만 ≈34435·≈35142, 겹쳐 새로 만들면 기다리던 호출이 묶인다).
1270
+ room.on(E.Reconnecting, () => {
1271
+ if (!current()) return;
1272
+ this._reconnecting = true;
1273
+ if (!this._reconnectionDone) {
1274
+ this._reconnection = new Promise((resolve) => { this._reconnectionDone = resolve; });
1275
+ }
1276
+ if (!this._connecting) this.emit('reconnecting');
1277
+ });
1278
+ // 시그널만 다시 붙은 것(SignalReconnecting → Reconnected)은 끊김을 알리지 않았으니 다시 붙음도 알리지 않는다 — 알리면 받는 쪽이
1279
+ // connected 를 한 번 더 낸다. 전체 재접속(Reconnecting 뒤)만 알린다.
1280
+ room.on(E.Reconnected, () => {
1281
+ if (!current()) return;
1282
+ const wasReconnecting = this._reconnecting;
1283
+ this._reconnecting = false;
1284
+ const done = this._reconnectionDone;
1285
+ this._reconnectionDone = null;
1286
+ done?.();
1287
+ // 전체 재접속은 게시를 내렸다 다시 올린다 — 다시 붙으면 화면 공유를 마지막 의도로 다시 맞춘다(그 사이 끄거나 다시 고른 화면,
1288
+ // 재접속 중 미룬 게시).
1289
+ this._reconcileScreen(room).catch(error => this.logger.warn('Failed to reconcile screen share:', error));
1290
+ // 다시 게시는 내린 카메라·마이크를 다시 열고 올린다 — 다시 맞추기가 끝나면 다시 열지 못한 장치를 잃은 장치로 맞춘다.
1291
+ if (wasReconnecting) {
1292
+ Promise.resolve(this._settling(room)).then(() => {
1293
+ if (current()) DEVICES.forEach(syncDevice);
1294
+ });
1295
+ }
1296
+ if (wasReconnecting && !this._connecting) this.emit('reconnected');
1297
+ });
1298
+ room.on(E.MediaDevicesChanged, () => {
1299
+ if (current()) this.emit('devicesChanged');
1300
+ });
1301
+ // livekit 이 스스로 옮긴 장치(끝난 트랙을 다른 장치로 다시 열기 ≈34811, 기기 변화로 출력 전환 ≈35827–35834)는 고른 장치 기억이
1302
+ // 따라간다 — 그 종류의 장치 바꾸기가 진행 중이면 그 결과가 맞추니 보지 않고, 고른 장치가 없으면(기본 장치) 그대로 둔다(처음 게시가
1303
+ // 기본 장치의 실제 id 를 알려도 기본 장치를 특정 장치로 묶지 않는다). 운영체제의 기본 장치 바뀜 알림은 쓰는 장치 표가 'default'
1304
+ // 그대로라 기억도 기본 장치다(≈35801).
1305
+ room.on(E.ActiveDeviceChanged, (kind) => {
1306
+ if (!current() || this._switching[kind] || this._chosen[kind] === undefined) return;
1307
+ const inUse = this.deviceInUse(kind);
1308
+ if (inUse === this._chosen[kind]) return;
1309
+ if (inUse === undefined) {
1310
+ delete this._chosen[kind];
1311
+ } else {
1312
+ this._chosen[kind] = inUse;
1313
+ }
1314
+ this.emit('deviceInUseChanged', { kind, deviceId: inUse });
1315
+ });
1316
+ room.on(E.Disconnected, (reason) => {
1317
+ // 세션은 한 번 쓰고 버린다 — 이 Room 이 끊기면(SDK·서버·실패한 접속) 세션도 끝이다. connect() 가 켜던 장치의 다음 단계(카메라)를
1318
+ // 시작하지 않게 표시한다(통화가 끝난 뒤 장치 획득 금지).
1319
+ this._disposed = true;
1320
+ this._reconnecting = false;
1321
+ this._end();
1322
+ // livekit-client 는 페이지 떠남(pagehide·beforeunload)·탭 얼림 때 스스로 disconnect() 를 부르고 사유는 CLIENT_INITIATED 다 —
1323
+ // SDK 가 끊지 않았으면 «미디어가 끊겼다» 로 알린다(안 그러면 통화가 끝나지 않은 채 남는다).
1324
+ const mapped = DISCONNECT_REASONS[reason] ?? 'connection_lost';
1325
+ const sdkReason = mapped === 'client' && !this._closing.has(room) ? 'connection_lost' : mapped;
1326
+ // livekit 은 Room 이 끊길 때 원격 참가자를 이벤트 없이 비운다 — 이 세션이 알린 사람마다 participantLeft 를 낸다(접속 중 트랙
1327
+ // 알림으로 먼저 알렸을 수도 있다). 서버가 연결을 뺀 뒤 다시 입장하면 새 연결이 같은 사람을 다시 알리므로 짝이 맞는다.
1328
+ this._leaveAnnounced();
1329
+ // 접속 중 실패의 Disconnected 는 connect() 가 던지는 것으로 대신한다 — 여기서도 알리면 받는 쪽이 «재접속 실패» 로 끝내
1330
+ // «첫 접속 실패 → 입장 한 번 더» 를 못 한다. 사유만 적어 그 오류에 싣는다(같은 사람의 다른 기기·방 삭제면 다시 입장하지 않게).
1331
+ if (!isConnected()) {
1332
+ this._failedReason = sdkReason;
1333
+ return;
1334
+ }
1335
+ // 내가 끊은 뒤(_room 이 이미 비었다)에도 사유는 알린다 — 받는 쪽이 «내가 끊음» 을 보고 무시한다.
1336
+ if (this._room === room) {
1337
+ this._room = null;
1338
+ }
1339
+ this.emit('disconnected', { reason: sdkReason });
1340
+ });
1341
+ }
1342
+
1343
+ _emitMedia(participant, userId = participant.identity) {
1344
+ this.emit('participantMediaChanged', mediaOf(participant, userId));
1345
+ }
1346
+
1347
+ /** 알린 사람마다 participantLeft 를 내고 비운다 — 이미 낸 사람은 다시 내지 않는다. */
1348
+ _leaveAnnounced() {
1349
+ for (const userId of this._announced) {
1350
+ this.emit('participantLeft', { userId });
1351
+ }
1352
+ this._announced.clear();
1353
+ }
1354
+ }
1355
+
1356
+ /**
1357
+ * wake — 획득 줄에서 기다리는 동안만 있다(새 의도를 보게 깨운다). lock — 장치 잠금의 줄 끝. holding — 고리가 livekit 을 부르는 중.
1358
+ * reserved — 그동안 바뀐 의도를 위해 미리 잡아 둔 다음 반영의 장치 잠금 자리. tracks — 이 세션이 그 장치로 가진 트랙(연 순서, 세션이
1359
+ * 끝날 때까지). current — 그 장치의 지금 트랙(게시된 것 중 가장 늦게 연 트랙).
1360
+ */
1361
+ function deviceState() {
1362
+ return { wanted: false, version: 0, done: { version: 0, value: false, ok: true }, applied: false, waiters: [], applying: false,
1363
+ wake: null, lock: Promise.resolve(), holding: false, reserved: null, tracks: new Set(), current: null };
1364
+ }
1365
+
1366
+ /** 잡아 둔 장치 잠금 자리를 놓는다(다음 반영이 잠금을 쓰지 않거나 끝났다). */
1367
+ function dropReserved(device) {
1368
+ device.reserved?.release();
1369
+ device.reserved = null;
1370
+ }
1371
+
1372
+ /** 장치 종류 → 반영 고리의 장치(스피커는 고리가 없다). */
1373
+ const SOURCE_OF_KIND = { audioinput: 'microphone', videoinput: 'camera' };
1374
+ /** livekit 이 «기본 장치» 를 적는 id(쓰는 장치 표의 처음 값 ≈31825, 브라우저의 기본 출력·입력 별칭). */
1375
+ const DEFAULT_DEVICE = 'default';
1376
+
1377
+ /** livekit 의 공유 화면 소스(Track.Source.ScreenShare·ScreenShareAudio) — 공유 중인지는 영상 트랙으로 본다. */
1378
+ const SCREEN_VIDEO = 'screen_share';
1379
+ const SCREEN_SOURCES = new Set([SCREEN_VIDEO, 'screen_share_audio']);
1380
+
1381
+ /** 획득 줄에서 빠졌다(기다리는 사이 그 장치를 껐다). */
1382
+ const SKIP = Symbol('skip');
1383
+
1384
+ /** 차례가 필요 없을 때의 «놓기». */
1385
+ const NO_LOCK = () => {};
1386
+
1387
+ /** 줄 끝에 선다 — 앞 사람(previous)이 놓으면 내 차례고, 내가 놓으면(release) 뒤 사람 차례다. */
1388
+ function joinLine(owner, key) {
1389
+ let release;
1390
+ const mine = new Promise((resolve) => { release = resolve; });
1391
+ const previous = owner[key];
1392
+ owner[key] = previous.then(() => mine);
1393
+ return { previous, release };
1394
+ }
1395
+
1396
+ /**
1397
+ * 그 게시의 트랙이 지금 연 장치 id — 게시가 없거나 트랙이 끝났으면(꺼진 카메라·빠진 장치) undefined. 끝난 트랙은 옛 장치를 보고하고,
1398
+ * 꺼진 트랙의 장치 바꾸기는 livekit 이 다음 켜기 때 열 장치로만 적는다(≈22829–22834) — 그때는 livekit 의 쓰는 장치 표가 맞다.
1399
+ */
1400
+ function liveDeviceId(publication) {
1401
+ const track = publication?.track;
1402
+ return live(track) ? track.mediaStreamTrack.getSettings().deviceId : undefined;
1403
+ }
1404
+
1405
+ /**
1406
+ * livekit 이 그 트랙을 다음에 열 장치 id — 트랙의 제약(`constraints` — 다시 열기·켜기는 이것으로 연다 ≈22918, 장치 바꾸기는 여기에
1407
+ * 적는다 ≈22831). 제약은 문자열·{exact}·{ideal}·배열일 수 있다. 장치가 없으면(기본 장치) undefined.
1408
+ */
1409
+ function nextDeviceId(track) {
1410
+ const wanted = track?.constraints?.deviceId;
1411
+ const id = wanted && typeof wanted === 'object' && !Array.isArray(wanted) ? (wanted.exact ?? wanted.ideal) : wanted;
1412
+ return Array.isArray(id) ? id[0] : id;
1413
+ }
1414
+
1415
+ /** 그 참가자 객체가 지금 Room 에 있는 그 연결인가 — 이미 떠났거나 같은 사람의 새 연결로 바뀌었으면 false. */
1416
+ function present(room, participant) {
1417
+ return room.remoteParticipants.get(participant.identity) === participant;
1418
+ }
1419
+
1420
+ /** 세션이 끝나면 기다리지 않고 value 로 끝낸다 — 그 뒤에 끝나는 원래 호출의 결과(실패 포함)는 버린다. */
1421
+ function untilEnded(promise, ended, value) {
1422
+ promise.catch(() => {});
1423
+ return Promise.race([promise, ended.then(() => value)]);
1424
+ }
1425
+
1426
+ /** 공유 화면 트랙들을 멈춘다(이미 멈춘 것도 그대로 둔다). */
1427
+ function stopAll(tracks) {
1428
+ tracks.forEach(track => track.stop());
1429
+ }
1430
+
1431
+ /** 트랙이 아직 살아 있나 — 브라우저의 «공유 중지»·SDK 가 멈춘 트랙·장치가 빠져 끝난 트랙은 아니다(트랙이 없으면 아니다). */
1432
+ function live(track) {
1433
+ return track?.mediaStreamTrack?.readyState === 'live';
1434
+ }
1435
+
1436
+ /** 카메라·마이크를 livekit 으로 켜고 끈다. */
1437
+ function setEnabled(room, source, enabled) {
1438
+ return source === 'camera' ? room.localParticipant.setCameraEnabled(enabled) : room.localParticipant.setMicrophoneEnabled(enabled);
1439
+ }
1440
+
1441
+ /** 그 장치의 게시된 트랙을 멈춘다(끝난 Room 에 늦게 붙은 게시). */
1442
+ function stopPublished(room, source) {
1443
+ room.localParticipant.getTrackPublication(source)?.track?.stop?.();
1444
+ }
1445
+
1446
+ /**
1447
+ * 게시된 마이크 트랙이 끝나 있으면(장치가 빠져 livekit 이 다시 열지 못했다) 켜기 전에 다시 연다 — livekit 의 마이크 음소거 풀기는 음소거가
1448
+ * 아닌 트랙을 다시 열지 않는다(카메라는 연다 ≈24050). 못 열면 그 켜기가 실패한다(장치가 돌아왔으면 켜진다).
1449
+ */
1450
+ async function reopenEnded(source, publication) {
1451
+ const track = publication?.track;
1452
+ if (source === 'microphone' && track && !live(track) && typeof track.restartTrack === 'function') {
1453
+ await track.restartTrack();
1454
+ }
1455
+ }
1456
+
1457
+ /**
1458
+ * 게시 없이 열린 채 남은 카메라·마이크 트랙을 멈춘다 — 다시 게시가 다시 열고 게시만 실패하면 트랙이 살아 있는데 게시가 없고, livekit 의
1459
+ * 끄기는 게시가 없으면 아무것도 안 한다(≈32088–32110).
1460
+ */
1461
+ function stopUnpublished(room, track) {
1462
+ if (track && live(track) && !room.localParticipant.getPublicationForTrack(track)) {
1463
+ track.stop();
1464
+ }
1465
+ }
1466
+
1467
+ /** livekit 이 스스로 끈 장치의 오류 — 끝난 트랙을 다시 열지 못했다(장치가 빠졌거나 다른 앱이 쓴다). */
1468
+ function deviceLost(source) {
1469
+ return Object.assign(new Error(`The ${source} stopped and could not be reopened`), { name: 'NotReadableError' });
1470
+ }
1471
+
1472
+ /** 동기로 던져도 promise 로 — 반영 고리가 «도는 중» 표시를 남긴 채 멈추지 않게. */
1473
+ function invoke(call) {
1474
+ try {
1475
+ return Promise.resolve(call());
1476
+ } catch (error) {
1477
+ return Promise.reject(error);
1478
+ }
1479
+ }
1480
+
1481
+ /**
1482
+ * 고른 장치를 Room 기본값으로 — livekit 의 «장치 바꾸기» 가 적는 자리와 같다. 입력 장치는 id 문자열로 준다 — livekit 은 그 장치를
1483
+ * 정확히 먼저 찾고, 없으면(그 사이 빠진 장치) 같은 id 를 «먼저» 로 다시 열어 다른 장치로 붙는다(createLocalTracks 의 exact → ideal 재시도).
1484
+ */
1485
+ function captureOptions(chosen) {
1486
+ const options = {};
1487
+ if (chosen.audioinput) options.audioCaptureDefaults = { deviceId: chosen.audioinput };
1488
+ if (chosen.videoinput) options.videoCaptureDefaults = { deviceId: chosen.videoinput };
1489
+ if (chosen.audiooutput) options.audioOutput = { deviceId: chosen.audiooutput };
1490
+ return options;
1491
+ }
1492
+
1493
+ /**
1494
+ * 지금 Room 에 있는 참가자 모두의 livekit 출력 기록을 바꾼다(참가자·트랙의 출력 — 뒤에 붙는 소리 요소가 쓴다). 그 요소 바꾸기도 livekit
1495
+ * 이 한다 — SDK 가 이미 바꾼 요소는 같은 값이라 바로 끝난다. 실패가 있으면 그 오류, 없으면 null.
1496
+ */
1497
+ async function setOutput(room, deviceId) {
1498
+ const results = await Promise.allSettled([...room.remoteParticipants.values()].map(participant => participant.setAudioOutput({ deviceId })));
1499
+ const failure = results.find(result => result.status === 'rejected');
1500
+ return failure ? (failure.reason ?? new Error('Failed to switch audio output')) : null;
1501
+ }
1502
+
1503
+ /** 지금 Room 의 원격 소리 요소(참가자의 소리 트랙마다 붙은 요소 — 마이크·화면 소리, 앱이 attach 한 것) 중 출력을 바꿀 수 있는 것. */
1504
+ function sinkElements(room) {
1505
+ const elements = new Set();
1506
+ for (const participant of room.remoteParticipants.values()) {
1507
+ for (const publication of participant.audioTrackPublications?.values() ?? []) {
1508
+ for (const element of publication.track?.attachedElements ?? []) {
1509
+ if (typeof element.setSinkId === 'function') {
1510
+ elements.add(element);
1511
+ }
1512
+ }
1513
+ }
1514
+ }
1515
+ return [...elements];
1516
+ }
1517
+
1518
+ /**
1519
+ * 그 요소들의 출력을 바꾸고 모두 끝날 때까지 기다린다 — 이미 그 출력인 요소는 부르지 않는다. 실패가 있으면 첫 오류, 없으면 null.
1520
+ */
1521
+ async function setSinks(elements, deviceId) {
1522
+ const results = await Promise.allSettled(elements.filter(element => !sameSink(element.sinkId, deviceId))
1523
+ .map(element => invoke(() => element.setSinkId(deviceId))));
1524
+ const failure = results.find(result => result.status === 'rejected');
1525
+ return failure ? (failure.reason ?? new Error('Failed to switch audio output')) : null;
1526
+ }
1527
+
1528
+ /** 같은 출력인가 — 빈 값(요소의 처음 출력)과 'default' 는 둘 다 기본 출력이다. */
1529
+ function sameSink(sinkId, deviceId) {
1530
+ return (sinkId || DEFAULT_DEVICE) === (deviceId || DEFAULT_DEVICE);
1531
+ }
1532
+
1533
+ /** 바꾸다 실패한 출력을 되돌리지 못했다 — 일부 소리 요소가 새 출력에 남았다(failed 는 바꾸기의 오류). */
1534
+ function outputSplit(failed, restoreFailed) {
1535
+ return Object.assign(new Error('Audio output was partly switched and could not be restored'), {
1536
+ cause: failed, restoreError: restoreFailed ?? undefined, outputMismatch: true
1537
+ });
1538
+ }
1539
+
1540
+ /** 이 브라우저가 출력 장치를 바꿀 수 있나(livekit 의 확인과 같다) — 브라우저 밖(시험)은 된다고 본다. */
1541
+ function canSetSink() {
1542
+ return typeof HTMLMediaElement === 'undefined' || 'setSinkId' in HTMLMediaElement.prototype;
1543
+ }
1544
+
1545
+ function screenNotShared() {
1546
+ return new Error('Screen share stopped before it was published');
1547
+ }
1548
+
1549
+ function notConnected() {
1550
+ const error = new Error('Media is not connected');
1551
+ error.code = ErrorTypes.INVALID_STATE;
1552
+ return error;
1553
+ }
1554
+
1555
+ function sourceOf(trackOrPublication) {
1556
+ return SOURCES[trackOrPublication?.source] ?? 'unknown';
1557
+ }
1558
+
1559
+ function mediaOf(participant, userId) {
1560
+ return {
1561
+ userId,
1562
+ camera: Boolean(participant?.isCameraEnabled),
1563
+ microphone: Boolean(participant?.isMicrophoneEnabled),
1564
+ screen: Boolean(participant?.isScreenShareEnabled)
1565
+ };
1566
+ }
1567
+
1568
+ export default LiveKitMediaSession;