@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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
LiveRoom.vue — 화상채팅 페이지.
|
|
3
|
+
|
|
4
|
+
역할 선택 (Publisher / Viewer) → 입장.
|
|
5
|
+
Publisher: FuzionXPublisher (mode=videochat) — 카메라 ON + 리모트 수신.
|
|
6
|
+
Viewer: FuzionXViewer (mode=videochat) — 리모트 수신만.
|
|
7
|
+
최대 9인 3×3 그리드 + 로컬 카메라 + 채팅 사이드바.
|
|
8
|
+
-->
|
|
9
|
+
<template>
|
|
10
|
+
<!-- ── 역할 선택 화면 ── -->
|
|
11
|
+
<div v-if="!joined" class="container">
|
|
12
|
+
<section class="hero-section" style="padding:3rem 0 2rem;">
|
|
13
|
+
<h1 class="hero-title">
|
|
14
|
+
👥 <span class="gradient-text">VideoChat</span>
|
|
15
|
+
</h1>
|
|
16
|
+
<p class="hero-subtitle">채널: <strong>{{ channelId }}</strong></p>
|
|
17
|
+
</section>
|
|
18
|
+
|
|
19
|
+
<div class="glass-card" style="max-width:480px;margin:0 auto;padding:2rem;">
|
|
20
|
+
<div class="form-group">
|
|
21
|
+
<label class="form-label">{{ t('live.nickname', '닉네임') }}</label>
|
|
22
|
+
<input v-model="nickname" type="text" class="form-input" placeholder="닉네임 입력" maxlength="20" />
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="form-group">
|
|
26
|
+
<label class="form-label">{{ t('live.role', '역할 선택') }}</label>
|
|
27
|
+
<div class="live-role-selector">
|
|
28
|
+
<button
|
|
29
|
+
:class="['live-role-btn', { active: role === 'publisher' }]"
|
|
30
|
+
@click="role = 'publisher'"
|
|
31
|
+
>
|
|
32
|
+
<span class="live-role-icon">🎥</span>
|
|
33
|
+
<span class="live-role-name">{{ t('live.publisher', '송출자') }}</span>
|
|
34
|
+
<span class="live-role-desc">카메라 ON</span>
|
|
35
|
+
</button>
|
|
36
|
+
<button
|
|
37
|
+
:class="['live-role-btn', { active: role === 'viewer' }]"
|
|
38
|
+
@click="role = 'viewer'"
|
|
39
|
+
>
|
|
40
|
+
<span class="live-role-icon">👁</span>
|
|
41
|
+
<span class="live-role-name">{{ t('live.viewer', '시청자') }}</span>
|
|
42
|
+
<span class="live-role-desc">카메라 OFF</span>
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div v-if="joinError" class="alert alert-error" style="margin-top:1rem;">{{ joinError }}</div>
|
|
48
|
+
|
|
49
|
+
<button
|
|
50
|
+
class="btn btn-hero-primary btn-full"
|
|
51
|
+
style="margin-top:1.5rem;"
|
|
52
|
+
:disabled="joining"
|
|
53
|
+
@click="joinRoom"
|
|
54
|
+
>
|
|
55
|
+
{{ joining ? t('live.connecting', '연결 중...') : t('live.join_room', '입장') }}
|
|
56
|
+
</button>
|
|
57
|
+
|
|
58
|
+
<button class="btn btn-outline btn-full" style="margin-top:0.5rem;" @click="goBack">
|
|
59
|
+
← {{ t('live.back', '목록으로') }}
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<!-- ── 화상채팅 화면 ── -->
|
|
65
|
+
<div v-else class="live-room-layout">
|
|
66
|
+
<!-- Video Area -->
|
|
67
|
+
<div class="live-room-video-area">
|
|
68
|
+
<div class="live-room-topbar">
|
|
69
|
+
<button class="btn btn-outline" style="padding:0.3rem 0.75rem;font-size:0.8rem;" @click="leaveRoom">
|
|
70
|
+
← {{ t('live.leave', '나가기') }}
|
|
71
|
+
</button>
|
|
72
|
+
<div class="live-room-info">
|
|
73
|
+
<span class="live-badge badge-videochat">👥 VideoChat</span>
|
|
74
|
+
<span style="font-weight:600;color:#e0e0e0;">{{ channelId }}</span>
|
|
75
|
+
<span style="font-size:0.75rem;color:var(--text-muted);">
|
|
76
|
+
{{ role === 'publisher' ? '🎥 송출자' : '👁 시청자' }}
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
<span :class="['live-status', `live-status-${status}`]">{{ statusText }}</span>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Remote 3×3 Grid -->
|
|
83
|
+
<div class="live-room-grid">
|
|
84
|
+
<div v-for="i in 9" :key="i - 1" class="live-room-slot">
|
|
85
|
+
<video :ref="el => setSlotRef(i - 1, el)" autoplay playsinline></video>
|
|
86
|
+
<div class="live-room-slot-label">
|
|
87
|
+
{{ slotLabels[i - 1] || `Slot ${i - 1}` }}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<!-- Chat Sidebar -->
|
|
94
|
+
<aside class="live-chat-sidebar">
|
|
95
|
+
<!-- Local Camera (Publisher only) — 채팅 상단 -->
|
|
96
|
+
<div v-if="role === 'publisher' && joined" class="live-local-preview">
|
|
97
|
+
<video ref="localVideoEl" autoplay playsinline muted></video>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="live-chat-header">
|
|
100
|
+
<h3>💬 {{ t('live.chat', 'Chat') }}</h3>
|
|
101
|
+
<button
|
|
102
|
+
v-if="role === 'publisher' && joined"
|
|
103
|
+
:class="['btn', 'btn-outline', 'btn-mic', { 'mic-off': !micEnabled }]"
|
|
104
|
+
@click="toggleMic"
|
|
105
|
+
>
|
|
106
|
+
{{ micEnabled ? '🎙 ON' : '🔇 OFF' }}
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div ref="chatContainer" class="live-chat-messages">
|
|
110
|
+
<template v-for="(msg, idx) in chatMessages" :key="idx">
|
|
111
|
+
<div v-if="msg.system" class="chat-msg-system">{{ msg.text }}</div>
|
|
112
|
+
<div v-else class="live-chat-msg">
|
|
113
|
+
<span class="live-chat-nick">{{ msg.nickname }}</span>
|
|
114
|
+
<span class="live-chat-text">{{ msg.text }}</span>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="live-chat-input-area">
|
|
119
|
+
<input
|
|
120
|
+
v-model="chatText"
|
|
121
|
+
type="text"
|
|
122
|
+
class="form-input"
|
|
123
|
+
:placeholder="t('live.chat_placeholder', '메시지 입력...')"
|
|
124
|
+
style="flex:1;"
|
|
125
|
+
@keydown.enter="sendChat"
|
|
126
|
+
/>
|
|
127
|
+
<button class="btn btn-primary" style="padding:0.4rem 0.75rem;" @click="sendChat">
|
|
128
|
+
{{ t('live.send', '전송') }}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
</aside>
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<script setup>
|
|
136
|
+
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
|
137
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
138
|
+
import { useLocale } from '../composables/useLocale.js';
|
|
139
|
+
import { useAuthStore } from '../stores/auth.js';
|
|
140
|
+
import { FuzionXPublisher, FuzionXViewer } from '@fuzionx/player';
|
|
141
|
+
|
|
142
|
+
const { t } = useLocale();
|
|
143
|
+
const route = useRoute();
|
|
144
|
+
const router = useRouter();
|
|
145
|
+
const authStore = useAuthStore();
|
|
146
|
+
|
|
147
|
+
const channelId = route.params.channelId;
|
|
148
|
+
const hubUrl = route.query.hub || 'http://127.0.0.1:9100';
|
|
149
|
+
|
|
150
|
+
/* ── State ── */
|
|
151
|
+
const joined = ref(false);
|
|
152
|
+
const joining = ref(false);
|
|
153
|
+
const joinError = ref('');
|
|
154
|
+
const nickname = ref(route.query.nickname || authStore.user?.name || '');
|
|
155
|
+
const role = ref(route.query.role || 'publisher'); // 'publisher' | 'viewer'
|
|
156
|
+
const status = ref('connecting');
|
|
157
|
+
const statusText = ref('connecting...');
|
|
158
|
+
const chatMessages = ref([]);
|
|
159
|
+
const chatText = ref('');
|
|
160
|
+
const slotLabels = ref({});
|
|
161
|
+
const micEnabled = ref(true);
|
|
162
|
+
|
|
163
|
+
const localVideoEl = ref(null);
|
|
164
|
+
const chatContainer = ref(null);
|
|
165
|
+
const pendingLocalStream = ref(null);
|
|
166
|
+
|
|
167
|
+
// slot video refs (dynamic)
|
|
168
|
+
const slotRefs = {};
|
|
169
|
+
function setSlotRef(index, el) {
|
|
170
|
+
if (el) slotRefs[index] = el;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let connection = null; // FuzionXPublisher or FuzionXViewer
|
|
174
|
+
|
|
175
|
+
/* ── Lifecycle ── */
|
|
176
|
+
onMounted(() => {
|
|
177
|
+
// query param으로 role, nickname이 전달되면 바로 입장
|
|
178
|
+
if (route.query.role && nickname.value) {
|
|
179
|
+
joinRoom();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
onUnmounted(() => {
|
|
184
|
+
leaveRoom();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/* ── Join ── */
|
|
188
|
+
async function joinRoom() {
|
|
189
|
+
if (!nickname.value.trim()) {
|
|
190
|
+
joinError.value = '닉네임을 입력하세요.';
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
joining.value = true;
|
|
195
|
+
joinError.value = '';
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
if (role.value === 'publisher') {
|
|
199
|
+
await joinAsPublisher();
|
|
200
|
+
} else {
|
|
201
|
+
await joinAsViewer();
|
|
202
|
+
}
|
|
203
|
+
joined.value = true;
|
|
204
|
+
// media 이벤트가 joined 전에 발생할 수 있으므로, DOM 렌더링 후 로컬 스트림 적용
|
|
205
|
+
nextTick(() => {
|
|
206
|
+
if (pendingLocalStream.value && localVideoEl.value) {
|
|
207
|
+
localVideoEl.value.srcObject = pendingLocalStream.value;
|
|
208
|
+
pendingLocalStream.value = null;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
} catch (e) {
|
|
212
|
+
joinError.value = e.message || String(e);
|
|
213
|
+
} finally {
|
|
214
|
+
joining.value = false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ── Publisher ── */
|
|
219
|
+
async function joinAsPublisher() {
|
|
220
|
+
connection = new FuzionXPublisher({
|
|
221
|
+
hubUrl,
|
|
222
|
+
channelId,
|
|
223
|
+
mode: 'videochat',
|
|
224
|
+
nickname: nickname.value.trim(),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
connection.on('media', (localStream) => {
|
|
228
|
+
// joined 전이면 DOM에 localVideoEl이 없으므로 보관
|
|
229
|
+
if (localVideoEl.value) {
|
|
230
|
+
localVideoEl.value.srcObject = localStream;
|
|
231
|
+
} else {
|
|
232
|
+
pendingLocalStream.value = localStream;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
connection.on('ready', () => {
|
|
237
|
+
status.value = 'connected';
|
|
238
|
+
statusText.value = 'Connected';
|
|
239
|
+
appendSystem('WebRTC 연결됨 (송출 중)');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
connection.on('stream', (stream, slotIndex) => {
|
|
243
|
+
appendSystem(`리모트 스트림 수신: Slot ${slotIndex}`);
|
|
244
|
+
nextTick(() => {
|
|
245
|
+
const videoEl = slotRefs[slotIndex];
|
|
246
|
+
if (videoEl) {
|
|
247
|
+
videoEl.srcObject = stream;
|
|
248
|
+
videoEl.play().catch(() => {});
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
connection.on('slot', (slot) => {
|
|
254
|
+
slotLabels.value = { ...slotLabels.value, [slot.slotIndex]: slot.nickname };
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
connection.on('slot_remove', ({ slotIndex }) => {
|
|
258
|
+
const labels = { ...slotLabels.value };
|
|
259
|
+
delete labels[slotIndex];
|
|
260
|
+
slotLabels.value = labels;
|
|
261
|
+
// 비디오 정리
|
|
262
|
+
const videoEl = slotRefs[slotIndex];
|
|
263
|
+
if (videoEl) videoEl.srcObject = null;
|
|
264
|
+
appendSystem(`Slot ${slotIndex} 해제됨`);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
connection.on('chat', ({ nickname: nick, text }) => {
|
|
268
|
+
appendChat(nick, text);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
connection.on('error', (err) => {
|
|
272
|
+
status.value = 'error';
|
|
273
|
+
statusText.value = 'Error';
|
|
274
|
+
appendSystem(`❌ ${err.message || err}`);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
connection.on('close', () => {
|
|
278
|
+
status.value = 'disconnected';
|
|
279
|
+
statusText.value = 'Disconnected';
|
|
280
|
+
appendSystem('연결 끊김');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await connection.connect();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ── Viewer ── */
|
|
287
|
+
async function joinAsViewer() {
|
|
288
|
+
connection = new FuzionXViewer({
|
|
289
|
+
hubUrl,
|
|
290
|
+
channelId,
|
|
291
|
+
mode: 'videochat',
|
|
292
|
+
nickname: nickname.value.trim(),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
connection.on('stream', (stream, slotIndex) => {
|
|
296
|
+
appendSystem(`리모트 스트림 수신: Slot ${slotIndex}`);
|
|
297
|
+
nextTick(() => {
|
|
298
|
+
const videoEl = slotRefs[slotIndex];
|
|
299
|
+
if (videoEl) {
|
|
300
|
+
videoEl.srcObject = stream;
|
|
301
|
+
videoEl.play().catch(() => {});
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
connection.on('connected', () => {
|
|
307
|
+
status.value = 'connected';
|
|
308
|
+
statusText.value = 'Connected';
|
|
309
|
+
appendSystem('WebRTC 연결됨 (시청 중)');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
connection.on('slot', (slot) => {
|
|
313
|
+
slotLabels.value = { ...slotLabels.value, [slot.slotIndex]: slot.nickname };
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
connection.on('slot_remove', ({ slotIndex }) => {
|
|
317
|
+
const labels = { ...slotLabels.value };
|
|
318
|
+
delete labels[slotIndex];
|
|
319
|
+
slotLabels.value = labels;
|
|
320
|
+
const videoEl = slotRefs[slotIndex];
|
|
321
|
+
if (videoEl) videoEl.srcObject = null;
|
|
322
|
+
appendSystem(`Slot ${slotIndex} 해제됨`);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
connection.on('chat', ({ nickname: nick, text }) => {
|
|
326
|
+
appendChat(nick, text);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
connection.on('error', (err) => {
|
|
330
|
+
status.value = 'error';
|
|
331
|
+
statusText.value = 'Error';
|
|
332
|
+
appendSystem(`❌ ${err.message || err}`);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
connection.on('close', () => {
|
|
336
|
+
status.value = 'disconnected';
|
|
337
|
+
statusText.value = 'Disconnected';
|
|
338
|
+
appendSystem('연결 끊김');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await connection.connect();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* ── Leave ── */
|
|
345
|
+
function leaveRoom() {
|
|
346
|
+
if (connection) {
|
|
347
|
+
if (typeof connection.disconnect === 'function') {
|
|
348
|
+
connection.disconnect();
|
|
349
|
+
}
|
|
350
|
+
connection = null;
|
|
351
|
+
}
|
|
352
|
+
joined.value = false;
|
|
353
|
+
status.value = 'connecting';
|
|
354
|
+
statusText.value = 'connecting...';
|
|
355
|
+
chatMessages.value = [];
|
|
356
|
+
slotLabels.value = {};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ── Chat ── */
|
|
360
|
+
function sendChat() {
|
|
361
|
+
const text = chatText.value.trim();
|
|
362
|
+
if (!text || !connection) return;
|
|
363
|
+
connection.chat(text);
|
|
364
|
+
appendChat(nickname.value, text);
|
|
365
|
+
chatText.value = '';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function appendChat(nick, text) {
|
|
369
|
+
chatMessages.value.push({ nickname: nick, text, system: false });
|
|
370
|
+
scrollChat();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function appendSystem(text) {
|
|
374
|
+
chatMessages.value.push({ text, system: true });
|
|
375
|
+
scrollChat();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function scrollChat() {
|
|
379
|
+
nextTick(() => {
|
|
380
|
+
if (chatContainer.value) {
|
|
381
|
+
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function goBack() {
|
|
387
|
+
router.push({ name: 'live' });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function toggleMic() {
|
|
391
|
+
if (!pendingLocalStream.value && !localVideoEl.value?.srcObject) return;
|
|
392
|
+
const stream = localVideoEl.value?.srcObject || pendingLocalStream.value;
|
|
393
|
+
if (!stream) return;
|
|
394
|
+
micEnabled.value = !micEnabled.value;
|
|
395
|
+
stream.getAudioTracks().forEach(t => { t.enabled = micEnabled.value; });
|
|
396
|
+
}
|
|
397
|
+
</script>
|
|
398
|
+
|
|
399
|
+
<style scoped>
|
|
400
|
+
/* ── Role Selector ── */
|
|
401
|
+
.live-role-selector {
|
|
402
|
+
display: flex;
|
|
403
|
+
gap: 1rem;
|
|
404
|
+
}
|
|
405
|
+
.live-role-btn {
|
|
406
|
+
flex: 1;
|
|
407
|
+
display: flex;
|
|
408
|
+
flex-direction: column;
|
|
409
|
+
align-items: center;
|
|
410
|
+
gap: 0.4rem;
|
|
411
|
+
padding: 1.25rem;
|
|
412
|
+
border: 2px solid var(--border-color, rgba(255,255,255,0.08));
|
|
413
|
+
border-radius: 12px;
|
|
414
|
+
background: transparent;
|
|
415
|
+
cursor: pointer;
|
|
416
|
+
transition: all 0.2s;
|
|
417
|
+
}
|
|
418
|
+
.live-role-btn.active {
|
|
419
|
+
border-color: rgba(99, 102, 241, 0.6);
|
|
420
|
+
background: rgba(99, 102, 241, 0.1);
|
|
421
|
+
}
|
|
422
|
+
.live-role-btn:hover {
|
|
423
|
+
border-color: rgba(99, 102, 241, 0.4);
|
|
424
|
+
}
|
|
425
|
+
.live-role-icon { font-size: 2rem; }
|
|
426
|
+
.live-role-name { font-weight: 600; color: #e0e0e0; font-size: 0.95rem; }
|
|
427
|
+
.live-role-desc { font-size: 0.75rem; color: var(--text-muted, #888); }
|
|
428
|
+
|
|
429
|
+
/* ── Room Layout ── */
|
|
430
|
+
.live-room-layout {
|
|
431
|
+
display: flex;
|
|
432
|
+
height: calc(100vh - 60px);
|
|
433
|
+
overflow: hidden;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.live-room-video-area {
|
|
437
|
+
flex: 1;
|
|
438
|
+
display: flex;
|
|
439
|
+
flex-direction: column;
|
|
440
|
+
background: #0a0a0f;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.live-room-topbar {
|
|
444
|
+
display: flex;
|
|
445
|
+
align-items: center;
|
|
446
|
+
gap: 1rem;
|
|
447
|
+
padding: 0.5rem 1rem;
|
|
448
|
+
background: rgba(10, 10, 15, 0.95);
|
|
449
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.live-room-info {
|
|
453
|
+
display: flex;
|
|
454
|
+
align-items: center;
|
|
455
|
+
gap: 0.75rem;
|
|
456
|
+
flex: 1;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* ── 3×3 Grid ── */
|
|
460
|
+
.live-room-grid {
|
|
461
|
+
flex: 1;
|
|
462
|
+
display: grid;
|
|
463
|
+
grid-template-columns: repeat(3, 1fr);
|
|
464
|
+
grid-template-rows: repeat(3, 1fr);
|
|
465
|
+
gap: 2px;
|
|
466
|
+
padding: 2px;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.live-room-slot {
|
|
470
|
+
position: relative;
|
|
471
|
+
background: #111;
|
|
472
|
+
border-radius: 4px;
|
|
473
|
+
overflow: hidden;
|
|
474
|
+
}
|
|
475
|
+
.live-room-slot video {
|
|
476
|
+
width: 100%;
|
|
477
|
+
height: 100%;
|
|
478
|
+
object-fit: cover;
|
|
479
|
+
}
|
|
480
|
+
.live-room-slot-label {
|
|
481
|
+
position: absolute;
|
|
482
|
+
bottom: 4px;
|
|
483
|
+
left: 4px;
|
|
484
|
+
background: rgba(0, 0, 0, 0.7);
|
|
485
|
+
color: #fff;
|
|
486
|
+
padding: 2px 8px;
|
|
487
|
+
border-radius: 4px;
|
|
488
|
+
font-size: 0.7rem;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/* ── Local Camera Preview (chat sidebar top) ── */
|
|
492
|
+
.live-local-preview {
|
|
493
|
+
padding: 0.5rem;
|
|
494
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
495
|
+
}
|
|
496
|
+
.live-local-preview video {
|
|
497
|
+
width: 100%;
|
|
498
|
+
border-radius: 6px;
|
|
499
|
+
max-height: 180px;
|
|
500
|
+
object-fit: cover;
|
|
501
|
+
}
|
|
502
|
+
.btn-mic {
|
|
503
|
+
padding: 0.25rem 0.6rem;
|
|
504
|
+
font-size: 0.75rem;
|
|
505
|
+
}
|
|
506
|
+
.btn-mic.mic-off {
|
|
507
|
+
color: #ef4444;
|
|
508
|
+
border-color: #ef4444;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/* ── Chat Sidebar (shared with LiveWatch) ── */
|
|
512
|
+
.live-chat-sidebar {
|
|
513
|
+
width: 320px;
|
|
514
|
+
background: rgba(18, 18, 31, 0.95);
|
|
515
|
+
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
|
516
|
+
display: flex;
|
|
517
|
+
flex-direction: column;
|
|
518
|
+
}
|
|
519
|
+
.live-chat-header {
|
|
520
|
+
padding: 0.75rem 1rem;
|
|
521
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
522
|
+
display: flex;
|
|
523
|
+
align-items: center;
|
|
524
|
+
justify-content: space-between;
|
|
525
|
+
}
|
|
526
|
+
.live-chat-header h3 { font-size: 0.9rem; color: #e0e0e0; margin: 0; }
|
|
527
|
+
.live-chat-messages {
|
|
528
|
+
flex: 1;
|
|
529
|
+
overflow-y: auto;
|
|
530
|
+
padding: 0.75rem;
|
|
531
|
+
font-size: 0.8rem;
|
|
532
|
+
line-height: 1.5;
|
|
533
|
+
}
|
|
534
|
+
.live-chat-msg { margin-bottom: 0.4rem; }
|
|
535
|
+
.live-chat-nick { color: #818cf8; font-weight: 600; margin-right: 0.4rem; }
|
|
536
|
+
.live-chat-text { color: #d0d0d0; }
|
|
537
|
+
.live-chat-input-area {
|
|
538
|
+
display: flex;
|
|
539
|
+
gap: 0.5rem;
|
|
540
|
+
padding: 0.5rem;
|
|
541
|
+
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/* badges / status */
|
|
545
|
+
.live-badge {
|
|
546
|
+
display: inline-flex;
|
|
547
|
+
align-items: center;
|
|
548
|
+
gap: 0.25rem;
|
|
549
|
+
padding: 0.2rem 0.6rem;
|
|
550
|
+
border-radius: 9999px;
|
|
551
|
+
font-size: 0.7rem;
|
|
552
|
+
font-weight: 600;
|
|
553
|
+
}
|
|
554
|
+
.badge-videochat {
|
|
555
|
+
background: rgba(99, 102, 241, 0.15);
|
|
556
|
+
color: #818cf8;
|
|
557
|
+
}
|
|
558
|
+
.live-status {
|
|
559
|
+
font-size: 0.75rem;
|
|
560
|
+
padding: 0.2rem 0.6rem;
|
|
561
|
+
border-radius: 9999px;
|
|
562
|
+
}
|
|
563
|
+
.live-status-connecting { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
|
|
564
|
+
.live-status-connected { background: rgba(16, 185, 129, 0.2); color: #10b981; }
|
|
565
|
+
.live-status-error { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
|
|
566
|
+
.live-status-disconnected { background: rgba(107, 114, 128, 0.2); color: #6b7280; }
|
|
567
|
+
|
|
568
|
+
@media (max-width: 768px) {
|
|
569
|
+
.live-room-layout { flex-direction: column; }
|
|
570
|
+
.live-chat-sidebar { width: 100%; height: 40vh; }
|
|
571
|
+
.live-room-grid { grid-template-columns: repeat(2, 1fr); grid-template-rows: auto; }
|
|
572
|
+
}
|
|
573
|
+
</style>
|