diff --git a/frontend/src/components/meeting/ParticipantTile.vue b/frontend/src/components/meeting/ParticipantTile.vue index 25849d4..8703e30 100644 --- a/frontend/src/components/meeting/ParticipantTile.vue +++ b/frontend/src/components/meeting/ParticipantTile.vue @@ -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 } diff --git a/frontend/src/composables/useLiveKitRoom.ts b/frontend/src/composables/useLiveKitRoom.ts index 89d8149..830b2c0 100644 --- a/frontend/src/composables/useLiveKitRoom.ts +++ b/frontend/src/composables/useLiveKitRoom.ts @@ -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)