Compare commits

...

18 Commits

Author SHA1 Message Date
Jacques ROUSSEL 1280e806bf 💚(github) remove secret fetch
The secrets are not managed in the folder anymore.
2025-02-05 11:24:40 +01:00
lebaudantoine 6e0948c696 🩹(frontend) prevent support toggle when Crisp chat is blocked
Some users have ad-blockers or privacy extensions that prevent the Crisp chat
widget script from loading properly. This was causing the support toggle to
still display in rooms, but clicking it had no effect since Crisp was blocked.

This ensures users don't see an inactive support button when their browser
is blocking Crisp functionality.
2025-01-31 17:01:57 +01:00
Nathan Vasse eff0cb4afb (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 16:52:44 +01:00
Nathan Vasse 08c5245b48 (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 16:52:44 +01:00
Nathan Vasse 465bf293f0 (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 16:52:44 +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
Nathan Vasse 206e74c645 (front) add blurring feature on join
This adds a button that opens a modal that allow user to enable
video effects on join screen.
2025-01-31 11:42:28 +01:00
Nathan Vasse 9ebfc8ea29 ♻️(front) refactor Effects
We need to have an agnostic component to apply effects on any video
track, enters EffectsConfiguration. It takes in input the video
track and outputs the processor applied. This is gonna be used in
order to use the same component on the pre join screen and the in
room customization side panel.
2025-01-31 11:42:28 +01:00
Nathan Vasse 03796fcbb2 (front) add button white variant
This variant is gonna be used on overlay over a video element.
2025-01-31 11:42:28 +01:00
Nathan Vasse 4b6bd3d1c8 ♻️(front) enable custom size on Dialog
We want to have a Dialog that is constrained in width. Thus,
introducing this variant enables this possibility and many more
in the future.
2025-01-31 11:42:28 +01:00
Nathan Vasse d45880ab5c ♻️(front) add clone method to processors
We need to make new processor instance between pre join and room
in order to apply effects.
2025-01-31 11:42:28 +01:00
lebaudantoine 4ae3d965f9 🚸(frontend) show support toggle only if support is enabled
Avoid misleading the user, if support is not enabled, as it's optional,
avoid displaying a useless control button.

Requested by Dutch counterparts.
2025-01-29 16:12:47 +01:00
lebaudantoine 8eab45b6d5 🩹(frontend) prevent runtime error when Crisp is not initialized
Prevents runtime error when Crisp chat hasn't initialized before component mount
Previously caused crash since we assumed $crisp global was always available.
2025-01-29 16:12:47 +01:00
50 changed files with 997 additions and 341 deletions
+4 -21
View File
@@ -11,34 +11,17 @@ jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
start-test-on-preprod:
needs:
+5 -74
View File
@@ -19,26 +19,9 @@ jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -48,7 +31,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -69,26 +52,9 @@ jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -98,7 +64,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -120,26 +86,9 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -149,7 +98,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
@@ -162,7 +111,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend
@@ -172,29 +120,12 @@ jobs:
if: |
github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
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
@@ -9,7 +9,6 @@ import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { ProConnectButton } from '@/components/ProConnectButton'
import { useCreateRoom } from '@/features/rooms'
import { usePersistentUserChoices } from '@livekit/components-react'
import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
@@ -18,6 +17,7 @@ import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
import { LiveKitRoom } from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -12,10 +12,11 @@ import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
import { css } from '@/styled-system/css'
import { LocalUserChoices } from '../routes/Room'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
export const Conference = ({
roomId,
@@ -111,7 +112,13 @@ export const Conference = ({
token={data?.livekit?.token}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
video={
userConfig.videoEnabled && {
processor: BackgroundProcessorFactory.deserializeProcessor(
userConfig.processorSerialized
),
}
}
connectOptions={connectOptions}
className={css({
backgroundColor: 'primaryDark.50 !important',
@@ -1,9 +1,5 @@
import { useTranslation } from 'react-i18next'
import {
usePersistentUserChoices,
usePreviewTracks,
type LocalUserChoices,
} from '@livekit/components-react'
import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useMemo, useEffect, useRef, useState } from 'react'
@@ -11,11 +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 { LocalUserChoices } from '../routes/Room'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import {
EffectsConfiguration,
EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
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,
}: {
@@ -28,8 +109,11 @@ export const Join = ({
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices({})
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const [audioDeviceId, setAudioDeviceId] = useState<string>(
initialUserChoices.audioDeviceId
)
@@ -37,6 +121,11 @@ export const Join = ({
initialUserChoices.videoDeviceId
)
const [username, setUsername] = useState<string>(initialUserChoices.username)
const [processor, setProcessor] = useState(
BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.processorSerialized
)
)
useEffect(() => {
saveAudioInputDeviceId(audioDeviceId)
@@ -50,8 +139,14 @@ export const Join = ({
saveUsername(username)
}, [username, saveUsername])
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
{
@@ -107,9 +202,20 @@ export const Join = ({
audioDeviceId,
videoDeviceId,
username,
processorSerialized: processor?.serialize(),
})
}
// 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.
useEffect(() => {
if (processor && videoTrack && !videoTrack.getProcessor()) {
videoTrack.setProcessor(processor)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack])
return (
<Screen footer={false}>
<div
@@ -174,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
@@ -204,8 +313,8 @@ export const Join = ({
</p>
</div>
)}
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
source={Track.Source.Microphone}
@@ -1,188 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { Text, P, ToggleButton, Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import {
BackgroundBlurFactory,
BackgroundBlurProcessorInterface,
} from './blur/index'
const Information = styled('div', {
base: {
backgroundColor: 'orange.50',
borderRadius: '4px',
padding: '0.75rem 0.75rem',
marginTop: '0.8rem',
alignItems: 'start',
},
})
enum BlurRadius {
NONE = 0,
LIGHT = 5,
NORMAL = 10,
}
const isSupported = BackgroundBlurFactory.isSupported()
export const Effects = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { isCameraEnabled, cameraTrack, localParticipant } =
useLocalParticipant()
const videoRef = useRef<HTMLVideoElement>(null)
const [processorPending, setProcessorPending] = useState(false)
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const getProcessor = () => {
return localCameraTrack?.getProcessor() as BackgroundBlurProcessorInterface
}
const getBlurRadius = (): BlurRadius => {
const processor = getProcessor()
return processor?.options.blurRadius || BlurRadius.NONE
}
const toggleBlur = async (blurRadius: number) => {
if (!isCameraEnabled) await localParticipant.setCameraEnabled(true)
if (!localCameraTrack) return
setProcessorPending(true)
const processor = getProcessor()
const currentBlurRadius = getBlurRadius()
try {
if (blurRadius == currentBlurRadius && processor) {
await localCameraTrack.stopProcessor()
} else if (!processor) {
await localCameraTrack.setProcessor(
BackgroundBlurFactory.getProcessor({ blurRadius })!
)
} else {
processor?.update({ blurRadius })
}
} catch (error) {
console.error('Error applying blur:', error)
} finally {
setProcessorPending(false)
}
}
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return
const attachVideoTrack = async () => localCameraTrack?.attach(videoElement)
attachVideoTrack()
return () => {
if (!videoElement) return
localCameraTrack.detach(videoElement)
}
}, [localCameraTrack, isCameraEnabled])
const isSelected = (blurRadius: BlurRadius) => {
return isCameraEnabled && getBlurRadius() == blurRadius
}
const tooltipLabel = (blurRadius: BlurRadius) => {
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
}
return (
<VStack padding="0 1.5rem" overflowY="scroll">
{localCameraTrack && isCameraEnabled ? (
<video
ref={videoRef}
width="100%"
muted
style={{
transform: 'rotateY(180deg)',
minHeight: '175px',
borderRadius: '8px',
}}
/>
) : (
<div
style={{
width: '100%',
height: '174px',
display: 'flex',
backgroundColor: 'black',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<P
style={{
color: 'white',
textAlign: 'center',
textWrap: 'balance',
marginBottom: 0,
}}
>
{t('activateCamera')}
</P>
</div>
)}
<Div
alignItems={'left'}
width={'100%'}
style={{
border: '1px solid #dadce0',
borderRadius: '8px',
margin: '0 .625rem',
padding: '0.5rem 1rem',
}}
>
<H
lvl={3}
style={{
marginBottom: '0.4rem',
}}
>
{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>
) : (
<Text variant="sm">{t('notAvailable')}</Text>
)}
<Information>
<Text
variant="sm"
style={{
textWrap: 'balance',
}}
>
{t('experimental')}
</Text>
</Information>
</Div>
</VStack>
)
}
@@ -8,9 +8,9 @@ import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
import { Transcript } from './Transcript'
import { Effects } from './effects/Effects'
type StyledSidePanelProps = {
title: string
@@ -4,7 +4,11 @@ import {
ProcessorWrapper,
} from '@livekit/track-processors'
import { ProcessorOptions, Track } from 'livekit-client'
import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
import {
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
/**
* This is simply a wrapper around track-processor-js Processor
@@ -12,14 +16,17 @@ import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
* used accross the project.
*/
export class BackgroundBlurTrackProcessorJsWrapper
implements BackgroundBlurProcessorInterface
implements BackgroundProcessorInterface
{
name: string = 'Blur'
name: string = 'blur'
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
constructor(opts: BackgroundOptions) {
this.processor = BackgroundBlur(opts.blurRadius)
this.opts = opts
}
async init(opts: ProcessorOptions<Track.Kind>) {
@@ -45,4 +52,17 @@ export class BackgroundBlurTrackProcessorJsWrapper
get options() {
return (this.processor.transformer as BackgroundTransformer).options
}
clone() {
return new BackgroundBlurTrackProcessorJsWrapper({
blurRadius: this.options!.blurRadius,
})
}
serialize() {
return {
type: ProcessorType.BLUR,
options: this.options,
}
}
}
@@ -11,7 +11,11 @@ import {
TIMEOUT_TICK,
timerWorkerScript,
} from './TimerWorker'
import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
import {
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -28,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
@@ -59,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() {
@@ -77,6 +88,7 @@ export class BackgroundBlurCustomProcessor
this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement
this._initVirtualBackgroundImage()
this._createMainCanvas()
this._createMaskCanvas()
@@ -94,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() {
@@ -197,6 +222,7 @@ export class BackgroundBlurCustomProcessor
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
@@ -209,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,
@@ -278,4 +354,15 @@ export class BackgroundBlurCustomProcessor
this.timerWorker?.terminate()
this.imageSegmenter?.close()
}
clone() {
return new BackgroundCustomProcessor(this.options)
}
serialize() {
return {
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,32 +1,63 @@
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 BackgroundBlurProcessorInterface
extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): void
export interface ProcessorSerialized {
type: ProcessorType
options: BackgroundOptions
}
export class BackgroundBlurFactory {
export interface BackgroundProcessorInterface
extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): void
options: BackgroundOptions
clone(): BackgroundProcessorInterface
serialize(): ProcessorSerialized
}
export enum ProcessorType {
BLUR = 'blur',
VIRTUAL = 'virtual',
}
export class BackgroundProcessorFactory {
static isSupported() {
return (
ProcessorWrapper.isSupported || BackgroundBlurCustomProcessor.isSupported
)
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
}
static getProcessor(opts: BackgroundOptions) {
if (ProcessorWrapper.isSupported) {
return new BackgroundBlurTrackProcessorJsWrapper(opts)
static getProcessor(
type: ProcessorType,
opts: BackgroundOptions
): 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)
}
}
if (BackgroundBlurCustomProcessor.isSupported) {
return new BackgroundBlurCustomProcessor(opts)
return undefined
}
static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type) {
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
}
return null
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>
)
@@ -13,7 +13,12 @@ import {
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client'
import {
LocalAudioTrack,
LocalVideoTrack,
Track,
VideoCaptureOptions,
} from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
@@ -21,6 +26,8 @@ import { ToggleDevice } from '@/features/rooms/livekit/components/controls/Toggl
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
export type ToggleSource = Exclude<
Track.Source,
@@ -92,6 +99,39 @@ export const SelectToggleDevice = <T extends ToggleSource>({
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const trackProps = useTrackToggle(props)
const { userChoices } = usePersistentUserChoices({})
const toggle = () => {
if (props.source === Track.Source.Camera) {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
} else {
trackProps.toggle()
}
}
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: config.kind, track })
@@ -120,6 +160,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
{...trackProps}
config={config}
variant={variant}
toggle={toggle}
toggleButtonProps={{
...(hideMenu
? {
@@ -4,10 +4,14 @@ import { useTranslation } from 'react-i18next'
import { Crisp } from 'crisp-sdk-web'
import { useEffect, useState } from 'react'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
const [isOpened, setIsOpened] = useState(() => {
return window?.$crisp?.is?.('chat:opened') || false
})
useEffect(() => {
if (!Crisp) {
@@ -26,6 +30,12 @@ export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
}
}, [])
const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled) {
return
}
return (
<ToggleButton
square
@@ -0,0 +1,27 @@
import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { css } from '@/styled-system/css'
import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
return (
<div
className={css({
padding: '0 1.5rem',
})}
>
<EffectsConfiguration
videoTrack={localCameraTrack}
layout="vertical"
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
)
}
@@ -0,0 +1,316 @@
import { LocalVideoTrack } from 'livekit-client'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
BackgroundProcessorFactory,
BackgroundProcessorInterface,
ProcessorType,
} from '../blur'
import { css } from '@/styled-system/css'
import { Text, P, ToggleButton, H } from '@/primitives'
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,
LIGHT = 5,
NORMAL = 10,
}
const isSupported = BackgroundProcessorFactory.isSupported()
const Information = styled('div', {
base: {
backgroundColor: 'orange.50',
borderRadius: '4px',
padding: '0.75rem 0.75rem',
alignItems: 'start',
},
})
export type EffectsConfigurationProps = {
videoTrack: LocalVideoTrack
onSubmit?: (processor?: BackgroundProcessorInterface) => void
layout?: 'vertical' | 'horizontal'
}
export const EffectsConfiguration = ({
videoTrack,
onSubmit,
layout = 'horizontal',
}: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const [processorPending, setProcessorPending] = useState(false)
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return
const attachVideoTrack = async () => videoTrack?.attach(videoElement)
attachVideoTrack()
return () => {
if (!videoElement) return
videoTrack.detach(videoElement)
}
}, [videoTrack, videoTrack?.isMuted])
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
if (!videoTrack) return
setProcessorPending(true)
const processor = getProcessor()
try {
if (isSelected(type, options)) {
// Stop processor.
await videoTrack.stopProcessor()
onSubmit?.(undefined)
} else if (!processor || processor.serialize().type !== type) {
// Change processor.
const newProcessor = BackgroundProcessorFactory.getProcessor(
type,
options
)!
await videoTrack.setProcessor(newProcessor)
onSubmit?.(newProcessor)
} else {
// 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 {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
}
}
const getProcessor = () => {
return videoTrack?.getProcessor() as BackgroundProcessorInterface
}
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
const processor = getProcessor()
const processorSerialized = processor?.serialize()
return (
!!processor &&
processorSerialized.type === type &&
JSON.stringify(processorSerialized.options) === JSON.stringify(options)
)
}
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
return (
<div
className={css(
layout === 'vertical'
? {
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
}
: {
display: 'flex',
gap: '1.5rem',
flexDirection: 'column',
md: {
flexDirection: 'row',
overflow: 'hidden',
},
}
)}
>
<div
className={css({
width: '100%',
aspectRatio: 16 / 9,
})}
>
{videoTrack && !videoTrack.isMuted ? (
<video
ref={videoRef}
width="100%"
muted
style={{
transform: 'rotateY(180deg)',
minHeight: '175px',
borderRadius: '8px',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: 'black',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<P
style={{
color: 'white',
textAlign: 'center',
textWrap: 'balance',
marginBottom: 0,
}}
>
{t('activateCamera')}
</P>
</div>
)}
</div>
<div
className={css(
layout === 'horizontal'
? {
md: {
borderLeft: '1px solid #dadce0',
paddingLeft: '1.5rem',
width: '420px',
flexShrink: 0,
},
}
: {}
)}
>
{isSupported ? (
<>
<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>
</>
) : (
<Information>
<Text variant="sm">{t('notAvailable')}</Text>
</Information>
)}
</div>
</div>
)
}
@@ -0,0 +1,56 @@
import { UsePersistentUserChoicesOptions } from '@livekit/components-react'
import React from 'react'
import { LocalUserChoices } from '../../routes/Room'
import { saveUserChoices, loadUserChoices } from '@livekit/components-core'
import { ProcessorSerialized } from '../components/blur'
/**
* From @livekit/component-react
*
* A hook that provides access to user choices stored in local storage, such as
* selected media devices and their current state (on or off), as well as the user name.
* @alpha
*/
export function usePersistentUserChoices(
options: UsePersistentUserChoicesOptions = {}
) {
const [userChoices, setSettings] = React.useState<LocalUserChoices>(
loadUserChoices(options.defaults, options.preventLoad ?? false)
)
const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, audioEnabled: isEnabled }))
}, [])
const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, videoEnabled: isEnabled }))
}, [])
const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, audioDeviceId: deviceId }))
}, [])
const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, videoDeviceId: deviceId }))
}, [])
const saveUsername = React.useCallback((username: string) => {
setSettings((prev) => ({ ...prev, username: username }))
}, [])
const saveProcessorSerialized = React.useCallback(
(processorSerialized?: ProcessorSerialized) => {
setSettings((prev) => ({ ...prev, processorSerialized }))
},
[]
)
React.useEffect(() => {
saveUserChoices(userChoices, options.preventSave ?? false)
}, [userChoices, options.preventSave])
return {
userChoices,
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
}
}
@@ -1,11 +1,11 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { MobileControlBar } from './MobileControlBar'
import { DesktopControlBar } from './DesktopControlBar'
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
import { useIsMobile } from '@/utils/useIsMobile'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
/** @public */
export type ControlBarControls = {
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices,
type LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
@@ -9,6 +9,11 @@ import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import { ProcessorSerialized } from '../livekit/components/blur'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
}
export const Room = () => {
const { isLoggedIn } = useUser()
@@ -1,15 +1,13 @@
import { A, Badge, Button, DialogProps, Field, H, P } from '@/primitives'
import { Trans, useTranslation } from 'react-i18next'
import {
usePersistentUserChoices,
useRoomContext,
} from '@livekit/components-react'
import { useRoomContext } from '@livekit/components-react'
import { logoutUrl, useUser } from '@/features/auth'
import { css } from '@/styled-system/css'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { HStack } from '@/styled-system/jsx'
import { useState } from 'react'
import { ProConnectButton } from '@/components/ProConnectButton'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { ApiUser } from '@/features/auth/api/ApiUser'
import { useConfig } from '@/api/useConfig'
export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
@@ -29,3 +30,18 @@ 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()
}
+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"
+12 -1
View File
@@ -18,6 +18,11 @@
"enable": "Enable microphone",
"label": "Microphone"
},
"effects": {
"description": "Apply effects",
"title": "Effects",
"subTitle": "Configure your camera's effects."
},
"heading": "Join the meeting",
"joinLabel": "Join",
"joinMeeting": "Join meeting",
@@ -98,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"
+12 -1
View File
@@ -19,6 +19,11 @@
"label": "Microphone"
},
"heading": "Rejoindre la réunion",
"effects": {
"description": "Appliquer des effets",
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
"joinLabel": "Rejoindre",
"joinMeeting": "Rejoindre la réjoindre",
"toggleOff": "Cliquez pour désactiver",
@@ -98,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": {
+31 -3
View File
@@ -11,6 +11,7 @@ import {
import { Div, Button, Box, VerticallyOffCenter } from '@/primitives'
import { text } from './Text'
import { MutableRefObject } from 'react'
import { css } from '@/styled-system/css'
const StyledModalOverlay = styled(ModalOverlay, {
base: {
@@ -48,6 +49,24 @@ const StyledRACDialog = styled(RACDialog, {
},
})
const ModalContent = styled('div', {
base: {
margin: 'auto',
},
variants: {
size: {
full: {
width: 'fit-content',
maxWidth: '100%',
},
large: {
width: '100%',
xl: { width: '1200px' },
},
},
},
})
export type DialogProps = RACDialogProps & {
title?: string
onClose?: () => void
@@ -63,6 +82,7 @@ export type DialogProps = RACDialogProps & {
onOpenChange?: (isOpen: boolean) => void
type?: 'flex'
innerRef?: MutableRefObject<HTMLDivElement | null>
size?: 'full' | 'large'
}
export const Dialog = ({
@@ -72,6 +92,7 @@ export const Dialog = ({
isOpen,
onOpenChange,
innerRef,
size = 'full',
...dialogProps
}: DialogProps) => {
const isAlert = dialogProps['role'] === 'alertdialog'
@@ -94,9 +115,16 @@ export const Dialog = ({
<StyledRACDialog {...dialogProps}>
{({ close }) => (
<VerticallyOffCenter>
<Div margin="auto" width="fit-content" maxWidth="full">
<ModalContent size={size}>
<Div margin="1rem" pointerEvents="auto">
<Box size="sm" type={boxType} ref={innerRef}>
<Box
size="sm"
type={boxType}
ref={innerRef}
className={css({
padding: '1.5rem',
})}
>
{!!title && (
<Heading
slot="title"
@@ -124,7 +152,7 @@ export const Dialog = ({
)}
</Box>
</Div>
</Div>
</ModalContent>
</VerticallyOffCenter>
)}
</StyledRACDialog>
+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}
@@ -73,6 +73,35 @@ export const buttonRecipe = cva({
color: 'greyscale.400',
},
},
whiteCircle: {
color: 'white',
border: '1px white solid',
width: '56px',
height: '56px',
borderRadius: '100%',
'&[data-hovered]': {
backgroundColor: 'greyscale.100/20',
},
'&[data-pressed]': {
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',