@fuzionx/framework 0.1.48 → 0.1.50

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,351 @@
1
+ {% extends "layouts/main.html" %}
2
+ {% block title %}Live Channels — FuzionX WebRTC{% endblock %}
3
+ {% block description %}FuzionX WebRTC live streaming and video chat demo. Broadcast and video conferencing powered by @fuzionx/player SDK.{% endblock %}
4
+ {% block content %}
5
+
6
+ <div class="container">
7
+ <!-- ── Header ── -->
8
+ <section class="hero-section" style="padding:3rem 0 2rem;">
9
+ <h1 class="hero-title">
10
+ <span class="gradient-text">Live Channels</span>
11
+ </h1>
12
+ <p class="hero-subtitle">{{ t(key="live.hero_sub", default="WebRTC 라이브 방송 · 화상채팅") }}</p>
13
+ </section>
14
+
15
+ <!-- ── Hub URL 설정 ── -->
16
+ <div class="glass-card" style="margin-bottom:1.5rem;padding:0.75rem 1.25rem;display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
17
+ <label style="font-size:0.8rem;color:var(--text-muted);white-space:nowrap;">Hub URL</label>
18
+ <input id="hub-url" type="text" class="form-input" value="http://127.0.0.1:9100"
19
+ style="flex:1;min-width:200px;padding:0.4rem 0.75rem;font-size:0.85rem;" />
20
+ <button class="btn btn-primary" style="padding:0.4rem 1rem;font-size:0.85rem;" onclick="fetchChannels()">
21
+ {{ t(key="live.refresh", default="새로고침") }}
22
+ </button>
23
+ <button class="btn btn-hero-primary" style="padding:0.4rem 1rem;font-size:0.85rem;" onclick="openCreateModal()">
24
+ + {{ t(key="live.create", default="새 채널") }}
25
+ </button>
26
+ </div>
27
+
28
+ <!-- ── Error ── -->
29
+ <div id="live-error" class="alert alert-error" style="display:none;margin-bottom:1rem;"></div>
30
+
31
+ <!-- ── Channel List ── -->
32
+ <div id="live-empty" class="glass-card" style="text-align:center;padding:3rem;display:none;">
33
+ <p style="color:var(--text-muted);font-size:1rem;">{{ t(key="live.no_channels", default="활성 채널이 없습니다.") }}</p>
34
+ <p style="color:var(--text-muted);font-size:0.85rem;margin-top:0.5rem;">{{ t(key="live.start_hint", default="새 채널을 만들어 시작하세요.") }}</p>
35
+ </div>
36
+
37
+ <div id="live-grid" class="live-grid"></div>
38
+ </div>
39
+
40
+ <!-- ── Create Modal ── -->
41
+ <div id="create-modal" class="live-modal-overlay" style="display:none;" onclick="if(event.target===this)closeCreateModal()">
42
+ <div class="glass-card live-modal">
43
+ <h2 style="margin-bottom:1.5rem;">📡 {{ t(key="live.create_title", default="새 채널 만들기") }}</h2>
44
+
45
+ <div class="form-group">
46
+ <label class="form-label">{{ t(key="live.channel_id", default="채널 ID") }}</label>
47
+ <input id="create-channel-id" type="text" class="form-input" placeholder="my-channel" />
48
+ </div>
49
+
50
+ <div class="form-group">
51
+ <label class="form-label">{{ t(key="live.type", default="타입") }}</label>
52
+ <div class="live-type-selector">
53
+ <button class="live-type-btn active" id="type-broadcast" onclick="selectType('broadcast')">
54
+ 🔴 {{ t(key="live.broadcast", default="방송") }}
55
+ </button>
56
+ <button class="live-type-btn" id="type-videochat" onclick="selectType('videochat')">
57
+ 👥 {{ t(key="live.videochat", default="화상채팅") }}
58
+ </button>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- Broadcast 옵션 -->
63
+ <div id="broadcast-options">
64
+ <div class="form-group">
65
+ <label class="form-label">{{ t(key="live.source", default="소스") }}</label>
66
+ <div class="live-type-selector">
67
+ <button class="live-type-btn active" id="src-url" onclick="selectSource('url')">
68
+ 🌐 {{ t(key="live.url_stream", default="외부 URL") }}
69
+ </button>
70
+ <button class="live-type-btn" id="src-file" onclick="selectSource('file')">
71
+ 📁 {{ t(key="live.file_stream", default="파일 스트리밍") }}
72
+ </button>
73
+ </div>
74
+ </div>
75
+ <div class="form-group">
76
+ <label class="form-label" id="source-label">{{ t(key="live.source_url", default="스트리밍 URL") }}</label>
77
+ <input id="create-source-url" type="text" class="form-input" placeholder="rtmp://..." />
78
+ </div>
79
+ </div>
80
+
81
+ <!-- 인코딩 옵션 (Broadcast 전용) -->
82
+ <div id="encoding-options" style="margin-top:0.5rem;">
83
+ <div class="form-group">
84
+ <label class="form-label">⚙️ {{ t(key="live.options", default="인코딩 옵션") }}</label>
85
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
86
+ <div>
87
+ <label class="form-label" style="font-size:0.75rem;">{{ t(key="live.resolution", default="해상도") }}</label>
88
+ <select id="opt-resolution" class="form-input" style="padding:0.4rem 0.5rem;font-size:0.85rem;">
89
+ <option value="">{{ t(key="live.default", default="기본값") }}</option>
90
+ <option value="1920x1080">1920×1080 (FHD)</option>
91
+ <option value="1280x720">1280×720 (HD)</option>
92
+ <option value="854x480">854×480 (SD)</option>
93
+ <option value="640x360">640×360</option>
94
+ </select>
95
+ </div>
96
+ <div>
97
+ <label class="form-label" style="font-size:0.75rem;">{{ t(key="live.video_bitrate", default="영상 비트레이트") }}</label>
98
+ <select id="opt-video-bitrate" class="form-input" style="padding:0.4rem 0.5rem;font-size:0.85rem;">
99
+ <option value="">{{ t(key="live.default", default="기본값") }}</option>
100
+ <option value="8000">8000 kbps</option>
101
+ <option value="5000">5000 kbps</option>
102
+ <option value="3000">3000 kbps</option>
103
+ <option value="1500">1500 kbps</option>
104
+ <option value="800">800 kbps</option>
105
+ </select>
106
+ </div>
107
+ <div>
108
+ <label class="form-label" style="font-size:0.75rem;">{{ t(key="live.audio_bitrate", default="오디오 비트레이트") }}</label>
109
+ <select id="opt-audio-bitrate" class="form-input" style="padding:0.4rem 0.5rem;font-size:0.85rem;">
110
+ <option value="">{{ t(key="live.default", default="기본값") }}</option>
111
+ <option value="320">320 kbps</option>
112
+ <option value="192">192 kbps</option>
113
+ <option value="128">128 kbps</option>
114
+ <option value="64">64 kbps</option>
115
+ </select>
116
+ </div>
117
+ <div>
118
+ <label class="form-label" style="font-size:0.75rem;">{{ t(key="live.video_codec", default="비디오 코덱") }}</label>
119
+ <select id="opt-video-codec" class="form-input" style="padding:0.4rem 0.5rem;font-size:0.85rem;">
120
+ <option value="">{{ t(key="live.default", default="기본값") }}</option>
121
+ <option value="h264">H.264</option>
122
+ <option value="vp8">VP8</option>
123
+ <option value="vp9">VP9</option>
124
+ </select>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ <div id="create-error" class="alert alert-error" style="display:none;margin-top:1rem;"></div>
131
+
132
+ <div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
133
+ <button class="btn btn-outline" style="flex:1;" onclick="closeCreateModal()">
134
+ {{ t(key="live.cancel", default="취소") }}
135
+ </button>
136
+ <button id="create-btn" class="btn btn-hero-primary" style="flex:1;" onclick="createChannel()">
137
+ {{ t(key="live.create_btn", default="생성") }}
138
+ </button>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <footer class="site-footer">
144
+ <div class="container">
145
+ <div class="footer-content">
146
+ <span class="footer-brand">FuzionX</span>
147
+ <span class="footer-text">Powered by <code>@fuzionx/player</code> WebRTC SDK</span>
148
+ </div>
149
+ </div>
150
+ </footer>
151
+
152
+ {% endblock %}
153
+
154
+ {% block scripts %}
155
+ <style>
156
+ .live-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1.25rem;}
157
+ .live-card{display:flex;flex-direction:column;padding:1.25rem;transition:transform .2s,box-shadow .2s;}
158
+ .live-card:hover{transform:translateY(-2px);box-shadow:0 8px 32px rgba(99,102,241,.15);}
159
+ .live-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem;}
160
+ .live-badge{display:inline-flex;align-items:center;gap:.25rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:600;}
161
+ .badge-broadcast{background:rgba(239,68,68,.15);color:#f87171;border:1px solid rgba(239,68,68,.3);}
162
+ .badge-videochat{background:rgba(99,102,241,.15);color:#818cf8;border:1px solid rgba(99,102,241,.3);}
163
+ .live-card-title{font-size:1.1rem;font-weight:600;margin-bottom:.5rem;color:var(--text-primary,#e0e0e0);}
164
+ .live-card-meta{font-size:.8rem;color:var(--text-muted);margin-bottom:.25rem;}
165
+ .live-card-actions{display:flex;gap:.5rem;margin-top:auto;padding-top:1rem;}
166
+ .live-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000;}
167
+ .live-modal{width:90%;max-width:480px;padding:2rem;}
168
+ .live-type-selector{display:flex;gap:.5rem;}
169
+ .live-type-btn{flex:1;padding:.6rem;border:1px solid var(--border-color,rgba(255,255,255,.08));border-radius:8px;background:transparent;color:var(--text-muted);cursor:pointer;font-size:.85rem;transition:all .2s;}
170
+ .live-type-btn.active{background:rgba(99,102,241,.15);color:#818cf8;border-color:rgba(99,102,241,.4);}
171
+ .live-type-btn:hover{border-color:rgba(99,102,241,.3);}
172
+ </style>
173
+
174
+ <script>
175
+ const __i18n = {
176
+ join: '{{ t(key="live.join", default="입장") }}',
177
+ stop: '{{ t(key="live.stop", default="종료") }}',
178
+ connection_failed: '{{ t(key="live.connection_failed", default="연결 실패") }}',
179
+ stop_failed: '{{ t(key="live.stop_failed", default="종료 실패") }}',
180
+ source_url: '{{ t(key="live.source_url", default="스트리밍 URL") }}',
181
+ file_path: '{{ t(key="live.file_path", default="파일 경로") }}',
182
+ enter_channel_id: '{{ t(key="live.enter_channel_id", default="채널 ID를 입력하세요.") }}',
183
+ channel_exists: '{{ t(key="live.channel_exists", default="이미 존재하는 채널입니다.") }}',
184
+ create_failed: '{{ t(key="live.create_failed", default="생성 실패") }}',
185
+ };
186
+
187
+ let _createType = 'broadcast';
188
+ let _createSource = 'url';
189
+
190
+ function getHubUrl() { return document.getElementById('hub-url').value.trim(); }
191
+
192
+ function showError(msg) {
193
+ const el = document.getElementById('live-error');
194
+ el.textContent = msg; el.style.display = msg ? 'block' : 'none';
195
+ }
196
+
197
+ let _lastChannelsJson = '';
198
+
199
+ async function fetchChannels() {
200
+ showError('');
201
+ try {
202
+ const res = await fetch(getHubUrl() + '/api/channels');
203
+ if (!res.ok) throw new Error('HTTP ' + res.status);
204
+ const channels = await res.json();
205
+ const json = JSON.stringify(channels);
206
+ if (json !== _lastChannelsJson) {
207
+ _lastChannelsJson = json;
208
+ renderChannels(channels);
209
+ }
210
+ } catch(e) {
211
+ showError(__i18n.connection_failed + ': ' + e.message);
212
+ if (_lastChannelsJson !== '[]') {
213
+ _lastChannelsJson = '[]';
214
+ renderChannels([]);
215
+ }
216
+ }
217
+ }
218
+
219
+ function renderChannels(channels) {
220
+ const grid = document.getElementById('live-grid');
221
+ const empty = document.getElementById('live-empty');
222
+ if (channels.length === 0) {
223
+ grid.innerHTML = ''; empty.style.display = 'block';
224
+ return;
225
+ }
226
+ empty.style.display = 'none';
227
+ grid.innerHTML = channels.map(ch => `
228
+ <div class="glass-card live-card">
229
+ <div class="live-card-header">
230
+ <span class="live-badge ${ch.source_type === 'webrtc' ? 'badge-videochat' : 'badge-broadcast'}">
231
+ ${ch.source_type === 'webrtc' ? '👥 VideoChat' : '🔴 Broadcast'}
232
+ </span>
233
+ <span style="font-size:.8rem;color:var(--text-muted);">👁 ${ch.viewer_count || 0}</span>
234
+ </div>
235
+ <div>
236
+ <h3 class="live-card-title">${escHtml(ch.channel_id)}</h3>
237
+ <p class="live-card-meta">${ch.source_url ? escHtml(ch.source_url) : ch.source_type}</p>
238
+ <p class="live-card-meta" style="font-size:.75rem;">Media: ${ch.media_id} · ${ch.media_ip}:${ch.webrtc_port}</p>
239
+ </div>
240
+ <div class="live-card-actions">
241
+ <button class="btn btn-primary" style="flex:1;" onclick="joinChannel('${escAttr(ch.channel_id)}','${ch.source_type}')">${__i18n.join}</button>
242
+ <button class="btn btn-outline" style="color:#ef4444;border-color:#ef4444;" onclick="stopChannel('${escAttr(ch.channel_id)}')">${__i18n.stop}</button>
243
+ </div>
244
+ </div>
245
+ `).join('');
246
+ }
247
+
248
+ function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
249
+ function escAttr(s) { return String(s).replace(/'/g,"\\'").replace(/"/g,'&quot;'); }
250
+
251
+ function joinChannel(channelId, sourceType) {
252
+ const hub = encodeURIComponent(getHubUrl());
253
+ if (sourceType === 'webrtc') {
254
+ location.href = `/live/room/${channelId}?hub=${hub}`;
255
+ } else {
256
+ location.href = `/live/watch/${channelId}?hub=${hub}`;
257
+ }
258
+ }
259
+
260
+ async function stopChannel(channelId) {
261
+ try {
262
+ await fetch(getHubUrl() + '/api/channels/' + channelId, { method: 'DELETE' });
263
+ fetchChannels();
264
+ } catch(e) {
265
+ showError(__i18n.stop_failed + ': ' + e.message);
266
+ }
267
+ }
268
+
269
+ // Modal
270
+ function openCreateModal() { document.getElementById('create-modal').style.display = 'flex'; }
271
+ function closeCreateModal() { document.getElementById('create-modal').style.display = 'none'; }
272
+
273
+ function selectType(type) {
274
+ _createType = type;
275
+ document.getElementById('type-broadcast').className = 'live-type-btn' + (type === 'broadcast' ? ' active' : '');
276
+ document.getElementById('type-videochat').className = 'live-type-btn' + (type === 'videochat' ? ' active' : '');
277
+ document.getElementById('broadcast-options').style.display = type === 'broadcast' ? 'block' : 'none';
278
+ document.getElementById('encoding-options').style.display = type === 'broadcast' ? 'block' : 'none';
279
+ }
280
+
281
+ function selectSource(src) {
282
+ _createSource = src;
283
+ document.getElementById('src-url').className = 'live-type-btn' + (src === 'url' ? ' active' : '');
284
+ document.getElementById('src-file').className = 'live-type-btn' + (src === 'file' ? ' active' : '');
285
+ const label = document.getElementById('source-label');
286
+ const input = document.getElementById('create-source-url');
287
+ if (src === 'url') {
288
+ label.textContent = __i18n.source_url; input.placeholder = 'rtmp://...';
289
+ } else {
290
+ label.textContent = __i18n.file_path; input.placeholder = '/path/to/video.mp4';
291
+ }
292
+ }
293
+
294
+ async function createChannel() {
295
+ const channelId = document.getElementById('create-channel-id').value.trim();
296
+ const errEl = document.getElementById('create-error');
297
+ if (!channelId) { errEl.textContent = __i18n.enter_channel_id; errEl.style.display = 'block'; return; }
298
+ errEl.style.display = 'none';
299
+
300
+ if (_createType === 'videochat') {
301
+ closeCreateModal();
302
+ const hub = encodeURIComponent(getHubUrl());
303
+ const nick = encodeURIComponent(window.__FX_USER_NAME__ || 'host-' + Math.random().toString(36).slice(2, 6));
304
+ location.href = '/live/room/' + channelId + '?hub=' + hub + '&role=publisher&nickname=' + nick;
305
+ return;
306
+ }
307
+
308
+ const body = {
309
+ channel_id: channelId,
310
+ source_type: 'url',
311
+ };
312
+ const url = document.getElementById('create-source-url').value.trim();
313
+ if (url) body.source_url = url;
314
+
315
+ const opts = { always_on: true };
316
+ const resolution = document.getElementById('opt-resolution').value;
317
+ const vBitrate = document.getElementById('opt-video-bitrate').value;
318
+ const aBitrate = document.getElementById('opt-audio-bitrate').value;
319
+ const vCodec = document.getElementById('opt-video-codec').value;
320
+ if (resolution) opts.resolution = resolution;
321
+ if (vBitrate) {
322
+ opts.video = { bitrate_kbps: parseInt(vBitrate) };
323
+ if (vCodec) opts.video.codec = vCodec;
324
+ } else if (vCodec) {
325
+ opts.video = { codec: vCodec };
326
+ }
327
+ if (aBitrate) opts.audio = { bitrate_kbps: parseInt(aBitrate) };
328
+ body.options = opts;
329
+
330
+ try {
331
+ document.getElementById('create-btn').disabled = true;
332
+ const res = await fetch(getHubUrl() + '/api/channels', {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json' },
335
+ body: JSON.stringify(body),
336
+ });
337
+ if (res.status === 409) { errEl.textContent = __i18n.channel_exists; errEl.style.display = 'block'; return; }
338
+ if (!res.ok) { const d = await res.json().catch(()=>({})); errEl.textContent = d.error || __i18n.create_failed; errEl.style.display = 'block'; return; }
339
+ closeCreateModal();
340
+ fetchChannels();
341
+ } catch(e) {
342
+ errEl.textContent = __i18n.connection_failed + ': ' + e.message; errEl.style.display = 'block';
343
+ } finally {
344
+ document.getElementById('create-btn').disabled = false;
345
+ }
346
+ }
347
+
348
+ fetchChannels();
349
+ setInterval(fetchChannels, 5000);
350
+ </script>
351
+ {% endblock %}