Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine 151f218e5d 📝(backend) add Firefox proxy workaround parameter to installation guide
Document LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND setting that enables
connection warmup by pre-calling WebSocket endpoint to resolve proxy
connectivity issues.
2025-08-01 16:16:35 +02:00
lebaudantoine b2767373a9 🩹(backend) allow enforcing WSS protocol to resolve browser compatibility
The LiveKit API URL is necessary to interact with the API. It uses https
protocol.

Eplicit wss protocol is necessary in Websocket constructor for some
older browsers.

This resolves critical compatibility issues with legacy browsers
(notably Firefox <124, Chrome <125, Edge <125) that lack support
for HTTPS URLs in the WebSocket() constructor. Without explicit WSS
URLs, WebSocket signaling connections may fail, crash, or be blocked
entirely in these environments.

The setting is optional and defaults to the current behavior when
not specified, ensuring zero breaking changes for existing deployments.
2025-08-01 16:16:35 +02:00
33 changed files with 392 additions and 1349 deletions
+2
View File
@@ -339,6 +339,8 @@ These are the environmental options available on meet backend.
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
+1
View File
@@ -52,6 +52,7 @@ def get_frontend_configuration(request):
},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
},
}
+3
View File
@@ -492,6 +492,9 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
)
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
environ_prefix=None,
@@ -1,109 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
<defs>
<style>
.st0, .st1, .st2, .st3, .st4, .st5 {
fill: none;
}
.st6 {
fill: #6dd58c;
}
.st6, .st1, .st2, .st7, .st8, .st9, .st10 {
stroke-linecap: round;
stroke-linejoin: round;
}
.st6, .st1, .st2, .st7, .st8, .st10 {
stroke-width: 3px;
}
.st6, .st1, .st7, .st8, .st4, .st5, .st10 {
stroke: #191c1e;
}
.st11, .st7, .st12, .st9 {
fill: #fff;
}
.st11, .st3 {
stroke: #000;
stroke-miterlimit: 10;
}
.st11, .st3, .st9 {
stroke-width: 5px;
}
.st2, .st9 {
stroke: #898989;
}
.st8 {
fill: #ff8669;
}
.st13 {
fill: #e3e3fb;
}
.st14 {
fill: #f7f7f7;
}
.st4, .st5 {
stroke-width: 4px;
}
.st5 {
stroke-linecap: square;
}
.st15 {
fill: #ca3632;
}
.st10 {
fill: #ffb929;
}
</style>
</defs>
<rect class="st12" x=".53" y=".53" width="1078.61" height="1080.36"/>
<path class="st12" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st1" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st12" d="M960.08,256.93h-110c-12.94,0-23.43-10.49-23.43-23.43v-71.56c0-12.94-10.48-23.43-23.42-23.44h-266.94c-12.94,0-23.43,10.49-23.43,23.44h0v71.56c0,12.94-10.49,23.43-23.43,23.43H120.5v137.62h839.58"/>
<path class="st8" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st10" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st6" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st1" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st7" d="M625.31,322.08h-27.15c-4.35,0-7.88,3.53-7.88,7.88h0v17.19c0,4.35,3.53,7.88,7.88,7.88h27.15c4.35,0,7.88-3.53,7.88-7.88h0v-17.19c0-4.35-3.53-7.88-7.88-7.88h0Z"/>
<path class="st12" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<path class="st1" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<g>
<ellipse class="st4" cx="707.63" cy="299.5" rx="18.37" ry="18.5"/>
<path class="st5" d="M624,299h63"/>
<circle class="st4" cx="643.5" cy="339.5" r="18.5"/>
<path class="st5" d="M728,339h-63"/>
</g>
<path class="st14" d="M192.29,138.5h767.79v118.43H120.5v-46.64c0-39.62,32.17-71.79,71.79-71.79h0Z"/>
<path class="st0" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st15" d="M382.41,636.74l39.92,39.92,13.47-13.47-188.53-188.53-13.47,13.47,11.35,11.35h-5.58c-5.26,0-9.52,4.26-9.52,9.52v133.31c0,5.26,4.26,9.52,9.52,9.52h133.31c5.26,0,9.52-4.26,9.52-9.52v-5.58h.01ZM363.37,617.69v15.1h-114.27v-114.27h15.1l99.17,99.17h0ZM439.55,633.17c0,2.02-1.19,3.6-2.78,4.33l-16.27-16.27v-75.65l-38.09,26.66v10.9l-19.04-19.04v-45.57h-45.57l-19.04-19.04h74.14c5.26,0,9.52,4.26,9.52,9.52v39.99l49.64-34.75c3.16-2.21,7.49.05,7.49,3.9v115.02h0Z"/>
<path class="st15" d="M375.25,866.34l43.58,43.58,12.93-12.93-180.97-180.97-12.93,12.93,51.25,51.25v14.49c0,25.24,20.46,45.7,45.7,45.7,4.41,0,8.67-.62,12.7-1.79l14.17,14.17c-8.17,3.79-17.28,5.9-26.87,5.9-32.23,0-58.9-23.84-63.33-54.84h-18.43c4.21,38.13,34.49,68.4,72.62,72.62v37.06h18.28v-37.06c11.28-1.25,21.87-4.77,31.3-10.11h0ZM330.71,821.81c-11.87-1.78-21.25-11.16-23.03-23.03l23.03,23.03ZM402.21,841.85l-13.18-13.18c4.65-7.4,7.82-15.82,9.11-24.84h18.43c-1.55,14.04-6.64,27.02-14.35,38.03h0ZM375.62,815.27l-14.15-14.15c.5-2.06.76-4.21.76-6.43v-36.56c0-15.14-12.28-27.42-27.42-27.42-11.83,0-21.91,7.49-25.76,17.99l-13.68-13.68c7.94-13.52,22.63-22.59,39.44-22.59,25.24,0,45.7,20.46,45.7,45.7v36.56c0,7.4-1.76,14.39-4.88,20.58h-.01Z"/>
<path d="M597.6,312.73c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM602.4,301.55c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM619.98,315.93h25.57v-6.39h-25.57v6.39ZM632.76,344.69c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM637.56,333.51c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM594.41,341.5v6.39h25.57v-6.39h-25.57Z"/>
<line class="st2" x1="960.08" y1="394.55" x2="836.91" y2="394.55"/>
<path class="st9" d="M527.76,394.55H120.5v-137.62h368.93c12.94,0,23.43-10.49,23.43-23.43v-71.56c0-12.95,10.49-23.44,23.43-23.44h266.94c12.94,0,23.43,10.5,23.42,23.44v71.56c0,2.09.27,4.12.79,6.05,1,3.77,2.92,7.17,5.51,9.94,4.28,4.58,10.37,7.44,17.13,7.44h110"/>
<path class="st13" d="M645.52,274.8h314.56v101.2h-314.56c-23.81,0-43.12-22.66-43.12-50.61h0c.01-27.94,19.31-50.59,43.12-50.59h0Z"/>
<g>
<path class="st13" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71h-181.11c-43.65,0-79.03-35.38-79.03-79.03,0-21.82,8.86-41.56,23.16-55.86,14.3-14.29,34.06-23.13,55.87-23.13h179.74c12.93,23.89,20.27,51.24,20.27,80.31Z"/>
<path d="M656.78,350.02v9.98h39.93v-9.98h-39.93ZM724.16,337.54c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.46,17.47,17.46,17.47-7.82,17.47-17.46-7.82-17.47-17.47-17.47ZM724.16,362.49c-4.13,0-7.49-3.35-7.49-7.48s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.36,7.48-7.49,7.48ZM696.71,300.11v9.98h39.93v-9.98h-39.93ZM669.26,287.63c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.47,17.47,17.47,17.47-7.82,17.47-17.47-7.82-17.47-17.47-17.47ZM669.26,312.59c-4.13,0-7.49-3.36-7.49-7.49s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.35,7.49-7.49,7.49Z"/>
</g>
<line class="st2" x1="813.4" y1="432.85" x2="551.26" y2="432.85"/>
<path class="st3" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71-5.35,10.32-11.74,20.02-19.02,28.96-30.98,38.03-78.19,62.32-131.07,62.32s-100.09-24.29-131.07-62.32c-23.7-29.09-37.92-66.22-37.92-106.67,0-93.33,75.66-168.99,168.99-168.99,64.26,0,120.15,35.87,148.72,88.68,12.93,23.89,20.27,51.24,20.27,80.31h0Z"/>
<polygon class="st11" points="705.12 406.95 681.79 637.91 746.21 597.51 802.28 751.56 852.2 733.39 796.14 579.34 871.46 568.88 705.12 406.95"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

+1
View File
@@ -39,6 +39,7 @@ export interface ApiConfig {
manifest_link?: string
livekit: {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
}
}
@@ -1,10 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
LiveKitRoom,
usePersistentUserChoices,
} from '@livekit/components-react'
import { LiveKitRoom } from '@livekit/components-react'
import {
DisconnectReason,
MediaDeviceFailure,
@@ -32,20 +29,18 @@ import { isFireFox } from '@/utils/livekit'
export const Conference = ({
roomId,
userConfig,
initialRoomData,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
userChoices: LocalUserChoices
}
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
@@ -97,16 +92,9 @@ export const Conference = ({
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [
userConfig.videoDeviceId,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
])
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -164,6 +152,20 @@ export const Conference = ({
kind: null,
})
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
*/
const serverUrl = useMemo(() => {
const livekit_url = apiConfig?.livekit.url
if (!livekit_url) return
if (apiConfig?.livekit.force_wss_protocol) {
return livekit_url.replace('https://', 'wss://')
}
return livekit_url
}, [apiConfig?.livekit])
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
@@ -187,7 +189,7 @@ export const Conference = ({
<Screen header={false} footer={false}>
<LiveKitRoom
room={room}
serverUrl={apiConfig?.livekit.url}
serverUrl={serverUrl}
token={data?.livekit?.token}
connect={isConnectionWarmedUp}
audio={userConfig.audioEnabled}
@@ -1,12 +1,14 @@
import { useTranslation } from 'react-i18next'
import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import { LocalTrack, Track } from 'livekit-client'
import { useCallback, useEffect, useMemo, 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 { VStack } from '@/styled-system/jsx'
import { HStack, VStack } from '@/styled-system/jsx'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import {
@@ -25,10 +27,7 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { ToggleDeviceJoin } from '@/features/rooms/components/ToggleDeviceJoin'
import { SelectDeviceJoin } from '@/features/rooms/components/SelectDeviceJoin'
import { usePreviewTracks } from '@/features/rooms/hooks/usePreviewTracks'
import { usePermissions } from '@/features/rooms/hooks/usePermissions'
import { LocalUserChoices } from '@/stores/userChoices'
const onError = (e: Error) => console.error('ERROR', e)
@@ -73,23 +72,45 @@ const Effects = ({
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
</Dialog>
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
zIndex: '1',
})}
>
<RiImageCircleAiFill size={24} />
</Button>
<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 = ({
enterRoom,
onSubmit,
roomId,
}: {
enterRoom: () => void
onSubmit: (choices: LocalUserChoices) => void
roomId: string
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -99,7 +120,6 @@ export const Join = ({
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
@@ -107,43 +127,55 @@ export const Join = ({
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveAudioOutputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices()
// fix - permission issue, when permission not set doesn't refresh
const { videoTrack, audioTrack } = usePreviewTracks(
const [processor, setProcessor] = useState(
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
)
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
{
audio: { deviceId: audioDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
video: { deviceId: videoDeviceId },
},
onError
)
const toggleTrack = (track: LocalTrack | undefined, isEnabled: boolean) => {
if (!track) return
if (isEnabled) {
track?.unmute()
} else {
track?.mute()
}
}
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)
const isVideoInitiated = useRef(false)
useEffect(() => {
const videoElement = videoEl.current as HTMLVideoElement | null
const handleVideoLoaded = () => {
if (videoElement) {
isVideoInitiated.current = true
videoElement.style.opacity = '1'
}
}
@@ -160,6 +192,25 @@ export const Join = ({
}
}, [videoTrack, videoEnabled])
const enterRoom = useCallback(() => {
onSubmit({
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processorSerialized: processor?.serialize(),
})
}, [
onSubmit,
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processor,
])
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
@@ -218,28 +269,15 @@ export const Join = ({
enterRoom()
}
const { isCameraGranted, isMicrophoneGranted } = usePermissions()
const hintMessage = useMemo(() => {
if (!isCameraGranted) {
if (!isMicrophoneGranted) {
return 'cameraAndMicNotGranted'
}
return 'cameraNotGranted'
// 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)
}
if (!videoEnabled) {
return 'cameraDisabled'
}
if (!isVideoInitiated.current) {
return 'cameraStarting'
}
if (videoTrack && videoEnabled) {
return ''
}
}, [videoTrack, videoEnabled, isCameraGranted, isMicrophoneGranted])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack])
const renderWaitingState = () => {
switch (status) {
@@ -292,10 +330,6 @@ export const Join = ({
submitButtonProps={{
fullWidth: true,
}}
className={css({
width: '100%',
maxWidth: '340px',
})}
>
<VStack marginBottom={1}>
<H lvl={1} margin="sm" centered>
@@ -327,264 +361,140 @@ export const Join = ({
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
flexDirection: 'column',
flexGrow: 1,
gap: { base: '1rem', sm: '2rem', lg: 0 },
lg: {
flexDirection: 'row',
alignItems: 'center',
},
})}
>
<div
className={css({
display: 'flex',
height: 'auto',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
gap: '2rem',
padding: '0 2rem',
flexDirection: 'column',
minWidth: 0,
maxWidth: '764px',
width: '100%',
lg: {
height: '540px',
flexGrow: 1,
flexDirection: 'row',
width: 'auto',
height: '570px',
},
})}
>
<div
className={css({
display: 'inline-flex',
flexDirection: 'column',
flexGrow: 1,
minWidth: 0,
flexShrink: { base: 0, sm: 1 },
width: '100%',
lg: {
width: '740px',
},
})}
>
<div
className={css({
borderRadius: '1rem',
flex: '0 1',
minWidth: '320px',
margin: {
base: '0.5rem',
sm: '1rem',
lg: '1rem 0.5rem 1rem 1rem',
},
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
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',
})}
>
<div
className={css({
position: 'relative',
width: '100%',
height: 'fit-content',
aspectRatio: '16 / 9',
})}
>
<div
{videoTrack && videoEnabled ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="1280"
height="720"
className={css({
backgroundColor: 'black',
position: 'absolute',
boxSizing: 'border-box',
top: 0,
display: 'block',
width: '100%',
height: '100%',
overflow: 'hidden',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
borderRadius: '1rem',
})}
>
<div
aria-label={t(
`videoPreview.${videoEnabled && isCameraGranted ? 'enabled' : 'disabled'}`
)}
role="status"
className={css({
position: 'absolute',
top: 0,
width: '100%',
})}
>
<div
className={css({
width: '100%',
height: 'auto',
aspectRatio: '16 / 9',
overflow: 'hidden',
position: 'absolute',
top: '-2px',
left: '-2px',
pointerEvents: 'none',
transform: 'scale(1.02)',
})}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoEl}
width="1280"
height="720"
style={{
display:
!videoEnabled || !isCameraGranted
? 'none'
: undefined,
}}
className={css({
position: 'absolute',
transform: 'rotateY(180deg)',
opacity: 0,
height: '100%',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
})}
disablePictureInPicture
disableRemotePlayback
/>
</div>
</div>
<div
role="alert"
className={css({
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
justifyContent: 'center',
textAlign: 'center',
alignItems: 'center',
padding: '0.24rem',
boxSizing: 'border-box',
})}
>
<p
className={css({
fontWeight: '400',
fontSize: { base: '1rem', sm: '1.25rem', lg: '1.5rem' },
textWrap: 'balance',
color: 'white',
})}
>
{hintMessage && t(hintMessage)}
</p>
</div>
</div>
disablePictureInPicture
disableRemotePlayback
/>
) : (
<div
className={css({
position: 'absolute',
right: '50%',
bottom: '1rem',
zIndex: '1',
width: '100%',
height: '100%',
color: 'white',
display: 'flex',
gap: '1.5rem',
justifyContent: 'center',
marginRight: '-68px',
alignItems: 'center',
})}
>
<ToggleDeviceJoin
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
onChange={(value) => {
toggleTrack(audioTrack, value)
saveAudioInputEnabled(value)
}}
/>
<ToggleDeviceJoin
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
onChange={(value) => {
const videoElement =
videoEl.current as HTMLVideoElement | null
if (isVideoInitiated.current && !value && videoElement) {
isVideoInitiated.current = false
videoElement.style.opacity = '0'
}
toggleTrack(videoTrack, value)
saveVideoInputEnabled(value)
}}
/>
<p
className={css({
fontSize: '24px',
fontWeight: '300',
})}
>
{!videoEnabled && t('cameraDisabled')}
{videoEnabled && !videoTrack && t('cameraStarting')}
</p>
</div>
<div
className={css({
position: 'absolute',
right: '1rem',
bottom: '1rem',
zIndex: '1',
})}
>
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
</div>
</div>
<div
className={css({
display: 'none',
sm: {
display: 'flex',
justifyContent: 'center',
gap: '2%',
width: '80%',
marginX: 'auto',
},
})}
>
<div
className={css({
width: '30%',
})}
>
<SelectDeviceJoin
kind="audioinput"
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
/>
</div>
<div
className={css({
width: '30%',
})}
>
<SelectDeviceJoin
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
<div
className={css({
width: '30%',
})}
>
<SelectDeviceJoin
kind="videoinput"
id={videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
)}
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={audioDeviceId}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={videoDeviceId}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
</HStack>
</div>
<div
className={css({
width: '100%',
flexShrink: 0,
padding: '0',
sm: {
width: '448px',
padding: '0 3rem 9rem 3rem',
},
})}
>
{renderWaitingState()}
</div>
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0 448px',
position: 'relative',
margin: '1rem 1rem 1rem 0.5rem',
})}
>
{renderWaitingState()}
</div>
</div>
</Screen>
@@ -1,90 +0,0 @@
import { Dialog, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { RiEqualizer2Line } from '@remixicon/react'
import { useEffect, useMemo } from 'react'
import { usePermissions } from '../hooks/usePermissions'
import { useModal } from '../hooks/useModal'
export const PermissionErrorModal = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionErrorDialog' })
const permissions = usePermissions()
const { isOpen, close } = useModal('permissions')
useEffect(() => {
if (
isOpen() &&
permissions.isCameraGranted &&
permissions.isMicrophoneGranted
) {
close()
}
}, [permissions, isOpen, close])
// Use to split the translation in half to inject an inline icon
const icon_placeholder = 'ICON_PLACEHOLDER'
const [openMenuFirstPart, openMenuSecondPart] = t('body.openMenu', {
icon_placeholder,
}).split(icon_placeholder)
const permissionLabel = useMemo(() => {
if (permissions.isMicrophoneDenied && permissions.isCameraDenied) {
return 'cameraAndMicrophone'
} else if (permissions.isCameraDenied) {
return 'camera'
} else if (permissions.isMicrophoneDenied) {
return 'microphone'
} else {
return 'default'
}
}, [permissions])
return (
<Dialog
isOpen={isOpen()}
role="dialog"
type="flex"
title={''}
aria-label={t(`heading.${permissionLabel}`, {
app_title: `${import.meta.env.VITE_APP_TITLE}`,
})}
onClose={() => close()}
>
<HStack>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
minWidth: '290px',
minHeight: '290px',
maxWidth: '290px',
})}
/>
<div
className={css({
maxWidth: '400px',
})}
>
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
app_title: `${import.meta.env.VITE_APP_TITLE}`,
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{openMenuFirstPart}
<span
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
<RiEqualizer2Line />
</span>
{openMenuSecondPart}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
</div>
</HStack>
</Dialog>
)
}
@@ -1,113 +0,0 @@
import {
RemixiconComponentType,
RiMicLine,
RiVideoOnLine,
RiVolumeDownLine,
} from '@remixicon/react'
import { Select } from '@/primitives/Select.tsx'
import { useMemo } from 'react'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePermissions } from '@/features/rooms/hooks/usePermissions'
type DeviceItems = Array<{ value: string; label: string }>
type DeviceConfig = {
icon: RemixiconComponentType
isGranted: boolean
}
type SelectDeviceJoinProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
}
export const SelectDeviceJoin = ({ kind, ...props }: SelectDeviceJoinProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { isMicrophoneGranted, isCameraGranted } = usePermissions()
const config = useMemo<DeviceConfig>(() => {
switch (kind) {
case 'audioinput':
return {
icon: RiMicLine,
isGranted: isMicrophoneGranted,
}
case 'audiooutput':
return {
icon: RiVolumeDownLine,
isGranted: isMicrophoneGranted,
}
case 'videoinput':
return {
icon: RiVideoOnLine,
isGranted: isCameraGranted,
}
}
}, [kind, isMicrophoneGranted, isCameraGranted])
if (!config.isGranted) {
return (
<Select
aria-label={'selector disabled - permissions are needed'}
label=""
items={[]}
isDisabled={true}
iconComponent={config.icon}
placeholder={t('selectDevice.permissionNeeded')}
/>
)
}
return <SelectDeviceJoinActive {...props} kind={kind} config={config} />
}
type SelectDeviceJoinActiveProps = {
config: DeviceConfig
} & SelectDeviceJoinProps
const SelectDeviceJoinActive = ({
id,
onSubmit,
kind,
config,
}: SelectDeviceJoinActiveProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const getDefaultSelectedKey = (items: DeviceItems) => {
if (!items || items.length === 0) return
const defaultItem =
items.find((item) => item.value === 'default') || items[0]
return defaultItem.value
}
const {
devices: devices,
activeDeviceId: activeDeviceId,
setActiveMediaDevice: setActiveMediaDevice,
} = useMediaDeviceSelect({ kind, requestPermissions: false })
const items: DeviceItems = devices
.filter((d) => !!d.deviceId)
.map((d) => ({
value: d.deviceId,
label: d.label,
}))
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length == 0}
items={items}
iconComponent={config?.icon}
placeholder={t('selectDevice.loading')}
defaultSelectedKey={id || activeDeviceId || getDefaultSelectedKey(items)}
onSelectionChange={(key) => {
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
/>
)
}
@@ -1,33 +0,0 @@
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { SelectToggleDeviceProps } from '@/features/rooms/livekit/components/controls/SelectToggleDevice'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice'
import {
SelectToggleSource,
ToggleSource,
} from '@/features/rooms/livekit/types/SelectToggleDevice'
import { SELECT_TOGGLE_DEVICE_CONFIG } from '@/features/rooms/livekit/config/SelectToggleDevice'
type ToggleDeviceJoinProps<T extends ToggleSource> = UseTrackToggleProps<T> &
Pick<SelectToggleDeviceProps<T>, 'track' | 'source' | 'variant'>
export const ToggleDeviceJoin = <T extends ToggleSource>(
props: ToggleDeviceJoinProps<T>
) => {
const config = SELECT_TOGGLE_DEVICE_CONFIG[props.source as SelectToggleSource]
if (!config) {
throw new Error('Invalid source')
}
const trackProps = useTrackToggle(props)
return (
<ToggleDevice
{...trackProps}
config={config}
variant="whiteCircle"
toggleButtonProps={{
groupPosition: undefined,
}}
/>
)
}
@@ -1,22 +0,0 @@
import { useSnapshot } from 'valtio'
import { ModalsState, modalsStore } from '@/stores/modals'
interface UseModalReturn {
isOpen: () => boolean
open: () => void
close: () => void
}
export const useModal = (name: keyof ModalsState): UseModalReturn => {
const modalsSnap = useSnapshot(modalsStore)
const isOpen = (): boolean => {
return modalsSnap[name] as boolean
}
return {
isOpen,
open: () => (modalsStore[name] = true),
close: () => (modalsStore[name] = false),
}
}
@@ -1,66 +0,0 @@
import { useEffect } from 'react'
import { permissionStore } from '@/stores/permissions.ts'
import { useSnapshot } from 'valtio'
export const usePermissionsSync = () => {
useEffect(() => {
// fixme - on safari, 'change' event is not trigger
// fixme - prevent error on old browsers
const checkPermissions = async () => {
try {
const [cameraPermission, microphonePermission] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
permissionStore.cameraPermission = cameraPermission.state
permissionStore.microphonePermission = microphonePermission.state
const handleCameraChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionStore.cameraPermission = target.state
}
const handleMicrophoneChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionStore.microphonePermission = target.state
}
if (!cameraPermission.onchange) {
cameraPermission.addEventListener('change', handleCameraChange)
}
if (!microphonePermission.onchange) {
microphonePermission.addEventListener(
'change',
handleMicrophoneChange
)
}
return () => {
cameraPermission.removeEventListener('change', handleCameraChange)
microphonePermission.removeEventListener(
'change',
handleMicrophoneChange
)
}
} catch (error) {
console.error('Error checking permissions:', error)
}
}
checkPermissions()
}, [])
}
export const usePermissions = () => {
const permissionSnap = useSnapshot(permissionStore)
return {
cameraPermission: permissionSnap.cameraPermission,
microphonePermission: permissionSnap.microphonePermission,
isCameraDenied: permissionSnap.cameraPermission == 'denied',
isCameraPrompted: permissionSnap.cameraPermission == 'prompt',
isCameraGranted: permissionSnap.cameraPermission == 'granted',
isMicrophoneDenied: permissionSnap.microphonePermission == 'denied',
isMicrophonePrompted: permissionSnap.microphonePermission == 'prompt',
isMicrophoneGranted: permissionSnap.microphonePermission == 'granted',
}
}
@@ -1,173 +0,0 @@
import {
AudioCaptureOptions,
createLocalTracks,
CreateLocalTracksOptions,
LocalAudioTrack,
LocalTrack,
LocalVideoTrack,
Mutex,
Track,
VideoCaptureOptions,
} from 'livekit-client'
import { useEffect, useMemo, useRef, useState } from 'react'
function roomOptionsStringifyReplacer(key: string, val: unknown) {
if (key === 'processor') {
return undefined
}
if (key === 'e2ee' && val) {
return 'e2ee-enabled'
}
return val
}
export function usePreviewTracks(
options: CreateLocalTracksOptions,
onError?: (err: Error) => void
) {
const trackLock = useMemo(() => {
return new Mutex()
}, [])
const videoTrackLock = useMemo(() => {
return new Mutex()
}, [])
const audioTrackLock = useMemo(() => {
return new Mutex()
}, [])
const isInitiated = useRef(false)
const videoTrackRef = useRef<LocalVideoTrack | undefined>()
const audioTrackRef = useRef<LocalAudioTrack | undefined>()
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | undefined>()
const [audioTrack, setAudioTrack] = useState<LocalAudioTrack | undefined>()
const extractTracks = (tracks: LocalTrack[]) => {
const video = tracks.find(
(track): track is LocalVideoTrack => track.kind === Track.Kind.Video
)
const audio = tracks.find(
(track): track is LocalAudioTrack => track.kind === Track.Kind.Audio
)
return { video, audio }
}
useEffect(() => {
if (isInitiated.current) {
return
}
let needsCleanup = false
let localTracks: Array<LocalTrack> = []
trackLock.lock().then(async (unlock) => {
try {
if (options.audio || options.video) {
localTracks = await createLocalTracks(options)
if (needsCleanup) {
localTracks.forEach((tr) => tr.stop())
} else {
const { audio, video } = extractTracks(localTracks)
isInitiated.current = true
setVideoTrack(video)
videoTrackRef.current = video
setAudioTrack(audio)
audioTrackRef.current = audio
}
}
} catch (e: unknown) {
if (onError && e instanceof Error) {
onError(e)
} else {
console.error(e)
}
} finally {
unlock()
}
})
return () => {
if (isInitiated.current) {
return
}
needsCleanup = true
localTracks.forEach((track) => {
track.stop()
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(options, roomOptionsStringifyReplacer),
onError,
trackLock,
])
const videoOptions = options?.video
useEffect(() => {
if (!isInitiated.current) {
return
}
videoTrackLock.lock().then(async (unlock) => {
try {
if (!videoOptions) return
await videoTrackRef.current?.restartTrack(
videoOptions as VideoCaptureOptions
)
} catch (e: unknown) {
if (onError && e instanceof Error) {
onError(e)
} else {
console.error(e)
}
} finally {
unlock()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(videoOptions, roomOptionsStringifyReplacer),
onError,
videoTrackLock,
])
const audioOptions = options?.audio
useEffect(() => {
if (!isInitiated.current) {
return
}
audioTrackLock.lock().then(async (unlock) => {
try {
if (!audioOptions) return
await audioTrackRef.current?.restartTrack(
audioOptions as AudioCaptureOptions
)
} catch (e: unknown) {
if (onError && e instanceof Error) {
onError(e)
} else {
console.error(e)
}
} finally {
unlock()
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(audioOptions, roomOptionsStringifyReplacer),
onError,
audioTrackLock,
])
return {
videoTrack,
audioTrack,
}
}
@@ -5,7 +5,14 @@ import {
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import {
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import {
LocalAudioTrack,
LocalVideoTrack,
@@ -13,19 +20,59 @@ import {
VideoCaptureOptions,
} from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
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'
import {
SelectToggleSource,
ToggleSource,
} from '../../types/SelectToggleDevice'
import { SELECT_TOGGLE_DEVICE_CONFIG } from '../../config/SelectToggleDevice'
export type SelectToggleDeviceProps<T extends ToggleSource> =
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
@@ -45,7 +92,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = SELECT_TOGGLE_DEVICE_CONFIG[props.source]
const config = selectToggleDeviceConfig[props.source]
if (!config) {
throw new Error('Invalid source')
}
@@ -131,7 +178,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
square
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowUpSLine />
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
@@ -3,6 +3,7 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
@@ -12,10 +13,6 @@ import {
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { SelectToggleDeviceConfig } from '../../types/SelectToggleDevice'
import { css } from '@/styled-system/css'
import { usePermissions } from '@/features/rooms/hooks/usePermissions'
import { useModal } from '@/features/rooms/hooks/useModal'
export type ToggleDeviceProps = {
enabled: boolean
@@ -34,7 +31,6 @@ export const ToggleDevice = ({
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { open } = useModal('permissions')
const { kind, shortcut, iconOn, iconOff, longPress } = config
const [pushToTalk, setPushToTalk] = useState(false)
@@ -50,78 +46,36 @@ export const ToggleDevice = ({
setPushToTalk(false)
}
const permissions = usePermissions()
const isPermissionDeniedOrPrompted = useMemo(() => {
switch (config.kind) {
case 'audioinput':
return (
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
)
case 'videoinput':
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [permissions, config.kind])
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
const isEnabledAndPermitted = enabled && !isPermissionDeniedOrPrompted
const toggleLabel = useMemo(() => {
const label = t(isEnabledAndPermitted ? 'disable' : 'enable', {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `join.${kind}`,
})
return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [isEnabledAndPermitted, kind, shortcut, t])
}, [enabled, kind, shortcut, t])
const Icon = isEnabledAndPermitted ? iconOn : iconOff
const Icon = enabled ? iconOn : iconOff
const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) {
return <ActiveSpeakerWrapper />
}
const errorVariant = variant == 'whiteCircle' ? 'errorCircle' : 'error2'
return (
<div
className={css({
position: 'relative',
})}
<ToggleButton
isSelected={!enabled}
variant={enabled ? variant : 'error2'}
shySelected
onPress={() => toggle()}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
{...toggleButtonProps}
>
<ToggleButton
isSelected={!enabled}
variant={isEnabledAndPermitted ? variant : errorVariant}
shySelected
onPress={() => (isPermissionDeniedOrPrompted ? open() : toggle())}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
{isPermissionDeniedOrPrompted && (
<span
className={css({
position: 'absolute',
height: '1.25rem',
width: '1.25rem',
backgroundColor: 'amber.400',
top: variant == 'whiteCircle' ? '-1px' : '-5px',
left: variant == 'whiteCircle' ? '-2px' : '-5px',
borderRadius: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: 'bold',
})}
>
!
</span>
)}
</div>
<Icon />
</ToggleButton>
)
}
@@ -1,5 +1,5 @@
import { LocalVideoTrack, Track } from 'livekit-client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
BackgroundProcessorFactory,
@@ -8,16 +8,16 @@ import {
BackgroundOptions,
} from '../blur'
import { css } from '@/styled-system/css'
import { Text, P, ToggleButton, H, Button } from '@/primitives'
import { Text, P, ToggleButton, H } from '@/primitives'
import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
import { usePermissions } from '@/features/rooms/hooks/usePermissions'
import { useModal } from '@/features/rooms/hooks/useModal'
enum BlurRadius {
NONE = 0,
@@ -37,7 +37,7 @@ const Information = styled('div', {
})
export type EffectsConfigurationProps = {
videoTrack?: LocalVideoTrack
videoTrack: LocalVideoTrack
onSubmit?: (processor?: BackgroundProcessorInterface) => void
layout?: 'vertical' | 'horizontal'
}
@@ -50,20 +50,10 @@ export const EffectsConfiguration = ({
const videoRef = useRef<HTMLVideoElement>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const { isCameraGranted } = usePermissions()
const { open } = useModal('permissions')
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
// Note: videoTrack.getProcessor() will return undefined during a transition
// but our selectedProcessor state maintains the intended value
const [selectedProcessor, setSelectedProcessor] = useState(
videoTrack?.getProcessor()
)
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return
@@ -73,13 +63,12 @@ export const EffectsConfiguration = ({
return () => {
if (!videoElement) return
videoTrack?.detach(videoElement)
videoTrack.detach(videoElement)
}
}, [videoTrack, videoTrack?.isMuted])
const clearEffect = async () => {
await videoTrack?.stopProcessor()
setSelectedProcessor(undefined)
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
@@ -103,6 +92,7 @@ export const EffectsConfiguration = ({
await toggle(true, {
processor: newProcessorTmp,
})
setTimeout(() => setProcessorPending(false))
return
}
@@ -110,10 +100,9 @@ export const EffectsConfiguration = ({
await toggle(true)
}
const processor = videoTrack?.getProcessor() as BackgroundProcessorInterface
const processor = getProcessor()
try {
if (isSelected(type, options)) {
setSelectedProcessor(undefined)
// Stop processor.
await clearEffect()
} else if (!processor || processor.serialize().type !== type) {
@@ -129,27 +118,28 @@ export const EffectsConfiguration = ({
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
await videoTrack.stopProcessor()
}
await videoTrack.setProcessor(newProcessor)
setSelectedProcessor(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)
setSelectedProcessor(processor)
}
} catch (error) {
console.error('Error applying effect:', error)
} finally {
setProcessorPending(false)
// 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) => {
if (!selectedProcessor) return false
const processor = selectedProcessor as BackgroundProcessorInterface
const processor = getProcessor()
const processorSerialized = processor?.serialize()
return (
!!processor &&
@@ -162,11 +152,6 @@ export const EffectsConfiguration = ({
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
const isDisabled = useMemo(
() => processorPending || videoTrack?.isMuted || !isCameraGranted,
[processorPending, videoTrack, isCameraGranted]
)
return (
<div
className={css(
@@ -180,7 +165,7 @@ export const EffectsConfiguration = ({
display: 'flex',
gap: '1.5rem',
flexDirection: 'column',
lg: {
md: {
flexDirection: 'row',
overflow: 'hidden',
},
@@ -190,65 +175,31 @@ export const EffectsConfiguration = ({
<div
className={css({
width: '100%',
aspectRatio: 16 / 9,
position: 'relative',
})}
>
{videoTrack && !videoTrack.isMuted ? (
<video
ref={videoRef}
width="100%"
muted
className={css(
layout === 'vertical'
? {
height: '175px',
width: '100%',
}
: {
minHeight: '175px',
maxWidth: '600px',
width: '100%',
objectFit: 'cover',
sm: {
aspectRatio: 16 / 9,
},
lg: {
maxWidth: '100%',
},
}
)}
style={{
transform: 'rotateY(180deg)',
[layout === 'vertical' ? 'height' : 'minHeight']: '175px',
borderRadius: '8px',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: 'black',
justifyContent: 'center',
flexDirection: 'column',
borderRadius: '8px',
}}
className={css(
layout === 'vertical'
? {
height: '175px',
width: '100%',
}
: {
minHeight: '175px',
maxWidth: '600px',
width: '100%',
objectFit: 'cover',
sm: {
aspectRatio: 16 / 9,
},
lg: {
maxWidth: '100%',
},
}
)}
>
<P
style={{
@@ -258,23 +209,19 @@ export const EffectsConfiguration = ({
marginBottom: 0,
}}
>
{t(isCameraGranted ? 'activateCamera' : 'permissionsCamera')}
{t('activateCamera')}
</P>
<Button
size="sm"
variant="tertiary"
onPress={async () => (isCameraGranted ? await toggle() : open())}
aria-label={t(
isCameraGranted ? 'activateButton' : 'permissionsButton'
)}
className={css({
width: 'fit-content',
marginX: 'auto',
marginTop: '1rem',
})}
>
{t(isCameraGranted ? 'activateButton' : 'permissionsButton')}
</Button>
</div>
)}
{processorPendingReveal && (
<div
className={css({
position: 'absolute',
right: '8px',
bottom: '8px',
})}
>
<Loader />
</div>
)}
</div>
@@ -295,7 +242,7 @@ export const EffectsConfiguration = ({
{hasFunnyEffectsAccess && (
<FunnyEffects
videoTrack={videoTrack}
isPending={processorPending}
isPending={processorPendingReveal}
onPending={setProcessorPending}
/>
)}
@@ -323,8 +270,8 @@ export const EffectsConfiguration = ({
onPress={async () => {
await clearEffect()
}}
isSelected={!selectedProcessor}
isDisabled={isDisabled}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal}
>
<RiProhibited2Line />
</ToggleButton>
@@ -336,7 +283,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={isDisabled}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
@@ -357,7 +304,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={isDisabled}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
@@ -405,7 +352,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={isDisabled}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
@@ -7,7 +7,7 @@ import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
import { LocalVideoTrack } from 'livekit-client'
export type FunnyEffectsProps = {
videoTrack?: LocalVideoTrack
videoTrack: LocalVideoTrack
isPending?: boolean
onPending: (value: boolean) => void
}
@@ -47,12 +47,12 @@ export const FunnyEffects = ({
try {
if (!newOptions.showGlasses && !newOptions.showFrench) {
await videoTrack?.stopProcessor()
await videoTrack.stopProcessor()
} else if (options.showGlasses || options.showFrench) {
await processor?.update(newOptions)
} else {
const newProcessor = new FaceLandmarksProcessor(newOptions)
await videoTrack?.setProcessor(newProcessor)
await videoTrack.setProcessor(newProcessor)
}
} catch (e) {
console.error('could not update processor', e)
@@ -1,33 +0,0 @@
import { Track } from 'livekit-client'
import { SelectToggleDeviceConfigMap } from '../types/SelectToggleDevice'
import {
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
export const SELECT_TOGGLE_DEVICE_CONFIG: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
@@ -16,9 +16,6 @@ export function usePersistentUserChoices() {
saveAudioInputDeviceId: (deviceId: string) => {
userChoicesStore.audioDeviceId = deviceId
},
saveAudioOutputDeviceId: (deviceId: string) => {
userChoicesStore.audioOutputDeviceId = deviceId
},
saveVideoInputDeviceId: (deviceId: string) => {
userChoicesStore.videoDeviceId = deviceId
},
@@ -1,22 +0,0 @@
import { Track } from 'livekit-client'
import { RemixiconComponentType } from '@remixicon/react'
import { Shortcut } from '@/features/shortcuts/types'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
export type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
export type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
+15 -18
View File
@@ -1,27 +1,21 @@
import { ReactNode, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { useLocation, useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { PermissionErrorModal } from '../components/PermissionErrorModal'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
import { usePermissionsSync } from '@/features/rooms/hooks/usePermissions'
const BaseRoom = ({ children }: { children: ReactNode }) => (
<UserAware>
<PermissionErrorModal />
{children}
</UserAware>
)
import { LocalUserChoices } from '@/stores/userChoices'
export const Room = () => {
const { isLoggedIn } = useUser()
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const { roomId } = useParams()
const [location, setLocation] = useLocation()
@@ -30,7 +24,6 @@ export const Room = () => {
const skipJoinScreen = isLoggedIn && mode === 'create'
useKeyboardShortcuts()
usePermissionsSync()
const clearRouterState = () => {
if (window?.history?.state) {
@@ -55,21 +48,25 @@ export const Room = () => {
return <ErrorScreen />
}
if (!hasSubmittedEntry && !skipJoinScreen) {
if (!userConfig && !skipJoinScreen) {
return (
<BaseRoom>
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
</BaseRoom>
<UserAware>
<Join onSubmit={setUserConfig} roomId={roomId} />
</UserAware>
)
}
return (
<BaseRoom>
<UserAware>
<Conference
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</BaseRoom>
</UserAware>
)
}
@@ -95,7 +95,6 @@ export const AudioTab = ({ id }: AudioTabProps) => {
userChoices: { noiseReductionEnabled },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const isSpeaking = useIsSpeaking(localParticipant)
@@ -184,10 +183,9 @@ export const AudioTab = ({ id }: AudioTabProps) => {
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={async (key) => {
onSelectionChange={async (key) =>
setActiveMediaDeviceOut(key as string)
saveAudioOutputDeviceId(key as string)
}}
}
{...disabledProps}
style={{
minWidth: 0,
@@ -0,0 +1,30 @@
import { useEffect, useRef, useState } from 'react'
/**
* If value stays truthy for more than waitFor ms, syncValue takes the value of value.
* @param value
* @param waitFor
* @returns
*/
export function useSyncAfterDelay<T>(value: T, waitFor: number = 300) {
const valueRef = useRef(value)
const timeoutRef = useRef<NodeJS.Timeout>()
const [syncValue, setSyncValue] = useState<T>()
useEffect(() => {
valueRef.current = value
if (value) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => {
setSyncValue(valueRef.current)
timeoutRef.current = undefined
}, waitFor)
}
} else {
setSyncValue(value)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
return syncValue
}
+3 -35
View File
@@ -8,10 +8,6 @@
"back": "Dem Meeting erneut beitreten"
},
"join": {
"selectDevice": {
"loading": "Lädt…",
"permissionNeeded": "Genehmigung erforderlich"
},
"videoinput": {
"choose": "Kamera auswählen",
"disable": "Kamera deaktivieren",
@@ -25,9 +21,6 @@
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
},
"audiooutput": {
"choose": "Lautsprecher auswählen"
},
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
@@ -43,14 +36,8 @@
"errors": {
"usernameEmpty": "Ihr Name darf nicht leer sein"
},
"cameraDisabled": "Die Kamera ist deaktiviert",
"cameraStarting": "Die Kamera wird gestartet",
"cameraNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen können?",
"cameraAndMicNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen und hören können?",
"videoPreview": {
"enabled": "Videovorschau aktiviert",
"disabled": "Videovorschau deaktiviert"
},
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraStarting": "Kamera wird gestartet.",
"waiting": {
"title": "Beitrittsanfrage wird gesendet...",
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
@@ -64,22 +51,6 @@
"body": "Niemand hat auf Ihre Anfrage reagiert"
}
},
"permissionErrorDialog": {
"heading": {
"camera": "{{app_title}} hat keine Berechtigung, Ihre Kamera zu verwenden",
"microphone": "{{app_title}} hat keine Berechtigung, Ihr Mikrofon zu verwenden",
"cameraAndMicrophone": "{{app_title}} hat keine Berechtigung, Ihr Mikrofon und Ihre Kamera zu verwenden",
"default": "{{app_title}} hat keine Berechtigung für bestimmte Zugriffe"
},
"body": {
"openMenu": "Klicken Sie auf das Einstellungssymbol {{icon_placeholder}} in der Adressleiste Ihres Browsers",
"details": {
"camera": "Zugriff auf die Kamera erlauben",
"microphone": "Zugriff auf das Mikrofon erlauben",
"cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben"
}
}
},
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
"shareDialog": {
"copy": "Meeting-Link kopieren",
@@ -183,10 +154,7 @@
}
},
"effects": {
"activateCamera": "Kamera ist aus.",
"activateButton": "Kamera einschalten",
"permissionsCamera": "Bitte erlauben Sie der App den Zugriff auf Ihre Kamera, um fortzufahren.",
"permissionsButton": "Kamerazugriff erlauben",
"activateCamera": "Ihre Kamera ist deaktiviert. Wählen Sie eine Option, um sie zu aktivieren.",
"notAvailable": "Videoeffekte werden bald in Ihrem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit können Sie Google Chrome für die beste Leistung oder Firefox verwenden :(",
"heading": "Unschärfe",
"clear": "Effekt deaktivieren",
+3 -35
View File
@@ -8,10 +8,6 @@
"back": "Rejoin the meeting"
},
"join": {
"selectDevice": {
"loading": "Loading…",
"permissionNeeded": "Permission needed"
},
"videoinput": {
"choose": "Select camera",
"disable": "Disable camera",
@@ -25,9 +21,6 @@
"enable": "Enable microphone",
"label": "Microphone"
},
"audiooutput": {
"choose": "Select speakers"
},
"effects": {
"description": "Apply effects",
"title": "Effects",
@@ -43,14 +36,8 @@
"errors": {
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "The camera is disabled",
"cameraStarting": "The camera is starting",
"cameraNotGranted": "Do you want others to be able to see you during the meeting?",
"cameraAndMicNotGranted": "Do you want others to be able to see and hear you during the meeting?",
"videoPreview": {
"enabled": "Video preview enabled",
"disabled": "Video preview disabled"
},
"cameraDisabled": "Camera is disabled.",
"cameraStarting": "Camera is starting.",
"waiting": {
"title": "Requesting to join...",
"body": "You will be able to join this call when someone authorizes you"
@@ -64,22 +51,6 @@
"body": "No one responded to your request"
}
},
"permissionErrorDialog": {
"heading": {
"camera": "{{app_title}} is not allowed to use your camera",
"microphone": "{{app_title}} is not allowed to use your microphone",
"cameraAndMicrophone": "{{app_title}} is not allowed to use your microphone or camera",
"default": "{{app_title}} is not allowed to use certain permissions"
},
"body": {
"openMenu": "Click the settings icon {{icon_placeholder}} in your browser's address bar",
"details": {
"camera": "Allow access to the camera",
"microphone": "Allow access to the microphone",
"cameraAndMicrophone": "Allow access to the camera and microphone"
}
}
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copy": "Copy the meeting link",
@@ -183,10 +154,7 @@
}
},
"effects": {
"activateCamera": "Your camera is disabled.",
"activateButton": "Turn on camera",
"permissionsCamera": "Please allow the app to access your camera to continue.",
"permissionsButton": "Allow camera access",
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
"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",
"clear": "Disable effect",
+3 -35
View File
@@ -8,10 +8,6 @@
"back": "Réintégrer la réunion"
},
"join": {
"selectDevice": {
"loading": "Chargement…",
"permissionNeeded": "Autorisations nécessaires"
},
"videoinput": {
"choose": "Choisir la webcam",
"disable": "Désactiver la webcam",
@@ -25,9 +21,6 @@
"enable": "Activer le micro",
"label": "Microphone"
},
"audiooutput": {
"choose": "Choisir le haut parleur"
},
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière plan",
@@ -43,14 +36,8 @@
"errors": {
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée",
"cameraStarting": "La caméra va démarrer",
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
"videoPreview": {
"enabled": "Apercu vidéo activé",
"disabled": "Apercu vidéo désactivé"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraStarting": "La caméra va démarrer.",
"waiting": {
"title": "Demande de participation…",
"body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera"
@@ -64,22 +51,6 @@
"body": "Personne n'a répondu à votre demande de participation à l'appel"
}
},
"permissionErrorDialog": {
"heading": {
"camera": "{{app_title}} n'est pas autorisé à utiliser votre caméra",
"microphone": "{{app_title}} n'est pas autorisé à utiliser votre micro",
"cameraAndMicrophone": "{{app_title}} n'est pas autorisé à utiliser votre micro ni votre caméra",
"default": "{{app_title}} n'est pas autorisé à utiliser certaines permissions"
},
"body": {
"openMenu": "Cliquez sur l'icône des paramètres {{icon_placeholder}} dans la barre d'adresse de votre navigateur",
"details": {
"camera": "Autorisez l'accès à la caméra",
"microphone": "Autorisez l'accès au microphone",
"cameraAndMicrophone": "Autorisez l'accès à la caméra et au microphone"
}
}
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copy": "Copier le lien de la réunion",
@@ -183,10 +154,7 @@
}
},
"effects": {
"activateCamera": "Caméra désactivée.",
"activateButton": "Activer la caméra",
"permissionsCamera": "Veuillez autoriser l'application à accéder à votre caméra pour continuer.",
"permissionsButton": "Autoriser l'accès à la caméra",
"activateCamera": "Votre caméra est désactivée. Choisissez une option pour l'activer.",
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
"heading": "Flou",
"clear": "Désactiver l'effect",
+3 -35
View File
@@ -8,10 +8,6 @@
"back": "Sluit weer bij de vergadering aan"
},
"join": {
"selectDevice": {
"loading": "Laden…",
"permissionNeeded": "Toestemming vereist"
},
"videoinput": {
"choose": "Selecteer camera",
"disable": "Camera uitschakelen",
@@ -25,9 +21,6 @@
"enable": "Microfoon dempen opheffen",
"label": "Microfoon"
},
"audiooutput": {
"choose": "Selecteer luidsprekers"
},
"effects": {
"description": "Pas effecten toe",
"title": "Effecten",
@@ -43,14 +36,8 @@
"errors": {
"usernameEmpty": "Uw naam kan niet leeg zijn"
},
"cameraDisabled": "De camera is uitgeschakeld",
"cameraStarting": "De camera wordt gestart…",
"cameraNotGranted": "Wil je dat anderen je kunnen zien tijdens de vergadering?",
"cameraAndMicNotGranted": "Wil je dat anderen je kunnen zien en horen tijdens de vergadering?",
"videoPreview": {
"enabled": "Videovoorbeeld ingeschakeld",
"disabled": "Videovoorbeeld uitgeschakeld"
},
"cameraDisabled": "Camera is uitgeschakeld.",
"cameraStarting": "Camera wordt ingeschakeld.",
"waiting": {
"title": "Verzoek tot deelname...",
"body": "U kunt deelnemen aan dit gesprek wanneer iemand u toestemming geeft"
@@ -64,22 +51,6 @@
"body": "Niemand heeft gereageerd op uw verzoek om deel te nemen aan het gesprek"
}
},
"permissionErrorDialog": {
"heading": {
"camera": "{{app_title}} heeft geen toestemming om je camera te gebruiken",
"microphone": "{{app_title}} heeft geen toestemming om je microfoon te gebruiken",
"cameraAndMicrophone": "{{app_title}} heeft geen toestemming om je microfoon en camera te gebruiken",
"default": "{{app_title}} heeft geen toestemming voor bepaalde rechten"
},
"body": {
"openMenu": "Klik op het instellingenpictogram {{icon_placeholder}} in de adresbalk van je browser",
"details": {
"camera": "Sta toegang tot de camera toe",
"microphone": "Sta toegang tot de microfoon toe",
"cameraAndMicrophone": "Sta toegang tot de camera en microfoon toe"
}
}
},
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
"shareDialog": {
"copy": "Kopieer de vergaderlink",
@@ -183,10 +154,7 @@
}
},
"effects": {
"activateCamera": "Camera is uit.",
"activateButton": "Camera inschakelen",
"permissionsCamera": "Sta de app toe om toegang te krijgen tot je camera om door te gaan.",
"permissionsButton": "Toegang tot camera toestaan",
"activateCamera": "Uw camera is uitgeschakeld. Kies een optie om deze in te schakelen.",
"notAvailable": "Video-effecten zijn binnenkort beschikbaar in uw browser. We werken eraan! In de tussentijd kunt u Google Chrome gebruiken voor de beste prestaties of Firefox :(",
"heading": "Vervaging",
"clear": "Effect uitschakelen",
+2 -22
View File
@@ -1,6 +1,6 @@
import { type ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
import { RiArrowDropDownLine } from '@remixicon/react'
import {
Button,
ListBox,
@@ -12,14 +12,12 @@ import {
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { css } from '@/styled-system/css'
const StyledButton = styled(Button, {
base: {
width: 'full',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingY: 0.125,
paddingX: 0.25,
border: '1px solid',
@@ -55,40 +53,22 @@ const StyledSelectValue = styled(SelectValue, {
},
})
const StyledIcon = styled('div', {
base: {
marginRight: '0.35rem',
flexShrink: 0,
},
})
export const Select = <T extends string | number>({
label,
iconComponent,
items,
errors,
...props
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
}) => {
const IconComponent = iconComponent
return (
<RACSelect {...props}>
{label}
<StyledButton>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
</StyledIcon>
)}
<StyledSelectValue />
<RiArrowDropDownLine
aria-hidden="true"
className={css({ flexShrink: 0 })}
/>
<RiArrowDropDownLine aria-hidden="true" />
</StyledButton>
<StyledPopover>
<Box size="sm" type="popover" variant="control">
@@ -228,20 +228,6 @@ export const buttonRecipe = cva({
color: 'error.200',
},
},
errorCircle: {
width: '56px',
height: '56px',
borderRadius: '100%',
backgroundColor: 'error.500',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'error.600',
},
'&[data-pressed]': {
backgroundColor: 'error.700',
color: 'error.200',
},
},
error2: {
backgroundColor: 'error.200',
color: 'error.900',
-9
View File
@@ -1,9 +0,0 @@
import { proxy } from 'valtio'
export type ModalsState = {
permissions: boolean
}
export const modalsStore = proxy<ModalsState>({
permissions: false,
})
-13
View File
@@ -1,13 +0,0 @@
import { proxy } from 'valtio'
type PermissionState = undefined | 'granted' | 'prompt' | 'denied'
type State = {
cameraPermission: PermissionState
microphonePermission: PermissionState
}
export const permissionStore = proxy<State>({
cameraPermission: undefined,
microphonePermission: undefined,
})
-2
View File
@@ -9,13 +9,11 @@ import {
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
}
function getUserChoicesState(): LocalUserChoices {
return {
noiseReductionEnabled: false,
audioOutputDeviceId: 'default',
...loadUserChoices(),
}
}
@@ -52,6 +52,7 @@ backend:
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
LIVEKIT_FORCE_WSS_PROTOCOL: True
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False