mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-08 16:35:49 +00:00
63a7751072
The sending resolution selector stopped at 720p while `VideoPresets` already exposes `h1080` (1920x1080), so publishers on a good uplink could not make use of the capacity they had. Add "Very high definition (1080p)" above the existing entries, translated in the five supported locales. The default stays `h720`, so nothing changes unless a user goes and picks the new entry. Being explicit about what that costs, since 1080p roughly doubles a publisher's uplink: this is a per-user choice, and an instance operator has no way today to decline it. Whether that warrants a server-side setting alongside the existing `ApiConfig` flags is a call for maintainers — happy to add one if you want it, rather than change the API contract unasked in a frontend PR. While here, make the option list harder to get wrong. Resolutions now come from a single `VIDEO_RESOLUTIONS` tuple that `VideoResolution` derives from, the selector items are built by mapping over it against a `Record<VideoResolution, string>` of labels — so a resolution cannot be added to one and forgotten in the other — and a persisted value that is not in the tuple falls back to `h720` instead of reaching `VideoPresets[...]` as `undefined`, since `loadUserChoices` spreads localStorage without validating it. Known limitation, unchanged by this patch: `restartTrack` passes the resolution as an `ideal` constraint, so a camera that cannot reach the selected height degrades silently. That is already true of 720p on a 480p webcam; 1080p is the first step where the gap is the common case rather than the edge one.
247 lines
7.5 KiB
TypeScript
247 lines
7.5 KiB
TypeScript
import { DialogProps, Field } from '@/primitives'
|
|
|
|
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
|
|
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { css } from '@/styled-system/css'
|
|
import {
|
|
createLocalVideoTrack,
|
|
type LocalVideoTrack,
|
|
Track,
|
|
VideoPresets,
|
|
VideoQuality,
|
|
} from 'livekit-client'
|
|
import { BackgroundProcessorFactory } from '@/features/rooms/livekit/components/blur'
|
|
import {
|
|
saveVideoInputDeviceId,
|
|
saveVideoPublishResolution,
|
|
saveVideoSubscribeQuality,
|
|
userChoicesStore,
|
|
VIDEO_RESOLUTIONS,
|
|
VideoResolution,
|
|
} from '@/stores/userChoices'
|
|
import { RowWrapper } from './layout/RowWrapper'
|
|
import { useSnapshot } from 'valtio'
|
|
|
|
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
|
Pick<TabPanelProps, 'id'>
|
|
|
|
type DeviceItems = Array<{ value: string; label: string }>
|
|
|
|
const EMPTY_PROPS = {}
|
|
|
|
export const VideoTab = ({ id }: VideoTabProps) => {
|
|
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
|
const room = useRoomContext()
|
|
const { localParticipant, remoteParticipants } = room
|
|
|
|
const {
|
|
videoDeviceId,
|
|
processorConfig,
|
|
videoPublishResolution,
|
|
videoSubscribeQuality,
|
|
} = useSnapshot(userChoicesStore)
|
|
|
|
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
|
|
null
|
|
)
|
|
|
|
const videoCallbackRef = useCallback((element: HTMLVideoElement | null) => {
|
|
setVideoElement(element)
|
|
}, [])
|
|
|
|
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
|
|
useMediaDeviceSelect({ kind: 'videoinput' })
|
|
|
|
const itemsIn: DeviceItems = devicesIn.map((d) => ({
|
|
value: d.deviceId,
|
|
label: d.label,
|
|
}))
|
|
|
|
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for camera permissions
|
|
// may raise an error. As a workaround, we infer camera permission status by checking if the list of camera input
|
|
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted camera access.
|
|
const isCamEnabled = devicesIn?.length > 0
|
|
|
|
const disabledProps = isCamEnabled
|
|
? EMPTY_PROPS
|
|
: {
|
|
placeholder: t('permissionsRequired'),
|
|
isDisabled: true,
|
|
}
|
|
|
|
const handleVideoResolutionChange = async (key: VideoResolution) => {
|
|
saveVideoPublishResolution(key)
|
|
const videoTrack = localParticipant.getTrackPublication(
|
|
Track.Source.Camera
|
|
)?.track
|
|
if (!videoTrack) {
|
|
return
|
|
}
|
|
|
|
await videoTrack.restartTrack({
|
|
resolution: VideoPresets[key].resolution,
|
|
deviceId: { exact: videoDeviceId },
|
|
processor:
|
|
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
const setUpVideoTrack = async () => {
|
|
if (videoElement) {
|
|
videoTrack = await createLocalVideoTrack({ deviceId: videoDeviceId })
|
|
videoTrack.attach(videoElement)
|
|
}
|
|
}
|
|
|
|
setUpVideoTrack()
|
|
|
|
return () => {
|
|
if (videoElement && videoTrack) {
|
|
videoTrack.detach()
|
|
videoTrack.stop()
|
|
}
|
|
}
|
|
}, [videoDeviceId, videoElement])
|
|
|
|
const resolutionItems = useMemo(() => {
|
|
const labels: Record<VideoResolution, string> = {
|
|
h1080: `${t('resolution.publish.items.veryHigh')} (1080p)`,
|
|
h720: `${t('resolution.publish.items.high')} (720p)`,
|
|
h360: `${t('resolution.publish.items.medium')} (360p)`,
|
|
h180: `${t('resolution.publish.items.low')} (180p)`,
|
|
}
|
|
return VIDEO_RESOLUTIONS.map((value) => ({ value, label: labels[value] }))
|
|
}, [t])
|
|
|
|
const videoQualityItems = useMemo(() => {
|
|
return [
|
|
{
|
|
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'),
|
|
},
|
|
]
|
|
}, [t])
|
|
|
|
return (
|
|
<TabPanel padding={'md'} flex id={id}>
|
|
<RowWrapper heading={t('camera.heading')}>
|
|
<Field
|
|
type="select"
|
|
label={t('camera.label')}
|
|
items={itemsIn}
|
|
selectedKey={videoDeviceId}
|
|
onSelectionChange={async (key) => {
|
|
await setActiveMediaDeviceIn(key as string)
|
|
saveVideoInputDeviceId(key as string)
|
|
}}
|
|
{...disabledProps}
|
|
style={{
|
|
width: '100%',
|
|
}}
|
|
/>
|
|
<div
|
|
role="status"
|
|
aria-label={t(
|
|
`camera.previewAriaLabel.${localParticipant.isCameraEnabled ? 'enabled' : 'disabled'}`
|
|
)}
|
|
>
|
|
{localParticipant.isCameraEnabled ? (
|
|
<>
|
|
{/* eslint-disable jsx-a11y/media-has-caption */}
|
|
<video
|
|
ref={videoCallbackRef}
|
|
width="160px"
|
|
height="56px"
|
|
style={{
|
|
display: !localParticipant.isCameraEnabled
|
|
? 'none'
|
|
: undefined,
|
|
}}
|
|
className={css({
|
|
transform: 'rotateY(180deg)',
|
|
height: '69px',
|
|
width: '160px',
|
|
})}
|
|
disablePictureInPicture
|
|
disableRemotePlayback
|
|
/>
|
|
</>
|
|
) : (
|
|
<span
|
|
className={css({
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
textAlign: 'center',
|
|
})}
|
|
>
|
|
{t('camera.disabled')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</RowWrapper>
|
|
<RowWrapper heading={t('resolution.heading')}>
|
|
<Field
|
|
type="select"
|
|
label={t('resolution.publish.label')}
|
|
items={resolutionItems}
|
|
selectedKey={videoPublishResolution}
|
|
onSelectionChange={async (key) => {
|
|
await handleVideoResolutionChange(key as VideoResolution)
|
|
}}
|
|
style={{
|
|
width: '100%',
|
|
}}
|
|
/>
|
|
<></>
|
|
</RowWrapper>
|
|
<RowWrapper>
|
|
<Field
|
|
type="select"
|
|
label={t('resolution.subscribe.label')}
|
|
items={videoQualityItems}
|
|
selectedKey={videoSubscribeQuality?.toString()}
|
|
onSelectionChange={(key) => {
|
|
if (key == undefined) return
|
|
const selectedQuality = Number(String(key))
|
|
saveVideoSubscribeQuality(selectedQuality)
|
|
updateExistingRemoteVideoQuality(selectedQuality)
|
|
}}
|
|
style={{
|
|
width: '100%',
|
|
}}
|
|
/>
|
|
<></>
|
|
</RowWrapper>
|
|
</TabPanel>
|
|
)
|
|
}
|