Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3adb5a897b | |||
| 9295d3bc2c | |||
| c8ef7b37f3 | |||
| db51ca1aa5 | |||
| 6935001aab | |||
| bbc2b1d9f6 | |||
| 12e2149b9e | |||
| 3bd9363879 | |||
| b48135c3b6 | |||
| 206e74c645 | |||
| 9ebfc8ea29 | |||
| 03796fcbb2 | |||
| 4b6bd3d1c8 | |||
| d45880ab5c | |||
| 4ae3d965f9 | |||
| 8eab45b6d5 | |||
| 4347d87f33 | |||
| 1b52d76168 | |||
| a44b6e8e34 | |||
| 126de638cd | |||
| bf28c5cb84 | |||
| 4f0e7d2c52 | |||
| 3ab5c48296 | |||
| 7f1f573af8 | |||
| d48a18b36b | |||
| eac9158734 | |||
| 994f335266 | |||
| 30bde2fe66 |
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.36.2",
|
||||
"boto3==1.36.6",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
@@ -83,7 +83,7 @@ dev = [
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.6",
|
||||
"ruff==0.9.2",
|
||||
"ruff==0.9.3",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"name": "meet",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.0",
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.51.1",
|
||||
@@ -17,25 +17,25 @@
|
||||
"@tanstack/react-query": "5.64.2",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.1",
|
||||
"i18next": "24.2.2",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-parser": "9.1.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.8.1",
|
||||
"posthog-js": "1.207.0",
|
||||
"posthog-js": "1.210.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "2.1.2",
|
||||
"wouter": "3.3.5"
|
||||
"valtio": "2.1.3",
|
||||
"wouter": "3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.51.1",
|
||||
"@tanstack/eslint-plugin-query": "5.64.2",
|
||||
"@tanstack/react-query-devtools": "5.64.2",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.10",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.21.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"postcss": "8.5.1",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.10",
|
||||
"vite": "6.0.11",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -436,6 +436,7 @@
|
||||
},
|
||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1240,9 +1241,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/components-react": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.0.tgz",
|
||||
"integrity": "sha512-Zgt2zv+j2gWJad0mcTDR8cS6KQNXYfAeOL9Si5Lb2Q2k3iPUJinPDMqubM6btnduhkPjy+EF4kA8bNAY0xu4Ig==",
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.1.tgz",
|
||||
"integrity": "sha512-XpuDu7iDMcN4pkV8CYNzHf9hLNdYOeEtbmCr7Zesy6Au3BxUl4aS1Ajmg0b75Rx7zTlkyCJt9Lm4VrEqbJCI6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/components-core": "0.12.1",
|
||||
@@ -3879,9 +3880,9 @@
|
||||
"integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
|
||||
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
|
||||
"version": "22.10.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz",
|
||||
"integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6395,9 +6396,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "24.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.1.tgz",
|
||||
"integrity": "sha512-Q2wC1TjWcSikn1VAJg13UGIjc+okpFxQTxjVAymOnSA3RpttBQNMPf2ovcgoFVsV4QNxTfNZMAxorXZXsk4fBA==",
|
||||
"version": "24.2.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.2.tgz",
|
||||
"integrity": "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -8444,9 +8445,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.207.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.207.0.tgz",
|
||||
"integrity": "sha512-Sx+xamhg1/iKGAtUNh3uAUtAAza4j/yBhxcfUxfqR++WrZdw0V6nmh7LSfVNl7+QVl2qmiPSoZA7z+5ojaWDDQ==",
|
||||
"version": "1.210.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.210.2.tgz",
|
||||
"integrity": "sha512-rIbn/h9ur7uA0PS4dClOr9w6txLfHS94yh9yafA5VM2eXToM951XtMYtIQ6bi6wFzpvpFvTQFeYLQ/9/xZ59AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
@@ -8503,9 +8504,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-compare": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz",
|
||||
"integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w=="
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
|
||||
"integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -9791,12 +9793,12 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/valtio": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.2.tgz",
|
||||
"integrity": "sha512-fhekN5Rq7dvHULHHBlJeXHrQDl0Jj9GXfNavCm3gkD06crGchaG1nf/J7gSlfZU2wPcRdVS5jBKWHtE2NNz97A==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.3.tgz",
|
||||
"integrity": "sha512-q+dVczY5zf7kO39JPVq2qR0VXMeEpF1t+P1njHU7hAhwgtyVa/p38YmgyHZq2IQYwnkNrkvh412k4sfDmcIJ2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"proxy-compare": "^3.0.0"
|
||||
"proxy-compare": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
@@ -9890,9 +9892,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.10.tgz",
|
||||
"integrity": "sha512-MEszunEcMo6pFsfXN1GhCFQqnE25tWRH0MA4f0Q7uanACi4y1Us+ZGpTMnITwCTnYzB2b9cpmnelTlxgTBmaBA==",
|
||||
"version": "6.0.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz",
|
||||
"integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10582,9 +10584,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wouter": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz",
|
||||
"integrity": "sha512-bx3fLQAMn+EhYbBdY3W1gw9ZfO/uchudxYMwOIBzF3HVgqNEEIT199vEoh7FLTC0Vz5+rpMO6NdFsOkGX1QQCw==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.4.0.tgz",
|
||||
"integrity": "sha512-Qx4L+EnYk7AF+pNU9TM+lQtkceL5Qm+oDZU9720DUx+/7K6v7fnlHnRLI78KjEbCqdGFeroW5P0M1t8WYSloQA==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"regexparam": "^3.0.0",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.0",
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.51.1",
|
||||
@@ -22,25 +22,25 @@
|
||||
"@tanstack/react-query": "5.64.2",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.1",
|
||||
"i18next": "24.2.2",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-parser": "9.1.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.8.1",
|
||||
"posthog-js": "1.207.0",
|
||||
"posthog-js": "1.210.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "2.1.2",
|
||||
"wouter": "3.3.5"
|
||||
"valtio": "2.1.3",
|
||||
"wouter": "3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.51.1",
|
||||
"@tanstack/eslint-plugin-query": "5.64.2",
|
||||
"@tanstack/react-query-devtools": "5.64.2",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/node": "22.10.10",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.21.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
"postcss": "8.5.1",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.10",
|
||||
"vite": "6.0.11",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 955 KiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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 (
|
||||
@@ -32,10 +32,10 @@ const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
justifyContent: 'normal',
|
||||
padding: '0 1rem',
|
||||
width: 'calc(100% - 2rem)',
|
||||
'@media(prefers-reduced-motion: reduce)': {
|
||||
_motionReduce: {
|
||||
opacity: 1,
|
||||
},
|
||||
'@media(prefers-reduced-motion: no-preference)': {
|
||||
_motionSafe: {
|
||||
opacity: 0,
|
||||
animation: '.5s ease-in fade 0s forwards',
|
||||
},
|
||||
|
||||
@@ -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,30 +1,390 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
||||
import { usePreviewTracks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useMemo, useEffect, useRef, useState } from 'react'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||
import { Field } from '@/primitives/Field'
|
||||
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,
|
||||
}: {
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { user } = useUser()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const {
|
||||
userChoices: initialUserChoices,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
saveUsername,
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices({})
|
||||
|
||||
const [audioEnabled, setAudioEnabled] = useState(true)
|
||||
const [videoEnabled, setVideoEnabled] = useState(true)
|
||||
const [audioDeviceId, setAudioDeviceId] = useState<string>(
|
||||
initialUserChoices.audioDeviceId
|
||||
)
|
||||
const [videoDeviceId, setVideoDeviceId] = useState<string>(
|
||||
initialUserChoices.videoDeviceId
|
||||
)
|
||||
const [username, setUsername] = useState<string>(initialUserChoices.username)
|
||||
const [processor, setProcessor] = useState(
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
initialUserChoices.processorSerialized
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
saveAudioInputDeviceId(audioDeviceId)
|
||||
}, [audioDeviceId, saveAudioInputDeviceId])
|
||||
|
||||
useEffect(() => {
|
||||
saveVideoInputDeviceId(videoDeviceId)
|
||||
}, [videoDeviceId, saveVideoInputDeviceId])
|
||||
|
||||
useEffect(() => {
|
||||
saveUsername(username)
|
||||
}, [username, saveUsername])
|
||||
|
||||
useEffect(() => {
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}, [
|
||||
processor,
|
||||
saveProcessorSerialized,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(processor?.serialize()),
|
||||
])
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
audio: { deviceId: initialUserChoices.audioDeviceId },
|
||||
video: { deviceId: initialUserChoices.videoDeviceId },
|
||||
},
|
||||
onError
|
||||
)
|
||||
|
||||
const videoTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Video
|
||||
)[0] as LocalVideoTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const audioTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Audio
|
||||
)[0] as LocalVideoTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const videoEl = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoEl.current as HTMLVideoElement | null
|
||||
|
||||
const handleVideoLoaded = () => {
|
||||
if (videoElement) {
|
||||
videoElement.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
if (videoElement && videoTrack && videoEnabled) {
|
||||
videoTrack.unmute()
|
||||
videoTrack.attach(videoElement)
|
||||
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
|
||||
}
|
||||
|
||||
return () => {
|
||||
videoTrack?.detach()
|
||||
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
function handleSubmit() {
|
||||
onSubmit({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
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 layout="centered" footer={false}>
|
||||
<CenteredContent title={t('join.heading')}>
|
||||
<PreJoin
|
||||
persistUserChoices
|
||||
onSubmit={onSubmit}
|
||||
micLabel={t('join.audioinput.label')}
|
||||
camLabel={t('join.videoinput.label')}
|
||||
joinLabel={t('join.joinLabel')}
|
||||
userLabel={t('join.usernameLabel')}
|
||||
defaults={{ username: user?.full_name }}
|
||||
/>
|
||||
</CenteredContent>
|
||||
<Screen footer={false}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
lg: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
height: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2rem',
|
||||
padding: '0 2rem',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
width: 'auto',
|
||||
height: '570px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
lg: {
|
||||
width: '740px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
borderRadius: '1rem',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: 16 / 9,
|
||||
'--tw-shadow':
|
||||
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
|
||||
'--tw-shadow-colored':
|
||||
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
||||
boxShadow:
|
||||
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
|
||||
backgroundColor: 'black',
|
||||
})}
|
||||
>
|
||||
{videoTrack && videoEnabled ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
ref={videoEl}
|
||||
width="1280"
|
||||
height="720"
|
||||
className={css({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transform: 'rotateY(180deg)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
borderRadius: '1rem',
|
||||
})}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
fontWeight: '300',
|
||||
})}
|
||||
>
|
||||
{!videoEnabled && t('cameraDisabled')}
|
||||
{videoEnabled && !videoTrack && t('cameraStarting')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
|
||||
</div>
|
||||
<HStack justify="center" padding={1.5}>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
initialState={audioEnabled}
|
||||
track={audioTrack}
|
||||
initialDeviceId={initialUserChoices.audioDeviceId}
|
||||
onChange={(enabled) => setAudioEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setAudioDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
initialState={videoEnabled}
|
||||
track={videoTrack}
|
||||
initialDeviceId={initialUserChoices.videoDeviceId}
|
||||
onChange={(enabled) => setVideoEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setVideoDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
padding: '0',
|
||||
sm: {
|
||||
width: '448px',
|
||||
padding: '0 3rem 9rem 3rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={t('joinLabel')}
|
||||
submitButtonProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
>
|
||||
<VStack marginBottom={1}>
|
||||
<H lvl={1} margin={false}>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
</VStack>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
|
||||
export const EffectsMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleEffects } = useSidePanel()
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onAction={() => toggleEffects()}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiImageCircleAiFill size={20} />
|
||||
{t('effects')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RiMegaphoneLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={GRIST_FORM}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
{t('feedback')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useFullScreen } from '../../../hooks/useFullScreen'
|
||||
|
||||
export const FullScreenMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleFullScreen, isCurrentlyFullscreen, isFullscreenAvailable } =
|
||||
useFullScreen()
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onAction={() => toggleFullScreen()}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
{isCurrentlyFullscreen ? (
|
||||
<>
|
||||
<RiFullscreenExitLine size={20} />
|
||||
{t('fullscreen.exit')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFullscreenLine size={20} />
|
||||
{t('fullscreen.enter')}
|
||||
</>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
import {
|
||||
RiAccountBoxLine,
|
||||
RiMegaphoneLine,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { MenuItem, Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { useSettingsDialog } from '../SettingsDialogContext'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
import { FullScreenMenuItem } from './FullScreenMenuItem'
|
||||
import { SettingsMenuItem } from './SettingsMenuItem'
|
||||
import { FeedbackMenuItem } from './FeedbackMenuItem'
|
||||
import { EffectsMenuItem } from './EffectsMenuItem'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
@@ -24,31 +15,13 @@ export const OptionsMenuItems = () => {
|
||||
}}
|
||||
>
|
||||
<MenuSection>
|
||||
<MenuItem
|
||||
onAction={() => toggleEffects()}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
{t('effects')}
|
||||
</MenuItem>
|
||||
<FullScreenMenuItem />
|
||||
<EffectsMenuItem />
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
<MenuSection>
|
||||
<MenuItem
|
||||
href={GRIST_FORM}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
{t('feedback')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => setDialogOpen(true)}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
</MenuItem>
|
||||
<FeedbackMenuItem />
|
||||
<SettingsMenuItem />
|
||||
</MenuSection>
|
||||
</RACMenu>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RiSettings3Line } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSettingsDialog } from '../SettingsDialogContext'
|
||||
|
||||
export const SettingsMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => setDialogOpen(true)}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -13,12 +13,21 @@ import {
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import { Track } from 'livekit-client'
|
||||
import {
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
VideoCaptureOptions,
|
||||
} from 'livekit-client'
|
||||
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
|
||||
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,
|
||||
@@ -65,16 +74,22 @@ const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
|
||||
|
||||
type SelectToggleDeviceProps<T extends ToggleSource> =
|
||||
UseTrackToggleProps<T> & {
|
||||
track?: LocalAudioTrack | LocalVideoTrack | undefined
|
||||
initialDeviceId?: string
|
||||
onActiveDeviceChange: (deviceId: string) => void
|
||||
source: SelectToggleSource
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
menuVariant?: 'dark' | 'light'
|
||||
hideMenu?: boolean
|
||||
variant?: 'dark' | 'light'
|
||||
}
|
||||
|
||||
export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
track,
|
||||
initialDeviceId,
|
||||
onActiveDeviceChange,
|
||||
hideMenu,
|
||||
variant = 'light',
|
||||
variant = 'primaryDark',
|
||||
menuVariant = 'light',
|
||||
...props
|
||||
}: SelectToggleDeviceProps<T>) => {
|
||||
const config = selectToggleDeviceConfig[props.source]
|
||||
@@ -84,8 +99,53 @@ 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 })
|
||||
useMediaDeviceSelect({ kind: config.kind, track })
|
||||
|
||||
/**
|
||||
* When providing only track outside of a room context, activeDeviceId is undefined.
|
||||
* So we need to initialize it with the initialDeviceId.
|
||||
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialDeviceId !== undefined) {
|
||||
setActiveMediaDevice(initialDeviceId)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setActiveMediaDevice])
|
||||
|
||||
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
|
||||
|
||||
@@ -99,6 +159,8 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
<ToggleDevice
|
||||
{...trackProps}
|
||||
config={config}
|
||||
variant={variant}
|
||||
toggle={toggle}
|
||||
toggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
@@ -108,13 +170,13 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Menu variant={variant}>
|
||||
<Menu variant={menuVariant}>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={trackProps.enabled ? 'primaryDark' : 'error2'}
|
||||
variant={trackProps.enabled ? variant : 'error2'}
|
||||
>
|
||||
<RiArrowDownSLine />
|
||||
</Button>
|
||||
@@ -128,7 +190,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
setActiveMediaDevice(value as string)
|
||||
onActiveDeviceChange(value as string)
|
||||
}}
|
||||
variant={variant}
|
||||
variant={menuVariant}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,13 +6,19 @@ import { useTranslation } from 'react-i18next'
|
||||
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import { useIsSpeaking, useLocalParticipant } from '@livekit/components-react'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useLocalParticipant,
|
||||
useMaybeRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export type ToggleDeviceProps = {
|
||||
enabled: boolean
|
||||
toggle: () => void
|
||||
config: SelectToggleDeviceConfig
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
toggleButtonProps?: Partial<ToggleButtonProps>
|
||||
}
|
||||
|
||||
@@ -20,6 +26,7 @@ export const ToggleDevice = ({
|
||||
config,
|
||||
enabled,
|
||||
toggle,
|
||||
variant = 'primaryDark',
|
||||
toggleButtonProps,
|
||||
}: ToggleDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
@@ -51,17 +58,15 @@ export const ToggleDevice = ({
|
||||
|
||||
const Icon = enabled ? iconOn : iconOff
|
||||
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
|
||||
if (kind === 'audioinput' && pushToTalk) {
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
const context = useMaybeRoomContext()
|
||||
if (kind === 'audioinput' && pushToTalk && context) {
|
||||
return <ActiveSpeakerWrapper />
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
variant={enabled ? 'primaryDark' : 'error2'}
|
||||
variant={enabled ? variant : 'error2'}
|
||||
shySelected
|
||||
onPress={() => toggle()}
|
||||
aria-label={toggleLabel}
|
||||
@@ -73,3 +78,9 @@ export const ToggleDevice = ({
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveSpeakerWrapper = () => {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export const TranscriptToggle = ({
|
||||
variant = 'primaryDark',
|
||||
variant = 'primaryTextDark',
|
||||
onPress,
|
||||
...props
|
||||
}: ToggleButtonProps) => {
|
||||
|
||||
@@ -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,68 @@
|
||||
// We use vendor prefix properties
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function useFullScreen() {
|
||||
const getIsFullscreen = () => {
|
||||
return !!(
|
||||
document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement ||
|
||||
document.msFullscreenElement
|
||||
)
|
||||
}
|
||||
|
||||
const [isFullscreenAvailable] = useState(
|
||||
() =>
|
||||
document.fullscreenEnabled ||
|
||||
document.webkitFullscreenEnabled ||
|
||||
document.mozFullScreenEnabled ||
|
||||
document.msFullscreenEnabled
|
||||
)
|
||||
|
||||
const enterFullscreen = async () => {
|
||||
try {
|
||||
const docEl = document.documentElement
|
||||
if (docEl.requestFullscreen) {
|
||||
await docEl.requestFullscreen()
|
||||
} else if (docEl.webkitRequestFullscreen) {
|
||||
await docEl.webkitRequestFullscreen()
|
||||
} else if (docEl.msRequestFullscreen) {
|
||||
await docEl.msRequestFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error entering fullscreen:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const exitFullscreen = async () => {
|
||||
try {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen()
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
await document.webkitExitFullscreen()
|
||||
} else if (document.msExitFullscreen) {
|
||||
await document.msExitFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error exiting fullscreen:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullScreen = async () => {
|
||||
const isCurrentlyFullscreen = getIsFullscreen()
|
||||
if (isCurrentlyFullscreen) {
|
||||
await exitFullscreen()
|
||||
} else {
|
||||
await enterFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isCurrentlyFullscreen: getIsFullscreen(),
|
||||
isFullscreenAvailable,
|
||||
toggleFullScreen,
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -8,10 +8,8 @@ import { HandToggle } from '../../components/controls/HandToggle'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { SupportToggle } from '../../components/controls/SupportToggle'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
@@ -21,9 +19,11 @@ export function DesktopControlBar({
|
||||
saveVideoInputDeviceId,
|
||||
}: ControlBarAuxProps) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
@@ -62,7 +62,7 @@ export function DesktopControlBar({
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="dark"
|
||||
menuVariant="dark"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
@@ -73,7 +73,7 @@ export function DesktopControlBar({
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="dark"
|
||||
menuVariant="dark"
|
||||
/>
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
@@ -87,21 +87,7 @@ export function DesktopControlBar({
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
paddingRight: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<ChatToggle />
|
||||
<ParticipantsToggle />
|
||||
<TranscriptToggle />
|
||||
<SupportToggle />
|
||||
</div>
|
||||
<MoreOptions parentElement={desktopControlBarEl} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { SupportToggle } from '../../components/controls/SupportToggle'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
import { useSize } from '../../hooks/useResizeObserver'
|
||||
import { useState, RefObject } from 'react'
|
||||
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
||||
import { Button } from '@/primitives'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const CONTROL_BAR_BREAKPOINT = 750
|
||||
|
||||
const NavigationControls = ({ onPress }: Partial<ToggleButtonProps>) => (
|
||||
<>
|
||||
<ChatToggle onPress={onPress} />
|
||||
<ParticipantsToggle onPress={onPress} />
|
||||
<TranscriptToggle onPress={onPress} />
|
||||
<SupportToggle onPress={onPress} />
|
||||
</>
|
||||
)
|
||||
|
||||
export const LateralMenu = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const handlePress = () => setIsOpen(!isOpen)
|
||||
const handleClose = () => setIsOpen(false)
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Button
|
||||
square
|
||||
variant="secondaryDark"
|
||||
aria-label={t('controls.moreOptions')}
|
||||
tooltip={t('controls.moreOptions')}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{isOpen ? <RiArrowDownSLine /> : <RiArrowUpSLine />}
|
||||
</Button>
|
||||
<Popover>
|
||||
<Dialog
|
||||
className={css({
|
||||
width: '65px',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
paddingTop: '10px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<NavigationControls onPress={handleClose} />
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export const MoreOptions = ({
|
||||
parentElement,
|
||||
}: {
|
||||
parentElement: RefObject<HTMLDivElement>
|
||||
}) => {
|
||||
const { width: parentWidth } = useSize(parentElement)
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
paddingRight: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{parentWidth > CONTROL_BAR_BREAKPOINT ? (
|
||||
<NavigationControls />
|
||||
) : (
|
||||
<LateralMenu />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,8 @@ export const useSupport = ({ id }: useSupportProps) => {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const useIsSupportEnabled = () => {
|
||||
const { data } = useConfig()
|
||||
return !!data?.support?.id
|
||||
}
|
||||
|
||||
@@ -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')}{' '}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"legalsTerms": "",
|
||||
"data": "",
|
||||
"accessibility": "",
|
||||
"ariaLabel": ""
|
||||
"ariaLabel": "",
|
||||
"code": ""
|
||||
},
|
||||
"mentions": "",
|
||||
"license": ""
|
||||
|
||||
@@ -19,12 +19,22 @@
|
||||
"label": ""
|
||||
},
|
||||
"heading": "",
|
||||
"effects": {
|
||||
"description": "",
|
||||
"title": "",
|
||||
"subTitle": ""
|
||||
},
|
||||
"joinLabel": "",
|
||||
"joinMeeting": "",
|
||||
"toggleOff": "",
|
||||
"toggleOn": "",
|
||||
"usernameHint": "",
|
||||
"usernameLabel": ""
|
||||
"usernameLabel": "",
|
||||
"errors": {
|
||||
"usernameEmpty": ""
|
||||
},
|
||||
"cameraDisabled": "",
|
||||
"cameraStarting": ""
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
@@ -75,7 +85,8 @@
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
"support": ""
|
||||
"support": "",
|
||||
"moreOptions": ""
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "",
|
||||
@@ -84,7 +95,11 @@
|
||||
"settings": "",
|
||||
"username": "",
|
||||
"effects": "",
|
||||
"switchCamera": ""
|
||||
"switchCamera": "",
|
||||
"fullscreen": {
|
||||
"enter": "",
|
||||
"exit": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
@@ -92,11 +107,17 @@
|
||||
"notAvailable": "",
|
||||
"heading": "",
|
||||
"blur": {
|
||||
"title": "",
|
||||
"light": "",
|
||||
"normal": "",
|
||||
"apply": "",
|
||||
"clear": ""
|
||||
},
|
||||
"virtual": {
|
||||
"title": "",
|
||||
"apply": "",
|
||||
"clear": ""
|
||||
},
|
||||
"experimental": ""
|
||||
},
|
||||
"sidePanel": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,13 +18,23 @@
|
||||
"enable": "Enable microphone",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Verify your settings",
|
||||
"effects": {
|
||||
"description": "Apply effects",
|
||||
"title": "Effects",
|
||||
"subTitle": "Configure your camera's effects."
|
||||
},
|
||||
"heading": "Join the meeting",
|
||||
"joinLabel": "Join",
|
||||
"joinMeeting": "Join meeting",
|
||||
"toggleOff": "Click to turn off",
|
||||
"toggleOn": "Click to turn on",
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name"
|
||||
"usernameLabel": "Your name",
|
||||
"errors": {
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
"cameraStarting": "Camera is starting."
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
@@ -74,7 +84,8 @@
|
||||
"open": "Hide AI assistant",
|
||||
"closed": "Show AI assistant"
|
||||
},
|
||||
"support": "Support"
|
||||
"support": "Support",
|
||||
"moreOptions": "More options"
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "More Options",
|
||||
@@ -83,19 +94,29 @@
|
||||
"settings": "Settings",
|
||||
"username": "Update Your Name",
|
||||
"effects": "Apply effects",
|
||||
"switchCamera": "Switch camera"
|
||||
"switchCamera": "Switch camera",
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
"exit": "Exit fullscreen mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,13 +18,23 @@
|
||||
"enable": "Activer le micro",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Vérifiez vos paramètres",
|
||||
"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",
|
||||
"toggleOn": "Cliquez pour activer",
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom"
|
||||
"usernameLabel": "Votre nom",
|
||||
"errors": {
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
},
|
||||
"cameraDisabled": "La caméra est désactivée.",
|
||||
"cameraStarting": "La caméra va démarrer."
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
@@ -74,7 +84,8 @@
|
||||
"open": "Masquer l'assistant IA",
|
||||
"closed": "Afficher l'assistant IA"
|
||||
},
|
||||
"support": "Support"
|
||||
"support": "Support",
|
||||
"moreOptions": "Plus d'options"
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "Plus d'options",
|
||||
@@ -83,19 +94,29 @@
|
||||
"settings": "Paramètres",
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Appliquer des effets",
|
||||
"switchCamera": "Changer de caméra"
|
||||
"switchCamera": "Changer de caméra",
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
"exit": "Quitter le mode plein écran"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -25,11 +25,31 @@ const FieldWrapper = styled('div', {
|
||||
marginBottom: 'textfield',
|
||||
minWidth: 0,
|
||||
},
|
||||
variants: {
|
||||
noMargin: {
|
||||
true: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
fullWidth: {
|
||||
true: {
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const StyledLabel = styled(Label, {
|
||||
base: {
|
||||
display: 'block',
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
variants: {
|
||||
center: {
|
||||
true: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -80,6 +100,8 @@ type FieldProps<T extends object> = (
|
||||
) & {
|
||||
label: string
|
||||
description?: string
|
||||
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
|
||||
labelProps?: React.ComponentProps<typeof StyledLabel>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +126,7 @@ export const Field = <T extends object>({
|
||||
}: FieldProps<T>) => {
|
||||
const LabelAndDescription = (
|
||||
<>
|
||||
<StyledLabel>{label}</StyledLabel>
|
||||
<StyledLabel {...props.labelProps}>{label}</StyledLabel>
|
||||
<FieldDescription slot="description">{description}</FieldDescription>
|
||||
</>
|
||||
)
|
||||
@@ -118,7 +140,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<RACTextField
|
||||
validate={validate as unknown as TextFieldProps['validate']}
|
||||
{...(props as PartialTextFieldProps)}
|
||||
@@ -133,7 +155,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'checkbox') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<Checkbox
|
||||
validate={validate as unknown as CheckboxProps['validate']}
|
||||
description={description}
|
||||
@@ -147,7 +169,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'checkboxGroup') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<CheckboxGroup
|
||||
validate={validate as unknown as CheckboxGroupProps['validate']}
|
||||
{...(props as PartialCheckboxGroupProps)}
|
||||
@@ -170,7 +192,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'radioGroup') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<RadioGroup
|
||||
validate={validate as unknown as RadioGroupProps['validate']}
|
||||
{...(props as PartialRadioGroupProps)}
|
||||
@@ -189,7 +211,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'select') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<Select
|
||||
validate={validate as unknown as SelectProps<T>['validate']}
|
||||
{...(props as PartialSelectProps<T>)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Form as RACForm, type FormProps } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button, useCloseDialog } from '@/primitives'
|
||||
import { ButtonProps } from '@/primitives/Button'
|
||||
|
||||
/**
|
||||
* From wrapper that exposes form data on submit and adds submit/cancel buttons
|
||||
@@ -13,6 +14,7 @@ import { Button, useCloseDialog } from '@/primitives'
|
||||
export const Form = ({
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
submitButtonProps,
|
||||
withCancelButton = true,
|
||||
onCancelButtonPress,
|
||||
children,
|
||||
@@ -25,6 +27,7 @@ export const Form = ({
|
||||
event: FormEvent<HTMLFormElement>
|
||||
) => void
|
||||
submitLabel: string
|
||||
submitButtonProps?: ButtonProps
|
||||
withCancelButton?: boolean
|
||||
onCancelButtonPress?: () => void
|
||||
}) => {
|
||||
@@ -47,7 +50,7 @@ export const Form = ({
|
||||
>
|
||||
{children}
|
||||
<HStack gap="gutter">
|
||||
<Button type="submit" variant="primary">
|
||||
<Button type="submit" variant="primary" {...submitButtonProps}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
{!!onCancel && (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Text } from './Text'
|
||||
import { Text, TextProps } from './Text'
|
||||
|
||||
export const H = ({
|
||||
children,
|
||||
lvl,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement> & { lvl: 1 | 2 | 3 }) => {
|
||||
}: React.HTMLAttributes<HTMLHeadingElement> & {
|
||||
lvl: 1 | 2 | 3
|
||||
} & TextProps) => {
|
||||
const tag = `h${lvl}` as const
|
||||
return (
|
||||
<Text as={tag} variant={tag} {...props}>
|
||||
|
||||
@@ -28,6 +28,14 @@ export const text = cva({
|
||||
paddingTop: 'heading',
|
||||
},
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: '1rem',
|
||||
color: 'greyscale.600',
|
||||
},
|
||||
bodyXsBold: {
|
||||
textStyle: 'body',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
body: {
|
||||
textStyle: 'body',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
@@ -109,6 +138,21 @@ export const buttonRecipe = cva({
|
||||
color: 'primaryDark.100 !important',
|
||||
},
|
||||
},
|
||||
secondaryDark: {
|
||||
backgroundColor: 'primaryDark.50',
|
||||
color: 'white',
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'primaryDark.200',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primaryDark.100',
|
||||
color: 'white',
|
||||
},
|
||||
'&[data-selected]': {
|
||||
backgroundColor: 'primaryDark.700 !important',
|
||||
color: 'primaryDark.100 !important',
|
||||
},
|
||||
},
|
||||
primaryTextDark: {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'white',
|
||||
|
||||
@@ -10,6 +10,7 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
port: parseInt(env.VITE_PORT) || 3000,
|
||||
host: env.VITE_HOST || 'localhost',
|
||||
allowedHosts: ['.nip.io'],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.13.0"
|
||||
"node": "22.13.1"
|
||||
},
|
||||
"repository": "https://github.com/numerique-gouv/meet",
|
||||
"author": "DINUM",
|
||||
|
||||
@@ -10,14 +10,14 @@ dependencies = [
|
||||
"celery==5.4.0",
|
||||
"redis==5.2.1",
|
||||
"minio==7.2.15",
|
||||
"openai==1.59.8",
|
||||
"openai==1.60.2",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk[fastapi, celery]==2.20.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.9.2",
|
||||
"ruff==0.9.3",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||