@fuzionx/player 0.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 +135 -0
- package/dist/fuzionx-player.esm.js +6 -0
- package/dist/fuzionx-player.esm.js.map +7 -0
- package/dist/fuzionx-player.umd.js +7 -0
- package/dist/fuzionx-player.umd.js.map +7 -0
- package/package.json +35 -0
- package/src/FuzionXPublisher.js +479 -0
- package/src/FuzionXSignaling.js +170 -0
- package/src/FuzionXViewer.js +383 -0
- package/src/constants.js +49 -0
- package/src/index.js +10 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fuzionx/player — FuzionXViewer (수신자 모드)
|
|
3
|
+
*
|
|
4
|
+
* 서버에서 MediaStream을 수신하여 <video> 엘리먼트에 렌더링.
|
|
5
|
+
* broadcast(1 stream) / videochat(최대 9 streams) 지원.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const viewer = new FuzionXViewer({
|
|
9
|
+
* url: 'wss://media:50002',
|
|
10
|
+
* channelId: 'my-live',
|
|
11
|
+
* mode: 'broadcast',
|
|
12
|
+
* });
|
|
13
|
+
* viewer.on('stream', (stream, slotIndex) => {
|
|
14
|
+
* videoEl.srcObject = stream;
|
|
15
|
+
* });
|
|
16
|
+
* viewer.connect();
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { FuzionXSignaling } from './FuzionXSignaling.js';
|
|
20
|
+
import { DEFAULT_ICE_SERVERS, SignalType, SessionMode, MAX_SLOTS } from './constants.js';
|
|
21
|
+
|
|
22
|
+
export class FuzionXViewer {
|
|
23
|
+
/**
|
|
24
|
+
* @param {Object} opts
|
|
25
|
+
* @param {string} opts.url - WebSocket URL
|
|
26
|
+
* @param {string} opts.channelId - 채널 ID
|
|
27
|
+
* @param {string} [opts.mode='broadcast'] - 'broadcast' | 'videochat'
|
|
28
|
+
* @param {string} [opts.nickname] - 닉네임
|
|
29
|
+
* @param {string} [opts.token] - 인증 토큰
|
|
30
|
+
* @param {string} [opts.peerId] - 피어 ID (자동 생성)
|
|
31
|
+
* @param {RTCConfiguration} [opts.rtcConfig] - WebRTC 설정 오버라이드
|
|
32
|
+
* @param {boolean} [opts.autoReconnect=true]
|
|
33
|
+
*/
|
|
34
|
+
constructor(opts) {
|
|
35
|
+
this.url = opts.url;
|
|
36
|
+
this.channelId = opts.channelId;
|
|
37
|
+
this.mode = opts.mode || SessionMode.BROADCAST;
|
|
38
|
+
this.nickname = opts.nickname || null;
|
|
39
|
+
this.token = opts.token || null;
|
|
40
|
+
this.peerId = opts.peerId || `viewer-${Math.random().toString(36).slice(2, 10)}`;
|
|
41
|
+
this.autoReconnect = opts.autoReconnect !== false;
|
|
42
|
+
|
|
43
|
+
this.rtcConfig = opts.rtcConfig || {
|
|
44
|
+
iceServers: DEFAULT_ICE_SERVERS,
|
|
45
|
+
bundlePolicy: 'max-bundle',
|
|
46
|
+
rtcpMuxPolicy: 'require',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** @private */
|
|
50
|
+
this._signaling = null;
|
|
51
|
+
this._pc = null;
|
|
52
|
+
this._listeners = {};
|
|
53
|
+
this._slots = new Map(); // slotIndex → { streamId, nickname, senderId, stream }
|
|
54
|
+
this._maxSlots = MAX_SLOTS[this.mode] || 1;
|
|
55
|
+
this._candidateQueue = [];
|
|
56
|
+
this._connected = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Event System ──
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 이벤트 리스너 등록.
|
|
63
|
+
* @param {'stream'|'slot'|'slot_remove'|'chat'|'error'|'close'|'connected'} event
|
|
64
|
+
* @param {Function} handler
|
|
65
|
+
*/
|
|
66
|
+
on(event, handler) {
|
|
67
|
+
if (!this._listeners[event]) this._listeners[event] = [];
|
|
68
|
+
this._listeners[event].push(handler);
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @private */
|
|
73
|
+
_emit(event, ...args) {
|
|
74
|
+
(this._listeners[event] || []).forEach((fn) => fn(...args));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Lifecycle ──
|
|
78
|
+
|
|
79
|
+
/** 서버 연결 + WebRTC 세션 시작. */
|
|
80
|
+
connect() {
|
|
81
|
+
this._signaling = new FuzionXSignaling({
|
|
82
|
+
url: this.url,
|
|
83
|
+
autoReconnect: this.autoReconnect,
|
|
84
|
+
onOpen: () => this._onSignalingOpen(),
|
|
85
|
+
onMessage: (msg) => this._onSignalingMessage(msg),
|
|
86
|
+
onClose: (evt) => this._onSignalingClose(evt),
|
|
87
|
+
onError: (err) => this._emit('error', err),
|
|
88
|
+
});
|
|
89
|
+
this._signaling.connect();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 연결 종료. */
|
|
93
|
+
disconnect() {
|
|
94
|
+
if (this._signaling) {
|
|
95
|
+
this._signaling.sendLeave();
|
|
96
|
+
this._signaling.disconnect();
|
|
97
|
+
}
|
|
98
|
+
this._closePeerConnection();
|
|
99
|
+
this._slots.clear();
|
|
100
|
+
this._connected = false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 채팅 전송. */
|
|
104
|
+
chat(text) {
|
|
105
|
+
if (this._signaling) {
|
|
106
|
+
this._signaling.sendChat(text, this.nickname);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 키프레임 요청. */
|
|
111
|
+
requestKeyframe() {
|
|
112
|
+
if (this._signaling) {
|
|
113
|
+
this._signaling.sendPLI();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @returns {Map} 현재 슬롯 정보 */
|
|
118
|
+
get slots() {
|
|
119
|
+
return this._slots;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Internal ──
|
|
123
|
+
|
|
124
|
+
/** @private */
|
|
125
|
+
_onSignalingOpen() {
|
|
126
|
+
this._signaling.sendJoin(this.peerId, this.channelId, {
|
|
127
|
+
nickname: this.nickname,
|
|
128
|
+
token: this.token,
|
|
129
|
+
mode: this.mode,
|
|
130
|
+
});
|
|
131
|
+
// Join 전송 후 바로 PeerConnection 생성
|
|
132
|
+
this._createPeerConnection().catch((e) => this._emit('error', e));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @private */
|
|
136
|
+
_onSignalingMessage(msg) {
|
|
137
|
+
switch (msg.type) {
|
|
138
|
+
case SignalType.ANSWER:
|
|
139
|
+
this._handleAnswer(msg);
|
|
140
|
+
break;
|
|
141
|
+
case SignalType.CANDIDATE:
|
|
142
|
+
this._handleCandidate(msg);
|
|
143
|
+
break;
|
|
144
|
+
case SignalType.SLOT_INFO:
|
|
145
|
+
this._handleSlotInfo(msg);
|
|
146
|
+
break;
|
|
147
|
+
case SignalType.CHAT:
|
|
148
|
+
this._emit('chat', {
|
|
149
|
+
peerId: msg.peer_id,
|
|
150
|
+
nickname: msg.nickname,
|
|
151
|
+
text: msg.text,
|
|
152
|
+
});
|
|
153
|
+
break;
|
|
154
|
+
case SignalType.ERROR:
|
|
155
|
+
this._emit('error', new Error(msg.message));
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** @private */
|
|
161
|
+
_onSignalingClose(evt) {
|
|
162
|
+
this._closePeerConnection();
|
|
163
|
+
this._connected = false;
|
|
164
|
+
this._emit('close', evt);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @private */
|
|
168
|
+
async _handleAnswer(msg) {
|
|
169
|
+
if (!this._pc) return;
|
|
170
|
+
try {
|
|
171
|
+
await this._pc.setRemoteDescription(
|
|
172
|
+
new RTCSessionDescription({ type: 'answer', sdp: msg.sdp })
|
|
173
|
+
);
|
|
174
|
+
// Flush queued ICE candidates
|
|
175
|
+
for (const c of this._candidateQueue) {
|
|
176
|
+
await this._pc.addIceCandidate(c);
|
|
177
|
+
}
|
|
178
|
+
this._candidateQueue = [];
|
|
179
|
+
} catch (e) {
|
|
180
|
+
this._emit('error', e);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @private */
|
|
185
|
+
async _handleCandidate(msg) {
|
|
186
|
+
const candidate = new RTCIceCandidate({
|
|
187
|
+
candidate: msg.candidate,
|
|
188
|
+
sdpMid: msg.sdp_mid,
|
|
189
|
+
sdpMLineIndex: msg.sdp_m_line_index,
|
|
190
|
+
});
|
|
191
|
+
if (this._pc && this._pc.remoteDescription) {
|
|
192
|
+
try {
|
|
193
|
+
await this._pc.addIceCandidate(candidate);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.warn('[FuzionX] ICE candidate error:', e);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
this._candidateQueue.push(candidate);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @private */
|
|
203
|
+
_handleSlotInfo(msg) {
|
|
204
|
+
const slotIndex = parseInt(msg.stream_id.replace('stream_', ''), 10);
|
|
205
|
+
|
|
206
|
+
// nickname이 빈 문자열이면 슬롯 해제
|
|
207
|
+
if (!msg.nickname || msg.nickname === '') {
|
|
208
|
+
this._slots.delete(slotIndex);
|
|
209
|
+
this._emit('slot_remove', { slotIndex, senderId: msg.sender_id });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const existing = this._slots.get(slotIndex) || {};
|
|
214
|
+
this._slots.set(slotIndex, {
|
|
215
|
+
...existing,
|
|
216
|
+
slotIndex,
|
|
217
|
+
streamId: msg.stream_id,
|
|
218
|
+
nickname: msg.nickname,
|
|
219
|
+
senderId: msg.sender_id,
|
|
220
|
+
});
|
|
221
|
+
this._emit('slot', this._slots.get(slotIndex));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* PeerConnection 생성 + Non-Trickle ICE Offer.
|
|
226
|
+
* old alloy-player 검증 패턴:
|
|
227
|
+
* 1. addTransceiver(recvonly) × maxSlots — 서버 m-line 매핑
|
|
228
|
+
* 2. createOffer → H264 SDP 강제
|
|
229
|
+
* 3. ICE gathering 완료 대기 후 SDP 전체 전송 (Non-Trickle)
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
async _createPeerConnection() {
|
|
233
|
+
this._pc = new RTCPeerConnection(this.rtcConfig);
|
|
234
|
+
|
|
235
|
+
// Non-Trickle: ICE candidate는 SDP에 포함되어 전송됨
|
|
236
|
+
// (onicecandidate는 gathering 추적용으로만 유지)
|
|
237
|
+
|
|
238
|
+
// 수신 트랙 매핑
|
|
239
|
+
let videoTrackCount = 0;
|
|
240
|
+
this._pc.ontrack = (event) => {
|
|
241
|
+
const track = event.track;
|
|
242
|
+
|
|
243
|
+
if (track.kind === 'video') {
|
|
244
|
+
const slotIndex = videoTrackCount++;
|
|
245
|
+
|
|
246
|
+
let stream = event.streams[0];
|
|
247
|
+
if (!stream) {
|
|
248
|
+
stream = new MediaStream();
|
|
249
|
+
stream.addTrack(track);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const slot = this._slots.get(slotIndex) || { slotIndex };
|
|
253
|
+
slot.stream = stream;
|
|
254
|
+
this._slots.set(slotIndex, slot);
|
|
255
|
+
|
|
256
|
+
this._emit('stream', stream, slotIndex);
|
|
257
|
+
|
|
258
|
+
if (!this._connected) {
|
|
259
|
+
this._connected = true;
|
|
260
|
+
this._emit('connected');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
this._pc.onconnectionstatechange = () => {
|
|
266
|
+
const state = this._pc?.connectionState;
|
|
267
|
+
if (state === 'failed' || state === 'disconnected') {
|
|
268
|
+
this._emit('error', new Error(`PeerConnection ${state}`));
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// recvonly Transceiver 추가 (서버 슬롯 수에 맞춤)
|
|
273
|
+
for (let i = 0; i < this._maxSlots; i++) {
|
|
274
|
+
this._pc.addTransceiver('video', { direction: 'recvonly' });
|
|
275
|
+
this._pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Offer 생성 + H264/Opus 코덱 강제
|
|
279
|
+
const offer = await this._pc.createOffer();
|
|
280
|
+
offer.sdp = FuzionXViewer._forceCodecs(offer.sdp);
|
|
281
|
+
await this._pc.setLocalDescription(offer);
|
|
282
|
+
|
|
283
|
+
// Non-Trickle ICE: gathering 완료 대기 후 전체 SDP 전송
|
|
284
|
+
await this._waitForIceGathering();
|
|
285
|
+
|
|
286
|
+
// gathering 완료 후 최종 SDP (ICE candidates 포함) 전송
|
|
287
|
+
const finalSdp = this._pc.localDescription?.sdp;
|
|
288
|
+
if (finalSdp) {
|
|
289
|
+
this._signaling.sendOffer(finalSdp);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* ICE gathering 완료 대기 (Non-Trickle)
|
|
295
|
+
* @private
|
|
296
|
+
*/
|
|
297
|
+
_waitForIceGathering() {
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
if (this._pc.iceGatheringState === 'complete') {
|
|
300
|
+
return resolve();
|
|
301
|
+
}
|
|
302
|
+
const check = () => {
|
|
303
|
+
if (this._pc?.iceGatheringState === 'complete') {
|
|
304
|
+
this._pc.removeEventListener('icegatheringstatechange', check);
|
|
305
|
+
resolve();
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
this._pc.addEventListener('icegatheringstatechange', check);
|
|
309
|
+
// Timeout 3s (Safari 호환)
|
|
310
|
+
setTimeout(() => {
|
|
311
|
+
if (this._pc) {
|
|
312
|
+
this._pc.removeEventListener('icegatheringstatechange', check);
|
|
313
|
+
}
|
|
314
|
+
resolve();
|
|
315
|
+
}, 3000);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* SDP에서 H264 + Opus 코덱 우선 강제.
|
|
321
|
+
* @private
|
|
322
|
+
*/
|
|
323
|
+
static _forceCodecs(sdp) {
|
|
324
|
+
let lines = sdp.split('\r\n');
|
|
325
|
+
|
|
326
|
+
// Video: H264 우선
|
|
327
|
+
const videoIdx = lines.findIndex((l) => l.startsWith('m=video'));
|
|
328
|
+
if (videoIdx !== -1) {
|
|
329
|
+
const h264Pts = [];
|
|
330
|
+
const h264Scores = new Map();
|
|
331
|
+
lines.forEach((l) => {
|
|
332
|
+
const m = l.match(/a=rtpmap:(\d+) H264\/90000/);
|
|
333
|
+
if (m) {
|
|
334
|
+
h264Pts.push(m[1]);
|
|
335
|
+
h264Scores.set(m[1], 0);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
lines.forEach((l) => {
|
|
339
|
+
if (l.startsWith('a=fmtp:')) {
|
|
340
|
+
const pt = l.split(' ')[0].split(':')[1];
|
|
341
|
+
if (h264Scores.has(pt)) {
|
|
342
|
+
if (l.includes('profile-level-id=42e01f')) h264Scores.set(pt, 100);
|
|
343
|
+
else if (l.includes('profile-level-id=42001f')) h264Scores.set(pt, 80);
|
|
344
|
+
if (l.includes('packetization-mode=1')) h264Scores.set(pt, (h264Scores.get(pt) || 0) + 10);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
if (h264Pts.length > 0) {
|
|
349
|
+
h264Pts.sort((a, b) => h264Scores.get(b) - h264Scores.get(a));
|
|
350
|
+
const parts = lines[videoIdx].split(' ');
|
|
351
|
+
const otherPts = parts.slice(3).filter((pt) => !h264Pts.includes(pt));
|
|
352
|
+
lines[videoIdx] = [...parts.slice(0, 3), ...h264Pts, ...otherPts].join(' ');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Audio: Opus 우선
|
|
357
|
+
const audioIdx = lines.findIndex((l) => l.startsWith('m=audio'));
|
|
358
|
+
if (audioIdx !== -1) {
|
|
359
|
+
const opusPts = [];
|
|
360
|
+
lines.forEach((l) => {
|
|
361
|
+
const m = l.match(/a=rtpmap:(\d+) opus\/48000/);
|
|
362
|
+
if (m) opusPts.push(m[1]);
|
|
363
|
+
});
|
|
364
|
+
if (opusPts.length > 0) {
|
|
365
|
+
const parts = lines[audioIdx].split(' ');
|
|
366
|
+
const otherPts = parts.slice(3).filter((pt) => !opusPts.includes(pt));
|
|
367
|
+
lines[audioIdx] = [...parts.slice(0, 3), ...opusPts, ...otherPts].join(' ');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return lines.join('\r\n');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** @private */
|
|
375
|
+
_closePeerConnection() {
|
|
376
|
+
if (this._pc) {
|
|
377
|
+
this._pc.close();
|
|
378
|
+
this._pc = null;
|
|
379
|
+
}
|
|
380
|
+
this._candidateQueue = [];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fuzionx/player — Constants & Default Configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Default ICE Servers */
|
|
6
|
+
export const DEFAULT_ICE_SERVERS = [
|
|
7
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
8
|
+
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/** Signaling message types (maps to server SignalMessage enum) */
|
|
12
|
+
export const SignalType = {
|
|
13
|
+
JOIN: 'join',
|
|
14
|
+
OFFER: 'offer',
|
|
15
|
+
ANSWER: 'answer',
|
|
16
|
+
CANDIDATE: 'candidate',
|
|
17
|
+
PLI: 'pli',
|
|
18
|
+
LEAVE: 'leave',
|
|
19
|
+
SLOT_INFO: 'slot_info',
|
|
20
|
+
CHAT: 'chat',
|
|
21
|
+
ERROR: 'error',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Session modes */
|
|
25
|
+
export const SessionMode = {
|
|
26
|
+
BROADCAST: 'broadcast',
|
|
27
|
+
VIDEOCHAT: 'videochat',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Max slots per mode */
|
|
31
|
+
export const MAX_SLOTS = {
|
|
32
|
+
[SessionMode.BROADCAST]: 1,
|
|
33
|
+
[SessionMode.VIDEOCHAT]: 9,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Default reconnect settings */
|
|
37
|
+
export const RECONNECT = {
|
|
38
|
+
MAX_RETRIES: 5,
|
|
39
|
+
BASE_DELAY_MS: 1000,
|
|
40
|
+
MAX_DELAY_MS: 30000,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Codec preferences */
|
|
44
|
+
export const CODEC = {
|
|
45
|
+
VIDEO_MIME: 'video/H264',
|
|
46
|
+
AUDIO_MIME: 'audio/opus',
|
|
47
|
+
VIDEO_CLOCK: 90000,
|
|
48
|
+
AUDIO_CLOCK: 48000,
|
|
49
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fuzionx/player — Entry Point
|
|
3
|
+
*
|
|
4
|
+
* FuzionX WebRTC Player SDK
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { FuzionXViewer } from './FuzionXViewer.js';
|
|
8
|
+
export { FuzionXPublisher } from './FuzionXPublisher.js';
|
|
9
|
+
export { FuzionXSignaling } from './FuzionXSignaling.js';
|
|
10
|
+
export { SignalType, SessionMode, MAX_SLOTS, DEFAULT_ICE_SERVERS, CODEC, RECONNECT } from './constants.js';
|