feat: 화상회의 수신 화질을 스테이지 역할 기반으로 중앙 제어
스테이지(featured) 유저만 HIGH, 나머지 원격 참여자는 LOW 로 구독한다. 스테이지가 바뀔 때마다 강등된 참여자가 LOW 로 내려가도록 보장한다. - adaptiveStream 비활성화: 화질이 "엘리먼트 크기"가 아니라 "스테이지 역할"로만 결정되도록(스테이지=HIGH 보장, 강등 반영 결정적). dynacast 는 유지. - 화질 제어를 useLiveKitRoom 로 중앙화: featuredIdentity 변경·참여자/트랙 변동 시마다 전원 재적용. 기존 타일 리마운트 의존(레이스로 강등자가 HIGH 유지되던 버그) 제거. - ParticipantTile 의 개별 setVideoQuality 제거(중복·레이스 원인), 영상 연결만 담당. 검증: type-check, lint 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user