@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.
- package/cli/templates/make/app-spa/views/default/spa/package.json +1 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/Navbar.vue +1 -0
- package/cli/templates/make/app-spa/views/default/spa/src/router/index.js +5 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/LiveList.vue +488 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/LiveRoom.vue +573 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/LiveWatch.vue +319 -0
- package/cli/templates/make/app-ssr/controllers/LiveController.js +36 -0
- package/cli/templates/make/app-ssr/routes/web.js +6 -0
- package/cli/templates/make/app-ssr/views/default/layouts/main.html +2 -0
- package/cli/templates/make/app-ssr/views/default/pages/live/index.html +351 -0
- package/cli/templates/make/app-ssr/views/default/pages/live/room.html +321 -0
- package/cli/templates/make/app-ssr/views/default/pages/live/watch.html +148 -0
- package/package.json +2 -2
|
@@ -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>
|