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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user