Compare commits

..

9 Commits

Author SHA1 Message Date
Nathan Vasse 3adb5a897b (front) implement new ui of EffectsConfiguration
This new ui implement the new sketches and also enables the usage of
virtual background in a nice way.
2025-01-31 15:48:27 +01:00
Nathan Vasse 9295d3bc2c (front) handle virtual background in custom processor
BackgroundBlurCustomProcessor is renamed to BackgroundCustomProcessor in
order to reflect the fact that is now handles virtual backgrounds too.
BackgroundBlurFactory is also renamed to BackgroundProcessorFactory.
The processor serialization handling has also been updated in order
to support various options, also if persisted in local storage.
2025-01-31 15:48:27 +01:00
Nathan Vasse c8ef7b37f3 (front) add BackgroundVirtualTrackProcessorJsWrapper
This is a wrapper around track-processor-js virtual background
processor. This is needed in order to be used in a generic way
in our code between firefox processors.
2025-01-31 15:45:03 +01:00
lebaudantoine db51ca1aa5 🩹(frontend) fix border radius issue on safari
I introduced a bug while moving the border radius css style to the
parent element of the video.

On safari, the video element wasn't rounded anymore.

Fix this! Please note our approach should be refactored, nit-picking,
but there are few pixels leaking from the black background on the
video corner.
2025-01-31 14:54:26 +01:00
lebaudantoine 6935001aab 🚸(frontend) disable few native html video option
Disable Picture-In-Picture option for browsers that support it,
to avoid having the option appearing on the video element.

It's not appropriate.
Actually, I am not sure we should disable remote playback ones,
feel free to challenge it.
2025-01-31 14:54:26 +01:00
lebaudantoine bbc2b1d9f6 🚸(frontend) avoid displaying the effects option when not optimal
On unsupported browser showing this option whitout offering the
blur effects to the user would be quite frustrating.

At the moment, safari user cannot blur their background.

Also, avoid offering the option on mobile which are really
cpu-constrained devices.
2025-01-31 14:54:26 +01:00
lebaudantoine 12e2149b9e ♻️(frontend) extract effects-related logic in a component
Wraps all logics related to the effects on prejoin screen in a
dedicated component.
2025-01-31 14:54:26 +01:00
lebaudantoine 3bd9363879 🔍️(frontend) add link to the source code in the footer
Great idea suggested by @fflorent — totally worth it!
2025-01-31 14:30:52 +01:00
lebaudantoine b48135c3b6 🚸(frontend) replace effects icons in menu option
Align effects icon with the one from the newly refactored pre-join screen.
This new icon is stylish.
2025-01-31 14:21:48 +01:00
38 changed files with 547 additions and 182 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
import { css } from '@/styled-system/css'
import { LocalUserChoices } from '../routes/Room'
import { BackgroundBlurFactory } from '../livekit/components/blur'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
export const Conference = ({
roomId,
@@ -114,7 +114,7 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={
userConfig.videoEnabled && {
processor: BackgroundBlurFactory.deserializeProcessor(
processor: BackgroundProcessorFactory.deserializeProcessor(
userConfig.processorSerialized
),
}
@@ -7,18 +7,96 @@ import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Form } from '@/primitives'
import { Button, Dialog, Text, Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { Button, Dialog } from '@/primitives'
import { LocalUserChoices } from '../routes/Room'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import { EffectsConfiguration } from '../livekit/components/effects/EffectsConfiguration'
import {
EffectsConfiguration,
EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundBlurFactory } from '../livekit/components/blur'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core'
const onError = (e: Error) => console.error('ERROR', e)
const Effects = ({
videoTrack,
onSubmit,
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
const [isDialogOpen, setIsDialogOpen] = useState(false)
const openDialog = () => setIsDialogOpen(true)
if (!BackgroundProcessorFactory.isSupported() || isMobileBrowser()) {
return
}
return (
<>
<Dialog
isOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
role="dialog"
type="flex"
size="large"
>
<Heading
slot="title"
level={1}
className={css({
textStyle: 'h1',
marginBottom: '0.25rem',
})}
>
{t('title')}
</Heading>
<Text
variant="subTitle"
className={css({
marginBottom: '1.5rem',
})}
>
{t('subTitle')}
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
</Dialog>
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
zIndex: '1',
})}
>
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
>
<RiImageCircleAiFill size={24} />
</Button>
</div>
<div
className={css({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '20%',
backgroundImage:
'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(255,255,255,0) 100%)',
borderBottomRadius: '1rem',
})}
/>
</>
)
}
export const Join = ({
onSubmit,
}: {
@@ -34,6 +112,8 @@ export const Join = ({
saveProcessorSerialized,
} = usePersistentUserChoices({})
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const [audioDeviceId, setAudioDeviceId] = useState<string>(
initialUserChoices.audioDeviceId
)
@@ -42,7 +122,7 @@ export const Join = ({
)
const [username, setUsername] = useState<string>(initialUserChoices.username)
const [processor, setProcessor] = useState(
BackgroundBlurFactory.deserializeProcessor(
BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.processorSerialized
)
)
@@ -58,12 +138,15 @@ export const Join = ({
useEffect(() => {
saveUsername(username)
}, [username, saveUsername])
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [processor, saveProcessorSerialized])
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
{
@@ -123,12 +206,6 @@ export const Join = ({
})
}
const [isEffectsOpen, setEffectsOpen] = useState(false)
const openEffects = () => {
setEffectsOpen(true)
}
// This hook is used to setup the persisted user choice processor on initialization.
// So it's on purpose that processor is not included in the deps.
// We just want to wait for the videoTrack to be loaded to apply the default processor.
@@ -141,30 +218,6 @@ export const Join = ({
return (
<Screen footer={false}>
<Dialog
isOpen={isEffectsOpen}
onOpenChange={setEffectsOpen}
role="dialog"
type="flex"
size="large"
>
<Heading
slot="title"
level={1}
className={css({
textStyle: 'h1',
marginBottom: '1.5rem',
})}
>
{t('effects.title')}
</Heading>
<EffectsConfiguration
videoTrack={videoTrack}
onSubmit={(processor) => {
setProcessor(processor)
}}
/>
</Dialog>
<div
className={css({
display: 'flex',
@@ -227,13 +280,16 @@ export const Join = ({
height="720"
className={css({
display: 'block',
width: '102%',
height: '102%',
width: '100%',
height: '100%',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
borderRadius: '1rem',
})}
disablePictureInPicture
disableRemotePlayback
/>
) : (
<div
@@ -257,34 +313,7 @@ export const Join = ({
</p>
</div>
)}
<div
className={css({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '20%',
backgroundImage:
'linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(255,255,255,0) 100%)',
})}
></div>
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
})}
>
<Button
variant="whiteCircle"
onPress={openEffects}
tooltip={t('effects.description')}
aria-label={t('effects.description')}
>
<RiImageCircleAiFill size={24} />
</Button>
</div>
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
@@ -5,7 +5,7 @@ import {
} from '@livekit/track-processors'
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundBlurProcessorInterface,
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
@@ -16,9 +16,9 @@ import {
* used accross the project.
*/
export class BackgroundBlurTrackProcessorJsWrapper
implements BackgroundBlurProcessorInterface
implements BackgroundProcessorInterface
{
name: string = 'Blur'
name: string = 'blur'
processor: ProcessorWrapper<BackgroundOptions>
@@ -12,7 +12,7 @@ import {
timerWorkerScript,
} from './TimerWorker'
import {
BackgroundBlurProcessorInterface,
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
@@ -32,9 +32,7 @@ const DEFAULT_BLUR = '10'
* It also make possible to run blurring on browser that does not implement MediaStreamTrackGenerator and
* MediaStreamTrackProcessor.
*/
export class BackgroundBlurCustomProcessor
implements BackgroundBlurProcessorInterface
{
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
options: BackgroundOptions
name: string
processedTrack?: MediaStreamTrack | undefined
@@ -63,9 +61,18 @@ export class BackgroundBlurCustomProcessor
timerWorker?: Worker
type: ProcessorType
virtualBackgroundImage?: HTMLImageElement
constructor(opts: BackgroundOptions) {
this.name = 'blur'
this.options = opts
if (this.options.blurRadius) {
this.type = ProcessorType.BLUR
} else {
this.type = ProcessorType.VIRTUAL
}
}
static get isSupported() {
@@ -81,6 +88,7 @@ export class BackgroundBlurCustomProcessor
this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement
this._initVirtualBackgroundImage()
this._createMainCanvas()
this._createMaskCanvas()
@@ -98,8 +106,21 @@ export class BackgroundBlurCustomProcessor
posthog.capture('firefox-blurring-init')
}
_initVirtualBackgroundImage() {
const needsUpdate =
this.options.imagePath &&
this.virtualBackgroundImage &&
this.virtualBackgroundImage.src !== this.options.imagePath
if (this.options.imagePath || needsUpdate) {
this.virtualBackgroundImage = document.createElement('img')
this.virtualBackgroundImage.crossOrigin = 'anonymous'
this.virtualBackgroundImage.src = this.options.imagePath!
}
}
update(opts: BackgroundOptions): void {
this.options = opts
this._initVirtualBackgroundImage()
}
_initWorker() {
@@ -201,6 +222,7 @@ export class BackgroundBlurCustomProcessor
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
@@ -213,19 +235,69 @@ export class BackgroundBlurCustomProcessor
this.videoElement!.videoHeight
)
// Draw clear body.
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
// Draw blurry background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.filter = `blur(${this.options.blurRadius ?? DEFAULT_BLUR}px)`
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
}
/**
* TODO: future improvement with WebGL.
*/
async drawVirtualBackground() {
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
}
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight
)
// Draw clear body.
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
// Draw virtual background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.drawImage(
this.virtualBackgroundImage!,
0,
0,
this.outputCanvas!.width,
this.outputCanvas!.height
)
}
async process() {
await this.sizeSource()
await this.segment()
await this.blur()
if (this.options.blurRadius) {
await this.blur()
} else {
await this.drawVirtualBackground()
}
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
@@ -284,12 +356,12 @@ export class BackgroundBlurCustomProcessor
}
clone() {
return new BackgroundBlurCustomProcessor(this.options)
return new BackgroundCustomProcessor(this.options)
}
serialize() {
return {
type: ProcessorType.BLUR,
type: this.type,
options: this.options,
}
}
@@ -0,0 +1,61 @@
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundOptions,
BackgroundProcessorInterface,
ProcessorType,
} from '.'
import {
BackgroundTransformer,
ProcessorWrapper,
VirtualBackground,
} from '@livekit/track-processors'
export class BackgroundVirtualTrackProcessorJsWrapper
implements BackgroundProcessorInterface
{
name = 'virtual'
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
constructor(opts: BackgroundOptions) {
this.processor = VirtualBackground(opts.imagePath!)
this.opts = opts
}
async init(opts: ProcessorOptions<Track.Kind>) {
return this.processor.init(opts)
}
async restart(opts: ProcessorOptions<Track.Kind>) {
return this.processor.restart(opts)
}
async destroy() {
return this.processor.destroy()
}
update(opts: BackgroundOptions): void {
this.processor.updateTransformerOptions(opts)
}
get processedTrack() {
return this.processor.processedTrack
}
get options() {
return (this.processor.transformer as BackgroundTransformer).options
}
clone() {
return new BackgroundVirtualTrackProcessorJsWrapper(this.options)
}
serialize() {
return {
type: ProcessorType.VIRTUAL,
options: this.options,
}
}
}
@@ -1,10 +1,12 @@
import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
import { BackgroundBlurCustomProcessor } from './BackgroundBlurCustomProcessor'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { BackgroundVirtualTrackProcessorJsWrapper } from './BackgroundVirtualTrackProcessorJsWrapper'
export type BackgroundOptions = {
blurRadius?: number
imagePath?: string
}
export interface ProcessorSerialized {
@@ -12,40 +14,49 @@ export interface ProcessorSerialized {
options: BackgroundOptions
}
export interface BackgroundBlurProcessorInterface
export interface BackgroundProcessorInterface
extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): void
options: BackgroundOptions
clone(): BackgroundBlurProcessorInterface
clone(): BackgroundProcessorInterface
serialize(): ProcessorSerialized
}
export enum ProcessorType {
BLUR = 'blur',
VIRTUAL = 'virtual',
}
export class BackgroundBlurFactory {
export class BackgroundProcessorFactory {
static isSupported() {
return (
ProcessorWrapper.isSupported || BackgroundBlurCustomProcessor.isSupported
)
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
}
static getProcessor(
type: ProcessorType,
opts: BackgroundOptions
): BackgroundBlurProcessorInterface | undefined {
if (ProcessorWrapper.isSupported) {
return new BackgroundBlurTrackProcessorJsWrapper(opts)
}
if (BackgroundBlurCustomProcessor.isSupported) {
return new BackgroundBlurCustomProcessor(opts)
): BackgroundProcessorInterface | undefined {
if (type === ProcessorType.BLUR) {
if (ProcessorWrapper.isSupported) {
return new BackgroundBlurTrackProcessorJsWrapper(opts)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
}
} else if (type === ProcessorType.VIRTUAL) {
if (ProcessorWrapper.isSupported) {
return new BackgroundVirtualTrackProcessorJsWrapper(opts)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
}
}
return undefined
}
static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type === ProcessorType.BLUR) {
return BackgroundBlurFactory.getProcessor(data?.options)
if (data?.type) {
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
}
return undefined
}
@@ -1,4 +1,4 @@
import { RiAccountBoxLine } from '@remixicon/react'
import { RiImageCircleAiFill } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
@@ -13,7 +13,7 @@ export const EffectsMenuItem = () => {
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiAccountBoxLine size={20} />
<RiImageCircleAiFill size={20} />
{t('effects')}
</MenuItem>
)
@@ -27,7 +27,7 @@ import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundBlurFactory } from '../blur'
import { BackgroundProcessorFactory } from '../blur'
export type ToggleSource = Exclude<
Track.Source,
@@ -115,7 +115,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundBlurFactory.deserializeProcessor(
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
@@ -2,12 +2,16 @@ import { LocalVideoTrack } from 'livekit-client'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
BackgroundBlurFactory,
BackgroundBlurProcessorInterface,
BackgroundProcessorFactory,
BackgroundProcessorInterface,
ProcessorType,
} from '../blur'
import { css } from '@/styled-system/css'
import { Text, P, ToggleButton, H } from '@/primitives'
import { HStack, styled } from '@/styled-system/jsx'
import { styled } from '@/styled-system/jsx'
import { BackgroundOptions } from '@livekit/track-processors'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
enum BlurRadius {
NONE = 0,
@@ -15,27 +19,28 @@ enum BlurRadius {
NORMAL = 10,
}
const isSupported = BackgroundBlurFactory.isSupported()
const isSupported = BackgroundProcessorFactory.isSupported()
const Information = styled('div', {
base: {
backgroundColor: 'orange.50',
borderRadius: '4px',
padding: '0.75rem 0.75rem',
marginTop: '0.8rem',
alignItems: 'start',
},
})
export type EffectsConfigurationProps = {
videoTrack: LocalVideoTrack
onSubmit?: (processor?: BackgroundProcessorInterface) => void
layout?: 'vertical' | 'horizontal'
}
export const EffectsConfiguration = ({
videoTrack,
onSubmit,
layout = 'horizontal',
}: {
videoTrack: LocalVideoTrack
onSubmit?: (processor?: BackgroundBlurProcessorInterface) => void
layout?: 'vertical' | 'horizontal'
}) => {
}: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const [processorPending, setProcessorPending] = useState(false)
@@ -53,46 +58,56 @@ export const EffectsConfiguration = ({
}
}, [videoTrack, videoTrack?.isMuted])
const toggleBlur = async (blurRadius: number) => {
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
if (!videoTrack) return
setProcessorPending(true)
const processor = getProcessor()
const currentBlurRadius = getBlurRadius()
try {
if (blurRadius == currentBlurRadius && processor) {
if (isSelected(type, options)) {
// Stop processor.
await videoTrack.stopProcessor()
onSubmit?.(undefined)
} else if (!processor) {
const newProcessor = BackgroundBlurFactory.getProcessor({ blurRadius })!
} else if (!processor || processor.serialize().type !== type) {
// Change processor.
const newProcessor = BackgroundProcessorFactory.getProcessor(
type,
options
)!
await videoTrack.setProcessor(newProcessor)
onSubmit?.(newProcessor)
} else {
processor?.update({ blurRadius })
// Update processor.
processor?.update(options)
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
} catch (error) {
console.error('Error applying blur:', error)
} finally {
setProcessorPending(false)
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
}
}
const getProcessor = () => {
return videoTrack?.getProcessor() as BackgroundBlurProcessorInterface
return videoTrack?.getProcessor() as BackgroundProcessorInterface
}
const getBlurRadius = (): BlurRadius => {
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
const processor = getProcessor()
return processor?.options.blurRadius || BlurRadius.NONE
const processorSerialized = processor?.serialize()
return (
!!processor &&
processorSerialized.type === type &&
JSON.stringify(processorSerialized.options) === JSON.stringify(options)
)
}
const isSelected = (blurRadius: BlurRadius) => {
return getBlurRadius() == blurRadius
}
const tooltipLabel = (blurRadius: BlurRadius) => {
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
return (
@@ -102,7 +117,7 @@ export const EffectsConfiguration = ({
? {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
gap: '1.5rem',
}
: {
display: 'flex',
@@ -110,6 +125,7 @@ export const EffectsConfiguration = ({
flexDirection: 'column',
md: {
flexDirection: 'row',
overflow: 'hidden',
},
}
)}
@@ -162,49 +178,138 @@ export const EffectsConfiguration = ({
md: {
borderLeft: '1px solid #dadce0',
paddingLeft: '1.5rem',
width: '420px',
flexShrink: 0,
},
}
: {}
)}
>
<H
lvl={3}
style={{
marginBottom: '0.4rem',
fontWeight: 'bold',
}}
>
{t('heading')}
</H>
{isSupported ? (
<HStack>
<ToggleButton
size={'sm'}
aria-label={tooltipLabel(BlurRadius.LIGHT)}
tooltip={tooltipLabel(BlurRadius.LIGHT)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.LIGHT)}
isSelected={isSelected(BlurRadius.LIGHT)}
>
{t('blur.light')}
</ToggleButton>
<ToggleButton
size={'sm'}
aria-label={tooltipLabel(BlurRadius.NORMAL)}
tooltip={tooltipLabel(BlurRadius.NORMAL)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.NORMAL)}
isSelected={isSelected(BlurRadius.NORMAL)}
>
{t('blur.normal')}
</ToggleButton>
</HStack>
<>
<div>
<div>
<H
lvl={3}
style={{
marginBottom: '1rem',
}}
variant="bodyXsBold"
>
{t('blur.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPending}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPending}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
>
<BlurOnStrong />
</ToggleButton>
</div>
</div>
<div
className={css({
marginTop: '1.5rem',
})}
>
<H
lvl={3}
style={{
marginBottom: '1rem',
}}
variant="bodyXsBold"
>
{t('virtual.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
flexWrap: 'wrap',
})}
>
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
return (
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPending}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
></ToggleButton>
)
})}
</div>
</div>
</div>
<Information className={css({ marginTop: '1rem' })}>
<Text variant="sm"> {t('experimental')}</Text>
</Information>
</>
) : (
<Text variant="sm">{t('notAvailable')}</Text>
<Information>
<Text variant="sm">{t('notAvailable')}</Text>
</Information>
)}
<Information>
<Text variant="sm"> {t('experimental')}</Text>
</Information>
</div>
</div>
)
@@ -31,17 +31,7 @@ export const useSupport = ({ id }: useSupportProps) => {
return null
}
// Some users may block Crisp chat widget with browser ad blockers or anti-tracking plugins
// So we need to safely check if Crisp is available and not blocked
const isCrispAvailable = () => {
try {
return !!window?.$crisp?.is
} catch {
return false
}
}
export const useIsSupportEnabled = () => {
const { data } = useConfig()
return !!data?.support?.id && isCrispAvailable()
return !!data?.support?.id
}
+12 -1
View File
@@ -242,7 +242,7 @@ export const Footer = () => {
{t('links.data')}
</A>
</StyledLi>
<StyledLi>
<StyledLi divider>
<A
externalIcon
underline={false}
@@ -255,6 +255,17 @@ export const Footer = () => {
{t('links.accessibility')}
</A>
</StyledLi>
<StyledLi>
<A
externalIcon
underline={false}
footer="minor"
href="https://github.com/numerique-gouv/meet/"
aria-label={t('links.code') + ' - ' + t('links.ariaLabel')}
>
{t('links.code')}
</A>
</StyledLi>
</SecondRow>
<ThirdRow>
{t('mentions')}{' '}
+2 -1
View File
@@ -34,7 +34,8 @@
"legalsTerms": "",
"data": "",
"accessibility": "",
"ariaLabel": ""
"ariaLabel": "",
"code": ""
},
"mentions": "",
"license": ""
+11
View File
@@ -19,6 +19,11 @@
"label": ""
},
"heading": "",
"effects": {
"description": "",
"title": "",
"subTitle": ""
},
"joinLabel": "",
"joinMeeting": "",
"toggleOff": "",
@@ -102,11 +107,17 @@
"notAvailable": "",
"heading": "",
"blur": {
"title": "",
"light": "",
"normal": "",
"apply": "",
"clear": ""
},
"virtual": {
"title": "",
"apply": "",
"clear": ""
},
"experimental": ""
},
"sidePanel": {
+2 -1
View File
@@ -34,7 +34,8 @@
"legalsTerms": "Legal Notice",
"data": "Personal Data and Cookies",
"accessibility": "Accessibility: audit in progress",
"ariaLabel": "new window"
"ariaLabel": "new window",
"code": "Open Source Code Repository"
},
"mentions": "Unless otherwise stated, the contents of this site are available under",
"license": "etalab 2.0 license"
+9 -2
View File
@@ -20,7 +20,8 @@
},
"effects": {
"description": "Apply effects",
"title": "Effects"
"title": "Effects",
"subTitle": "Configure your camera's effects."
},
"heading": "Join the meeting",
"joinLabel": "Join",
@@ -102,14 +103,20 @@
},
"effects": {
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
"notAvailable": "Video effects will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
"heading": "Blur",
"blur": {
"title": "Background blur",
"light": "Light blur",
"normal": "Blur",
"apply": "Enable blur",
"clear": "Disable blur"
},
"virtual": {
"title": "Virtual background",
"apply": "Enable virtual background",
"clear": "Disable virtual background"
},
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
},
"sidePanel": {
+2 -1
View File
@@ -34,7 +34,8 @@
"legalsTerms": "Mentions légales",
"data": "Données personnelles et cookie",
"accessibility": "Accessibilités : audit en cours",
"ariaLabel": "nouvelle fenêtre"
"ariaLabel": "nouvelle fenêtre",
"code": "Dépôt de code Open Source"
},
"mentions": "Sauf mention contraire, les contenus de ce site sont disponibles sous",
"license": "licence etalab 2.0"
+9 -2
View File
@@ -21,7 +21,8 @@
"heading": "Rejoindre la réunion",
"effects": {
"description": "Appliquer des effets",
"title": "Effets"
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
"joinLabel": "Rejoindre",
"joinMeeting": "Rejoindre la réjoindre",
@@ -102,14 +103,20 @@
},
"effects": {
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
"notAvailable": "Les effets vidéo seront bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
"heading": "Flou",
"blur": {
"title": "Flou d'arrière-plan",
"light": "Léger flou",
"normal": "Flou",
"apply": "Appliquer le flou",
"clear": "Désactiver le flou"
},
"virtual": {
"title": "Arrière-plan virtuel",
"apply": "Appliquer l'arrière plan virtuel",
"clear": "Désactiver l'arrière plan virtuel"
},
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet sur tous les navigateurs et une meilleur qualité."
},
"sidePanel": {
+8
View File
@@ -28,6 +28,14 @@ export const text = cva({
paddingTop: 'heading',
},
},
subTitle: {
fontSize: '1rem',
color: 'greyscale.600',
},
bodyXsBold: {
textStyle: 'body',
fontWeight: 'bold',
},
body: {
textStyle: 'body',
},
+1 -1
View File
@@ -27,7 +27,7 @@ export const ToggleButton = ({
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={buttonRecipe(variantProps)}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
@@ -86,6 +86,22 @@ export const buttonRecipe = cva({
backgroundColor: 'greyscale.100/50',
},
},
bigSquare: {
width: '56px',
height: '56px',
borderColor: 'greyscale.200',
borderRadius: '4px',
backgroundColor: 'greyscale.50',
padding: '0',
flexShrink: 0,
'&[data-hovered]': {
backgroundColor: 'greyscale.100',
},
transition: 'box-shadow 0.2s ease-in-out',
'&[data-selected]': {
boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
},
},
tertiary: {
backgroundColor: 'primary.100',
color: 'primary.800',