From c330ec6ff49b4b46c93f89fe1dcbea3a430571b1 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 13 Aug 2025 18:31:09 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20subscription=20vide?= =?UTF-8?q?o=20quality=20selector=20to=20video=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../hooks/useVideoResolutionSubscription.ts | 51 ++++++++++++ .../rooms/livekit/prefabs/VideoConference.tsx | 2 + .../settings/components/tabs/VideoTab.tsx | 77 ++++++++++++++++++- src/frontend/src/locales/de/settings.json | 10 ++- src/frontend/src/locales/en/settings.json | 10 ++- src/frontend/src/locales/fr/settings.json | 10 ++- src/frontend/src/locales/nl/settings.json | 10 ++- 7 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useVideoResolutionSubscription.ts diff --git a/src/frontend/src/features/rooms/livekit/hooks/useVideoResolutionSubscription.ts b/src/frontend/src/features/rooms/livekit/hooks/useVideoResolutionSubscription.ts new file mode 100644 index 00000000..13c518e2 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useVideoResolutionSubscription.ts @@ -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]) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 96d6b74a..a02b1528 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -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(null) useConnectionObserver() + useVideoResolutionSubscription() const tracks = useTracks( [ diff --git a/src/frontend/src/features/settings/components/tabs/VideoTab.tsx b/src/frontend/src/features/settings/components/tabs/VideoTab.tsx index db089f70..b301eef5 100644 --- a/src/frontend/src/features/settings/components/tabs/VideoTab.tsx +++ b/src/frontend/src/features/settings/components/tabs/VideoTab.tsx @@ -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( 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) => { }} /> + +
+ { + if (key == undefined) return + const selectedQuality = Number(String(key)) + saveVideoSubscribeQuality(selectedQuality) + updateExistingRemoteVideoQuality(selectedQuality) + }} + style={{ + width: '100%', + }} + /> +
+
+ ) } diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json index 4254bb59..35c7e0cc 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -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" diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index f06ddd8b..d86cd31c 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -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" diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index c2bcb684..41a7968e 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -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" diff --git a/src/frontend/src/locales/nl/settings.json b/src/frontend/src/locales/nl/settings.json index bd539b51..235382db 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -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"