(frontend) add subscription video quality selector to video tab

Add configuration option allowing users to set maximum video quality
for subscribed tracks, requested by users who prefer manual control
over automatic behavior.

Implements custom handling for existing and new video tracks since
LiveKit lacks simple global subscription parameter mechanism. Users can
now override automatic quality decisions with their own preferences.
This commit is contained in:
lebaudantoine
2025-08-13 18:31:09 +02:00
committed by aleb_the_flash
parent 8245270f28
commit c330ec6ff4
7 changed files with 164 additions and 6 deletions

View File

@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { usePersistentUserChoices } from './usePersistentUserChoices'
import { useRoomContext } from '@livekit/components-react'
import {
RemoteParticipant,
RemoteTrackPublication,
RoomEvent,
Track,
VideoQuality,
} from 'livekit-client'
/**
* This hook sets initial video quality for new participants as they join.
* LiveKit doesn't allow handling video quality preferences at the room level.
*/
export const useVideoResolutionSubscription = () => {
const {
userChoices: { videoSubscribeQuality },
} = usePersistentUserChoices()
const room = useRoomContext()
useEffect(() => {
if (!room) return
const handleTrackPublished = (
publication: RemoteTrackPublication,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_participant: RemoteParticipant
) => {
// By default, the maximum quality is set to high
if (
videoSubscribeQuality === undefined ||
videoSubscribeQuality === VideoQuality.HIGH
)
return
if (
publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare
) {
publication.setVideoQuality(videoSubscribeQuality)
}
}
room.on(RoomEvent.TrackPublished, handleTrackPublished)
return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished)
}
}, [room, videoSubscribeQuality])
}

View File

@@ -32,6 +32,7 @@ import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
const LayoutWrapper = styled(
'div',
@@ -77,6 +78,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useConnectionObserver()
useVideoResolutionSubscription()
const tracks = useTracks(
[

View File

@@ -12,6 +12,7 @@ import {
LocalVideoTrack,
Track,
VideoPresets,
VideoQuality,
} from 'livekit-client'
import { BackgroundProcessorFactory } from '@/features/rooms/livekit/components/blur'
import { VideoResolution } from '@/stores/userChoices'
@@ -61,12 +62,18 @@ type DeviceItems = Array<{ value: string; label: string }>
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant } = useRoomContext()
const { localParticipant, remoteParticipants } = useRoomContext()
const {
userChoices: { videoDeviceId, processorSerialized, videoPublishResolution },
userChoices: {
videoDeviceId,
processorSerialized,
videoPublishResolution,
videoSubscribeQuality,
},
saveVideoInputDeviceId,
saveVideoPublishResolution,
saveVideoSubscribeQuality,
} = usePersistentUserChoices()
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null
@@ -112,6 +119,22 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}
}
/**
* Updates video quality for all existing remote video tracks when user preference changes.
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
* so this function applies the selected quality to all existing remote video tracks.
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
*/
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.videoQuality !== selectedQuality) {
publication.setVideoQuality(selectedQuality)
}
})
})
}
useEffect(() => {
let videoTrack: LocalVideoTrack | null = null
@@ -237,6 +260,56 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}}
/>
</HStack>
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
<Field
type="select"
label={t('resolution.subscribe.label')}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
}}
style={{
width: '100%',
}}
/>
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
/>
</HStack>
</TabPanel>
)
}

View File

@@ -43,12 +43,20 @@
"resolution": {
"heading": "Auflösung",
"publish": {
"label": "Wählen Sie Ihre maximale Sendeauflösung",
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
"items": {
"high": "Hohe Auflösung",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
},
"subscribe": {
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
"items": {
"high": "Hohe Auflösung (automatisch)",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
}
},
"permissionsRequired": "Berechtigungen erforderlich"

View File

@@ -43,12 +43,20 @@
"resolution": {
"heading": "Resolution",
"publish": {
"label": "Select your sending resolution (maximum)",
"label": "Select your sending resolution (max.)",
"items": {
"high": "High definition",
"medium": "Standard definition",
"low": "Low definition"
}
},
"subscribe": {
"label": "Select your reception resolution (max.)",
"items": {
"high": "High definition (automatic)",
"medium": "Standard definition",
"low": "Low definition"
}
}
},
"permissionsRequired": "Permissions required"

View File

@@ -43,12 +43,20 @@
"resolution": {
"heading": "Résolution",
"publish": {
"label": "Sélectionner votre résolution d'envoi (maximum)",
"label": "Sélectionner votre résolution d'envoi (max.)",
"items": {
"high": "Haute définition",
"medium": "Définition standard",
"low": "Basse définition"
}
},
"subscribe": {
"label": "Sélectionner votre résolution de réception (max.)",
"items": {
"high": "Haute définition (automatique)",
"medium": "Définition standard",
"low": "Basse définition"
}
}
},
"permissionsRequired": "Autorisations nécessaires"

View File

@@ -43,12 +43,20 @@
"resolution": {
"heading": "Resolutie",
"publish": {
"label": "Selecteer uw verzendresolutie (maximum)",
"label": "Selecteer uw verzendresolutie (max.)",
"items": {
"high": "Hoge definitie",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
},
"subscribe": {
"label": "Selecteer uw ontvangstresolutie (max.)",
"items": {
"high": "Hoge definitie (automatisch)",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
}
},
"permissionsRequired": "Machtigingen vereist"