Merge feature/video-meeting-phase3: 화상회의 단계 3(화면 구성, 임시 레이아웃)

This commit is contained in:
2026-06-23 18:06:23 +09:00
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>
+15 -8
View File
@@ -9,12 +9,12 @@
## 1. 범위
- **포함(단계 1)**: 방 입장 → LiveKit 연결 → 카메라/마이크 발행 → 참여자 영상 그리드 표시 → 나가기. 마이크/카메라 on·off.
- **제외(후속 단계, 이번 UI에서 구현 안 함)**
- 단계 2: 관리자(진행자)/일반 사용자 **권한 분기** (토큰 grant 차등)
- 단계 3: **진행자 고해상도 큰 화면 + 참여자 썸네일**, 특정 참여자 **화면 승격**, **그리드 페이지네이션**(동시 표시 ~50명)
- 단계 4: 방 목록/예약/참여 제한
- 즉 지금은 "같은 방 이름으로 들어온 사람끼리 동등하게 연결되는 기본 회의실"이다.
- **포함(단계 1~3 구현됨)**
- 단계 1: 방 입장 → LiveKit 연결 → 카메라/마이크 발행 → 나가기, 마이크/카메라 on·off
- 단계 2: **권한 분기** — 방 생성자=진행자(host), 이후 입장자=일반. 역할별 토큰 grant 차등
- 단계 3: 진행자/승격된 참여자 **큰 화면(stage)** + 나머지 **썸네일**, 진행자의 **화면 승격**(데이터 채널 브로드캐스트), 썸네일 **페이지네이션**
- **제외(후속 단계)**: 단계 4(방 목록/예약/참여 제한 UI), 5(부하 테스트), 6(셀프호스팅 배포)
- ⚠️ **현재 화면 레이아웃은 임시(대충)이며 UI 는 새로 개편 예정.** 아래 "데이터 계약"만 그대로 연결하면 동작이 유지된다.
---
@@ -70,9 +70,12 @@
- 마이크 토글 → `toggleMic()` (상태 `micEnabled`)
- 카메라 토글 → `toggleCam()` (상태 `camEnabled`)
- 나가기 → `onLeave()` (연결 해제 후 `/meeting` 로 이동)
- **영상 그리드(grid)**: `participants` 를 순회하며 `ParticipantTile` 렌더. 첫 번째(`index === 0`)가 **본인(localParticipant)**.
- **큰 화면(stage)**: `featured`(승격된 참여자 > 진행자 > 첫 참여자) 1명을 크게 렌더.
- **썸네일(thumbs)**: 나머지 참여자를 작은 타일로 렌더. 진행자에게만 각 썸네일에 **"크게"(승격) 버튼** 표시 → `promote(identity)` 호출(데이터 채널로 전원 동기화).
- **페이지네이션(pager)**: 썸네일이 페이지당 12명을 넘으면 이전/다음. **현재 페이지 타일만 렌더**되므로 adaptiveStream 이 안 보이는 참여자의 미디어를 자동으로 구독 해제(대역폭 절약).
- 본인 식별: `participants[0]` 이 항상 본인. 타일 `is-local``p.identity === participants[0].identity` 로 판정.
> 현재 컨트롤은 이모지(🎙/📹) 버튼이고 상단 우측에 있다 → 새 UI에서 하단 중앙 컨트롤 바 + 아이콘 버튼 등으로 자유롭게 재배치 가능. **연결되는 함수/상태만 동일하게 쓰면 된다.**
> 현재 컨트롤은 이모지(🎙/📹) 버튼·상단 바 등 임시 배치다 → 새 UI에서 하단 중앙 컨트롤 바, 큰화면/썸네일 비율, 승격 버튼 위치 등 자유롭게 재구성 가능. **연결되는 함수/상태만 동일하게 쓰면 된다.**
---
@@ -132,10 +135,14 @@ LiveKit 방 연결/생명주기를 캡슐화. 반환 객체:
| `micEnabled` | `Ref<boolean>` | 본인 마이크 on/off |
| `camEnabled` | `Ref<boolean>` | 본인 카메라 on/off |
| `errorMsg` | `Ref<string \| null>` | 연결 이후 미디어 오류 메시지 |
| `featuredIdentity` | `Ref<string \| null>` | 승격되어 크게 볼 참여자 identity(없으면 진행자/첫 참여자가 기본) |
| `connect(token)` | `(token: string) => Promise<void>` | 입장(연결 + 카메라/마이크 발행) |
| `disconnect()` | `() => Promise<void>` | 나가기(연결 해제, 상태 초기화) |
| `toggleMic()` | `() => Promise<void>` | 마이크 on/off |
| `toggleCam()` | `() => Promise<void>` | 카메라 on/off |
| `promote(identity)` | `(identity: string) => Promise<void>` | 해당 참여자를 큰 화면으로 승격(진행자만 호출, 전원 동기화) |
> **역할 식별**: `useMeeting().getToken()` 응답의 `role`('host'\|'participant')로 본인 역할을 안다. 다른 참여자의 진행자 여부는 `participant.metadata`(JSON `{"role":"host"}`)로 판정한다.
- 접속 URL은 `import.meta.env.VITE_LIVEKIT_URL`(없으면 `ws://localhost:7880`). 개발은 LiveKit Cloud(`wss://<project>.livekit.cloud`).
- `Room` 옵션: `adaptiveStream: true`, `dynacast: true`.