@fuzionx/framework 0.1.49 → 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
+ }
@@ -2,6 +2,7 @@ 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';
@@ -25,6 +26,11 @@ export default (r) => {
25
26
 
26
27
  // ── 인증 필요 (built-in auth 미들웨어) ──
27
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
+
28
34
  // 채팅
29
35
  r.get('/chat', ChatController.index);
30
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">