From 3c7aec86a246a482b102047b7e9fecc7cb8949ee Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 23 Jun 2026 18:06:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=94=EC=83=81=ED=9A=8C=EC=9D=98=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=203=20=E2=80=94=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1(=EC=9E=84=EC=8B=9C=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 진행자/승격 참여자 큰 화면 + 썸네일 + 페이지네이션 + 화면 승격을 구현한다. (레이아웃은 임시 — UI 는 추후 개편 예정. 데이터/로직만 확정.) - useLiveKitRoom: featuredIdentity 상태 + promote(identity) 추가. 진행자가 데이터 채널로 승격을 브로드캐스트 → RoomEvent.DataReceived 로 전원 동기화. - MeetingPage: · stage(featured: 승격>진행자>첫 참여자) 큰 화면 + 나머지 썸네일 · 진행자에게만 썸네일 "크게"(승격) 버튼 · 썸네일 페이지네이션(페이지당 12명) — 현재 페이지만 렌더 → adaptiveStream 이 안 보이는 참여자 미디어 자동 미구독(대역폭 절약) - 화상회의_화면명세.md: 단계 2~3 반영(범위/화면 구성/데이터 계약 갱신) 백엔드 변경 없음(승격은 클라이언트 데이터 채널). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/composables/useLiveKitRoom.ts | 33 +++++ frontend/src/pages/relay/MeetingPage.vue | 165 ++++++++++++++++++++- 화상회의_화면명세.md | 23 ++- 3 files changed, 205 insertions(+), 16 deletions(-) diff --git a/frontend/src/composables/useLiveKitRoom.ts b/frontend/src/composables/useLiveKitRoom.ts index c2d7d82..643f51d 100644 --- a/frontend/src/composables/useLiveKitRoom.ts +++ b/frontend/src/composables/useLiveKitRoom.ts @@ -17,6 +17,8 @@ export function useLiveKitRoom() { const micEnabled = ref(false) const camEnabled = ref(false) const errorMsg = ref(null) + // 크게 보기(stage)로 승격된 참여자 identity. 진행자가 데이터 채널로 브로드캐스트 → 전원 동기화. + const featuredIdentity = ref(null) // 로컬 + 원격 참여자 목록 갱신(진행자 화면 구성은 단계 3에서 고도화) function refresh() { @@ -34,6 +36,33 @@ export function useLiveKitRoom() { function onDisconnected() { connected.value = false participants.value = [] + featuredIdentity.value = null + } + + // 데이터 채널 수신 — 진행자의 승격(promote) 메시지를 받아 featured 갱신 + function onData(payload: Uint8Array) { + try { + const msg = JSON.parse(new TextDecoder().decode(payload)) as { + type?: string + identity?: string + } + if (msg.type === 'promote' && typeof msg.identity === 'string') { + featuredIdentity.value = msg.identity + } + } catch { + // 알 수 없는 메시지는 무시 + } + } + + // 특정 참여자를 크게 보기로 승격 — 진행자만 호출(UI에서 제한). 전원에게 브로드캐스트. + async function promote(identity: string): Promise { + const r = room.value + if (!r) return + featuredIdentity.value = identity + const data = new TextEncoder().encode( + JSON.stringify({ type: 'promote', identity }), + ) + await r.localParticipant.publishData(data, { reliable: true }) } // 방 입장 — 토큰으로 연결 후 카메라/마이크 발행 @@ -49,6 +78,7 @@ export function useLiveKitRoom() { .on(RoomEvent.TrackUnsubscribed, refresh) .on(RoomEvent.LocalTrackPublished, refresh) .on(RoomEvent.LocalTrackUnpublished, refresh) + .on(RoomEvent.DataReceived, onData) .on(RoomEvent.Disconnected, onDisconnected) await r.connect(LIVEKIT_URL, token) @@ -82,6 +112,7 @@ export function useLiveKitRoom() { camEnabled.value = false micEnabled.value = false participants.value = [] + featuredIdentity.value = null } async function toggleMic(): Promise { @@ -106,9 +137,11 @@ export function useLiveKitRoom() { micEnabled, camEnabled, errorMsg, + featuredIdentity, connect, disconnect, toggleMic, toggleCam, + promote, } } diff --git a/frontend/src/pages/relay/MeetingPage.vue b/frontend/src/pages/relay/MeetingPage.vue index a4c975c..8570976 100644 --- a/frontend/src/pages/relay/MeetingPage.vue +++ b/frontend/src/pages/relay/MeetingPage.vue @@ -1,8 +1,9 @@