@fuzionx/framework 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/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
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
{% extends "layouts/main.html" %}
|
|
2
|
+
{% block title %}{{ channelId }} — VideoChat{% endblock %}
|
|
3
|
+
{% block description %}FuzionX WebRTC video chat room. Up to 9 participants.{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block head %}
|
|
6
|
+
<script src="/public/js/fuzionx-player.umd.js"></script>
|
|
7
|
+
<style>
|
|
8
|
+
.live-role-selector{display:flex;gap:1rem;}
|
|
9
|
+
.live-role-btn{flex:1;display:flex;flex-direction:column;align-items:center;gap:.4rem;padding:1.25rem;border:2px solid var(--border-color,rgba(255,255,255,.08));border-radius:12px;background:transparent;cursor:pointer;transition:all .2s;}
|
|
10
|
+
.live-role-btn.active{border-color:rgba(99,102,241,.6);background:rgba(99,102,241,.1);}
|
|
11
|
+
.live-role-btn:hover{border-color:rgba(99,102,241,.4);}
|
|
12
|
+
.live-role-icon{font-size:2rem;}
|
|
13
|
+
.live-role-name{font-weight:600;color:#e0e0e0;font-size:.95rem;}
|
|
14
|
+
.live-role-desc{font-size:.75rem;color:var(--text-muted,#888);}
|
|
15
|
+
.live-room-layout{display:flex;height:calc(100vh - 60px);overflow:hidden;}
|
|
16
|
+
.live-room-video-area{flex:1;display:flex;flex-direction:column;background:#0a0a0f;}
|
|
17
|
+
.live-room-topbar{display:flex;align-items:center;gap:1rem;padding:.5rem 1rem;background:rgba(10,10,15,.95);border-bottom:1px solid rgba(255,255,255,.06);}
|
|
18
|
+
.live-room-info{display:flex;align-items:center;gap:.75rem;flex:1;}
|
|
19
|
+
.live-room-grid{flex:1;display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(3,1fr);gap:2px;padding:2px;}
|
|
20
|
+
.live-room-slot{position:relative;background:#111;border-radius:4px;overflow:hidden;}
|
|
21
|
+
.live-room-slot video{width:100%;height:100%;object-fit:cover;}
|
|
22
|
+
.live-room-slot-label{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;padding:2px 8px;border-radius:4px;font-size:.7rem;}
|
|
23
|
+
.live-room-local{display:flex;align-items:center;gap:.75rem;padding:.5rem 1rem;background:rgba(10,10,15,.95);border-top:1px solid rgba(255,255,255,.06);}
|
|
24
|
+
.live-room-local-label{font-size:.8rem;color:var(--text-muted,#888);}
|
|
25
|
+
.live-badge{display:inline-flex;align-items:center;gap:.25rem;padding:.2rem .6rem;border-radius:9999px;font-size:.7rem;font-weight:600;}
|
|
26
|
+
.badge-videochat{background:rgba(99,102,241,.15);color:#818cf8;}
|
|
27
|
+
.live-status{font-size:.75rem;padding:.2rem .6rem;border-radius:9999px;}
|
|
28
|
+
.live-status-connecting{background:rgba(245,158,11,.2);color:#f59e0b;}
|
|
29
|
+
.live-status-connected{background:rgba(16,185,129,.2);color:#10b981;}
|
|
30
|
+
.live-status-error{background:rgba(239,68,68,.2);color:#ef4444;}
|
|
31
|
+
.live-status-disconnected{background:rgba(107,114,128,.2);color:#6b7280;}
|
|
32
|
+
.live-chat-sidebar{width:320px;background:rgba(18,18,31,.95);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;}
|
|
33
|
+
.live-chat-header{padding:.75rem 1rem;border-bottom:1px solid rgba(255,255,255,.06);display:flex;align-items:center;justify-content:space-between;}
|
|
34
|
+
.live-chat-header h3{font-size:.9rem;color:#e0e0e0;margin:0;}
|
|
35
|
+
.live-chat-messages{flex:1;overflow-y:auto;padding:.75rem;font-size:.8rem;line-height:1.5;}
|
|
36
|
+
.live-chat-msg{margin-bottom:.4rem;}
|
|
37
|
+
.live-chat-nick{color:#818cf8;font-weight:600;margin-right:.4rem;}
|
|
38
|
+
.live-chat-text{color:#d0d0d0;}
|
|
39
|
+
.live-chat-input-area{display:flex;gap:.5rem;padding:.5rem;border-top:1px solid rgba(255,255,255,.06);}
|
|
40
|
+
.live-local-preview{padding:.5rem;border-bottom:1px solid rgba(255,255,255,.06);}
|
|
41
|
+
.live-local-preview video{width:100%;border-radius:6px;max-height:180px;object-fit:cover;}
|
|
42
|
+
.live-local-controls{display:flex;align-items:center;justify-content:space-between;margin-top:.4rem;padding:0 .25rem;}
|
|
43
|
+
.btn-mic{padding:.25rem .6rem;font-size:.75rem;}
|
|
44
|
+
.btn-mic.mic-off{color:#ef4444;border-color:#ef4444;}
|
|
45
|
+
@media(max-width:768px){.live-room-layout{flex-direction:column;}.live-chat-sidebar{width:100%;height:40vh;}.live-room-grid{grid-template-columns:repeat(2,1fr);grid-template-rows:auto;}}
|
|
46
|
+
</style>
|
|
47
|
+
{% endblock %}
|
|
48
|
+
|
|
49
|
+
{% block content %}
|
|
50
|
+
<!-- ── 역할 선택 화면 ── -->
|
|
51
|
+
<div id="join-screen" class="container">
|
|
52
|
+
<section class="hero-section" style="padding:3rem 0 2rem;">
|
|
53
|
+
<h1 class="hero-title">👥 <span class="gradient-text">VideoChat</span></h1>
|
|
54
|
+
<p class="hero-subtitle">{{ t(key="live.channel_id", default="채널 ID") }}: <strong>{{ channelId }}</strong></p>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<div class="glass-card" style="max-width:480px;margin:0 auto;padding:2rem;">
|
|
58
|
+
<div class="form-group">
|
|
59
|
+
<label class="form-label">{{ t(key="live.nickname", default="닉네임") }}</label>
|
|
60
|
+
<input id="nickname-input" type="text" class="form-input" placeholder="{{ t(key="live.nickname", default="닉네임") }}" maxlength="20" />
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="form-group">
|
|
64
|
+
<label class="form-label">{{ t(key="live.role", default="역할 선택") }}</label>
|
|
65
|
+
<div class="live-role-selector">
|
|
66
|
+
<button class="live-role-btn active" id="role-publisher" onclick="selectRole('publisher')">
|
|
67
|
+
<span class="live-role-icon">🎥</span>
|
|
68
|
+
<span class="live-role-name">{{ t(key="live.publisher", default="송출자") }}</span>
|
|
69
|
+
<span class="live-role-desc">Camera ON</span>
|
|
70
|
+
</button>
|
|
71
|
+
<button class="live-role-btn" id="role-viewer" onclick="selectRole('viewer')">
|
|
72
|
+
<span class="live-role-icon">👁</span>
|
|
73
|
+
<span class="live-role-name">{{ t(key="live.viewer", default="시청자") }}</span>
|
|
74
|
+
<span class="live-role-desc">Camera OFF</span>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div id="join-error" class="alert alert-error" style="display:none;margin-top:1rem;"></div>
|
|
80
|
+
|
|
81
|
+
<button id="join-btn" class="btn btn-hero-primary btn-full" style="margin-top:1.5rem;" onclick="joinRoom()">
|
|
82
|
+
{{ t(key="live.join_room", default="입장") }}
|
|
83
|
+
</button>
|
|
84
|
+
<a class="btn btn-outline btn-full" style="margin-top:.5rem;text-decoration:none;text-align:center;" href="/live">
|
|
85
|
+
← {{ t(key="live.back", default="목록으로") }}
|
|
86
|
+
</a>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- ── 화상채팅 화면 ── -->
|
|
91
|
+
<div id="room-screen" class="live-room-layout" style="display:none;">
|
|
92
|
+
<div class="live-room-video-area">
|
|
93
|
+
<div class="live-room-topbar">
|
|
94
|
+
<button class="btn btn-outline" style="padding:.3rem .75rem;font-size:.8rem;" onclick="leaveRoom()">
|
|
95
|
+
← {{ t(key="live.leave", default="나가기") }}
|
|
96
|
+
</button>
|
|
97
|
+
<div class="live-room-info">
|
|
98
|
+
<span class="live-badge badge-videochat">👥 VideoChat</span>
|
|
99
|
+
<span style="font-weight:600;color:#e0e0e0;">{{ channelId }}</span>
|
|
100
|
+
<span id="role-label" style="font-size:.75rem;color:var(--text-muted);"></span>
|
|
101
|
+
</div>
|
|
102
|
+
<span id="status-badge" class="live-status live-status-connecting">{{ t(key="live.connecting", default="연결 중...") }}</span>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<!-- 3×3 Grid -->
|
|
106
|
+
<div class="live-room-grid">
|
|
107
|
+
<div class="live-room-slot" id="slot-0"><video id="slot-video-0" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-0">Slot 0</div></div>
|
|
108
|
+
<div class="live-room-slot" id="slot-1"><video id="slot-video-1" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-1">Slot 1</div></div>
|
|
109
|
+
<div class="live-room-slot" id="slot-2"><video id="slot-video-2" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-2">Slot 2</div></div>
|
|
110
|
+
<div class="live-room-slot" id="slot-3"><video id="slot-video-3" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-3">Slot 3</div></div>
|
|
111
|
+
<div class="live-room-slot" id="slot-4"><video id="slot-video-4" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-4">Slot 4</div></div>
|
|
112
|
+
<div class="live-room-slot" id="slot-5"><video id="slot-video-5" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-5">Slot 5</div></div>
|
|
113
|
+
<div class="live-room-slot" id="slot-6"><video id="slot-video-6" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-6">Slot 6</div></div>
|
|
114
|
+
<div class="live-room-slot" id="slot-7"><video id="slot-video-7" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-7">Slot 7</div></div>
|
|
115
|
+
<div class="live-room-slot" id="slot-8"><video id="slot-video-8" autoplay playsinline></video><div class="live-room-slot-label" id="slot-label-8">Slot 8</div></div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<!-- Chat Sidebar -->
|
|
120
|
+
<aside class="live-chat-sidebar">
|
|
121
|
+
<!-- Local Camera (Publisher only) — 채팅 상단 -->
|
|
122
|
+
<div id="local-area" class="live-local-preview" style="display:none;">
|
|
123
|
+
<video id="local-video" autoplay playsinline muted></video>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="live-chat-header">
|
|
126
|
+
<h3>💬 {{ t(key="live.chat", default="Chat") }}</h3>
|
|
127
|
+
<button id="mic-toggle" class="btn btn-outline btn-mic" style="display:none;" onclick="toggleMic()">🎙 ON</button>
|
|
128
|
+
</div>
|
|
129
|
+
<div id="chat-messages" class="live-chat-messages"></div>
|
|
130
|
+
<div class="live-chat-input-area">
|
|
131
|
+
<input id="chat-input" type="text" class="form-input" placeholder="{{ t(key="live.chat_placeholder", default="메시지 입력...") }}" style="flex:1;" />
|
|
132
|
+
<button class="btn btn-primary" style="padding:.4rem .75rem;" onclick="sendChat()">{{ t(key="live.send", default="전송") }}</button>
|
|
133
|
+
</div>
|
|
134
|
+
</aside>
|
|
135
|
+
</div>
|
|
136
|
+
{% endblock %}
|
|
137
|
+
|
|
138
|
+
{% block scripts %}
|
|
139
|
+
<script>
|
|
140
|
+
const CHANNEL_ID = '{{ channelId }}';
|
|
141
|
+
const HUB_URL = new URLSearchParams(location.search).get('hub') || 'http://127.0.0.1:9100';
|
|
142
|
+
const { FuzionXPublisher, FuzionXViewer } = FuzionXPlayer;
|
|
143
|
+
|
|
144
|
+
let _role = new URLSearchParams(location.search).get('role') || 'publisher';
|
|
145
|
+
let _autoNickname = new URLSearchParams(location.search).get('nickname') || '';
|
|
146
|
+
let connection = null;
|
|
147
|
+
|
|
148
|
+
if (_autoNickname) {
|
|
149
|
+
document.getElementById('nickname-input').value = _autoNickname;
|
|
150
|
+
selectRole(_role);
|
|
151
|
+
setTimeout(() => joinRoom(), 100);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function selectRole(role) {
|
|
155
|
+
_role = role;
|
|
156
|
+
document.getElementById('role-publisher').className = 'live-role-btn' + (role === 'publisher' ? ' active' : '');
|
|
157
|
+
document.getElementById('role-viewer').className = 'live-role-btn' + (role === 'viewer' ? ' active' : '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function setStatus(cls, text) {
|
|
161
|
+
const el = document.getElementById('status-badge');
|
|
162
|
+
el.className = 'live-status live-status-' + cls;
|
|
163
|
+
el.textContent = text;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function appendChat(nick, text) {
|
|
167
|
+
const c = document.getElementById('chat-messages');
|
|
168
|
+
const div = document.createElement('div');
|
|
169
|
+
div.className = 'live-chat-msg';
|
|
170
|
+
div.innerHTML = '<span class="live-chat-nick">' + nick + '</span><span class="live-chat-text">' + text + '</span>';
|
|
171
|
+
c.appendChild(div);
|
|
172
|
+
c.scrollTop = c.scrollHeight;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function appendSystem(text) {
|
|
176
|
+
const c = document.getElementById('chat-messages');
|
|
177
|
+
const div = document.createElement('div');
|
|
178
|
+
div.className = 'chat-msg-system';
|
|
179
|
+
div.textContent = text;
|
|
180
|
+
c.appendChild(div);
|
|
181
|
+
c.scrollTop = c.scrollHeight;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sendChat() {
|
|
185
|
+
const input = document.getElementById('chat-input');
|
|
186
|
+
const text = input.value.trim();
|
|
187
|
+
if (!text || !connection) return;
|
|
188
|
+
connection.chat(text);
|
|
189
|
+
appendChat('Me', text);
|
|
190
|
+
input.value = '';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
document.getElementById('chat-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendChat(); });
|
|
194
|
+
|
|
195
|
+
async function joinRoom() {
|
|
196
|
+
const nickname = document.getElementById('nickname-input').value.trim();
|
|
197
|
+
const errEl = document.getElementById('join-error');
|
|
198
|
+
if (!nickname) { errEl.textContent = '{{ t(key="live.nickname", default="닉네임") }}'; errEl.style.display = 'block'; return; }
|
|
199
|
+
errEl.style.display = 'none';
|
|
200
|
+
|
|
201
|
+
const btn = document.getElementById('join-btn');
|
|
202
|
+
btn.disabled = true;
|
|
203
|
+
btn.textContent = '{{ t(key="live.connecting", default="연결 중...") }}';
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
if (_role === 'publisher') {
|
|
207
|
+
await joinAsPublisher(nickname);
|
|
208
|
+
} else {
|
|
209
|
+
await joinAsViewer(nickname);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
document.getElementById('join-screen').style.display = 'none';
|
|
213
|
+
document.getElementById('room-screen').style.display = 'flex';
|
|
214
|
+
document.getElementById('role-label').textContent = _role === 'publisher' ? '🎥 {{ t(key="live.publisher", default="송출자") }}' : '👁 {{ t(key="live.viewer", default="시청자") }}';
|
|
215
|
+
if (_role === 'publisher') {
|
|
216
|
+
document.getElementById('local-area').style.display = 'block';
|
|
217
|
+
document.getElementById('mic-toggle').style.display = 'inline-flex';
|
|
218
|
+
}
|
|
219
|
+
} catch(e) {
|
|
220
|
+
errEl.textContent = e.message || String(e);
|
|
221
|
+
errEl.style.display = 'block';
|
|
222
|
+
btn.disabled = false;
|
|
223
|
+
btn.textContent = '{{ t(key="live.join_room", default="입장") }}';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function joinAsPublisher(nickname) {
|
|
228
|
+
connection = new FuzionXPublisher({
|
|
229
|
+
hubUrl: HUB_URL,
|
|
230
|
+
channelId: CHANNEL_ID,
|
|
231
|
+
mode: 'videochat',
|
|
232
|
+
nickname: nickname,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
connection.on('media', (localStream) => {
|
|
236
|
+
_localStream = localStream;
|
|
237
|
+
const localVideo = document.getElementById('local-video');
|
|
238
|
+
if (localVideo) { localVideo.srcObject = localStream; }
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
connection.on('ready', () => { setStatus('connected', 'Connected'); });
|
|
242
|
+
|
|
243
|
+
connection.on('stream', (stream, slotIndex) => {
|
|
244
|
+
const videoEl = document.getElementById('slot-video-' + slotIndex);
|
|
245
|
+
if (videoEl) { videoEl.srcObject = stream; videoEl.play().catch(() => {}); }
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
connection.on('slot', (slot) => {
|
|
249
|
+
const label = document.getElementById('slot-label-' + slot.slotIndex);
|
|
250
|
+
if (label) label.textContent = slot.nickname;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
connection.on('slot_remove', ({ slotIndex }) => {
|
|
254
|
+
const videoEl = document.getElementById('slot-video-' + slotIndex);
|
|
255
|
+
if (videoEl) videoEl.srcObject = null;
|
|
256
|
+
const label = document.getElementById('slot-label-' + slotIndex);
|
|
257
|
+
if (label) label.textContent = 'Slot ' + slotIndex;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
connection.on('chat', ({ nickname, text }) => { appendChat(nickname, text); });
|
|
261
|
+
connection.on('error', (err) => { setStatus('error', 'Error'); });
|
|
262
|
+
connection.on('close', () => { setStatus('disconnected', 'Disconnected'); });
|
|
263
|
+
|
|
264
|
+
await connection.connect();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function joinAsViewer(nickname) {
|
|
268
|
+
connection = new FuzionXViewer({
|
|
269
|
+
hubUrl: HUB_URL,
|
|
270
|
+
channelId: CHANNEL_ID,
|
|
271
|
+
mode: 'videochat',
|
|
272
|
+
nickname: nickname,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
connection.on('stream', (stream, slotIndex) => {
|
|
276
|
+
const videoEl = document.getElementById('slot-video-' + slotIndex);
|
|
277
|
+
if (videoEl) { videoEl.srcObject = stream; videoEl.play().catch(() => {}); }
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
connection.on('connected', () => { setStatus('connected', 'Connected'); });
|
|
281
|
+
|
|
282
|
+
connection.on('slot', (slot) => {
|
|
283
|
+
const label = document.getElementById('slot-label-' + slot.slotIndex);
|
|
284
|
+
if (label) label.textContent = slot.nickname;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
connection.on('slot_remove', ({ slotIndex }) => {
|
|
288
|
+
const videoEl = document.getElementById('slot-video-' + slotIndex);
|
|
289
|
+
if (videoEl) videoEl.srcObject = null;
|
|
290
|
+
const label = document.getElementById('slot-label-' + slotIndex);
|
|
291
|
+
if (label) label.textContent = 'Slot ' + slotIndex;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
connection.on('chat', ({ nickname, text }) => { appendChat(nickname, text); });
|
|
295
|
+
connection.on('error', (err) => { setStatus('error', 'Error'); });
|
|
296
|
+
connection.on('close', () => { setStatus('disconnected', 'Disconnected'); });
|
|
297
|
+
|
|
298
|
+
await connection.connect();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let _localStream = null;
|
|
302
|
+
let _micEnabled = true;
|
|
303
|
+
|
|
304
|
+
function toggleMic() {
|
|
305
|
+
if (!_localStream) return;
|
|
306
|
+
_micEnabled = !_micEnabled;
|
|
307
|
+
_localStream.getAudioTracks().forEach(t => { t.enabled = _micEnabled; });
|
|
308
|
+
const btn = document.getElementById('mic-toggle');
|
|
309
|
+
btn.textContent = _micEnabled ? '🎙 ON' : '🔇 OFF';
|
|
310
|
+
btn.className = 'btn btn-outline btn-mic' + (_micEnabled ? '' : ' mic-off');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function leaveRoom() {
|
|
314
|
+
if (connection) {
|
|
315
|
+
if (typeof connection.disconnect === 'function') connection.disconnect();
|
|
316
|
+
connection = null;
|
|
317
|
+
}
|
|
318
|
+
location.href = '/live';
|
|
319
|
+
}
|
|
320
|
+
</script>
|
|
321
|
+
{% endblock %}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
{% extends "layouts/main.html" %}
|
|
2
|
+
{% block title %}{{ channelId }} — Live Watch{% endblock %}
|
|
3
|
+
{% block description %}Watch live broadcast on FuzionX WebRTC.{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block head %}
|
|
6
|
+
<script src="/public/js/fuzionx-player.umd.js"></script>
|
|
7
|
+
<style>
|
|
8
|
+
.live-watch-layout{display:flex;height:calc(100vh - 60px);overflow:hidden;}
|
|
9
|
+
.live-watch-video{flex:1;display:flex;flex-direction:column;background:#000;}
|
|
10
|
+
.live-watch-topbar{display:flex;align-items:center;gap:1rem;padding:.5rem 1rem;background:rgba(10,10,15,.9);border-bottom:1px solid rgba(255,255,255,.06);}
|
|
11
|
+
.live-watch-info{display:flex;align-items:center;gap:.75rem;flex:1;}
|
|
12
|
+
.live-watch-channel{font-weight:600;font-size:.95rem;color:#e0e0e0;}
|
|
13
|
+
.live-watch-player{flex:1;display:flex;align-items:center;justify-content:center;position:relative;}
|
|
14
|
+
.live-watch-player video{max-width:100%;max-height:100%;object-fit:contain;}
|
|
15
|
+
.live-watch-placeholder{position:absolute;color:var(--text-muted,#888);font-size:1rem;}
|
|
16
|
+
.live-badge{display:inline-flex;align-items:center;gap:.25rem;padding:.2rem .6rem;border-radius:9999px;font-size:.7rem;font-weight:600;}
|
|
17
|
+
.badge-broadcast{background:rgba(239,68,68,.15);color:#f87171;}
|
|
18
|
+
.live-status{font-size:.75rem;padding:.2rem .6rem;border-radius:9999px;}
|
|
19
|
+
.live-status-connecting{background:rgba(245,158,11,.2);color:#f59e0b;}
|
|
20
|
+
.live-status-connected{background:rgba(16,185,129,.2);color:#10b981;}
|
|
21
|
+
.live-status-error{background:rgba(239,68,68,.2);color:#ef4444;}
|
|
22
|
+
.live-status-disconnected{background:rgba(107,114,128,.2);color:#6b7280;}
|
|
23
|
+
.live-chat-sidebar{width:320px;background:rgba(18,18,31,.95);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;}
|
|
24
|
+
.live-chat-header{padding:.75rem 1rem;border-bottom:1px solid rgba(255,255,255,.06);}
|
|
25
|
+
.live-chat-header h3{font-size:.9rem;color:#e0e0e0;margin:0;}
|
|
26
|
+
.live-chat-messages{flex:1;overflow-y:auto;padding:.75rem;font-size:.8rem;line-height:1.5;}
|
|
27
|
+
.live-chat-msg{margin-bottom:.4rem;}
|
|
28
|
+
.live-chat-nick{color:#818cf8;font-weight:600;margin-right:.4rem;}
|
|
29
|
+
.live-chat-text{color:#d0d0d0;}
|
|
30
|
+
.live-chat-input-area{display:flex;gap:.5rem;padding:.5rem;border-top:1px solid rgba(255,255,255,.06);}
|
|
31
|
+
@media(max-width:768px){.live-watch-layout{flex-direction:column;}.live-chat-sidebar{width:100%;height:40vh;}}
|
|
32
|
+
</style>
|
|
33
|
+
{% endblock %}
|
|
34
|
+
|
|
35
|
+
{% block content %}
|
|
36
|
+
<div class="live-watch-layout">
|
|
37
|
+
<!-- Video Area -->
|
|
38
|
+
<div class="live-watch-video">
|
|
39
|
+
<div class="live-watch-topbar">
|
|
40
|
+
<a class="btn btn-outline" style="padding:.3rem .75rem;font-size:.8rem;text-decoration:none;" href="/live">
|
|
41
|
+
← {{ t(key="live.back", default="목록으로") }}
|
|
42
|
+
</a>
|
|
43
|
+
<div class="live-watch-info">
|
|
44
|
+
<span class="live-badge badge-broadcast">🔴 {{ t(key="live.broadcast", default="방송") }}</span>
|
|
45
|
+
<span class="live-watch-channel">{{ channelId }}</span>
|
|
46
|
+
<span style="font-size:.8rem;color:var(--text-muted);">👁 <span id="viewer-count">0</span></span>
|
|
47
|
+
</div>
|
|
48
|
+
<span id="status-badge" class="live-status live-status-connecting">{{ t(key="live.connecting", default="연결 중...") }}</span>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="live-watch-player">
|
|
51
|
+
<video id="player-video" autoplay playsinline></video>
|
|
52
|
+
<div id="player-placeholder" class="live-watch-placeholder">{{ t(key="live.connecting", default="연결 중...") }}</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- Chat Sidebar -->
|
|
57
|
+
<aside class="live-chat-sidebar">
|
|
58
|
+
<div class="live-chat-header"><h3>💬 {{ t(key="live.chat", default="Chat") }}</h3></div>
|
|
59
|
+
<div id="chat-messages" class="live-chat-messages"></div>
|
|
60
|
+
<div class="live-chat-input-area">
|
|
61
|
+
<input id="chat-input" type="text" class="form-input" placeholder="{{ t(key="live.chat_placeholder", default="메시지 입력...") }}" style="flex:1;" />
|
|
62
|
+
<button class="btn btn-primary" style="padding:.4rem .75rem;" onclick="sendChat()">{{ t(key="live.send", default="전송") }}</button>
|
|
63
|
+
</div>
|
|
64
|
+
</aside>
|
|
65
|
+
</div>
|
|
66
|
+
{% endblock %}
|
|
67
|
+
|
|
68
|
+
{% block scripts %}
|
|
69
|
+
<script>
|
|
70
|
+
const CHANNEL_ID = '{{ channelId }}';
|
|
71
|
+
const HUB_URL = new URLSearchParams(location.search).get('hub') || 'http://127.0.0.1:9100';
|
|
72
|
+
const { FuzionXViewer } = FuzionXPlayer;
|
|
73
|
+
|
|
74
|
+
let viewer = null;
|
|
75
|
+
|
|
76
|
+
function setStatus(cls, text) {
|
|
77
|
+
const el = document.getElementById('status-badge');
|
|
78
|
+
el.className = 'live-status live-status-' + cls;
|
|
79
|
+
el.textContent = text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function appendChat(nick, text) {
|
|
83
|
+
const c = document.getElementById('chat-messages');
|
|
84
|
+
const div = document.createElement('div');
|
|
85
|
+
div.className = 'live-chat-msg';
|
|
86
|
+
div.innerHTML = '<span class="live-chat-nick">' + nick + '</span><span class="live-chat-text">' + text + '</span>';
|
|
87
|
+
c.appendChild(div);
|
|
88
|
+
c.scrollTop = c.scrollHeight;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function appendSystem(text) {
|
|
92
|
+
const c = document.getElementById('chat-messages');
|
|
93
|
+
const div = document.createElement('div');
|
|
94
|
+
div.className = 'chat-msg-system';
|
|
95
|
+
div.textContent = text;
|
|
96
|
+
c.appendChild(div);
|
|
97
|
+
c.scrollTop = c.scrollHeight;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sendChat() {
|
|
101
|
+
const input = document.getElementById('chat-input');
|
|
102
|
+
const text = input.value.trim();
|
|
103
|
+
if (!text || !viewer) return;
|
|
104
|
+
viewer.chat(text);
|
|
105
|
+
appendChat('Me', text);
|
|
106
|
+
input.value = '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
document.getElementById('chat-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendChat(); });
|
|
110
|
+
|
|
111
|
+
function connectViewer() {
|
|
112
|
+
viewer = new FuzionXViewer({
|
|
113
|
+
hubUrl: HUB_URL,
|
|
114
|
+
channelId: CHANNEL_ID,
|
|
115
|
+
mode: 'broadcast',
|
|
116
|
+
nickname: 'viewer-' + Math.random().toString(36).slice(2, 6),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
viewer.on('stream', (stream) => {
|
|
120
|
+
const video = document.getElementById('player-video');
|
|
121
|
+
video.srcObject = stream;
|
|
122
|
+
video.play().catch(() => {});
|
|
123
|
+
document.getElementById('player-placeholder').style.display = 'none';
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
viewer.on('connected', () => {
|
|
127
|
+
setStatus('connected', 'Connected');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
viewer.on('chat', ({ nickname, text }) => {
|
|
131
|
+
appendChat(nickname, text);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
viewer.on('error', (err) => {
|
|
135
|
+
setStatus('error', 'Error');
|
|
136
|
+
document.getElementById('player-placeholder').textContent = '❌ ' + (err.message || err);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
viewer.on('close', () => {
|
|
140
|
+
setStatus('disconnected', 'Disconnected');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
viewer.connect();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
connectViewer();
|
|
147
|
+
</script>
|
|
148
|
+
{% endblock %}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzionx/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.51",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
|
|
6
6
|
"main": "index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"url": "https://github.com/saytohenry/fuzionx"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@fuzionx/core": "^0.1.
|
|
37
|
+
"@fuzionx/core": "^0.1.51",
|
|
38
38
|
"better-sqlite3": "^12.8.0",
|
|
39
39
|
"knex": "^3.2.5",
|
|
40
40
|
"mongoose": "^9.3.2",
|