-
+
- {{ displayName.charAt(0) }}
+ {{ initial }}
-
-
-
-
@@ -114,12 +179,23 @@ onBeforeUnmount(() => {
.tile {
position: relative;
aspect-ratio: 16 / 9;
- background: #1b1d23;
- border-radius: 0.625rem;
+ border-radius: 0.75rem;
overflow: hidden;
- border: 1px solid var(--border);
+ background: var(--tile);
+ border: 0.125rem solid transparent;
+}
+.tile.featured {
+ aspect-ratio: auto;
+ height: 100%;
+ border-radius: 0.875rem;
+}
+.tile.speaking {
+ border-color: var(--green);
+ box-shadow: 0 0 0 1px var(--green), 0 0 22px -4px rgba(31, 157, 87, 0.7);
}
.vid {
+ position: absolute;
+ inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
@@ -131,62 +207,124 @@ onBeforeUnmount(() => {
.vid.hidden {
display: none;
}
-.placeholder {
+.tile-off {
position: absolute;
inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #1b1d23;
+ display: grid;
+ place-items: center;
+ background: var(--tile);
}
-.initial {
- width: 3.5rem;
- height: 3.5rem;
+.av-lg {
+ width: clamp(2.875rem, 9vh, 5.75rem);
+ height: clamp(2.875rem, 9vh, 5.75rem);
border-radius: 50%;
- background: var(--accent);
+ background: var(--av, #555);
color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 1.5rem;
+ display: grid;
+ place-items: center;
+ font-size: clamp(1.25rem, 4vh, 2.375rem);
font-weight: 700;
text-transform: uppercase;
}
-.meta {
+.tile-q {
position: absolute;
- left: 0.5rem;
- bottom: 0.5rem;
+ top: 0.5625rem;
+ left: 0.5625rem;
+ z-index: 3;
+}
+.tile-meta {
+ position: absolute;
+ left: 0.5625rem;
+ bottom: 0.5625rem;
+ right: 0.5625rem;
+ z-index: 3;
display: flex;
align-items: center;
gap: 0.375rem;
- padding: 0.1875rem 0.5rem;
- background: rgba(0, 0, 0, 0.55);
- border-radius: 0.375rem;
- max-width: calc(100% - 1rem);
}
-.meta .name {
+.tile.featured .tile-meta {
+ left: 0.875rem;
+ bottom: 0.875rem;
+}
+.mmic {
+ width: 1.375rem;
+ height: 1.375rem;
+ border-radius: 0.375rem;
+ background: rgba(0, 0, 0, 0.6);
color: #fff;
- font-size: 0.75rem;
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+}
+.mmic svg {
+ width: 0.8125rem;
+ height: 0.8125rem;
+}
+.mmic.off {
+ color: var(--d-red);
+}
+.mname {
+ font-size: 0.78125rem;
font-weight: 600;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.55);
+ padding: 0.1875rem 0.5625rem;
+ border-radius: 0.375rem;
+ white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- white-space: nowrap;
}
-.meta .mic {
- font-size: 0.75rem;
- line-height: 1;
+.tile.featured .mname {
+ font-size: 0.875rem;
+ padding: 0.3125rem 0.75rem;
}
-.meta .mic.off {
- opacity: 0.6;
-}
-.host-badge {
- flex-shrink: 0;
- padding: 0.0625rem 0.375rem;
- border-radius: 0.25rem;
- background: var(--accent);
+.mhost {
+ width: 1.375rem;
+ height: 1.375rem;
+ border-radius: 0.375rem;
+ background: rgba(124, 92, 240, 0.85);
color: #fff;
- font-size: 0.625rem;
- font-weight: 700;
- line-height: 1.4;
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+}
+.mhost svg {
+ width: 0.75rem;
+ height: 0.75rem;
+}
+.tile-promote {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ z-index: 4;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3125rem;
+ height: 1.75rem;
+ padding: 0 0.625rem;
+ border: none;
+ border-radius: 0.4375rem;
+ background: rgba(0, 0, 0, 0.55);
+ color: #fff;
+ font-family: inherit;
+ font-size: 0.75rem;
+ font-weight: 600;
+ cursor: pointer;
+ opacity: 0;
+ transform: translateY(-0.125rem);
+ transition:
+ opacity 0.12s,
+ transform 0.12s;
+}
+.tile-promote svg {
+ width: 0.8125rem;
+ height: 0.8125rem;
+}
+.tile:hover .tile-promote {
+ opacity: 1;
+ transform: none;
+}
+.tile-promote:hover {
+ background: var(--accent);
}
diff --git a/frontend/src/components/meeting/QualityBars.vue b/frontend/src/components/meeting/QualityBars.vue
new file mode 100644
index 0000000..078a813
--- /dev/null
+++ b/frontend/src/components/meeting/QualityBars.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/composables/useLiveKitRoom.ts b/frontend/src/composables/useLiveKitRoom.ts
index 643f51d..4bea8eb 100644
--- a/frontend/src/composables/useLiveKitRoom.ts
+++ b/frontend/src/composables/useLiveKitRoom.ts
@@ -1,9 +1,65 @@
import { ref, shallowRef } from 'vue'
-import { Room, RoomEvent, type Participant } from 'livekit-client'
+import {
+ Room,
+ RoomEvent,
+ Track,
+ ConnectionQuality,
+ type Participant,
+ type RemoteParticipant,
+ type RemoteTrack,
+ type RemoteTrackPublication,
+} from 'livekit-client'
+import type { ChatMessage, MeetingQuality } from '@/types/meeting'
// 브라우저가 접속할 LiveKit WebSocket URL (build arg/env 로 주입, 미설정 시 로컬 기본값)
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://localhost:7880'
+// 채팅 메시지 최대 길이(데이터 채널 패킷 과대 방지) — 입력/전송에서 공유한다.
+export const MAX_CHAT_LENGTH = 2000
+
+// 입장 옵션 — 프리조인에서 정한 초기 마이크/카메라 상태
+export interface ConnectOptions {
+ cam?: boolean
+ mic?: boolean
+}
+
+// LiveKit ConnectionQuality → UI 막대 단계로 정규화
+export function normalizeQuality(q: ConnectionQuality | undefined): MeetingQuality {
+ switch (q) {
+ case ConnectionQuality.Excellent:
+ case ConnectionQuality.Good:
+ return 'good'
+ case ConnectionQuality.Poor:
+ return 'medium'
+ case ConnectionQuality.Lost:
+ return 'poor'
+ default:
+ return 'good'
+ }
+}
+
+// 참여자 metadata(JSON)에서 프로필 이미지 URL 파싱 — 토큰 발급 시 백엔드가 실어준다.
+function parseMetaAvatar(metadata: string | undefined): string | null {
+ if (!metadata) return null
+ try {
+ return (
+ (JSON.parse(metadata) as { avatarUrl?: string | null }).avatarUrl ?? null
+ )
+ } catch {
+ return null
+ }
+}
+
+// 참여자 metadata 가 진행자(host)인지 — 서버 서명 토큰의 값이라 위조 불가.
+function isHostMeta(metadata: string | undefined): boolean {
+ if (!metadata) return false
+ try {
+ return (JSON.parse(metadata) as { role?: string }).role === 'host'
+ } catch {
+ return false
+ }
+}
+
// LiveKit 방 연결/생명주기 관리 Composable
// 미디어 전송 자체는 LiveKit SDK 가 담당하고, 여기서는 연결 상태와 참여자 목록만 반응형으로 노출한다.
export function useLiveKitRoom() {
@@ -19,8 +75,11 @@ export function useLiveKitRoom() {
const errorMsg = ref
(null)
// 크게 보기(stage)로 승격된 참여자 identity. 진행자가 데이터 채널로 브로드캐스트 → 전원 동기화.
const featuredIdentity = ref(null)
+ // 채팅 메시지(인메모리) — 데이터 채널로 주고받음. 서버에 저장하지 않으므로 새로고침 시 사라진다.
+ const messages = ref([])
- // 로컬 + 원격 참여자 목록 갱신(진행자 화면 구성은 단계 3에서 고도화)
+ // 로컬 + 원격 참여자 목록 갱신. 마이크/발화/품질 등 참여자 상태 변동 시에도 호출해
+ // 배열 참조를 새로 만들어(=동일 Participant 인스턴스 유지) 의존 computed 를 재평가시킨다.
function refresh() {
const r = room.value
if (!r) {
@@ -33,21 +92,83 @@ export function useLiveKitRoom() {
]
}
+ // --- 원격 오디오는 화면 타일(페이지네이션)과 무관하게 항상 재생되어야 한다 ---
+ // 타일에 attach 하면 현재 페이지에 없는 참여자(>50번째)는 무음이 되므로,
+ // 숨은 컨테이너에 트랙별 audio 엘리먼트를 붙여 룸 레벨에서 전원 오디오를 재생한다.
+ let audioContainer: HTMLElement | null = null
+ const audioEls = new Map()
+
+ function attachRemoteAudio(track: RemoteTrack, pub: RemoteTrackPublication) {
+ if (track.kind !== Track.Kind.Audio) return
+ if (!audioContainer) {
+ audioContainer = document.createElement('div')
+ audioContainer.style.display = 'none'
+ document.body.appendChild(audioContainer)
+ }
+ const el = track.attach() // 새 audio 엘리먼트 생성
+ audioContainer.appendChild(el)
+ audioEls.set(pub.trackSid, el)
+ }
+ function detachRemoteAudio(track: RemoteTrack, pub: RemoteTrackPublication) {
+ if (track.kind !== Track.Kind.Audio) return
+ track.detach()
+ const el = audioEls.get(pub.trackSid)
+ if (el) {
+ el.remove()
+ audioEls.delete(pub.trackSid)
+ }
+ }
+ function cleanupAudio() {
+ audioEls.forEach((el) => el.remove())
+ audioEls.clear()
+ audioContainer?.remove()
+ audioContainer = null
+ }
+
+ // TrackSubscribed/Unsubscribed: 목록 갱신 + (오디오면) 룸 레벨 재생 관리
+ function onTrackSubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
+ refresh()
+ attachRemoteAudio(track, pub)
+ }
+ function onTrackUnsubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
+ refresh()
+ detachRemoteAudio(track, pub)
+ }
+
function onDisconnected() {
connected.value = false
participants.value = []
featuredIdentity.value = null
+ messages.value = []
+ cleanupAudio()
}
- // 데이터 채널 수신 — 진행자의 승격(promote) 메시지를 받아 featured 갱신
- function onData(payload: Uint8Array) {
+ // 데이터 채널 수신 — 진행자의 승격(promote) + 채팅(chat) 메시지를 처리
+ function onData(payload: Uint8Array, sender?: RemoteParticipant) {
try {
const msg = JSON.parse(new TextDecoder().decode(payload)) as {
type?: string
identity?: string
+ text?: string
+ ts?: number
}
if (msg.type === 'promote' && typeof msg.identity === 'string') {
- featuredIdentity.value = msg.identity
+ // 진행자가 보낸 승격만 적용 — 일반 참여자가 보낸 promote 는 무시(인가).
+ if (isHostMeta(sender?.metadata)) featuredIdentity.value = msg.identity
+ } else if (msg.type === 'chat' && typeof msg.text === 'string') {
+ messages.value = [
+ ...messages.value,
+ {
+ id: `${sender?.identity ?? 'remote'}-${msg.ts ?? performance.now()}`,
+ senderId: sender?.identity ?? 'unknown',
+ // 표시 이름은 서버 서명 토큰의 name/identity 만 신뢰(클라이언트가 보낸 name 은 사칭 위험이라 무시).
+ senderName: sender?.name || sender?.identity || '참여자',
+ senderAvatarUrl: parseMetaAvatar(sender?.metadata),
+ text: msg.text,
+ ts: msg.ts ?? Date.now(),
+ self: false,
+ },
+ ]
}
} catch {
// 알 수 없는 메시지는 무시
@@ -65,19 +186,55 @@ export function useLiveKitRoom() {
await r.localParticipant.publishData(data, { reliable: true })
}
- // 방 입장 — 토큰으로 연결 후 카메라/마이크 발행
- async function connect(token: string): Promise {
+ // 채팅 전송 — 데이터 채널로 브로드캐스트 + 본인 목록에 즉시 추가
+ async function sendChat(text: string): Promise {
+ const r = room.value
+ // 데이터 채널 패킷 권장 크기를 넘지 않도록 길이 제한(과도한 페이로드 방지).
+ const body = text.trim().slice(0, MAX_CHAT_LENGTH)
+ if (!r || !body) return
+ const ts = Date.now()
+ const me = r.localParticipant
+ messages.value = [
+ ...messages.value,
+ {
+ id: `${me.identity}-${ts}`,
+ senderId: me.identity,
+ senderName: me.name || me.identity,
+ senderAvatarUrl: parseMetaAvatar(me.metadata),
+ text: body,
+ ts,
+ self: true,
+ },
+ ]
+ // name 은 보내지 않는다 — 수신 측은 서명된 sender.name 을 사용(사칭 방지).
+ const data = new TextEncoder().encode(
+ JSON.stringify({ type: 'chat', text: body, ts }),
+ )
+ await me.publishData(data, { reliable: true })
+ }
+
+ // 방 입장 — 토큰으로 연결 후 옵션에 맞춰 카메라/마이크 발행
+ async function connect(token: string, opts: ConnectOptions = {}): Promise {
if (connecting.value || connected.value) return
+ const wantCam = opts.cam ?? true
+ const wantMic = opts.mic ?? true
connecting.value = true
errorMsg.value = null
try {
const r = new Room({ adaptiveStream: true, dynacast: true })
+ // 참여자 목록(배열) 재생성이 필요한 이벤트만 구독한다.
+ // 발화(ActiveSpeakers)·연결품질(ConnectionQuality)은 매우 잦게 발생하는데
+ // 각 타일이 ParticipantEvent 로 자체 갱신하므로, 여기서 전체 배열을 재생성하면
+ // 대규모(수십 명) 화면에서 불필요한 리렌더로 버벅임을 유발한다 → 제외.
r.on(RoomEvent.ParticipantConnected, refresh)
.on(RoomEvent.ParticipantDisconnected, refresh)
- .on(RoomEvent.TrackSubscribed, refresh)
- .on(RoomEvent.TrackUnsubscribed, refresh)
+ .on(RoomEvent.TrackSubscribed, onTrackSubscribed)
+ .on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
+ .on(RoomEvent.TrackMuted, refresh)
+ .on(RoomEvent.TrackUnmuted, refresh)
.on(RoomEvent.LocalTrackPublished, refresh)
.on(RoomEvent.LocalTrackUnpublished, refresh)
+ .on(RoomEvent.ParticipantMetadataChanged, refresh)
.on(RoomEvent.DataReceived, onData)
.on(RoomEvent.Disconnected, onDisconnected)
@@ -86,13 +243,24 @@ export function useLiveKitRoom() {
connected.value = true
// 카메라/마이크 권한 요청 및 발행 (실패해도 연결은 유지)
- try {
- await r.localParticipant.enableCameraAndMicrophone()
- camEnabled.value = true
- micEnabled.value = true
- } catch {
+ if (wantCam || wantMic) {
+ try {
+ if (wantCam && wantMic) {
+ await r.localParticipant.enableCameraAndMicrophone()
+ } else {
+ if (wantMic) await r.localParticipant.setMicrophoneEnabled(true)
+ if (wantCam) await r.localParticipant.setCameraEnabled(true)
+ }
+ camEnabled.value = wantCam
+ micEnabled.value = wantMic
+ } catch {
+ errorMsg.value =
+ '카메라/마이크를 사용할 수 없습니다. 권한을 확인해 주세요.'
+ }
+ } else {
+ // 권한 거부 입장 — 영상·음성 없이 참관
errorMsg.value =
- '카메라/마이크를 사용할 수 없습니다. 권한을 확인해 주세요.'
+ '카메라·마이크 없이 참여 중입니다. 아래 컨트롤에서 언제든 켤 수 있습니다.'
}
refresh()
} catch (e) {
@@ -113,20 +281,33 @@ export function useLiveKitRoom() {
micEnabled.value = false
participants.value = []
featuredIdentity.value = null
+ messages.value = []
+ cleanupAudio()
}
async function toggleMic(): Promise {
const r = room.value
if (!r) return
- micEnabled.value = !micEnabled.value
- await r.localParticipant.setMicrophoneEnabled(micEnabled.value)
+ const next = !micEnabled.value
+ micEnabled.value = next
+ try {
+ await r.localParticipant.setMicrophoneEnabled(next)
+ } catch {
+ micEnabled.value = !next // 실패 시 상태 원복(표시와 실제 송출 불일치 방지)
+ }
+ refresh()
}
async function toggleCam(): Promise {
const r = room.value
if (!r) return
- camEnabled.value = !camEnabled.value
- await r.localParticipant.setCameraEnabled(camEnabled.value)
+ const next = !camEnabled.value
+ camEnabled.value = next
+ try {
+ await r.localParticipant.setCameraEnabled(next)
+ } catch {
+ camEnabled.value = !next
+ }
refresh()
}
@@ -138,10 +319,12 @@ export function useLiveKitRoom() {
camEnabled,
errorMsg,
featuredIdentity,
+ messages,
connect,
disconnect,
toggleMic,
toggleCam,
promote,
+ sendChat,
}
}
diff --git a/frontend/src/pages/relay/MeetingPage.vue b/frontend/src/pages/relay/MeetingPage.vue
index 6fe3c04..ca70b61 100644
--- a/frontend/src/pages/relay/MeetingPage.vue
+++ b/frontend/src/pages/relay/MeetingPage.vue
@@ -1,21 +1,24 @@