Merge feature/meeting-stage-quality: 스테이지 기반 수신 화질 중앙 제어

This commit is contained in:
2026-06-26 15:15:41 +09:00
2 changed files with 60 additions and 13 deletions
@@ -6,8 +6,6 @@ import {
Track,
ParticipantEvent,
ConnectionQuality,
RemoteTrackPublication,
VideoQuality,
type Participant,
} from 'livekit-client'
import QualityBars from './QualityBars.vue'
@@ -63,15 +61,12 @@ function sync() {
isHost.value = parseHost(p.metadata)
// 오디오는 룸 레벨(useLiveKitRoom)에서 전원 재생하므로 여기서는 영상만 다룬다.
// 영상 트랙 연결만 담당한다. 수신 화질(스테이지=HIGH/나머지=LOW)은 타일이 아니라
// useLiveKitRoom 가 스테이지 변경에 맞춰 중앙에서 제어한다(강등 시 누락·레이스 방지).
const camPub = p.getTrackPublication(Track.Source.Camera)
if (camPub?.track && videoEl.value) {
camPub.track.attach(videoEl.value)
hasVideo.value = !camPub.isMuted
// 원격 참여자: 스테이지(featured)는 고화질, 썸네일은 저화질로 구독한다.
// (adaptiveStream 의 타일 크기 기반 선택과도 일치 — 대역폭 절약)
if (!props.isLocal && camPub instanceof RemoteTrackPublication) {
camPub.setVideoQuality(props.featured ? VideoQuality.HIGH : VideoQuality.LOW)
}
} else {
hasVideo.value = false
}
+58 -6
View File
@@ -1,13 +1,14 @@
import { ref, shallowRef } from 'vue'
import { ref, shallowRef, watch } from 'vue'
import {
Room,
RoomEvent,
Track,
ConnectionQuality,
RemoteTrackPublication,
VideoQuality,
type Participant,
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
} from 'livekit-client'
import type { ChatMessage, MeetingQuality } from '@/types/meeting'
@@ -92,6 +93,51 @@ export function useLiveKitRoom() {
]
}
// 스테이지(featured)로 보일 참여자 식별자 해석 — 명시 승격 > 진행자 > 첫 참여자.
// (MeetingRoom 의 레이아웃 featured 해석과 동일 규칙이라 화질=스테이지가 일치한다)
function effectiveFeaturedId(): string | null {
const r = room.value
if (!r) return null
const all = [
r.localParticipant,
...Array.from(r.remoteParticipants.values()),
]
if (
featuredIdentity.value &&
all.some((p) => p.identity === featuredIdentity.value)
) {
return featuredIdentity.value
}
return (all.find((p) => isHostMeta(p.metadata)) ?? all[0])?.identity ?? null
}
// 수신 화질 적용 — 스테이지 유저만 HIGH, 나머지 원격 참여자는 모두 LOW 로 구독한다.
// 각 클라이언트가 자기 시점에서 적용하므로 "남들이 보기에" 스테이지=고화질이 된다.
// 스테이지 변경·참여자/트랙 변동 시마다 호출해 강등된 참여자도 확실히 LOW 로 내린다.
// (adaptiveStream 을 끈 상태라 setVideoQuality 가 곧 실효 화질 — 크기에 덮어써지지 않음)
function applyVideoQuality() {
const r = room.value
if (!r) return
const fid = effectiveFeaturedId()
r.remoteParticipants.forEach((p) => {
const pub = p.getTrackPublication(Track.Source.Camera)
if (pub instanceof RemoteTrackPublication && pub.isSubscribed) {
pub.setVideoQuality(
p.identity === fid ? VideoQuality.HIGH : VideoQuality.LOW,
)
}
})
}
// 참여자 변동(입장/퇴장/메타변경) 시 목록 갱신 + 화질 재적용(스테이지 폴백이 바뀔 수 있음)
function onParticipantsChanged() {
refresh()
applyVideoQuality()
}
// 스테이지(featuredIdentity)가 바뀌면 즉시 화질 재적용 — 강등된 참여자를 LOW 로 내린다.
watch(featuredIdentity, () => applyVideoQuality())
// --- 원격 오디오는 화면 타일(페이지네이션)과 무관하게 항상 재생되어야 한다 ---
// 타일에 attach 하면 현재 페이지에 없는 참여자(>50번째)는 무음이 되므로,
// 숨은 컨테이너에 트랙별 audio 엘리먼트를 붙여 룸 레벨에서 전원 오디오를 재생한다.
@@ -129,6 +175,8 @@ export function useLiveKitRoom() {
function onTrackSubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
refresh()
attachRemoteAudio(track, pub)
// 새로 구독된 영상에도 스테이지 여부에 맞는 화질을 적용한다(늦게 켠 카메라 등).
applyVideoQuality()
}
function onTrackUnsubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
refresh()
@@ -221,20 +269,24 @@ export function useLiveKitRoom() {
connecting.value = true
errorMsg.value = null
try {
const r = new Room({ adaptiveStream: true, dynacast: true })
// adaptiveStream 은 끈다 — 화질을 "엘리먼트 크기"가 아니라 "스테이지 역할"로
// 명시 제어하기 위함(스테이지=HIGH, 나머지=LOW 보장). 켜져 있으면 setVideoQuality 가
// 타일 크기에 덮어써져(min) 스테이지가 HD 가 안 되거나 강등 반영이 비결정적이 된다.
// dynacast 는 유지 — 아무도 구독 안 하는 레이어는 발행자가 송출을 멈춘다.
const r = new Room({ dynacast: true })
// 참여자 목록(배열) 재생성이 필요한 이벤트만 구독한다.
// 발화(ActiveSpeakers)·연결품질(ConnectionQuality)은 매우 잦게 발생하는데
// 각 타일이 ParticipantEvent 로 자체 갱신하므로, 여기서 전체 배열을 재생성하면
// 대규모(수십 명) 화면에서 불필요한 리렌더로 버벅임을 유발한다 → 제외.
r.on(RoomEvent.ParticipantConnected, refresh)
.on(RoomEvent.ParticipantDisconnected, refresh)
r.on(RoomEvent.ParticipantConnected, onParticipantsChanged)
.on(RoomEvent.ParticipantDisconnected, onParticipantsChanged)
.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.ParticipantMetadataChanged, onParticipantsChanged)
.on(RoomEvent.DataReceived, onData)
.on(RoomEvent.Disconnected, onDisconnected)