@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.
@@ -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
+ }
@@ -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';