@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.
- package/cli/templates/make/app-spa/views/default/spa/package.json +1 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/FileUpload.vue +27 -11
- 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/composables/useApi.js +2 -2
- 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/BoardForm.vue +3 -3
- 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 +12 -4
- package/cli/templates/make/app-ssr/views/default/layouts/main.html +2 -0
- package/cli/templates/make/app-ssr/views/default/pages/board/form.html +35 -9
- 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/index.js +1 -1
- package/lib/middleware/index.js +1 -0
- package/lib/middleware/loadUser.js +48 -0
- package/package.json +2 -2
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
</div>
|
|
55
55
|
<div class="progress-info" style="display: flex; justify-content: space-between; margin-top: 6px; font-size: 0.85rem; color: #aaa;">
|
|
56
56
|
<span>{{ progressText }}</span>
|
|
57
|
-
<span>{{
|
|
57
|
+
<span>{{ sizeText }}</span>
|
|
58
|
+
<span>{{ speedLabel }}</span>
|
|
58
59
|
</div>
|
|
59
60
|
</div>
|
|
60
61
|
</div>
|
|
@@ -62,6 +63,9 @@
|
|
|
62
63
|
|
|
63
64
|
<script setup>
|
|
64
65
|
import { ref, computed } from 'vue';
|
|
66
|
+
import { useLocale } from '../composables/useLocale.js';
|
|
67
|
+
|
|
68
|
+
const { t } = useLocale();
|
|
65
69
|
|
|
66
70
|
/**
|
|
67
71
|
* @prop {string} label - 레이블 텍스트
|
|
@@ -83,14 +87,16 @@ const selectedFiles = ref([]);
|
|
|
83
87
|
const uploading = ref(false);
|
|
84
88
|
const progress = ref(0);
|
|
85
89
|
const progressText = ref('0%');
|
|
86
|
-
const
|
|
90
|
+
const sizeText = ref('');
|
|
91
|
+
const speedLabel = ref('');
|
|
87
92
|
const progressColor = ref('linear-gradient(90deg, #667eea, #764ba2)');
|
|
88
93
|
|
|
89
94
|
/** 파일 크기 포맷 */
|
|
90
95
|
function formatSize(bytes) {
|
|
91
96
|
if (bytes < 1024) return bytes + ' B';
|
|
92
97
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
93
|
-
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
98
|
+
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
99
|
+
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
/** 파일 선택 이벤트 */
|
|
@@ -122,23 +128,32 @@ function setFiles(fileList) {
|
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
/** 진행률 업데이트 (외부에서 호출) */
|
|
125
|
-
function setProgress(pct, speed) {
|
|
131
|
+
function setProgress(pct, speed, loaded, total) {
|
|
126
132
|
uploading.value = true;
|
|
127
133
|
progress.value = pct;
|
|
128
134
|
progressText.value = pct + '%';
|
|
129
|
-
|
|
135
|
+
|
|
136
|
+
// 파일 크기 표시
|
|
137
|
+
if (loaded !== undefined && total !== undefined) {
|
|
138
|
+
sizeText.value = formatSize(loaded) + ' / ' + formatSize(total);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 속도 레이블
|
|
142
|
+
if (speed) {
|
|
143
|
+
speedLabel.value = t('board.upload_speed', '속도') + ': ' + speed;
|
|
144
|
+
}
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
/** 업로드 완료 */
|
|
133
|
-
function setComplete(text
|
|
148
|
+
function setComplete(text) {
|
|
134
149
|
progress.value = 100;
|
|
135
|
-
progressText.value = text;
|
|
136
|
-
|
|
150
|
+
progressText.value = text || t('board.upload_complete', '업로드 완료!');
|
|
151
|
+
speedLabel.value = '';
|
|
137
152
|
}
|
|
138
153
|
|
|
139
154
|
/** 업로드 오류 */
|
|
140
|
-
function setError(text
|
|
141
|
-
progressText.value = text;
|
|
155
|
+
function setError(text) {
|
|
156
|
+
progressText.value = text || t('board.upload_error', '오류 발생');
|
|
142
157
|
progressColor.value = '#e74c3c';
|
|
143
158
|
}
|
|
144
159
|
|
|
@@ -153,7 +168,8 @@ function reset() {
|
|
|
153
168
|
uploading.value = false;
|
|
154
169
|
progress.value = 0;
|
|
155
170
|
progressText.value = '0%';
|
|
156
|
-
|
|
171
|
+
sizeText.value = '';
|
|
172
|
+
speedLabel.value = '';
|
|
157
173
|
progressColor.value = 'linear-gradient(90deg, #667eea, #764ba2)';
|
|
158
174
|
}
|
|
159
175
|
|
|
@@ -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>
|
|
@@ -80,7 +80,7 @@ export function useApi() {
|
|
|
80
80
|
* XHR 업로드 — 진행률 콜백 지원.
|
|
81
81
|
* @param {string} url - 업로드 URL
|
|
82
82
|
* @param {FormData} formData - 폼 데이터
|
|
83
|
-
* @param {Function} onProgress - 진행률 콜백 (percent, speed)
|
|
83
|
+
* @param {Function} onProgress - 진행률 콜백 (percent, speed, loaded, total)
|
|
84
84
|
* @param {string} [method='POST'] - HTTP 메서드
|
|
85
85
|
* @returns {Promise<{ok: boolean, responseURL: string}>}
|
|
86
86
|
*/
|
|
@@ -100,7 +100,7 @@ export function useApi() {
|
|
|
100
100
|
? (bps / 1048576).toFixed(1) + ' MB/s'
|
|
101
101
|
: (bps / 1024).toFixed(0) + ' KB/s';
|
|
102
102
|
}
|
|
103
|
-
onProgress(pct, speed);
|
|
103
|
+
onProgress(pct, speed, ev.loaded, ev.total);
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
xhr.onload = () => {
|
|
@@ -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 } },
|
|
@@ -162,10 +162,10 @@ async function handleSubmit() {
|
|
|
162
162
|
try {
|
|
163
163
|
if (hasFiles) {
|
|
164
164
|
// XHR 업로드 + 진행률
|
|
165
|
-
const result = await api.uploadWithProgress(uploadUrl, formData, (pct, speed) => {
|
|
166
|
-
fileUploader.value?.setProgress(pct, speed);
|
|
165
|
+
const result = await api.uploadWithProgress(uploadUrl, formData, (pct, speed, loaded, total) => {
|
|
166
|
+
fileUploader.value?.setProgress(pct, speed, loaded, total);
|
|
167
167
|
}, method);
|
|
168
|
-
fileUploader.value?.setComplete(
|
|
168
|
+
fileUploader.value?.setComplete();
|
|
169
169
|
toast.success(isEdit.value ? t('board.updated', '글이 수정되었습니다.') : t('board.created', '글이 작성되었습니다.'));
|
|
170
170
|
setTimeout(() => {
|
|
171
171
|
router.push(isEdit.value ? `/board/${route.params.id}` : '/board');
|
|
@@ -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>
|