@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,319 @@
1
+ <!--
2
+ LiveWatch.vue — 방송 시청 페이지.
3
+
4
+ FuzionXViewer (mode=broadcast) 사용.
5
+ Hub API에서 채널 정보 조회 후 자동 연결.
6
+ 비디오 플레이어 + 채팅 사이드바.
7
+ -->
8
+ <template>
9
+ <div class="live-watch-layout">
10
+ <!-- ── Video Area ── -->
11
+ <div class="live-watch-video">
12
+ <div class="live-watch-topbar">
13
+ <button class="btn btn-outline" style="padding:0.3rem 0.75rem;font-size:0.8rem;" @click="goBack">
14
+ ← {{ t('live.back', '목록으로') }}
15
+ </button>
16
+ <div class="live-watch-info">
17
+ <span class="live-badge badge-broadcast">🔴 Broadcast</span>
18
+ <span class="live-watch-channel">{{ channelId }}</span>
19
+ <span class="live-viewers">👁 {{ viewerCount }}</span>
20
+ </div>
21
+ <span :class="['live-status', `live-status-${status}`]">{{ statusText }}</span>
22
+ </div>
23
+
24
+ <div class="live-watch-player">
25
+ <video ref="videoEl" autoplay playsinline></video>
26
+ <div v-if="!connected" class="live-watch-placeholder">
27
+ <div v-if="error" style="color:#f87171;">❌ {{ error }}</div>
28
+ <div v-else>{{ t('live.connecting', '연결 중...') }}</div>
29
+ </div>
30
+ </div>
31
+ </div>
32
+
33
+ <!-- ── Chat Sidebar ── -->
34
+ <aside class="live-chat-sidebar">
35
+ <div class="live-chat-header">
36
+ <h3>💬 {{ t('live.chat', 'Chat') }}</h3>
37
+ </div>
38
+ <div ref="chatContainer" class="live-chat-messages">
39
+ <template v-for="(msg, i) in chatMessages" :key="i">
40
+ <div v-if="msg.system" class="chat-msg-system">{{ msg.text }}</div>
41
+ <div v-else class="live-chat-msg">
42
+ <span class="live-chat-nick">{{ msg.nickname }}</span>
43
+ <span class="live-chat-text">{{ msg.text }}</span>
44
+ </div>
45
+ </template>
46
+ </div>
47
+ <div class="live-chat-input-area">
48
+ <input
49
+ v-model="chatText"
50
+ type="text"
51
+ class="form-input"
52
+ :placeholder="t('live.chat_placeholder', '메시지 입력...')"
53
+ style="flex:1;"
54
+ @keydown.enter="sendChat"
55
+ />
56
+ <button class="btn btn-primary" style="padding:0.4rem 0.75rem;" @click="sendChat">
57
+ {{ t('live.send', '전송') }}
58
+ </button>
59
+ </div>
60
+ </aside>
61
+ </div>
62
+ </template>
63
+
64
+ <script setup>
65
+ import { ref, onMounted, onUnmounted, nextTick } from 'vue';
66
+ import { useRoute, useRouter } from 'vue-router';
67
+ import { useLocale } from '../composables/useLocale.js';
68
+ import { FuzionXViewer } from '@fuzionx/player';
69
+
70
+ const { t } = useLocale();
71
+ const route = useRoute();
72
+ const router = useRouter();
73
+
74
+ const channelId = route.params.channelId;
75
+ const hubUrl = route.query.hub || 'http://127.0.0.1:9100';
76
+
77
+ /* ── State ── */
78
+ const videoEl = ref(null);
79
+ const chatContainer = ref(null);
80
+ const connected = ref(false);
81
+ const status = ref('connecting');
82
+ const statusText = ref('connecting...');
83
+ const error = ref('');
84
+ const viewerCount = ref(0);
85
+ const chatMessages = ref([]);
86
+ const chatText = ref('');
87
+
88
+ let viewer = null;
89
+
90
+ /* ── Lifecycle ── */
91
+ onMounted(() => {
92
+ connectViewer();
93
+ });
94
+
95
+ onUnmounted(() => {
96
+ if (viewer) {
97
+ viewer.disconnect();
98
+ viewer = null;
99
+ }
100
+ });
101
+
102
+ /* ── Connect ── */
103
+ function connectViewer() {
104
+ viewer = new FuzionXViewer({
105
+ hubUrl,
106
+ channelId,
107
+ mode: 'broadcast',
108
+ nickname: 'viewer-' + Math.random().toString(36).slice(2, 6),
109
+ });
110
+
111
+ viewer.on('stream', (stream) => {
112
+ if (videoEl.value) {
113
+ videoEl.value.srcObject = stream;
114
+ videoEl.value.play().catch(() => {});
115
+ }
116
+ });
117
+
118
+ viewer.on('connected', () => {
119
+ connected.value = true;
120
+ status.value = 'connected';
121
+ statusText.value = 'Connected';
122
+ appendSystem('WebRTC 연결됨');
123
+ });
124
+
125
+ viewer.on('chat', ({ nickname, text }) => {
126
+ appendChat(nickname, text);
127
+ });
128
+
129
+ viewer.on('error', (err) => {
130
+ error.value = err.message || String(err);
131
+ status.value = 'error';
132
+ statusText.value = 'Error';
133
+ appendSystem(`❌ ${err.message || err}`);
134
+ });
135
+
136
+ viewer.on('close', () => {
137
+ connected.value = false;
138
+ status.value = 'disconnected';
139
+ statusText.value = 'Disconnected';
140
+ appendSystem('연결 끊김');
141
+ });
142
+
143
+ viewer.connect();
144
+ }
145
+
146
+ /* ── Chat ── */
147
+ function sendChat() {
148
+ const text = chatText.value.trim();
149
+ if (!text || !viewer) return;
150
+ viewer.chat(text);
151
+ appendChat('나', text);
152
+ chatText.value = '';
153
+ }
154
+
155
+ function appendChat(nickname, text) {
156
+ chatMessages.value.push({ nickname, text, system: false });
157
+ scrollChat();
158
+ }
159
+
160
+ function appendSystem(text) {
161
+ chatMessages.value.push({ text, system: true });
162
+ scrollChat();
163
+ }
164
+
165
+ function scrollChat() {
166
+ nextTick(() => {
167
+ if (chatContainer.value) {
168
+ chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
169
+ }
170
+ });
171
+ }
172
+
173
+ function goBack() {
174
+ router.push({ name: 'live' });
175
+ }
176
+ </script>
177
+
178
+ <style scoped>
179
+ .live-watch-layout {
180
+ display: flex;
181
+ height: calc(100vh - 60px);
182
+ overflow: hidden;
183
+ }
184
+
185
+ .live-watch-video {
186
+ flex: 1;
187
+ display: flex;
188
+ flex-direction: column;
189
+ background: #000;
190
+ }
191
+
192
+ .live-watch-topbar {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 1rem;
196
+ padding: 0.5rem 1rem;
197
+ background: rgba(10, 10, 15, 0.9);
198
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
199
+ }
200
+
201
+ .live-watch-info {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 0.75rem;
205
+ flex: 1;
206
+ }
207
+
208
+ .live-watch-channel {
209
+ font-weight: 600;
210
+ font-size: 0.95rem;
211
+ color: #e0e0e0;
212
+ }
213
+
214
+ .live-viewers {
215
+ font-size: 0.8rem;
216
+ color: var(--text-muted, #888);
217
+ }
218
+
219
+ .live-status {
220
+ font-size: 0.75rem;
221
+ padding: 0.2rem 0.6rem;
222
+ border-radius: 9999px;
223
+ }
224
+ .live-status-connecting { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
225
+ .live-status-connected { background: rgba(16, 185, 129, 0.2); color: #10b981; }
226
+ .live-status-error { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
227
+ .live-status-disconnected { background: rgba(107, 114, 128, 0.2); color: #6b7280; }
228
+
229
+ .live-watch-player {
230
+ flex: 1;
231
+ display: flex;
232
+ align-items: center;
233
+ justify-content: center;
234
+ position: relative;
235
+ }
236
+
237
+ .live-watch-player video {
238
+ max-width: 100%;
239
+ max-height: 100%;
240
+ object-fit: contain;
241
+ }
242
+
243
+ .live-watch-placeholder {
244
+ position: absolute;
245
+ color: var(--text-muted, #888);
246
+ font-size: 1rem;
247
+ }
248
+
249
+ /* Chat Sidebar */
250
+ .live-chat-sidebar {
251
+ width: 320px;
252
+ background: rgba(18, 18, 31, 0.95);
253
+ border-left: 1px solid rgba(255, 255, 255, 0.06);
254
+ display: flex;
255
+ flex-direction: column;
256
+ }
257
+
258
+ .live-chat-header {
259
+ padding: 0.75rem 1rem;
260
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
261
+ }
262
+ .live-chat-header h3 {
263
+ font-size: 0.9rem;
264
+ color: #e0e0e0;
265
+ margin: 0;
266
+ }
267
+
268
+ .live-chat-messages {
269
+ flex: 1;
270
+ overflow-y: auto;
271
+ padding: 0.75rem;
272
+ font-size: 0.8rem;
273
+ line-height: 1.5;
274
+ }
275
+
276
+ .live-chat-msg {
277
+ margin-bottom: 0.4rem;
278
+ }
279
+ .live-chat-nick {
280
+ color: #818cf8;
281
+ font-weight: 600;
282
+ margin-right: 0.4rem;
283
+ }
284
+ .live-chat-text {
285
+ color: #d0d0d0;
286
+ }
287
+
288
+ .live-chat-input-area {
289
+ display: flex;
290
+ gap: 0.5rem;
291
+ padding: 0.5rem;
292
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
293
+ }
294
+
295
+ /* badges */
296
+ .live-badge {
297
+ display: inline-flex;
298
+ align-items: center;
299
+ gap: 0.25rem;
300
+ padding: 0.2rem 0.6rem;
301
+ border-radius: 9999px;
302
+ font-size: 0.7rem;
303
+ font-weight: 600;
304
+ }
305
+ .badge-broadcast {
306
+ background: rgba(239, 68, 68, 0.15);
307
+ color: #f87171;
308
+ }
309
+
310
+ @media (max-width: 768px) {
311
+ .live-watch-layout {
312
+ flex-direction: column;
313
+ }
314
+ .live-chat-sidebar {
315
+ width: 100%;
316
+ height: 40vh;
317
+ }
318
+ }
319
+ </style>
@@ -0,0 +1,36 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SSR LiveController — WebRTC 라이브 채널 페이지
5
+ *
6
+ * 인증 불필요 — 공개 페이지.
7
+ */
8
+ export default class LiveController extends Controller {
9
+ /** @type {import('@fuzionx/framework').RouteHandler} 채널 목록 */
10
+ static index;
11
+ /** @type {import('@fuzionx/framework').RouteHandler} 방송 시청 */
12
+ static watch;
13
+ /** @type {import('@fuzionx/framework').RouteHandler} 화상채팅 입장 */
14
+ static room;
15
+
16
+ /**
17
+ * GET /live — 라이브 채널 목록
18
+ */
19
+ async index(ctx) {
20
+ ctx.render('live/index');
21
+ }
22
+
23
+ /**
24
+ * GET /live/watch/:channelId — 방송 시청
25
+ */
26
+ async watch(ctx) {
27
+ ctx.render('live/watch', { channelId: ctx.params.channelId });
28
+ }
29
+
30
+ /**
31
+ * GET /live/room/:channelId — 화상채팅 입장
32
+ */
33
+ async room(ctx) {
34
+ ctx.render('live/room', { channelId: ctx.params.channelId });
35
+ }
36
+ }
@@ -1,15 +1,18 @@
1
- import { auth } from '@fuzionx/framework';
1
+ import { auth, loadUser } from '@fuzionx/framework';
2
2
  import HomeController from '../controllers/HomeController.js';
3
3
  import FeaturesController from '../controllers/FeaturesController.js';
4
4
  import ChatController from '../controllers/ChatController.js';
5
+ import LiveController from '../controllers/LiveController.js';
5
6
  import AuthController from '../controllers/AuthController.js';
6
7
  import UserController from '../controllers/UserController.js';
7
8
  import PostController from '../controllers/PostController.js';
8
9
 
9
10
  export default (r) => {
10
- // ── 공개 페이지 (인증 불필요) ──
11
- r.get('/', HomeController.index);
12
- r.get('/features', FeaturesController.index);
11
+ // ── 공개 페이지 (인증 불필요, 세션 유저 로드) ──
12
+ r.group('', { middleware: [loadUser()] }, (r) => {
13
+ r.get('/', HomeController.index);
14
+ r.get('/features', FeaturesController.index);
15
+ });
13
16
 
14
17
  // ── 인증 ──
15
18
  r.get('/login', AuthController.loginPage);
@@ -23,6 +26,11 @@ export default (r) => {
23
26
 
24
27
  // ── 인증 필요 (built-in auth 미들웨어) ──
25
28
  r.group('', { middleware: [auth({ redirectTo: '/login' })] }, (r) => {
29
+ // 라이브
30
+ r.get('/live', LiveController.index);
31
+ r.get('/live/watch/:channelId', LiveController.watch);
32
+ r.get('/live/room/:channelId', LiveController.room);
33
+
26
34
  // 채팅
27
35
  r.get('/chat', ChatController.index);
28
36
 
@@ -13,6 +13,7 @@
13
13
  {% block head %}{% endblock %}
14
14
  <script>
15
15
  window.__FX__ENC_ENABLED = {{ config.bridge.asp.enabled | default(value=false) }} || {{ config.app.asp.enabled | default(value=false) }}
16
+ {% if auth.user %}window.__FX_USER_NAME__ = "{{ auth.user.name }}";{% endif %}
16
17
  </script>
17
18
  </head>
18
19
  <body>
@@ -28,6 +29,7 @@
28
29
  <a href="/features" class="nav-link">{{ t(key="nav.features", default="Features") }}</a>
29
30
  <a href="/chat" class="nav-link">{{ t(key="nav.chat", default="Chat Demo") }}</a>
30
31
  <a href="/board" class="nav-link">{{ t(key="nav.board", default="게시판") }}</a>
32
+ <a href="/live" class="nav-link">{{ t(key="nav.live", default="Live") }}</a>
31
33
  {% if auth.user %}
32
34
  <a href="/profile" class="nav-link">{{ t(key="nav.profile", default="프로필") }}</a>
33
35
  <form action="/logout" method="POST" class="nav-logout">
@@ -81,6 +81,7 @@
81
81
  </div>
82
82
  <div class="progress-info" style="display:flex;justify-content:space-between;margin-top:6px;font-size:0.85rem;color:#aaa">
83
83
  <span id="progressText">0%</span>
84
+ <span id="progressSize"></span>
84
85
  <span id="progressSpeed"></span>
85
86
  </div>
86
87
  </div>
@@ -128,7 +129,8 @@ function editorInsert(text) {
128
129
  function formatSize(bytes) {
129
130
  if (bytes < 1024) return bytes + ' B';
130
131
  if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
131
- return (bytes / 1048576).toFixed(1) + ' MB';
132
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
133
+ return (bytes / 1073741824).toFixed(2) + ' GB';
132
134
  }
133
135
 
134
136
  function renderFiles() {
@@ -168,7 +170,25 @@ function editorInsert(text) {
168
170
  });
169
171
  })();
170
172
 
171
- /** XHR 업로드 — 진행률 + 자동 리다이렉트 */
173
+ /** i18n 텍스트 */
174
+ var _uploadLabels = {
175
+ speed: '{{ t(key="board.upload_speed", default="속도") }}',
176
+ complete: '{{ t(key="board.upload_complete", default="업로드 완료!") }}',
177
+ error: '{{ t(key="board.upload_error", default="오류 발생") }}',
178
+ networkError: '{{ t(key="board.upload_network_error", default="네트워크 오류") }}',
179
+ uploading: '{{ t(key="board.uploading", default="업로드 중...") }}',
180
+ thumbnailExtracting: '{{ t(key="board.thumbnail_extracting", default="🔄 썸네일 추출중...") }}'
181
+ };
182
+
183
+ /** 파일 크기 포맷 (진행률용) */
184
+ function _fmtSize(bytes) {
185
+ if (bytes < 1024) return bytes + ' B';
186
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
187
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
188
+ return (bytes / 1073741824).toFixed(2) + ' GB';
189
+ }
190
+
191
+ /** XHR 업로드 — 진행률 + 파일 크기 + 속도 + 자동 리다이렉트 */
172
192
  function handleUpload(e) {
173
193
  var fileInput = document.getElementById('fileInput');
174
194
  if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
@@ -181,10 +201,11 @@ function handleUpload(e) {
181
201
  var progress = document.getElementById('uploadProgress');
182
202
  var fill = document.getElementById('progressFill');
183
203
  var pText = document.getElementById('progressText');
204
+ var pSize = document.getElementById('progressSize');
184
205
  var pSpeed = document.getElementById('progressSpeed');
185
206
 
186
207
  btn.disabled = true;
187
- btn.textContent = '업로드 중...';
208
+ btn.textContent = _uploadLabels.uploading;
188
209
  progress.style.display = 'block';
189
210
 
190
211
  var formData = new FormData(form);
@@ -197,30 +218,35 @@ function handleUpload(e) {
197
218
  fill.style.width = pct + '%';
198
219
  pText.textContent = pct + '%';
199
220
 
221
+ // 파일 크기 표시 (loaded / total)
222
+ pSize.textContent = _fmtSize(ev.loaded) + ' / ' + _fmtSize(ev.total);
223
+
200
224
  var elapsed = (Date.now() - startTime) / 1000;
201
225
  if (elapsed > 0.5) {
202
226
  var bps = ev.loaded / elapsed;
227
+ var speedVal;
203
228
  if (bps > 1048576) {
204
- pSpeed.textContent = (bps / 1048576).toFixed(1) + ' MB/s';
229
+ speedVal = (bps / 1048576).toFixed(1) + ' MB/s';
205
230
  } else {
206
- pSpeed.textContent = (bps / 1024).toFixed(0) + ' KB/s';
231
+ speedVal = (bps / 1024).toFixed(0) + ' KB/s';
207
232
  }
233
+ pSpeed.textContent = _uploadLabels.speed + ': ' + speedVal;
208
234
  }
209
235
  };
210
236
 
211
237
  xhr.onload = function() {
212
238
  if (xhr.status >= 200 && xhr.status < 400) {
213
239
  fill.style.width = '100%';
214
- pText.textContent = '업로드 완료!';
240
+ pText.textContent = _uploadLabels.complete;
215
241
  pSpeed.textContent = '';
216
- btn.textContent = '🔄 썸네일 추출중...';
242
+ btn.textContent = _uploadLabels.thumbnailExtracting;
217
243
  setTimeout(function() {
218
244
  window.location.href = xhr.responseURL || '/board';
219
245
  }, 1500);
220
246
  } else {
221
247
  btn.disabled = false;
222
248
  btn.textContent = '재시도';
223
- pText.textContent = '오류 발생';
249
+ pText.textContent = _uploadLabels.error;
224
250
  fill.style.background = '#e74c3c';
225
251
  }
226
252
  };
@@ -228,7 +254,7 @@ function handleUpload(e) {
228
254
  xhr.onerror = function() {
229
255
  btn.disabled = false;
230
256
  btn.textContent = '재시도';
231
- pText.textContent = '네트워크 오류';
257
+ pText.textContent = _uploadLabels.networkError;
232
258
  fill.style.background = '#e74c3c';
233
259
  };
234
260