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 +1 -1
- package/templates/common/locales/en.json +46 -2
- package/templates/common/locales/ko.json +46 -2
- package/templates/common/package.json.tpl +2 -2
- package/templates/spa/meta.json +1 -1
- package/templates/spa/views/default/spa/package.json +1 -0
- package/templates/spa/views/default/spa/src/components/Navbar.vue +1 -0
- package/templates/spa/views/default/spa/src/router/index.js +5 -0
- package/templates/spa/views/default/spa/src/views/LiveList.vue +488 -0
- package/templates/spa/views/default/spa/src/views/LiveRoom.vue +573 -0
- package/templates/spa/views/default/spa/src/views/LiveWatch.vue +319 -0
- package/templates/ssr/controllers/LiveController.js +36 -0
- package/templates/ssr/routes/web.js +6 -0
- package/templates/ssr/views/default/layouts/main.html +2 -0
- package/templates/ssr/views/default/pages/live/index.html +351 -0
- package/templates/ssr/views/default/pages/live/room.html +321 -0
- package/templates/ssr/views/default/pages/live/watch.html +148 -0
package/package.json
CHANGED
|
@@ -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
|
}
|
package/templates/spa/meta.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@fuzionx/client": "^0.1.51"
|
|
8
8
|
},
|
|
9
9
|
"devDependencies": {},
|
|
10
10
|
"spaDevDependencies": {
|
|
@@ -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>
|