feat: 화상회의 단계 3 — 화면 구성(임시 레이아웃)

진행자/승격 참여자 큰 화면 + 썸네일 + 페이지네이션 + 화면 승격을 구현한다.
(레이아웃은 임시 — UI 는 추후 개편 예정. 데이터/로직만 확정.)

- useLiveKitRoom: featuredIdentity 상태 + promote(identity) 추가.
  진행자가 데이터 채널로 승격을 브로드캐스트 → RoomEvent.DataReceived 로 전원 동기화.
- MeetingPage:
  · stage(featured: 승격>진행자>첫 참여자) 큰 화면 + 나머지 썸네일
  · 진행자에게만 썸네일 "크게"(승격) 버튼
  · 썸네일 페이지네이션(페이지당 12명) — 현재 페이지만 렌더 →
    adaptiveStream 이 안 보이는 참여자 미디어 자동 미구독(대역폭 절약)
- 화상회의_화면명세.md: 단계 2~3 반영(범위/화면 구성/데이터 계약 갱신)

백엔드 변경 없음(승격은 클라이언트 데이터 채널).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 18:06:23 +09:00
parent 9a4d287ffd
commit 3c7aec86a2
3 changed files with 205 additions and 16 deletions
@@ -17,6 +17,8 @@ export function useLiveKitRoom() {
const micEnabled = ref(false)
const camEnabled = ref(false)
const errorMsg = ref<string | null>(null)
// 크게 보기(stage)로 승격된 참여자 identity. 진행자가 데이터 채널로 브로드캐스트 → 전원 동기화.
const featuredIdentity = ref<string | null>(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<void> {
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<void> {
@@ -106,9 +137,11 @@ export function useLiveKitRoom() {
micEnabled,
camEnabled,
errorMsg,
featuredIdentity,
connect,
disconnect,
toggleMic,
toggleCam,
promote,
}
}
+157 -8
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
// 화상회의 (단계 1: 인프라 토대) — 방 입장 → LiveKit 연결 → 참여자 영상 그리드
// 진행자/일반 권한 분기(단계 2), 화면 승격/페이지네이션(단계 3)은 후속 단계에서 구현한다.
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Participant } from 'livekit-client'
import AppShell from '@/layouts/AppShell.vue'
import ParticipantTile from '@/components/meeting/ParticipantTile.vue'
import { useMeeting, type MeetingRole } from '@/composables/useMeeting'
@@ -18,10 +19,12 @@ const {
micEnabled,
camEnabled,
errorMsg,
featuredIdentity,
connect,
disconnect,
toggleMic,
toggleCam,
promote,
} = useLiveKitRoom()
// 방 이름 — 공유 링크(/meeting/:room)에서 가져오거나 직접 입력
@@ -69,6 +72,47 @@ async function onLeave() {
const participantCount = computed(() => participants.value.length)
// 본인 식별자(refresh 시 항상 0번이 본인) + 본인 진행자 여부
const localId = computed(() => participants.value[0]?.identity ?? null)
const isHost = computed(() => myRole.value === 'host')
// 참여자 metadata 에서 진행자 여부 파싱(기본 featured 결정용)
function isHostMeta(p: Participant): boolean {
if (!p.metadata) return false
try {
return (JSON.parse(p.metadata) as { role?: string }).role === 'host'
} catch {
return false
}
}
// 크게 볼 참여자(stage) — 승격된 사람 > 진행자 > 첫 참여자
const featured = computed<Participant | null>(() => {
const list = participants.value
if (!list.length) return null
if (featuredIdentity.value) {
const f = list.find((p) => p.identity === featuredIdentity.value)
if (f) return f
}
return list.find(isHostMeta) ?? list[0] ?? null
})
// 나머지(썸네일) + 페이지네이션 — 현재 페이지만 렌더 → adaptiveStream 이 안 보이는 참여자 미디어 미구독
const others = computed(() => participants.value.filter((p) => p !== featured.value))
const PAGE_SIZE = 12
const page = ref(0)
const pageCount = computed(() =>
Math.max(1, Math.ceil(others.value.length / PAGE_SIZE)),
)
const pagedOthers = computed(() => {
const start = page.value * PAGE_SIZE
return others.value.slice(start, start + PAGE_SIZE)
})
// 참여자 수 변동으로 현재 페이지가 범위를 벗어나면 보정
watch(pageCount, (count) => {
if (page.value >= count) page.value = Math.max(0, count - 1)
})
// 화면 이탈 시 연결 정리
onBeforeUnmount(() => {
void disconnect()
@@ -174,14 +218,64 @@ onBeforeUnmount(() => {
{{ errorMsg }}
</p>
<div class="grid">
<!-- 화면(stage): 진행자 또는 승격된 참여자 -->
<div
v-if="featured"
class="stage"
>
<ParticipantTile
v-for="(p, i) in participants"
:key="p.sid || p.identity"
:participant="p"
:is-local="i === 0"
:participant="featured"
:is-local="featured.identity === localId"
/>
</div>
<!-- 썸네일(나머지) 현재 페이지만 렌더 -->
<div
v-if="pagedOthers.length"
class="thumbs"
>
<div
v-for="p in pagedOthers"
:key="p.sid || p.identity"
class="thumb"
>
<ParticipantTile
:participant="p"
:is-local="p.identity === localId"
/>
<!-- 진행자만: 해당 참여자를 화면으로 승격 -->
<button
v-if="isHost"
class="promote-btn"
title="크게 보기"
@click="promote(p.identity)"
>
크게
</button>
</div>
</div>
<!-- 페이지네이션 -->
<div
v-if="pageCount > 1"
class="pager"
>
<button
class="pager-btn"
:disabled="page === 0"
@click="page--"
>
이전
</button>
<span class="pager-info">{{ page + 1 }} / {{ pageCount }}</span>
<button
class="pager-btn"
:disabled="page >= pageCount - 1"
@click="page++"
>
다음
</button>
</div>
</div>
</AppShell>
</template>
@@ -354,9 +448,64 @@ onBeforeUnmount(() => {
font-size: 0.781rem;
color: var(--amber);
}
.grid {
/* 큰 화면(stage) — 레이아웃은 임시(추후 UI 개편 예정) */
.stage {
width: 100%;
max-width: 56rem;
margin: 0 auto;
}
/* 썸네일 그리드 */
.thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.5rem;
}
.thumb {
position: relative;
}
.promote-btn {
position: absolute;
top: 0.375rem;
right: 0.375rem;
padding: 0.1875rem 0.5rem;
border: none;
border-radius: 0.3125rem;
background: rgba(79, 70, 229, 0.9);
color: #fff;
font-size: 0.6875rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.promote-btn:hover {
background: var(--accent);
}
/* 페이지네이션 */
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.pager-btn {
height: 2rem;
padding: 0 0.875rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
color: var(--text-2);
font-size: 0.8125rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.pager-btn:disabled {
opacity: 0.5;
cursor: default;
}
.pager-info {
font-size: 0.8125rem;
color: var(--text-2);
font-weight: 600;
}
</style>