@vibexnpm/talkx 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +327 -0
- package/dist/index.d.ts +1771 -0
- package/dist/talkflow-sdk.esm.js +2 -0
- package/dist/talkflow-sdk.esm.js.map +1 -0
- package/dist/talkflow-sdk.standalone.js +2 -0
- package/dist/talkflow-sdk.standalone.js.map +1 -0
- package/dist/talkflow-sdk.umd.js +2 -0
- package/dist/talkflow-sdk.umd.js.map +1 -0
- package/package.json +51 -0
- package/src/TalkFlowClient.js +481 -0
- package/src/chat/ChatClient.js +2221 -0
- package/src/constants.js +411 -0
- package/src/core/ConnectionManager.js +517 -0
- package/src/index.js +97 -0
- package/src/push/PushManager.js +893 -0
- package/src/talkflow/delegates.js +112 -0
- package/src/talkflow/eventForwarding.js +93 -0
- package/src/talkflow/session.js +355 -0
- package/src/utils/ApiClient.js +305 -0
- package/src/utils/EventEmitter.js +113 -0
- package/src/utils/Logger.js +88 -0
- package/src/utils/jwtUtils.js +213 -0
- package/src/webrtc/MediaStreamManager.js +478 -0
- package/src/webrtc/PeerConnectionManager.js +467 -0
- package/src/webrtc/WebRTCClient.js +1041 -0
- package/types/index.d.ts +1771 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT 유틸리티 함수
|
|
3
|
+
* JWT 토큰 파싱 및 검증
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ErrorTypes } from '../constants.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Bearer 접두사 제거
|
|
10
|
+
* @param {string} token - JWT 토큰
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function stripBearer(token) {
|
|
14
|
+
if (!token || typeof token !== 'string') {
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
return token.replace(/^Bearer\s+/i, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Base64URL 디코딩 (UTF-8 안전)
|
|
22
|
+
* @param {string} str - Base64URL 인코딩된 문자열
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function base64UrlDecode(str) {
|
|
26
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
27
|
+
const padding = base64.length % 4;
|
|
28
|
+
if (padding) {
|
|
29
|
+
base64 += '='.repeat(4 - padding);
|
|
30
|
+
}
|
|
31
|
+
// atob()은 Latin-1 기반이라 멀티바이트 UTF-8(한국어 등)이 깨짐
|
|
32
|
+
// → Uint8Array 변환 후 TextDecoder로 UTF-8 디코딩
|
|
33
|
+
const binaryString = atob(base64);
|
|
34
|
+
const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));
|
|
35
|
+
return new TextDecoder().decode(bytes);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* JWT 토큰 검증 및 파싱
|
|
40
|
+
* @param {string} jwtToken - JWT 토큰 (Bearer 접두사 포함 가능)
|
|
41
|
+
* @param {Object} [options] - 옵션
|
|
42
|
+
* @param {number} [options.bufferSeconds=30] - 만료 버퍼 시간 (초)
|
|
43
|
+
* @param {boolean} [options.validateExpiry=true] - 만료 시간 검증 여부
|
|
44
|
+
* @returns {{userId: string, payload: Object}}
|
|
45
|
+
* @throws {Error}
|
|
46
|
+
*/
|
|
47
|
+
export function validateAndParseJWT(jwtToken, options = {}) {
|
|
48
|
+
const { bufferSeconds = 30, validateExpiry = true } = options;
|
|
49
|
+
|
|
50
|
+
if (!jwtToken || typeof jwtToken !== 'string') {
|
|
51
|
+
const error = new Error('JWT token is required');
|
|
52
|
+
error.code = ErrorTypes.JWT_INVALID;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tokenWithoutBearer = stripBearer(jwtToken);
|
|
57
|
+
const parts = tokenWithoutBearer.split('.');
|
|
58
|
+
|
|
59
|
+
if (parts.length !== 3) {
|
|
60
|
+
const error = new Error('Invalid JWT format: expected 3 parts separated by dots');
|
|
61
|
+
error.code = ErrorTypes.JWT_INVALID;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let payload;
|
|
66
|
+
try {
|
|
67
|
+
payload = JSON.parse(base64UrlDecode(parts[1]));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const error = new Error('Invalid JWT: failed to decode payload');
|
|
70
|
+
error.code = ErrorTypes.JWT_PARSE_FAILED;
|
|
71
|
+
error.originalError = e;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (validateExpiry && payload.exp) {
|
|
76
|
+
const expiryTime = payload.exp * 1000;
|
|
77
|
+
const bufferTime = bufferSeconds * 1000;
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
|
|
80
|
+
if (expiryTime < now + bufferTime) {
|
|
81
|
+
const error = new Error(
|
|
82
|
+
expiryTime < now
|
|
83
|
+
? 'JWT token expired'
|
|
84
|
+
: `JWT token expires within ${bufferSeconds} seconds`
|
|
85
|
+
);
|
|
86
|
+
error.code = ErrorTypes.JWT_EXPIRED;
|
|
87
|
+
error.expiredAt = new Date(expiryTime).toISOString();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (payload.iat) {
|
|
93
|
+
const issuedAt = payload.iat * 1000;
|
|
94
|
+
const clockSkew = 60 * 1000;
|
|
95
|
+
|
|
96
|
+
if (issuedAt > Date.now() + clockSkew) {
|
|
97
|
+
const error = new Error('JWT token issued in the future');
|
|
98
|
+
error.code = ErrorTypes.JWT_INVALID;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const userId = payload.userId || payload.sub || payload.user_id;
|
|
104
|
+
|
|
105
|
+
if (!userId) {
|
|
106
|
+
const error = new Error('Cannot extract userId from JWT: missing userId, sub, or user_id claim');
|
|
107
|
+
error.code = ErrorTypes.JWT_INVALID;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { userId, payload };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* JWT에서 userId만 추출
|
|
116
|
+
* @param {string} jwtToken - JWT 토큰
|
|
117
|
+
* @returns {string|null}
|
|
118
|
+
*/
|
|
119
|
+
export function extractUserIdFromJWT(jwtToken) {
|
|
120
|
+
try {
|
|
121
|
+
const tokenWithoutBearer = stripBearer(jwtToken);
|
|
122
|
+
const parts = tokenWithoutBearer.split('.');
|
|
123
|
+
|
|
124
|
+
if (parts.length !== 3) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]));
|
|
129
|
+
return payload.userId || payload.sub || payload.user_id || null;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('Failed to extract userId from JWT:', error);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* JWT 만료 여부 확인
|
|
138
|
+
* @param {string} jwtToken - JWT 토큰
|
|
139
|
+
* @param {number} [bufferSeconds=0] - 버퍼 시간 (초)
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
export function isJWTExpired(jwtToken, bufferSeconds = 0) {
|
|
143
|
+
try {
|
|
144
|
+
const tokenWithoutBearer = stripBearer(jwtToken);
|
|
145
|
+
const parts = tokenWithoutBearer.split('.');
|
|
146
|
+
|
|
147
|
+
if (parts.length !== 3) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]));
|
|
152
|
+
|
|
153
|
+
if (!payload.exp) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const expiryTime = payload.exp * 1000;
|
|
158
|
+
const bufferTime = bufferSeconds * 1000;
|
|
159
|
+
|
|
160
|
+
return expiryTime < Date.now() + bufferTime;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error('Failed to check JWT expiration:', error);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* JWT 남은 유효 시간 반환
|
|
169
|
+
* @param {string} jwtToken - JWT 토큰
|
|
170
|
+
* @returns {number} - 밀리초, 만료 시 음수
|
|
171
|
+
*/
|
|
172
|
+
export function getJWTRemainingTime(jwtToken) {
|
|
173
|
+
try {
|
|
174
|
+
const tokenWithoutBearer = stripBearer(jwtToken);
|
|
175
|
+
const parts = tokenWithoutBearer.split('.');
|
|
176
|
+
|
|
177
|
+
if (parts.length !== 3) {
|
|
178
|
+
return -1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]));
|
|
182
|
+
|
|
183
|
+
if (!payload.exp) {
|
|
184
|
+
return Infinity;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return (payload.exp * 1000) - Date.now();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error('Failed to get JWT remaining time:', error);
|
|
190
|
+
return -1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* JWT payload 디코딩 (검증 없이)
|
|
196
|
+
* @param {string} jwtToken - JWT 토큰
|
|
197
|
+
* @returns {Object|null}
|
|
198
|
+
*/
|
|
199
|
+
export function decodeJWTPayload(jwtToken) {
|
|
200
|
+
try {
|
|
201
|
+
const tokenWithoutBearer = stripBearer(jwtToken);
|
|
202
|
+
const parts = tokenWithoutBearer.split('.');
|
|
203
|
+
|
|
204
|
+
if (parts.length !== 3) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return JSON.parse(base64UrlDecode(parts[1]));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error('Failed to decode JWT payload:', error);
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaStreamManager
|
|
3
|
+
* 로컬 미디어 스트림 관리 (카메라/마이크/화면공유)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import EventEmitter from '../utils/EventEmitter.js';
|
|
7
|
+
import Logger from '../utils/Logger.js';
|
|
8
|
+
import { ErrorTypes, LogLevel } from '../constants.js';
|
|
9
|
+
|
|
10
|
+
class MediaStreamManager extends EventEmitter {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {number} [options.logLevel] - 로그 레벨
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
super();
|
|
17
|
+
|
|
18
|
+
this.localStream = null;
|
|
19
|
+
this.screenStream = null;
|
|
20
|
+
this.videoEnabled = true;
|
|
21
|
+
this.audioEnabled = true;
|
|
22
|
+
|
|
23
|
+
this.logger = new Logger(options.logLevel || LogLevel.WARN, 'MediaStreamManager');
|
|
24
|
+
this._deviceChangeHandler = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 사용자 미디어 가져오기
|
|
29
|
+
* @param {Object} constraints - 미디어 제약조건
|
|
30
|
+
* @returns {Promise<MediaStream>}
|
|
31
|
+
*/
|
|
32
|
+
async getUserMedia(constraints = { video: true, audio: true }) {
|
|
33
|
+
try {
|
|
34
|
+
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
35
|
+
this.videoEnabled = constraints.video !== false;
|
|
36
|
+
this.audioEnabled = constraints.audio !== false;
|
|
37
|
+
|
|
38
|
+
this.emit('streamStarted', { stream: this.localStream });
|
|
39
|
+
this.logger.info('User media stream started');
|
|
40
|
+
return this.localStream;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
this.logger.error('Failed to get user media:', error);
|
|
43
|
+
this.emit('error', {
|
|
44
|
+
type: ErrorTypes.MEDIA_ACCESS_DENIED,
|
|
45
|
+
message: this._getMediaErrorMessage(error),
|
|
46
|
+
error
|
|
47
|
+
});
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 미디어 에러 메시지 변환
|
|
54
|
+
* @private
|
|
55
|
+
* @param {Error} error - 에러 객체
|
|
56
|
+
* @returns {string} 변환된 에러 메시지
|
|
57
|
+
*/
|
|
58
|
+
_getMediaErrorMessage(error) {
|
|
59
|
+
switch (error.name) {
|
|
60
|
+
case 'NotAllowedError':
|
|
61
|
+
return 'Camera/Microphone permission denied';
|
|
62
|
+
case 'NotFoundError':
|
|
63
|
+
return 'Camera/Microphone not found';
|
|
64
|
+
case 'NotReadableError':
|
|
65
|
+
return 'Camera/Microphone is already in use';
|
|
66
|
+
case 'OverconstrainedError':
|
|
67
|
+
return 'Camera/Microphone constraints cannot be satisfied';
|
|
68
|
+
case 'AbortError':
|
|
69
|
+
return 'Media access aborted';
|
|
70
|
+
default:
|
|
71
|
+
return error.message || 'Unknown media error';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 화면 공유 가져오기
|
|
77
|
+
* @param {Object} [options] - 화면 공유 옵션
|
|
78
|
+
* @returns {Promise<MediaStream>}
|
|
79
|
+
*/
|
|
80
|
+
async getDisplayMedia(options = { video: true, audio: false }) {
|
|
81
|
+
try {
|
|
82
|
+
this.screenStream = await navigator.mediaDevices.getDisplayMedia(options);
|
|
83
|
+
|
|
84
|
+
// 화면 공유 종료 감지
|
|
85
|
+
this.screenStream.getVideoTracks()[0].onended = () => {
|
|
86
|
+
this.emit('screenShareEnded', {});
|
|
87
|
+
this.screenStream = null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
this.emit('screenShareStarted', { stream: this.screenStream });
|
|
91
|
+
this.logger.info('Screen share stream started');
|
|
92
|
+
return this.screenStream;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
this.logger.error('Failed to get display media:', error);
|
|
95
|
+
this.emit('error', {
|
|
96
|
+
type: ErrorTypes.SCREEN_SHARE_DENIED,
|
|
97
|
+
message: error.name === 'NotAllowedError'
|
|
98
|
+
? 'Screen share permission denied'
|
|
99
|
+
: error.message,
|
|
100
|
+
error
|
|
101
|
+
});
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 비디오 토글
|
|
108
|
+
* @returns {boolean} 현재 상태
|
|
109
|
+
*/
|
|
110
|
+
toggleVideo() {
|
|
111
|
+
if (!this.localStream) {
|
|
112
|
+
this.logger.warn('No local stream available');
|
|
113
|
+
return this.videoEnabled;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const videoTracks = this.localStream.getVideoTracks();
|
|
117
|
+
if (videoTracks.length === 0) {
|
|
118
|
+
this.logger.warn('No video track available');
|
|
119
|
+
return this.videoEnabled;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.videoEnabled = !this.videoEnabled;
|
|
123
|
+
videoTracks.forEach(track => {
|
|
124
|
+
track.enabled = this.videoEnabled;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.emit('videoToggled', { enabled: this.videoEnabled });
|
|
128
|
+
this.logger.debug(`Video toggled: ${this.videoEnabled}`);
|
|
129
|
+
return this.videoEnabled;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 오디오 토글
|
|
134
|
+
* @returns {boolean} 현재 상태
|
|
135
|
+
*/
|
|
136
|
+
toggleAudio() {
|
|
137
|
+
if (!this.localStream) {
|
|
138
|
+
this.logger.warn('No local stream available');
|
|
139
|
+
return this.audioEnabled;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const audioTracks = this.localStream.getAudioTracks();
|
|
143
|
+
if (audioTracks.length === 0) {
|
|
144
|
+
this.logger.warn('No audio track available');
|
|
145
|
+
return this.audioEnabled;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.audioEnabled = !this.audioEnabled;
|
|
149
|
+
audioTracks.forEach(track => {
|
|
150
|
+
track.enabled = this.audioEnabled;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
this.emit('audioToggled', { enabled: this.audioEnabled });
|
|
154
|
+
this.logger.debug(`Audio toggled: ${this.audioEnabled}`);
|
|
155
|
+
return this.audioEnabled;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 비디오 상태 설정
|
|
160
|
+
* @param {boolean} enabled
|
|
161
|
+
*/
|
|
162
|
+
setVideoEnabled(enabled) {
|
|
163
|
+
if (!this.localStream) {
|
|
164
|
+
this.logger.warn('No local stream available');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const videoTracks = this.localStream.getVideoTracks();
|
|
169
|
+
videoTracks.forEach(track => {
|
|
170
|
+
track.enabled = enabled;
|
|
171
|
+
});
|
|
172
|
+
this.videoEnabled = enabled;
|
|
173
|
+
this.emit('videoToggled', { enabled });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 오디오 상태 설정
|
|
178
|
+
* @param {boolean} enabled
|
|
179
|
+
*/
|
|
180
|
+
setAudioEnabled(enabled) {
|
|
181
|
+
if (!this.localStream) {
|
|
182
|
+
this.logger.warn('No local stream available');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const audioTracks = this.localStream.getAudioTracks();
|
|
187
|
+
audioTracks.forEach(track => {
|
|
188
|
+
track.enabled = enabled;
|
|
189
|
+
});
|
|
190
|
+
this.audioEnabled = enabled;
|
|
191
|
+
this.emit('audioToggled', { enabled });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 로컬 스트림 가져오기
|
|
196
|
+
* @returns {MediaStream|null}
|
|
197
|
+
*/
|
|
198
|
+
getLocalStream() {
|
|
199
|
+
return this.localStream;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 화면 공유 스트림 가져오기
|
|
204
|
+
* @returns {MediaStream|null}
|
|
205
|
+
*/
|
|
206
|
+
getScreenStream() {
|
|
207
|
+
return this.screenStream;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 현재 비디오 상태
|
|
212
|
+
* @returns {boolean}
|
|
213
|
+
*/
|
|
214
|
+
isVideoEnabled() {
|
|
215
|
+
return this.videoEnabled;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 현재 오디오 상태
|
|
220
|
+
* @returns {boolean}
|
|
221
|
+
*/
|
|
222
|
+
isAudioEnabled() {
|
|
223
|
+
return this.audioEnabled;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 모든 트랙 중지
|
|
228
|
+
*/
|
|
229
|
+
stopAll() {
|
|
230
|
+
if (this.localStream) {
|
|
231
|
+
this.localStream.getTracks().forEach(track => track.stop());
|
|
232
|
+
this.localStream = null;
|
|
233
|
+
this.emit('streamStopped', {});
|
|
234
|
+
this.logger.info('All media tracks stopped');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (this.screenStream) {
|
|
238
|
+
this.screenStream.getTracks().forEach(track => track.stop());
|
|
239
|
+
this.screenStream = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 특정 트랙 교체 (내부용 - switchDevice에서 호출)
|
|
245
|
+
* @private
|
|
246
|
+
* @param {MediaStreamTrack} oldTrack
|
|
247
|
+
* @param {MediaStreamTrack} newTrack
|
|
248
|
+
*/
|
|
249
|
+
replaceTrack(oldTrack, newTrack) {
|
|
250
|
+
if (!this.localStream) {
|
|
251
|
+
this.logger.warn('No local stream available');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.localStream.removeTrack(oldTrack);
|
|
256
|
+
this.localStream.addTrack(newTrack);
|
|
257
|
+
oldTrack.stop();
|
|
258
|
+
|
|
259
|
+
this.emit('trackReplaced', { oldTrack, newTrack });
|
|
260
|
+
this.logger.debug(`Track replaced: ${oldTrack.kind}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 특정 종류의 트랙 가져오기
|
|
265
|
+
* @param {string} kind - 'video' or 'audio'
|
|
266
|
+
* @returns {MediaStreamTrack|null}
|
|
267
|
+
*/
|
|
268
|
+
getTrack(kind) {
|
|
269
|
+
if (!this.localStream) return null;
|
|
270
|
+
|
|
271
|
+
const tracks = kind === 'video'
|
|
272
|
+
? this.localStream.getVideoTracks()
|
|
273
|
+
: this.localStream.getAudioTracks();
|
|
274
|
+
|
|
275
|
+
return tracks.length > 0 ? tracks[0] : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 디바이스 목록 가져오기
|
|
280
|
+
* @returns {Promise<Object>}
|
|
281
|
+
*/
|
|
282
|
+
async getDevices() {
|
|
283
|
+
try {
|
|
284
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
videoInputs: devices.filter(d => d.kind === 'videoinput'),
|
|
288
|
+
audioInputs: devices.filter(d => d.kind === 'audioinput'),
|
|
289
|
+
audioOutputs: devices.filter(d => d.kind === 'audiooutput')
|
|
290
|
+
};
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.logger.error('Failed to enumerate devices:', error);
|
|
293
|
+
this.emit('error', {
|
|
294
|
+
type: ErrorTypes.ENUMERATE_DEVICES_FAILED,
|
|
295
|
+
message: error.message,
|
|
296
|
+
error
|
|
297
|
+
});
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 특정 디바이스로 전환
|
|
304
|
+
* @param {string} deviceId - 디바이스 ID
|
|
305
|
+
* @param {string} kind - 'video' or 'audio'
|
|
306
|
+
* @returns {Promise<MediaStreamTrack>}
|
|
307
|
+
* @throws {Error} 스트림이 없거나 디바이스 전환 실패 시
|
|
308
|
+
*/
|
|
309
|
+
async switchDevice(deviceId, kind) {
|
|
310
|
+
if (!this.localStream) {
|
|
311
|
+
throw new Error('No local stream available');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const constraints = kind === 'video'
|
|
316
|
+
? { video: { deviceId: { exact: deviceId } }, audio: false }
|
|
317
|
+
: { video: false, audio: { deviceId: { exact: deviceId } } };
|
|
318
|
+
|
|
319
|
+
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
320
|
+
const newTrack = newStream.getTracks()[0];
|
|
321
|
+
|
|
322
|
+
const oldTrack = kind === 'video'
|
|
323
|
+
? this.localStream.getVideoTracks()[0]
|
|
324
|
+
: this.localStream.getAudioTracks()[0];
|
|
325
|
+
|
|
326
|
+
if (oldTrack) {
|
|
327
|
+
this.replaceTrack(oldTrack, newTrack);
|
|
328
|
+
|
|
329
|
+
this.emit('deviceSwitched', {
|
|
330
|
+
kind,
|
|
331
|
+
deviceId,
|
|
332
|
+
newTrack
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
this.logger.info(`${kind} device switched to ${deviceId}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return newTrack;
|
|
339
|
+
|
|
340
|
+
} catch (error) {
|
|
341
|
+
this.logger.error(`Failed to switch ${kind} device:`, error);
|
|
342
|
+
this.emit('error', {
|
|
343
|
+
type: ErrorTypes.DEVICE_SWITCH_FAILED,
|
|
344
|
+
message: error.message,
|
|
345
|
+
error
|
|
346
|
+
});
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 디바이스 변경 감지 시작
|
|
353
|
+
*/
|
|
354
|
+
startDeviceChangeDetection() {
|
|
355
|
+
if (this._deviceChangeHandler) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
this._deviceChangeHandler = async () => {
|
|
360
|
+
try {
|
|
361
|
+
const devices = await this.getDevices();
|
|
362
|
+
this.emit('deviceChange', devices);
|
|
363
|
+
this.logger.debug('Device change detected');
|
|
364
|
+
} catch (error) {
|
|
365
|
+
this.logger.warn('Failed to get devices on change:', error);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
navigator.mediaDevices.addEventListener('devicechange', this._deviceChangeHandler);
|
|
370
|
+
this.logger.debug('Device change detection started');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 디바이스 변경 감지 중지
|
|
375
|
+
*/
|
|
376
|
+
stopDeviceChangeDetection() {
|
|
377
|
+
if (this._deviceChangeHandler) {
|
|
378
|
+
navigator.mediaDevices.removeEventListener('devicechange', this._deviceChangeHandler);
|
|
379
|
+
this._deviceChangeHandler = null;
|
|
380
|
+
this.logger.debug('Device change detection stopped');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 스트림 정보
|
|
386
|
+
* @returns {Object}
|
|
387
|
+
*/
|
|
388
|
+
getStreamInfo() {
|
|
389
|
+
if (!this.localStream) {
|
|
390
|
+
return {
|
|
391
|
+
hasStream: false,
|
|
392
|
+
videoEnabled: this.videoEnabled,
|
|
393
|
+
audioEnabled: this.audioEnabled,
|
|
394
|
+
tracks: []
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
hasStream: true,
|
|
400
|
+
videoEnabled: this.videoEnabled,
|
|
401
|
+
audioEnabled: this.audioEnabled,
|
|
402
|
+
tracks: this.localStream.getTracks().map(track => ({
|
|
403
|
+
kind: track.kind,
|
|
404
|
+
id: track.id,
|
|
405
|
+
label: track.label,
|
|
406
|
+
enabled: track.enabled,
|
|
407
|
+
readyState: track.readyState,
|
|
408
|
+
muted: track.muted
|
|
409
|
+
}))
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 비디오 해상도 변경
|
|
415
|
+
* @param {Object} constraints - { width, height, frameRate }
|
|
416
|
+
*/
|
|
417
|
+
async applyVideoConstraints(constraints) {
|
|
418
|
+
if (!this.localStream) {
|
|
419
|
+
throw new Error('No local stream available');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const videoTrack = this.localStream.getVideoTracks()[0];
|
|
423
|
+
if (!videoTrack) {
|
|
424
|
+
throw new Error('No video track available');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
await videoTrack.applyConstraints(constraints);
|
|
429
|
+
this.emit('videoConstraintsApplied', { constraints });
|
|
430
|
+
this.logger.info('Video constraints applied:', constraints);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
this.logger.error('Failed to apply video constraints:', error);
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 현재 비디오 설정 가져오기
|
|
439
|
+
* @returns {MediaTrackSettings|null}
|
|
440
|
+
*/
|
|
441
|
+
getVideoSettings() {
|
|
442
|
+
if (!this.localStream) return null;
|
|
443
|
+
|
|
444
|
+
const videoTrack = this.localStream.getVideoTracks()[0];
|
|
445
|
+
return videoTrack ? videoTrack.getSettings() : null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 현재 오디오 설정 가져오기
|
|
450
|
+
* @returns {MediaTrackSettings|null}
|
|
451
|
+
*/
|
|
452
|
+
getAudioSettings() {
|
|
453
|
+
if (!this.localStream) return null;
|
|
454
|
+
|
|
455
|
+
const audioTrack = this.localStream.getAudioTracks()[0];
|
|
456
|
+
return audioTrack ? audioTrack.getSettings() : null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 로그 레벨 설정
|
|
461
|
+
* @param {number} level
|
|
462
|
+
*/
|
|
463
|
+
setLogLevel(level) {
|
|
464
|
+
this.logger.setLevel(level);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 리소스 정리
|
|
469
|
+
*/
|
|
470
|
+
destroy() {
|
|
471
|
+
this.stopAll();
|
|
472
|
+
this.stopDeviceChangeDetection();
|
|
473
|
+
this.removeAllListeners();
|
|
474
|
+
this.logger.info('MediaStreamManager destroyed');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export default MediaStreamManager;
|