create-fuzionx 0.1.49 β†’ 0.1.51

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-fuzionx",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "description": "Create a new FuzionX application β€” npx create-fuzionx my-app",
5
5
  "type": "module",
6
6
  "bin": {
@@ -235,7 +235,8 @@
235
235
  "login": "Login",
236
236
  "logout": "Logout",
237
237
  "profile": "Profile",
238
- "register": "Register"
238
+ "register": "Register",
239
+ "live": "Live"
239
240
  },
240
241
  "page": {
241
242
  "404": "Page not found",
@@ -252,5 +253,48 @@
252
253
  "title": "Edit Profile",
253
254
  "update_success": "Profile updated."
254
255
  },
255
- "welcome": "Welcome, {name}! πŸ‘‹"
256
+ "welcome": "Welcome, {name}! πŸ‘‹",
257
+ "live": {
258
+ "back": "Back to list",
259
+ "broadcast": "Broadcast",
260
+ "cancel": "Cancel",
261
+ "channel_id": "Channel ID",
262
+ "channel_exists": "Channel already exists.",
263
+ "chat": "Chat",
264
+ "chat_placeholder": "Type a message...",
265
+ "connecting": "Connecting...",
266
+ "connection_failed": "connection failed",
267
+ "create": "New Channel",
268
+ "create_btn": "Create",
269
+ "create_failed": "Create failed",
270
+ "create_title": "Create New Channel",
271
+ "default": "Default",
272
+ "enter_channel_id": "Please enter a channel ID.",
273
+ "file_path": "File Path",
274
+ "file_stream": "File Streaming",
275
+ "hero_sub": "WebRTC Live Broadcast Β· VideoChat",
276
+ "join": "Join",
277
+ "join_room": "Enter Room",
278
+ "leave": "Leave",
279
+ "nickname": "Nickname",
280
+ "no_channels": "No active channels.",
281
+ "options": "Encoding Options",
282
+ "publisher": "Publisher",
283
+ "refresh": "Refresh",
284
+ "resolution": "Resolution",
285
+ "role": "Select Role",
286
+ "send": "Send",
287
+ "source": "Source",
288
+ "source_url": "Streaming URL",
289
+ "start_hint": "Create a new channel to get started.",
290
+ "stop": "Stop",
291
+ "stop_failed": "Stop failed",
292
+ "type": "Type",
293
+ "url_stream": "External URL",
294
+ "video_bitrate": "Video Bitrate",
295
+ "audio_bitrate": "Audio Bitrate",
296
+ "video_codec": "Video Codec",
297
+ "videochat": "VideoChat",
298
+ "viewer": "Viewer"
299
+ }
256
300
  }
@@ -235,7 +235,8 @@
235
235
  "login": "둜그인",
236
236
  "logout": "λ‘œκ·Έμ•„μ›ƒ",
237
237
  "profile": "ν”„λ‘œν•„",
238
- "register": "νšŒμ›κ°€μž…"
238
+ "register": "νšŒμ›κ°€μž…",
239
+ "live": "Live"
239
240
  },
240
241
  "page": {
241
242
  "404": "νŽ˜μ΄μ§€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€",
@@ -252,5 +253,48 @@
252
253
  "title": "ν”„λ‘œν•„ μˆ˜μ •",
253
254
  "update_success": "ν”„λ‘œν•„μ΄ μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€."
254
255
  },
255
- "welcome": "ν™˜μ˜ν•©λ‹ˆλ‹€, {name}λ‹˜ πŸ‘‹"
256
+ "welcome": "ν™˜μ˜ν•©λ‹ˆλ‹€, {name}λ‹˜ πŸ‘‹",
257
+ "live": {
258
+ "back": "λͺ©λ‘μœΌλ‘œ",
259
+ "broadcast": "방솑",
260
+ "cancel": "μ·¨μ†Œ",
261
+ "channel_id": "채널 ID",
262
+ "channel_exists": "이미 μ‘΄μž¬ν•˜λŠ” μ±„λ„μž…λ‹ˆλ‹€.",
263
+ "chat": "μ±„νŒ…",
264
+ "chat_placeholder": "λ©”μ‹œμ§€ μž…λ ₯...",
265
+ "connecting": "μ—°κ²° 쀑...",
266
+ "connection_failed": "μ—°κ²° μ‹€νŒ¨",
267
+ "create": "μƒˆ 채널",
268
+ "create_btn": "생성",
269
+ "create_failed": "생성 μ‹€νŒ¨",
270
+ "create_title": "μƒˆ 채널 λ§Œλ“€κΈ°",
271
+ "default": "κΈ°λ³Έκ°’",
272
+ "enter_channel_id": "채널 IDλ₯Ό μž…λ ₯ν•˜μ„Έμš”.",
273
+ "file_path": "파일 경둜",
274
+ "file_stream": "파일 슀트리밍",
275
+ "hero_sub": "WebRTC 라이브 방솑 Β· ν™”μƒμ±„νŒ…",
276
+ "join": "μž…μž₯",
277
+ "join_room": "μž…μž₯",
278
+ "leave": "λ‚˜κ°€κΈ°",
279
+ "nickname": "λ‹‰λ„€μž„",
280
+ "no_channels": "ν™œμ„± 채널이 μ—†μŠ΅λ‹ˆλ‹€.",
281
+ "options": "인코딩 μ˜΅μ…˜",
282
+ "publisher": "μ†‘μΆœμž",
283
+ "refresh": "μƒˆλ‘œκ³ μΉ¨",
284
+ "resolution": "해상도",
285
+ "role": "μ—­ν•  선택",
286
+ "send": "전솑",
287
+ "source": "μ†ŒμŠ€",
288
+ "source_url": "슀트리밍 URL",
289
+ "start_hint": "μƒˆ 채널을 λ§Œλ“€μ–΄ μ‹œμž‘ν•˜μ„Έμš”.",
290
+ "stop": "μ’…λ£Œ",
291
+ "stop_failed": "μ’…λ£Œ μ‹€νŒ¨",
292
+ "type": "νƒ€μž…",
293
+ "url_stream": "μ™ΈλΆ€ URL",
294
+ "video_bitrate": "μ˜μƒ λΉ„νŠΈλ ˆμ΄νŠΈ",
295
+ "audio_bitrate": "μ˜€λ””μ˜€ λΉ„νŠΈλ ˆμ΄νŠΈ",
296
+ "video_codec": "λΉ„λ””μ˜€ 코덱",
297
+ "videochat": "ν™”μƒμ±„νŒ…",
298
+ "viewer": "μ‹œμ²­μž"
299
+ }
256
300
  }
@@ -9,8 +9,8 @@
9
9
  "test": "vitest run"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/framework": "^0.1.49",
13
- "@fuzionx/client": "^0.1.49",
12
+ "@fuzionx/framework": "^0.1.51",
13
+ "@fuzionx/client": "^0.1.51",
14
14
  "joi": "^18.1.1"
15
15
  },
16
16
  "devDependencies": {
@@ -4,7 +4,7 @@
4
4
  "description": "Vue.js 3 SPA + Tera SSR ν•˜μ΄λΈŒλ¦¬λ“œ. WASM μ•”ν˜Έν™” 톡신.",
5
5
  "features": ["auth", "board", "i18n", "asp", "wasm"],
6
6
  "dependencies": {
7
- "@fuzionx/client": "^0.1.49"
7
+ "@fuzionx/client": "^0.1.51"
8
8
  },
9
9
  "devDependencies": {},
10
10
  "spaDevDependencies": {
@@ -9,6 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
+ "@fuzionx/player": "^0.1.51",
12
13
  "pinia": "^3.0.4",
13
14
  "vue": "^3.5.0",
14
15
  "vue-router": "^4.5.0"
@@ -14,6 +14,7 @@
14
14
  <router-link to="/features" class="nav-link">{{ t('nav.features', 'Features') }}</router-link>
15
15
  <router-link to="/chat" class="nav-link">{{ t('nav.chat', 'Chat Demo') }}</router-link>
16
16
  <router-link to="/board" class="nav-link">{{ t('nav.board', 'κ²Œμ‹œνŒ') }}</router-link>
17
+ <router-link to="/live" class="nav-link">{{ t('nav.live', 'Live') }}</router-link>
17
18
  <template v-if="authStore.isAuthenticated">
18
19
  <router-link to="/profile" class="nav-link">{{ t('nav.profile', 'ν”„λ‘œν•„') }}</router-link>
19
20
  <button class="nav-link btn-link" @click="handleLogout">{{ t('nav.logout', 'λ‘œκ·Έμ•„μ›ƒ') }}</button>
@@ -15,6 +15,11 @@ export const routes = [
15
15
  { path: '/', name: 'home', component: HomeView },
16
16
  { path: '/features', name: 'features', component: () => import('../views/FeaturesView.vue') },
17
17
 
18
+ // ── Live (인증 ν•„μš”) ──
19
+ { path: '/live', name: 'live', component: () => import('../views/LiveList.vue'), meta: { auth: true } },
20
+ { path: '/live/watch/:channelId', name: 'live-watch', component: () => import('../views/LiveWatch.vue'), meta: { auth: true } },
21
+ { path: '/live/room/:channelId', name: 'live-room', component: () => import('../views/LiveRoom.vue'), meta: { auth: true } },
22
+
18
23
  // ── 게슀트 μ „μš© (둜그인 μƒνƒœλ©΄ ν™ˆμœΌλ‘œ) ──
19
24
  { path: '/login', name: 'login', component: () => import('../views/Login.vue'), meta: { guest: true } },
20
25
  { path: '/register', name: 'register', component: () => import('../views/Register.vue'), meta: { guest: true } },
@@ -0,0 +1,488 @@
1
+ <!--
2
+ LiveList.vue β€” 라이브 채널 λͺ©λ‘ + 생성/μ’…λ£Œ.
3
+
4
+ Hub API (GET /api/channels) 폴링으둜 채널 ν‘œμ‹œ.
5
+ 생성 λͺ¨λ‹¬: Broadcast (URL / 파일) λ˜λŠ” VideoChat.
6
+ μ’…λ£Œ: DELETE /api/channels/:id.
7
+ -->
8
+ <template>
9
+ <div class="container">
10
+ <!-- ── Header ── -->
11
+ <section class="hero-section" style="padding:3rem 0 2rem;">
12
+ <h1 class="hero-title">
13
+ <span class="gradient-text">Live Channels</span>
14
+ </h1>
15
+ <p class="hero-subtitle">{{ t('live.hero_sub', 'WebRTC Live Broadcast Β· VideoChat') }}</p>
16
+ </section>
17
+
18
+ <!-- ── Hub URL μ„€μ • ── -->
19
+ <div class="live-hub-bar glass-card" style="margin-bottom:1.5rem;padding:0.75rem 1.25rem;display:flex;align-items:center;gap:0.75rem;">
20
+ <label style="font-size:0.8rem;color:var(--text-muted);white-space:nowrap;">Hub URL</label>
21
+ <input
22
+ v-model="hubUrl"
23
+ type="text"
24
+ class="form-input"
25
+ style="flex:1;padding:0.4rem 0.75rem;font-size:0.85rem;"
26
+ placeholder="http://127.0.0.1:9100"
27
+ />
28
+ <button class="btn btn-primary" style="padding:0.4rem 1rem;font-size:0.85rem;" @click="fetchChannels">
29
+ {{ t('live.refresh', 'μƒˆλ‘œκ³ μΉ¨') }}
30
+ </button>
31
+ <button class="btn btn-hero-primary" style="padding:0.4rem 1rem;font-size:0.85rem;" @click="showCreateModal = true">
32
+ + {{ t('live.create', 'μƒˆ 채널') }}
33
+ </button>
34
+ </div>
35
+
36
+ <!-- ── Error ── -->
37
+ <div v-if="error" class="alert alert-error" style="margin-bottom:1rem;">
38
+ {{ error }}
39
+ </div>
40
+
41
+ <!-- ── Channel List ── -->
42
+ <div v-if="channels.length === 0 && !loading" class="glass-card" style="text-align:center;padding:3rem;">
43
+ <p style="color:var(--text-muted);font-size:1rem;">{{ t('live.no_channels', 'ν™œμ„± 채널이 μ—†μŠ΅λ‹ˆλ‹€.') }}</p>
44
+ <p style="color:var(--text-muted);font-size:0.85rem;margin-top:0.5rem;">{{ t('live.start_hint', 'Create a new channel to get started.') }}</p>
45
+ </div>
46
+
47
+ <div v-else class="live-grid">
48
+ <div v-for="ch in channels" :key="ch.channel_id" class="glass-card live-card">
49
+ <div class="live-card-header">
50
+ <span :class="['live-badge', ch.source_type === 'webrtc' ? 'badge-videochat' : 'badge-broadcast']">
51
+ {{ ch.source_type === 'webrtc' ? 'πŸ‘₯ ' + t('live.videochat', 'VideoChat') : 'πŸ”΄ ' + t('live.broadcast', 'Broadcast') }}
52
+ </span>
53
+ <span class="live-viewers">
54
+ πŸ‘ {{ ch.viewer_count || 0 }}
55
+ </span>
56
+ </div>
57
+ <div class="live-card-body">
58
+ <h3 class="live-card-title">{{ ch.channel_id }}</h3>
59
+ <p class="live-card-meta">
60
+ <span v-if="ch.source_url" style="word-break:break-all;">{{ ch.source_url }}</span>
61
+ <span v-else>{{ ch.source_type }}</span>
62
+ </p>
63
+ <p class="live-card-meta" style="font-size:0.75rem;">
64
+ Media: {{ ch.media_id }} Β· {{ ch.media_ip }}:{{ ch.webrtc_port }}
65
+ </p>
66
+ </div>
67
+ <div class="live-card-actions">
68
+ <button
69
+ class="btn btn-primary"
70
+ style="flex:1;"
71
+ @click="joinChannel(ch)"
72
+ >
73
+ {{ t('live.join', 'μž…μž₯') }}
74
+ </button>
75
+ <button
76
+ class="btn btn-outline"
77
+ style="color:#ef4444;border-color:#ef4444;"
78
+ @click="stopChannel(ch.channel_id)"
79
+ >
80
+ {{ t('live.stop', 'μ’…λ£Œ') }}
81
+ </button>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- ── Create Modal ── -->
87
+ <div v-if="showCreateModal" class="live-modal-overlay" @click.self="showCreateModal = false">
88
+ <div class="glass-card live-modal">
89
+ <h2 style="margin-bottom:1.5rem;">πŸ“‘ {{ t('live.create_title', 'μƒˆ 채널 λ§Œλ“€κΈ°') }}</h2>
90
+
91
+ <div class="form-group">
92
+ <label class="form-label">{{ t('live.channel_id', '채널 ID') }}</label>
93
+ <input v-model="createForm.channelId" type="text" class="form-input" placeholder="my-channel" />
94
+ </div>
95
+
96
+ <div class="form-group">
97
+ <label class="form-label">{{ t('live.type', 'νƒ€μž…') }}</label>
98
+ <div class="live-type-selector">
99
+ <button
100
+ :class="['live-type-btn', { active: createForm.type === 'broadcast' }]"
101
+ @click="createForm.type = 'broadcast'"
102
+ >
103
+ πŸ”΄ {{ t('live.broadcast', '방솑') }}
104
+ </button>
105
+ <button
106
+ :class="['live-type-btn', { active: createForm.type === 'videochat' }]"
107
+ @click="createForm.type = 'videochat'"
108
+ >
109
+ πŸ‘₯ {{ t('live.videochat', 'ν™”μƒμ±„νŒ…') }}
110
+ </button>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- Broadcast μ˜΅μ…˜ -->
115
+ <template v-if="createForm.type === 'broadcast'">
116
+ <div class="form-group">
117
+ <label class="form-label">{{ t('live.source', 'μ†ŒμŠ€') }}</label>
118
+ <div class="live-type-selector">
119
+ <button
120
+ :class="['live-type-btn', { active: createForm.sourceMode === 'url' }]"
121
+ @click="createForm.sourceMode = 'url'"
122
+ >
123
+ 🌐 {{ t('live.url_stream', 'μ™ΈλΆ€ URL') }}
124
+ </button>
125
+ <button
126
+ :class="['live-type-btn', { active: createForm.sourceMode === 'file' }]"
127
+ @click="createForm.sourceMode = 'file'"
128
+ >
129
+ πŸ“ {{ t('live.file_stream', '파일 슀트리밍') }}
130
+ </button>
131
+ </div>
132
+ </div>
133
+
134
+ <div v-if="createForm.sourceMode === 'url'" class="form-group">
135
+ <label class="form-label">{{ t('live.source_url', '슀트리밍 URL') }}</label>
136
+ <input v-model="createForm.sourceUrl" type="text" class="form-input" placeholder="rtmp://... λ˜λŠ” http://..." />
137
+ </div>
138
+
139
+ <div v-if="createForm.sourceMode === 'file'" class="form-group">
140
+ <label class="form-label">{{ t('live.file_path', '파일 경둜') }}</label>
141
+ <input v-model="createForm.sourceUrl" type="text" class="form-input" placeholder="/path/to/video.mp4" />
142
+ </div>
143
+ </template>
144
+
145
+ <!-- 인코딩 μ˜΅μ…˜ (Broadcast μ „μš©) -->
146
+ <template v-if="createForm.type === 'broadcast'">
147
+ <div class="form-group" style="margin-top:0.5rem;">
148
+ <label class="form-label">βš™οΈ {{ t('live.options', '인코딩 μ˜΅μ…˜') }}</label>
149
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
150
+ <div>
151
+ <label class="form-label" style="font-size:0.75rem;">{{ t('live.resolution', '해상도') }}</label>
152
+ <select v-model="createForm.resolution" class="form-input" style="padding:0.4rem 0.5rem;font-size:0.85rem;">
153
+ <option value="">{{ t('live.default', 'Default') }}</option>
154
+ <option value="1920x1080">1920Γ—1080 (FHD)</option>
155
+ <option value="1280x720">1280Γ—720 (HD)</option>
156
+ <option value="854x480">854Γ—480 (SD)</option>
157
+ <option value="640x360">640Γ—360</option>
158
+ </select>
159
+ </div>
160
+ <div>
161
+ <label class="form-label" style="font-size:0.75rem;">{{ t('live.video_bitrate', 'μ˜μƒ λΉ„νŠΈλ ˆμ΄νŠΈ') }}</label>
162
+ <select v-model="createForm.videoBitrate" class="form-input" style="padding:0.4rem 0.5rem;font-size:0.85rem;">
163
+ <option value="">{{ t('live.default', 'Default') }}</option>
164
+ <option value="8000">8000 kbps</option>
165
+ <option value="5000">5000 kbps</option>
166
+ <option value="3000">3000 kbps</option>
167
+ <option value="1500">1500 kbps</option>
168
+ <option value="800">800 kbps</option>
169
+ </select>
170
+ </div>
171
+ <div>
172
+ <label class="form-label" style="font-size:0.75rem;">{{ t('live.audio_bitrate', 'μ˜€λ””μ˜€ λΉ„νŠΈλ ˆμ΄νŠΈ') }}</label>
173
+ <select v-model="createForm.audioBitrate" class="form-input" style="padding:0.4rem 0.5rem;font-size:0.85rem;">
174
+ <option value="">{{ t('live.default', 'Default') }}</option>
175
+ <option value="320">320 kbps</option>
176
+ <option value="192">192 kbps</option>
177
+ <option value="128">128 kbps</option>
178
+ <option value="64">64 kbps</option>
179
+ </select>
180
+ </div>
181
+ <div>
182
+ <label class="form-label" style="font-size:0.75rem;">{{ t('live.video_codec', 'λΉ„λ””μ˜€ 코덱') }}</label>
183
+ <select v-model="createForm.videoCodec" class="form-input" style="padding:0.4rem 0.5rem;font-size:0.85rem;">
184
+ <option value="">{{ t('live.default', 'Default') }}</option>
185
+ <option value="h264">H.264</option>
186
+ <option value="vp8">VP8</option>
187
+ <option value="vp9">VP9</option>
188
+ </select>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </template>
193
+
194
+ <div v-if="createError" class="alert alert-error" style="margin-top:1rem;">{{ createError }}</div>
195
+
196
+ <div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
197
+ <button class="btn btn-outline" style="flex:1;" @click="showCreateModal = false">
198
+ {{ t('live.cancel', 'μ·¨μ†Œ') }}
199
+ </button>
200
+ <button class="btn btn-hero-primary" style="flex:1;" :disabled="creating" @click="createChannel">
201
+ {{ creating ? '...' : t('live.create_btn', '생성') }}
202
+ </button>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+
208
+ <!-- ── Footer ── -->
209
+ <footer class="site-footer">
210
+ <div class="container">
211
+ <div class="footer-content">
212
+ <span class="footer-brand">FuzionX</span>
213
+ <span class="footer-text">Powered by <code>@fuzionx/player</code> WebRTC SDK</span>
214
+ </div>
215
+ </div>
216
+ </footer>
217
+ </template>
218
+
219
+ <script setup>
220
+ import { ref, onMounted, onUnmounted } from 'vue';
221
+ import { useRouter } from 'vue-router';
222
+ import { useLocale } from '../composables/useLocale.js';
223
+ import { useAuthStore } from '../stores/auth.js';
224
+
225
+ const { t } = useLocale();
226
+ const router = useRouter();
227
+ const authStore = useAuthStore();
228
+
229
+ /* ── State ── */
230
+ const hubUrl = ref('http://127.0.0.1:9100');
231
+ const channels = ref([]);
232
+ const loading = ref(false);
233
+ const error = ref('');
234
+ const showCreateModal = ref(false);
235
+ const creating = ref(false);
236
+ const createError = ref('');
237
+
238
+ const createForm = ref({
239
+ channelId: '',
240
+ type: 'broadcast', // 'broadcast' | 'videochat'
241
+ sourceMode: 'url', // 'url' | 'file'
242
+ sourceUrl: '',
243
+ resolution: '', // '1920x1080', '1280x720', etc.
244
+ videoBitrate: '', // kbps
245
+ audioBitrate: '', // kbps
246
+ videoCodec: '', // 'h264', 'vp8', 'vp9'
247
+ });
248
+
249
+ let pollTimer = null;
250
+
251
+ /* ── Lifecycle ── */
252
+ onMounted(() => {
253
+ fetchChannels();
254
+ pollTimer = setInterval(fetchChannels, 5000);
255
+ });
256
+
257
+ onUnmounted(() => {
258
+ if (pollTimer) clearInterval(pollTimer);
259
+ });
260
+
261
+ /* ── Hub API ── */
262
+ async function fetchChannels() {
263
+ try {
264
+ // 첫 λ‘œλ“œμ—λ§Œ loading ν‘œμ‹œ (폴링 μ‹œ κΉœλΉ‘μž„ λ°©μ§€)
265
+ const isFirstLoad = channels.value.length === 0 && !error.value;
266
+ if (isFirstLoad) loading.value = true;
267
+ const res = await fetch(`${hubUrl.value}/api/channels`);
268
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
269
+ const newData = await res.json();
270
+ // 성곡 μ‹œ μ—λŸ¬ μ΄ˆκΈ°ν™”
271
+ error.value = '';
272
+ // 데이터가 λ³€κ²½λœ κ²½μš°μ—λ§Œ μ—…λ°μ΄νŠΈ (Vue λΆˆν•„μš” μž¬λ Œλ” λ°©μ§€)
273
+ if (JSON.stringify(newData) !== JSON.stringify(channels.value)) {
274
+ channels.value = newData;
275
+ }
276
+ } catch (e) {
277
+ // 폴링 μ—λŸ¬ μ‹œ κΈ°μ‘΄ 채널 λͺ©λ‘μ€ μœ μ§€ (κΉœλΉ‘μž„ λ°©μ§€)
278
+ // 첫 λ‘œλ“œ μ‹€νŒ¨ μ‹œμ—λ§Œ 빈 λͺ©λ‘ ν‘œμ‹œ
279
+ if (channels.value.length === 0) {
280
+ error.value = `Hub ${t('live.connection_failed', 'connection failed')}: ${e.message}`;
281
+ }
282
+ } finally {
283
+ loading.value = false;
284
+ }
285
+ }
286
+
287
+ async function createChannel() {
288
+ const form = createForm.value;
289
+ if (!form.channelId.trim()) {
290
+ createError.value = t('live.enter_channel_id', 'Please enter a channel ID.');
291
+ return;
292
+ }
293
+
294
+ creating.value = true;
295
+ createError.value = '';
296
+
297
+ try {
298
+ const channelId = form.channelId.trim();
299
+
300
+ // ν™”μƒμ±„νŒ…: SDK(FuzionXPublisher.connect)κ°€ 채널 생성을 μ²˜λ¦¬ν•˜λ―€λ‘œ
301
+ // Hub APIλ₯Ό 직접 ν˜ΈμΆœν•˜μ§€ μ•Šκ³  λ°”λ‘œ room μž…μž₯
302
+ if (form.type === 'videochat') {
303
+ showCreateModal.value = false;
304
+ createForm.value = { channelId: '', type: 'broadcast', sourceMode: 'url', sourceUrl: '', resolution: '', videoBitrate: '', audioBitrate: '', videoCodec: '' };
305
+ const nick = authStore.user?.name || 'host-' + Math.random().toString(36).slice(2, 6);
306
+ router.push({ name: 'live-room', params: { channelId }, query: { hub: hubUrl.value, role: 'publisher', nickname: nick } });
307
+ return;
308
+ }
309
+
310
+ // 방솑: Hub API둜 채널 생성 (always_on=true, 영ꡬ 보쑴)
311
+ const body = {
312
+ channel_id: channelId,
313
+ source_type: 'url',
314
+ };
315
+
316
+ if (form.sourceUrl.trim()) {
317
+ body.source_url = form.sourceUrl.trim();
318
+ }
319
+
320
+ // 인코딩 μ˜΅μ…˜ 쑰립
321
+ const opts = { always_on: true };
322
+ if (form.resolution) opts.resolution = form.resolution;
323
+ if (form.videoBitrate) {
324
+ opts.video = { bitrate_kbps: parseInt(form.videoBitrate) };
325
+ if (form.videoCodec) opts.video.codec = form.videoCodec;
326
+ } else if (form.videoCodec) {
327
+ opts.video = { codec: form.videoCodec };
328
+ }
329
+ if (form.audioBitrate) opts.audio = { bitrate_kbps: parseInt(form.audioBitrate) };
330
+ body.options = opts;
331
+
332
+ const res = await fetch(`${hubUrl.value}/api/channels`, {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json' },
335
+ body: JSON.stringify(body),
336
+ });
337
+
338
+ if (res.status === 409) {
339
+ createError.value = t('live.channel_exists', 'Channel already exists.');
340
+ return;
341
+ }
342
+ if (!res.ok) {
343
+ const data = await res.json().catch(() => ({}));
344
+ createError.value = data.error || `${t('live.create_failed', 'Create failed')} (${res.status})`;
345
+ return;
346
+ }
347
+
348
+ showCreateModal.value = false;
349
+ createForm.value = { channelId: '', type: 'broadcast', sourceMode: 'url', sourceUrl: '', resolution: '', videoBitrate: '', audioBitrate: '', videoCodec: '' };
350
+ await fetchChannels();
351
+ } catch (e) {
352
+ createError.value = `Hub μ—°κ²° μ‹€νŒ¨: ${e.message}`;
353
+ } finally {
354
+ creating.value = false;
355
+ }
356
+ }
357
+
358
+ async function stopChannel(channelId) {
359
+ try {
360
+ await fetch(`${hubUrl.value}/api/channels/${channelId}`, { method: 'DELETE' });
361
+ await fetchChannels();
362
+ } catch (e) {
363
+ error.value = `${t('live.stop_failed', 'Stop failed')}: ${e.message}`;
364
+ }
365
+ }
366
+
367
+ function joinChannel(ch) {
368
+ if (ch.source_type === 'webrtc') {
369
+ router.push({ name: 'live-room', params: { channelId: ch.channel_id }, query: { hub: hubUrl.value } });
370
+ } else {
371
+ router.push({ name: 'live-watch', params: { channelId: ch.channel_id }, query: { hub: hubUrl.value } });
372
+ }
373
+ }
374
+ </script>
375
+
376
+ <style scoped>
377
+ .live-grid {
378
+ display: grid;
379
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
380
+ gap: 1.25rem;
381
+ }
382
+
383
+ .live-card {
384
+ display: flex;
385
+ flex-direction: column;
386
+ padding: 1.25rem;
387
+ transition: transform 0.2s, box-shadow 0.2s;
388
+ }
389
+ .live-card:hover {
390
+ transform: translateY(-2px);
391
+ box-shadow: 0 8px 32px rgba(99, 102, 241, 0.15);
392
+ }
393
+
394
+ .live-card-header {
395
+ display: flex;
396
+ justify-content: space-between;
397
+ align-items: center;
398
+ margin-bottom: 0.75rem;
399
+ }
400
+
401
+ .live-badge {
402
+ display: inline-flex;
403
+ align-items: center;
404
+ gap: 0.25rem;
405
+ padding: 0.25rem 0.75rem;
406
+ border-radius: 9999px;
407
+ font-size: 0.75rem;
408
+ font-weight: 600;
409
+ }
410
+ .badge-broadcast {
411
+ background: rgba(239, 68, 68, 0.15);
412
+ color: #f87171;
413
+ border: 1px solid rgba(239, 68, 68, 0.3);
414
+ }
415
+ .badge-videochat {
416
+ background: rgba(99, 102, 241, 0.15);
417
+ color: #818cf8;
418
+ border: 1px solid rgba(99, 102, 241, 0.3);
419
+ }
420
+
421
+ .live-viewers {
422
+ font-size: 0.8rem;
423
+ color: var(--text-muted);
424
+ }
425
+
426
+ .live-card-title {
427
+ font-size: 1.1rem;
428
+ font-weight: 600;
429
+ margin-bottom: 0.5rem;
430
+ color: var(--text-primary, #e0e0e0);
431
+ }
432
+
433
+ .live-card-meta {
434
+ font-size: 0.8rem;
435
+ color: var(--text-muted);
436
+ margin-bottom: 0.25rem;
437
+ }
438
+
439
+ .live-card-actions {
440
+ display: flex;
441
+ gap: 0.5rem;
442
+ margin-top: auto;
443
+ padding-top: 1rem;
444
+ }
445
+
446
+ /* Modal */
447
+ .live-modal-overlay {
448
+ position: fixed;
449
+ inset: 0;
450
+ background: rgba(0, 0, 0, 0.6);
451
+ backdrop-filter: blur(4px);
452
+ display: flex;
453
+ align-items: center;
454
+ justify-content: center;
455
+ z-index: 1000;
456
+ }
457
+
458
+ .live-modal {
459
+ width: 90%;
460
+ max-width: 480px;
461
+ padding: 2rem;
462
+ }
463
+
464
+ .live-type-selector {
465
+ display: flex;
466
+ gap: 0.5rem;
467
+ }
468
+
469
+ .live-type-btn {
470
+ flex: 1;
471
+ padding: 0.6rem;
472
+ border: 1px solid var(--border-color, rgba(255,255,255,0.08));
473
+ border-radius: 8px;
474
+ background: transparent;
475
+ color: var(--text-muted);
476
+ cursor: pointer;
477
+ font-size: 0.85rem;
478
+ transition: all 0.2s;
479
+ }
480
+ .live-type-btn.active {
481
+ background: rgba(99, 102, 241, 0.15);
482
+ color: #818cf8;
483
+ border-color: rgba(99, 102, 241, 0.4);
484
+ }
485
+ .live-type-btn:hover {
486
+ border-color: rgba(99, 102, 241, 0.3);
487
+ }
488
+ </style>