Compare commits

..

23 Commits

Author SHA1 Message Date
lebaudantoine 419892d441 wip manually mute/unmute track 2025-07-31 21:48:36 +02:00
lebaudantoine 9b1b51da1e wip arrow up
it makes more sense to have the arrow up as the menu open to the top
2025-07-31 21:10:31 +02:00
lebaudantoine fc54486645 wip handle permission issue in SelectDeviceJoin component 2025-07-31 21:10:31 +02:00
lebaudantoine d669f4b0e6 wip refactor the hook permission
avoid having several event listener, maintain a global state
2025-07-31 21:10:31 +02:00
lebaudantoine 4593728812 wip refacto responsive join screen 2025-07-31 21:10:31 +02:00
lebaudantoine 4d3fac8b56 wip prevent interacting with empty select in device selection 2025-07-31 21:10:31 +02:00
lebaudantoine 727f0c362f wip disable effects configurations when permissions are disabled 2025-07-31 21:10:31 +02:00
lebaudantoine fca58114ed wip import join 2025-07-31 21:10:31 +02:00
lebaudantoine 66e9913c58 refactor usePreview to create track separately when needed 2025-07-31 21:10:31 +02:00
lebaudantoine b3795bc9a3 wip warning 2025-07-31 21:10:31 +02:00
lebaudantoine a3c2406f9d wip encapsulate it in the hook 2025-07-31 21:10:31 +02:00
lebaudantoine 597406f550 vendor the usePreviewTracks to adapt it
only modification I made was on the roomOptionsStringifyReplacer
I ignore processor to avoid stopping / starting local track when a processor
is changed or apply
2025-07-31 21:10:31 +02:00
lebaudantoine 0183afcf77 wip handle permission when toggling device + display modal to guide user 2025-07-31 21:10:31 +02:00
lebaudantoine 1eff4e1477 wip introduce permissionErrorDialog
add svg to guide user to give permissions
2025-07-31 21:10:28 +02:00
lebaudantoine 4a25279555 wip add store and hook to gloablly manage model openness when necessary 2025-07-31 21:10:28 +02:00
lebaudantoine e3251bb980 wip hook to watch permissions 2025-07-31 21:10:28 +02:00
lebaudantoine 1ae08420a4 wip allow selecting audioutput from join screen 2025-07-31 21:09:00 +02:00
lebaudantoine e29c241d4e wip init audioOutput device with user choice and persist it 2025-07-29 14:21:00 +02:00
lebaudantoine f1cf14ae31 wip persist audioOutputDeviceId in local storage 2025-07-29 00:13:44 +02:00
lebaudantoine b9e924a304 wip add an icon to the select primitive 2025-07-29 00:13:13 +02:00
lebaudantoine 35125d965c wip 2025-07-29 00:01:36 +02:00
lebaudantoine f847f07c24 wip refactor effects configuration
- add a button to explicitely enable camera when a room
display selected option even if camera is disabled, and suggest to the user to enable its
camera to allow her configure the effects

- remove useless useSyncAfterDelay
issue with rendering when enabling a processor, livekit is
working on it, it's a bad approach to fix it with a deteministic delay
it doesn't work and complexify the code

I thought about listening to some html event however, we should fix the root cause instead
2025-07-28 18:30:51 +02:00
lebaudantoine 3e94ed0a8b wip refactor effects prejoin
- avoid props drillig, rely on localstorage
- using an explicit flag to trigger the entry instead of depending of userconfig state (better encapsulation)
- init processor from video initiation, avoid weird blink, less hook and callback

overall, simplify and improve the user experience
2025-07-28 12:17:21 +02:00
122 changed files with 1967 additions and 3798 deletions
-2
View File
@@ -82,7 +82,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -187,7 +186,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
-7
View File
@@ -85,13 +85,6 @@ clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
migration = '''
set -eu
# get k8s pod name from tilt resource name
+3
View File
@@ -35,6 +35,9 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
+3 -2
View File
@@ -217,6 +217,9 @@ DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
## Deployment
@@ -336,8 +339,6 @@ 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,7 +52,6 @@ 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,9 +492,6 @@ 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,
+11 -22
View File
@@ -13,12 +13,10 @@
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
@@ -26,7 +24,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.5",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -3379,12 +3377,12 @@
}
},
"node_modules/@react-types/overlays": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
"version": "3.8.16",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.8.16.tgz",
"integrity": "sha512-Aj9jIFwALk9LiOV/s3rVie+vr5qWfaJp/6aGOuc2StSNDTHvj1urSAr3T0bT8wDlkrqnlS4JjEGE40ypfOkbAA==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.31.0"
"@react-types/shared": "^3.30.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3440,9 +3438,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.30.0.tgz",
"integrity": "sha512-COIazDAx1ncDg046cTJ8SFYsX8aS3lB/08LDnbkH/SkdYrFPWDlXMrO/sUam8j1WWM+PJ+4d1mj7tODIKNiFog==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -5296,15 +5294,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/derive-valtio": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0-rc.0"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -7460,9 +7449,9 @@
}
},
"node_modules/livekit-client": {
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.5.tgz",
"integrity": "sha512-zn36akmDlqZxlrTOUgYXtxtj35HQ44aJ+mgKat9BTSPiZru4RjEHOtp8RJE6jGoN2miJlWiOeEKHB2+ae3YrSw==",
"version": "2.15.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.2.tgz",
"integrity": "sha512-hf0A0JFN7M0iVGZxMfTk6a3cW7TNTVdqxkykjKBweORlqhQX1ITVloh6aLvplLZOxpkUE5ZVLz1DeS3+ERglog==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
+1 -3
View File
@@ -18,12 +18,10 @@
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
@@ -31,7 +29,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.5",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
-1
View File
@@ -39,7 +39,6 @@ export interface ApiConfig {
manifest_link?: string
livekit: {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
}
}
@@ -1,187 +1,100 @@
import { useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Bold, Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { ApiAccessLevel, ApiRoom } from '@/features/rooms/api/ApiRoom'
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
// fixme - duplication with the InviteDialog
export const LaterMeetingDialog = ({
room,
roomId,
...dialogProps
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home')
const roomUrl = getRouteUrl('room', roomId)
const roomUrl = room && getRouteUrl('room', room?.slug)
const telephony = useTelephony()
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const [isHovered, setIsHovered] = useState(false)
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && room?.pin_code
}, [telephony?.enabled, room?.pin_code])
const {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(room || undefined)
return (
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
<P>{t('description')}</P>
{!!roomUrl && (
<>
{isTelephonyReadyForUse ? (
<div
className={css({
width: '100%',
backgroundColor: 'gray.50',
borderRadius: '0.75rem',
display: 'flex',
flexDirection: 'column',
padding: '1.75rem 1.5rem',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
})}
>
<Dialog
isOpen={!!roomId}
{...dialogProps}
title={t('laterMeetingDialog.heading')}
>
<P>{t('laterMeetingDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('laterMeetingDialog.copy')}
style={{
justifyContent: 'start',
}}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('laterMeetingDialog.copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('laterMeetingDialog.copy')
) : (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
)}
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(room?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'tertiaryText'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
onPress={copyRoomToClipboard}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
/>
{t('copy')}
</>
)}
</Button>
</div>
) : (
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('copy')
) : (
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
>
{roomUrl?.replace(/^https?:\/\//, '')}
</div>
)}
</>
)}
</Button>
)}
{room?.access_level == ApiAccessLevel.PUBLIC && (
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
{roomUrl.replace(/^https?:\/\//, '')}
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</>
)}
)}
</>
)}
</Button>
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('laterMeetingDialog.permissions')}
</Text>
</HStack>
</Dialog>
)
}
@@ -18,7 +18,6 @@ import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -154,7 +153,7 @@ export const Home = () => {
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
const { data } = useConfig()
@@ -203,7 +202,7 @@ export const Home = () => {
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
@@ -252,8 +251,8 @@ export const Home = () => {
</RightColumn>
</Columns>
<LaterMeetingDialog
room={laterRoom}
onOpenChange={() => setLaterRoom(null)}
roomId={laterRoomId ?? ''}
onOpenChange={() => setLaterRoomId(null)}
/>
</Screen>
</UserAware>
@@ -18,6 +18,7 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -98,7 +99,6 @@ export const MainNotificationToast = () => {
case NotificationType.ScreenRecordingLimitReached:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.ALERT }
@@ -155,14 +155,17 @@ export const MainNotificationToast = () => {
useEffect(() => {
const handleNotificationReceived = (
changedAttributes: Record<string, string>,
prevMetadataStr: string | undefined,
participant: Participant
) => {
if (!participant) return
if (isMobileBrowser()) return
if (participant.isLocal) return
if (!('handRaisedAt' in changedAttributes)) return
const prevMetadata = safeParseMetadata(prevMetadataStr)
const metadata = safeParseMetadata(participant.metadata)
if (prevMetadata?.raised == metadata?.raised) return
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
@@ -170,12 +173,12 @@ export const MainNotificationToast = () => {
toast.content.type === NotificationType.HandRaised
)
if (existingToast && !changedAttributes?.handRaisedAt) {
if (existingToast && prevMetadata.raised && !metadata.raised) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && !!changedAttributes?.handRaisedAt) {
if (!existingToast && !prevMetadata.raised && metadata.raised) {
triggerNotificationSound(NotificationType.HandRaised)
toastQueue.add(
{
@@ -187,13 +190,10 @@ export const MainNotificationToast = () => {
}
}
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
return () => {
room.off(
RoomEvent.ParticipantAttributesChanged,
handleNotificationReceived
)
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
}
}, [room, triggerNotificationSound])
@@ -10,9 +10,6 @@ import {
MediaDeviceFailure,
Room,
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
VideoPresets,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -87,21 +84,15 @@ export const Conference = ({
retry: false,
})
const isAdaptiveStreamSupported = supportsAdaptiveStream()
const isDynacastSupported = supportsDynacast()
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: isAdaptiveStreamSupported,
dynacast: isDynacastSupported,
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
resolution: userConfig.videoPublishResolution
? VideoPresets[userConfig.videoPublishResolution].resolution
: undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
@@ -113,11 +104,8 @@ export const Conference = ({
// do not rely on the userConfig object directly as its reference may change on every render
}, [
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -176,20 +164,6 @@ 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.
@@ -213,7 +187,7 @@ export const Conference = ({
<Screen header={false} footer={false}>
<LiveKitRoom
room={room}
serverUrl={serverUrl}
serverUrl={apiConfig?.livekit.url}
token={data?.livekit?.token}
connect={isConnectionWarmedUp}
audio={userConfig.audioEnabled}
@@ -249,6 +223,7 @@ export const Conference = ({
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
/>
)}
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
import { Div, Button, type DialogProps, P } from '@/primitives'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
@@ -10,13 +10,8 @@ import {
RiFileCopyLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useMemo } from 'react'
import { useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
// fixme - extract in a proper primitive this dialog without overlay
const StyledRACDialog = styled(Dialog, {
@@ -39,27 +34,24 @@ const StyledRACDialog = styled(Dialog, {
},
})
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
export const InviteDialog = ({
roomId,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms')
const roomUrl = getRouteUrl('room', roomId)
const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug)
const [isCopied, setIsCopied] = useState(false)
const telephony = useTelephony()
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && roomData?.pin_code
}, [telephony?.enabled, roomData?.pin_code])
const {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(roomData)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
return (
<StyledRACDialog {...props}>
<StyledRACDialog {...dialogProps}>
{({ close }) => (
<VStack
alignItems="left"
@@ -68,7 +60,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
{t('heading')}
{t('shareDialog.heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
@@ -76,7 +68,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
variant="tertiaryText"
size="xs"
onPress={() => {
props.onClose?.()
dialogProps.onClose?.()
close()
}}
aria-label={t('closeDialog')}
@@ -84,125 +76,49 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<RiCloseLine />
</Button>
</Div>
<P>{t('description')}</P>
{isTelephonyReadyForUse ? (
<P>{t('shareDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={t('shareDialog.copy')}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('shareDialog.copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('shareDialog.copyButton')}
</>
)}
</Button>
<HStack>
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<div
<RiSpam2Fill
size={22}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fill: 'primary.500',
})}
>
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && roomUrl && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
)}
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
/>
{t('copy')}
</>
)}
</Button>
/>
</div>
) : (
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
</>
)}
</Button>
)}
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('shareDialog.permissions')}
</Text>
</HStack>
</VStack>
)}
</StyledRACDialog>
@@ -1,15 +1,8 @@
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 {
createLocalVideoTrack,
createLocalAudioTrack,
LocalAudioTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { LocalTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
@@ -20,8 +13,6 @@ import {
EffectsConfiguration,
EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration'
import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice'
import { ToggleDevice } from '../livekit/components/controls/Device/ToggleDevice'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core'
@@ -34,11 +25,10 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit'
import type { LocalUserChoices } from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
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'
const onError = (e: Error) => console.error('ERROR', e)
@@ -115,153 +105,35 @@ export const Join = ({
username,
},
saveAudioInputEnabled,
saveAudioOutputDeviceId,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveAudioOutputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null)
if (initialUserChoices.current === null) {
initialUserChoices.current = {
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
}
}
const tracks = usePreviewTracks(
// fix - permission issue, when permission not set doesn't refresh
const { videoTrack, audioTrack } = usePreviewTracks(
{
audio: !!initialUserChoices.current &&
initialUserChoices.current?.audioEnabled && {
deviceId: initialUserChoices.current.audioDeviceId,
},
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
},
audio: { deviceId: audioDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
},
onError
)
const [dynamicVideoTrack, setDynamicVideoTrack] =
useState<LocalVideoTrack | null>(null)
const [dynamicAudioTrack, setDynamicAudioTrack] =
useState<LocalAudioTrack | null>(null)
const previewVideoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
)[0] as LocalVideoTrack,
[tracks]
)
const previewAudioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalAudioTrack,
[tracks]
)
/*
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
*/
useEffect(() => {
const createVideoTrack = async () => {
try {
const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
})
setDynamicVideoTrack(track)
} catch (error) {
onError(error as Error)
}
const toggleTrack = (track: LocalTrack | undefined, isEnabled: boolean) => {
if (!track) return
if (isEnabled) {
track?.unmute()
} else {
track?.mute()
}
if (
videoEnabled &&
!initialUserChoices.current?.videoEnabled &&
!previewVideoTrack &&
!dynamicVideoTrack
) {
createVideoTrack()
}
}, [
videoEnabled,
videoDeviceId,
processorSerialized,
previewVideoTrack,
dynamicVideoTrack,
])
useEffect(() => {
const createAudioTrack = async () => {
try {
const track = await createLocalAudioTrack({
deviceId: { exact: audioDeviceId },
})
setDynamicAudioTrack(track)
} catch (error) {
onError(error as Error)
}
}
if (
audioEnabled &&
!initialUserChoices.current?.audioEnabled &&
!previewAudioTrack &&
!dynamicAudioTrack
) {
createAudioTrack()
}
}, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack])
// Cleanup dynamic tracks
useEffect(() => {
return () => {
dynamicVideoTrack?.stop()
}
}, [dynamicVideoTrack])
useEffect(() => {
return () => {
dynamicAudioTrack?.stop()
}
}, [dynamicAudioTrack])
// Final tracks (dynamic takes precedence over preview)
const videoTrack = dynamicVideoTrack || previewVideoTrack
const audioTrack = dynamicAudioTrack || previewAudioTrack
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveInitiallyDefaultDeviceId(
audioDeviceId,
audioTrack,
saveAudioInputDeviceId
)
useResolveInitiallyDefaultDeviceId(
videoDeviceId,
videoTrack,
saveVideoInputDeviceId
)
}
const videoEl = useRef(null)
const isVideoInitiated = useRef(false)
@@ -277,17 +149,14 @@ export const Join = ({
}
if (videoElement && videoTrack && videoEnabled) {
videoTrack.unmute()
videoTrack.attach(videoElement)
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
}
return () => {
videoTrack?.detach()
if (videoElement) {
videoElement.removeEventListener('loadedmetadata', handleVideoLoaded)
videoElement.style.opacity = '0'
}
isVideoInitiated.current = false
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
}
}, [videoTrack, videoEnabled])
@@ -349,43 +218,28 @@ export const Join = ({
enterRoom()
}
const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput')
const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput')
const { isCameraGranted, isMicrophoneGranted } = usePermissions()
const hintMessage = useMemo(() => {
if (isCameraDeniedOrPrompted) {
return isMicrophoneDeniedOrPrompted
? 'cameraAndMicNotGranted'
: 'cameraNotGranted'
if (!isCameraGranted) {
if (!isMicrophoneGranted) {
return 'cameraAndMicNotGranted'
}
return 'cameraNotGranted'
}
if (!videoEnabled) {
return 'cameraDisabled'
}
if (!isVideoInitiated.current) {
return 'cameraStarting'
}
if (videoTrack && videoEnabled) {
return ''
}
}, [
videoTrack,
videoEnabled,
isCameraDeniedOrPrompted,
isMicrophoneDeniedOrPrompted,
])
const permissionsButtonLabel = useMemo(() => {
if (!isMicrophoneDeniedOrPrompted && !isCameraDeniedOrPrompted) {
return null
}
if (isCameraDeniedOrPrompted && isMicrophoneDeniedOrPrompted) {
return 'cameraAndMicNotGranted'
}
if (isCameraDeniedOrPrompted && !isMicrophoneDeniedOrPrompted) {
return 'cameraNotGranted'
}
return null
}, [isMicrophoneDeniedOrPrompted, isCameraDeniedOrPrompted])
}, [videoTrack, videoEnabled, isCameraGranted, isMicrophoneGranted])
const renderWaitingState = () => {
switch (status) {
@@ -438,6 +292,10 @@ export const Join = ({
submitButtonProps={{
fullWidth: true,
}}
className={css({
width: '100%',
maxWidth: '340px',
})}
>
<VStack marginBottom={1}>
<H lvl={1} margin="sm" centered>
@@ -447,7 +305,6 @@ export const Join = ({
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
@@ -521,28 +378,6 @@ export const Join = ({
alignItems: 'center',
})}
>
<div
className={css({
position: 'absolute',
top: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to bottom, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 40%, rgba(0, 0, 0, 0.1) 80%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'absolute',
bottom: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 35%, rgba(0, 0, 0, 0.1) 75%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'relative',
@@ -564,7 +399,7 @@ export const Join = ({
>
<div
aria-label={t(
`videoPreview.${videoEnabled ? 'enabled' : 'disabled'}`
`videoPreview.${videoEnabled && isCameraGranted ? 'enabled' : 'disabled'}`
)}
role="status"
className={css({
@@ -593,7 +428,7 @@ export const Join = ({
height="720"
style={{
display:
!videoEnabled || isCameraDeniedOrPrompted
!videoEnabled || !isCameraGranted
? 'none'
: undefined,
}}
@@ -622,7 +457,6 @@ export const Join = ({
alignItems: 'center',
padding: '0.24rem',
boxSizing: 'border-box',
gap: '1rem',
})}
>
<p
@@ -635,53 +469,43 @@ export const Join = ({
>
{hintMessage && t(hintMessage)}
</p>
{isCameraDeniedOrPrompted && (
<Button
size="sm"
variant="tertiary"
onPress={openPermissionsDialog}
>
{t(`permissionsButton.${permissionsButtonLabel}`)}
</Button>
)}
</div>
</div>
<div
className={css({
position: 'absolute',
right: '50%',
bottom: '1rem',
zIndex: '1',
display: 'flex',
gap: '1rem',
gap: '1.5rem',
justifyContent: 'center',
left: '50%',
transform: 'translateX(-50%)',
marginRight: '-68px',
})}
>
<ToggleDevice
kind="audioinput"
context="join"
enabled={audioEnabled}
toggle={async () => {
saveAudioInputEnabled(!audioEnabled)
if (audioEnabled) {
await audioTrack?.mute()
} else {
await audioTrack?.unmute()
}
<ToggleDeviceJoin
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
onChange={(value) => {
toggleTrack(audioTrack, value)
saveAudioInputEnabled(value)
}}
/>
<ToggleDevice
kind="videoinput"
context="join"
enabled={videoEnabled}
toggle={async () => {
saveVideoInputEnabled(!videoEnabled)
if (videoEnabled) {
await videoTrack?.mute()
} else {
await videoTrack?.unmute()
<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)
}}
/>
</div>
@@ -704,11 +528,14 @@ export const Join = ({
</div>
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '2%',
width: '80%',
marginX: 'auto',
display: 'none',
sm: {
display: 'flex',
justifyContent: 'center',
gap: '2%',
width: '80%',
marginX: 'auto',
},
})}
>
<div
@@ -716,52 +543,32 @@ export const Join = ({
width: '30%',
})}
>
<SelectDevice
<SelectDeviceJoin
kind="audioinput"
id={audioDeviceId}
onSubmit={async (id) => {
try {
saveAudioInputDeviceId(id)
if (audioTrack) {
await audioTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch microphone device', err)
}
}}
onSubmit={saveAudioInputDeviceId}
/>
</div>
{!isSafari() && (
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
)}
<div
className={css({
width: '30%',
})}
>
<SelectDevice
<SelectDeviceJoin
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
<div
className={css({
width: '30%',
})}
>
<SelectDeviceJoin
kind="videoinput"
id={videoDeviceId}
onSubmit={async (id) => {
try {
saveVideoInputDeviceId(id)
if (videoTrack) {
await await videoTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch camera device', err)
}
}}
onSubmit={saveVideoInputDeviceId}
/>
</div>
</div>
@@ -0,0 +1,90 @@
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,112 +0,0 @@
import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
import { css } from '@/styled-system/css'
import { Dialog, H } from '@/primitives'
import { RiEqualizer2Line } from '@remixicon/react'
import { useEffect, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
import { useTranslation } from 'react-i18next'
import { injectIconIntoTranslation } from '@/utils/translation'
import { isSafari } from '@/utils/livekit'
/**
* Singleton component - ensures permissions sync runs only once across the app.
* WARNING: This component should only be instantiated once in the interface.
* Multiple instances may cause unexpected behavior or performance issues.
*/
export const Permissions = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionErrorDialog' })
useWatchPermissions()
const permissions = useSnapshot(permissionsStore)
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])
const [descriptionBeforeIcon, descriptionAfterIcon] =
injectIconIntoTranslation(t('body.openMenu.others'))
useEffect(() => {
if (
permissions.isPermissionDialogOpen &&
permissions.isCameraGranted &&
permissions.isMicrophoneGranted
) {
closePermissionsDialog()
}
}, [permissions])
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
return (
<Dialog
isOpen={permissions.isPermissionDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
})}
onClose={closePermissionsDialog}
>
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<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}`, {
appTitle,
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{isSafari() ? (
t('body.openMenu.safari', {
appDomain: window.origin.replace('https://', ''),
})
) : (
<>
{descriptionBeforeIcon}
<span
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
<RiEqualizer2Line />
</span>
{descriptionAfterIcon}
</>
)}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
</div>
</div>
</Dialog>
)
}
@@ -0,0 +1,113 @@
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)
}}
/>
)
}
@@ -0,0 +1,33 @@
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,
}}
/>
)
}
@@ -0,0 +1,22 @@
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),
}
}
@@ -0,0 +1,66 @@
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',
}
}
@@ -0,0 +1,173 @@
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,
}
}
@@ -1,160 +0,0 @@
import { useEffect } from 'react'
import { permissionsStore } from '@/stores/permissions'
import { isSafari } from '@/utils/livekit'
const POLLING_TIME = 500
export const useWatchPermissions = () => {
useEffect(() => {
let cleanup: (() => void) | undefined
let intervalId: NodeJS.Timeout | undefined
let isCancelled = false
const checkPermissions = async () => {
try {
if (!navigator.permissions) {
if (!isCancelled) {
permissionsStore.cameraPermission = 'unavailable'
permissionsStore.microphonePermission = 'unavailable'
}
return
}
const [cameraPermission, microphonePermission] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
/**
* Safari Permission API Limitation Workaround
*
* Safari has a known issue where permission change events are not reliably fired
* when users interact with permission prompts. This is documented in Apple's forums:
* https://developer.apple.com/forums/thread/757353
*
* The problem:
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
* - This leaves the UI in an inconsistent state showing outdated permission status
*
* The solution:
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
* - Continue polling until both permissions are no longer in 'prompt' state
* - This ensures we catch permission changes even when Safari fails to fire events
*
* This polling is Safari-specific and only activates when needed to minimize performance impact.
*/
if (
isSafari() &&
(cameraPermission.state === 'prompt' ||
microphonePermission.state === 'prompt')
) {
// Start polling every 1 second if either permission is in 'prompt' state
if (!intervalId) {
intervalId = setInterval(async () => {
try {
const [updatedCamera, updatedMicrophone] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
const cameraChanged =
permissionsStore.cameraPermission !== updatedCamera.state
const microphoneChanged =
permissionsStore.microphonePermission !==
updatedMicrophone.state
if (cameraChanged) {
permissionsStore.cameraPermission = updatedCamera.state
}
if (microphoneChanged) {
permissionsStore.microphonePermission =
updatedMicrophone.state
}
if (
updatedCamera.state !== 'prompt' &&
updatedMicrophone.state !== 'prompt'
) {
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
console.error('Error polling permissions:', error)
}
}
}, POLLING_TIME)
}
}
permissionsStore.cameraPermission = cameraPermission.state
permissionsStore.microphonePermission = microphonePermission.state
const handleCameraChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.cameraPermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
const handleMicrophoneChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.microphonePermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
cameraPermission.addEventListener('change', handleCameraChange)
microphonePermission.addEventListener('change', handleMicrophoneChange)
cleanup = () => {
cameraPermission.removeEventListener('change', handleCameraChange)
microphonePermission.removeEventListener(
'change',
handleMicrophoneChange
)
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
console.error('Error checking permissions:', error)
}
} finally {
if (!isCancelled) {
permissionsStore.isLoading = false
}
}
}
checkPermissions()
return () => {
isCancelled = true
cleanup?.()
}
}, [])
}
@@ -2,6 +2,7 @@ import { Participant } from 'livekit-client'
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const useLowerHandParticipant = () => {
const data = useRoomData()
@@ -10,12 +11,8 @@ export const useLowerHandParticipant = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
const newMetadata = safeParseMetadata(participant.metadata) || {}
newMetadata.raised = !newMetadata.raised
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
@@ -27,7 +24,7 @@ export const useLowerHandParticipant = () => {
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
attributes: newAttributes,
metadata: JSON.stringify(newMetadata),
permission: participant.permissions,
}),
}
@@ -1,7 +1,7 @@
import { css } from '@/styled-system/css'
import { Button, Text } from '@/primitives'
import { useMemo, useRef } from 'react'
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
import { useSnapshot } from 'valtio'
import { useLocalParticipant } from '@livekit/components-react'
import { useSize } from '../hooks/useResizeObserver'
@@ -18,7 +18,7 @@ export const FullScreenShareWarning = ({
const warningContainerRef = useRef<HTMLDivElement>(null)
const { width: containerWidth } = useSize(warningContainerRef)
const { localParticipant } = useLocalParticipant()
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
const isFullScreenSharing = useMemo(() => {
if (trackReference?.source !== 'screen_share') return false
@@ -62,7 +62,7 @@ export const FullScreenShareWarning = ({
}
const handleDismissWarning = () => {
screenSharePreferenceStore.enabled = false
ScreenSharePreferenceStore.enabled = false
}
if (!shouldShowWarning) return null
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { useEffect, useState } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
@@ -8,22 +8,24 @@ import { getRouteUrl } from '@/navigation/getRouteUrl'
import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
const telephony = useTelephony()
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && data?.pin_code
}, [telephony?.enabled, data?.pin_code])
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
return (
<Div
display="flex"
@@ -51,9 +53,9 @@ export const Info = () => {
})}
>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl.replace(/^https?:\/\//, '')}
{roomUrl}
</Text>
{isTelephonyReadyForUse && (
{telephony?.enabled && data?.pin_code && (
<>
<Text as="p" variant="xsNote" wrap="pretty">
<Bold>{t('roomInformation.phone.call')}</Bold> (
@@ -70,7 +72,10 @@ export const Info = () => {
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={copyRoomToClipboard}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
data-attr="copy-info-sidepannel"
style={{
marginLeft: '-8px',
@@ -22,7 +22,7 @@ import {
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { useRaisedHand } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
@@ -97,10 +97,6 @@ export const ParticipantTile: (
participant: trackReference.participant,
})
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
participant: trackReference.participant,
})
const isScreenShare = trackReference.source != Track.Source.Camera
return (
@@ -145,32 +141,24 @@ export const ParticipantTile: (
style={{
padding: '0.1rem 0.25rem',
backgroundColor:
isHandRaised && !isScreenShare
? firstInQueue
? '#fde047'
: 'white'
: undefined,
isHandRaised && !isScreenShare ? 'white' : undefined,
color:
isHandRaised && !isScreenShare ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{isHandRaised && !isScreenShare && (
<>
<span>{positionInQueue}</span>
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
marginLeft: '0.1rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
</>
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -1,130 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import { Track } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import Source = Track.Source
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
'source' | 'onChange'
> & {
hideMenu?: boolean
}
export const AudioDevicesControl = ({
hideMenu,
...props
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const {
userChoices: { audioDeviceId, audioOutputDeviceId },
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const trackProps = useTrackToggle({
source: Source.Microphone,
onChange,
...props,
})
const kind = 'audioinput'
const cannotUseDevice = useCannotUseDevice(kind)
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
kind={kind}
toggle={trackProps.toggle as () => Promise<void>}
overrideToggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
}
>
<RiArrowUpSLine />
</Button>
{({ close }) => (
<div
className={css({
maxWidth: '36rem',
padding: '0.15rem',
display: 'flex',
gap: '0.5rem',
})}
>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind={kind}
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
/>
</div>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
<SettingsButton
settingTab={SettingsDialogExtendedKey.AUDIO}
onPress={close}
/>
</div>
)}
</Popover>
)}
</div>
)
}
@@ -1,47 +0,0 @@
import { Button } from '@/primitives'
import { RiErrorWarningFill } from '@remixicon/react'
import { openPermissionsDialog } from '@/stores/permissions'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
export const PermissionNeededButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
return (
<div
className={css({
position: 'absolute',
bottom: 'auto',
left: '-.55rem',
top: '-.55rem',
zIndex: 1,
})}
>
<Button
aria-label={t('ariaLabel')}
tooltip={t('tooltip')}
onPress={openPermissionsDialog}
variant="permission"
>
<div
className={css({
position: 'relative',
zIndex: 2,
})}
>
<RiErrorWarningFill size={28} />
</div>
<div
className={css({
width: '18px',
height: '18px',
position: 'absolute',
top: '4px',
left: '4px',
backgroundColor: 'black',
borderRadius: '100%',
})}
/>
</Button>
</div>
)
}
@@ -1,123 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useEffect, useMemo } from 'react'
import { Select, SelectProps } from '@/primitives/Select'
import { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
type DeviceItems = Array<{ value: string; label: string }>
type SelectDeviceContext = {
variant?: 'light' | 'dark'
placement?: Placement
}
type SelectDeviceProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
context?: 'join' | 'room'
}
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
Pick<SelectProps<T>, 'placement' | 'variant' | 'iconComponent'>
const SelectDevicePermissions = <T extends string | number>({
id,
kind,
onSubmit,
iconComponent,
...props
}: SelectDevicePermissionsProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
const items: DeviceItems = devices
.filter((d) => !!d.deviceId)
.map((d) => ({
value: d.deviceId,
label: d.label,
}))
/**
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
* Auto-selects the only available audio output device when currently on 'default'
*/
useEffect(() => {
if (
kind !== 'audiooutput' ||
items.length !== 1 ||
items[0].value === 'default' ||
activeDeviceId !== 'default'
)
return
onSubmit?.(items[0].value)
setActiveMediaDevice(items[0].value)
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
const selectedKey = id || activeDeviceId
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length === 0}
items={items}
iconComponent={iconComponent}
placeholder={items.length === 0 ? t('loading') : t('select')}
selectedKey={selectedKey}
onSelectionChange={(key) => {
if (key === selectedKey) return
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
{...props}
/>
)
}
export const SelectDevice = ({
id,
onSubmit,
kind,
context = 'join',
}: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const contextProps = useMemo<SelectDeviceContext>(() => {
if (context == 'room') {
return { variant: 'dark', placement: 'top' }
}
return {}
}, [context])
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
if (cannotUseDevice) {
return (
<Select
aria-label={t(`${kind}.permissionsNeeded`)}
label=""
isDisabled={true}
items={[]}
placeholder={t('permissionsNeeded')}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
return (
<SelectDevicePermissions
id={id}
onSubmit={onSubmit}
kind={kind}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
@@ -1,32 +0,0 @@
import { Button } from '@/primitives'
import { RiSettings3Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export const SettingsButton = ({
settingTab,
onPress,
}: {
settingTab: SettingsDialogExtendedKey
onPress?: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { openSettingsDialog } = useSettingsDialog()
return (
<Button
size="sm"
square
tooltip={t(`settings.${settingTab}`)}
aria-label={t(`settings.${settingTab}`)}
variant="primaryDark"
onPress={() => {
openSettingsDialog(settingTab)
onPress?.()
}}
>
<RiSettings3Line size={24} />
</Button>
)
}
@@ -1,142 +0,0 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
type ToggleDeviceStyleProps = {
variant?: NonNullable<ButtonRecipeProps>['variant']
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
export type ToggleDeviceProps<T extends ToggleSource> = {
enabled: boolean
toggle: (
forceState?: boolean,
captureOptions?: CaptureOptionsBySource<T>
) => Promise<void | boolean | undefined>
context?: 'room' | 'join'
kind: 'audioinput' | 'videoinput'
overrideToggleButtonProps?: Partial<ToggleButtonProps>
}
export const ToggleDevice = <T extends ToggleSource>({
kind,
enabled,
toggle,
context = 'room',
overrideToggleButtonProps,
}: ToggleDeviceProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const {
variant,
errorVariant,
toggleButtonProps: computedToggleButtonProps,
} = useMemo<ToggleDeviceStyleProps>(() => {
if (context === 'join') {
return {
variant: 'whiteCircle',
errorVariant: 'errorCircle',
toggleButtonProps: {
groupPosition: undefined,
},
} as ToggleDeviceStyleProps
}
return {
variant: 'primaryDark',
errorVariant: 'error2',
toggleButtonProps: {
groupPosition: 'left',
},
} as ToggleDeviceStyleProps
}, [context])
const [pushToTalk, setPushToTalk] = useState(false)
const onKeyDown = () => {
if (pushToTalk || enabled) return
toggle()
setPushToTalk(true)
}
const onKeyUp = () => {
if (!pushToTalk) return
toggle()
setPushToTalk(false)
}
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceShortcut = useDeviceShortcut(kind)
useRegisterKeyboardShortcut({
shortcut: deviceShortcut,
handler: async () => await toggle(),
isDisabled: cannotUseDevice,
})
useLongPress({
keyCode: kind === 'audioinput' ? 'Space' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
})
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `selectDevice.${kind}`,
})
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
}, [enabled, kind, deviceShortcut, t])
const Icon =
enabled && !cannotUseDevice ? deviceIcons.toggleOn : deviceIcons.toggleOff
const roomContext = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && roomContext) {
return <ActiveSpeakerWrapper />
}
return (
<div style={{ position: 'relative' }}>
{cannotUseDevice && <PermissionNeededButton />}
<ToggleButton
isSelected={!enabled}
variant={enabled && !cannotUseDevice ? variant : errorVariant}
shySelected
onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
aria-label={toggleLabel}
tooltip={
cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps}
{...overrideToggleButtonProps}
>
<Icon />
</ToggleButton>
</div>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -1,164 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
import { Track, VideoCaptureOptions } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size="sm"
square
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
}}
>
<RiImageCircleAiFill size={24} />
</Button>
)
}
type VideoDeviceControlProps = Omit<
UseTrackToggleProps<Source.Camera>,
'source' | 'onChange'
> & {
hideMenu?: boolean
}
export const VideoDeviceControl = ({
hideMenu,
...props
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const trackProps = useTrackToggle({
source: Source.Camera,
onChange,
...props,
})
const kind = 'videoinput'
const cannotUseDevice = useCannotUseDevice(kind)
const toggleWithProcessor = async () => {
/**
* 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>
await toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
}
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
kind={kind}
toggle={toggleWithProcessor}
overrideToggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
}
>
<RiArrowUpSLine />
</Button>
{({ close }) => (
<div
className={css({
maxWidth: '36rem',
padding: '0.15rem',
display: 'flex',
gap: '0.5rem',
})}
>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind={kind}
id={userChoices.videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
<EffectsButton onPress={close} />
<SettingsButton
settingTab={SettingsDialogExtendedKey.VIDEO}
onPress={close}
/>
</div>
)}
</Popover>
)}
</div>
)
}
@@ -2,16 +2,16 @@ import { RiSettings3Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { useSettingsDialog } from '../SettingsDialogContext'
export const SettingsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { openSettingsDialog } = useSettingsDialog()
const { setDialogOpen } = useSettingsDialog()
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => openSettingsDialog()}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
{t('settings')}
@@ -11,6 +11,7 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
@@ -34,15 +35,10 @@ export const ParticipantsList = () => {
...sortedRemoteParticipants,
]
const raisedHandParticipants = participants
.filter((participant) => !!participant.attributes.handRaisedAt)
.sort((a, b) => {
const dateA = new Date(a.attributes.handRaisedAt)
const dateB = new Date(b.attributes.handRaisedAt)
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
return timeA - timeB
})
const raisedHandParticipants = participants.filter((participant) => {
const data = safeParseMetadata(participant.metadata)
return data.raised
})
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
@@ -0,0 +1,152 @@
import { useTranslation } from 'react-i18next'
import {
useMediaDeviceSelect,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import {
LocalAudioTrack,
LocalVideoTrack,
Track,
VideoCaptureOptions,
} from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
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> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
}
export const SelectToggleDevice = <T extends ToggleSource>({
track,
initialDeviceId,
onActiveDeviceChange,
hideMenu,
variant = 'primaryDark',
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = SELECT_TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const trackProps = useTrackToggle(props)
const { userChoices } = usePersistentUserChoices()
const toggle = () => {
if (props.source === Track.Source.Camera) {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
} else {
trackProps.toggle()
}
}
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: config.kind, track })
/**
* 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}` })
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
config={config}
variant={variant}
toggle={toggle}
toggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Menu variant={menuVariant}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowUpSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
variant={menuVariant}
/>
</Menu>
)}
</div>
)
}
@@ -0,0 +1,36 @@
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
import React, { createContext, useContext, useState } from 'react'
const SettingsDialogContext = createContext<
| {
dialogOpen: boolean
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
}
| undefined
>(undefined)
export const SettingsDialogProvider: React.FC<{
children: React.ReactNode
}> = ({ children }) => {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
{children}
<SettingsDialogExtended
isOpen={dialogOpen}
onOpenChange={(v) => !v && setDialogOpen(false)}
/>
</SettingsDialogContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useSettingsDialog = () => {
const context = useContext(SettingsDialogContext)
if (!context) {
throw new Error(
'useSettingsDialog must be used within a SettingsDialogProvider'
)
}
return context
}
@@ -0,0 +1,132 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} 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
toggle: () => void
config: SelectToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
export const ToggleDevice = ({
config,
enabled,
toggle,
variant = 'primaryDark',
toggleButtonProps,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { open } = useModal('permissions')
const { kind, shortcut, iconOn, iconOff, longPress } = config
const [pushToTalk, setPushToTalk] = useState(false)
const onKeyDown = () => {
if (pushToTalk || enabled) return
toggle()
setPushToTalk(true)
}
const onKeyUp = () => {
if (!pushToTalk) return
toggle()
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', {
keyPrefix: `join.${kind}`,
})
return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [isEnabledAndPermitted, kind, shortcut, t])
const Icon = isEnabledAndPermitted ? 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={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>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -1,5 +1,5 @@
import { LocalVideoTrack, Track } from 'livekit-client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, 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 } from '@/primitives'
import { Text, P, ToggleButton, H, Button } 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,10 +50,20 @@ 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
@@ -63,12 +73,13 @@ export const EffectsConfiguration = ({
return () => {
if (!videoElement) return
videoTrack.detach(videoElement)
videoTrack?.detach(videoElement)
}
}, [videoTrack, videoTrack?.isMuted])
const clearEffect = async () => {
await videoTrack.stopProcessor()
await videoTrack?.stopProcessor()
setSelectedProcessor(undefined)
onSubmit?.(undefined)
}
@@ -92,7 +103,6 @@ export const EffectsConfiguration = ({
await toggle(true, {
processor: newProcessorTmp,
})
setTimeout(() => setProcessorPending(false))
return
}
@@ -100,9 +110,10 @@ export const EffectsConfiguration = ({
await toggle(true)
}
const processor = getProcessor()
const processor = videoTrack?.getProcessor() as BackgroundProcessorInterface
try {
if (isSelected(type, options)) {
setSelectedProcessor(undefined)
// Stop processor.
await clearEffect()
} else if (!processor || processor.serialize().type !== type) {
@@ -118,28 +129,27 @@ 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 {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
setProcessorPending(false)
}
}
const getProcessor = () => {
return videoTrack?.getProcessor() as BackgroundProcessorInterface
}
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
const processor = getProcessor()
if (!selectedProcessor) return false
const processor = selectedProcessor as BackgroundProcessorInterface
const processorSerialized = processor?.serialize()
return (
!!processor &&
@@ -152,6 +162,11 @@ 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(
@@ -165,7 +180,7 @@ export const EffectsConfiguration = ({
display: 'flex',
gap: '1.5rem',
flexDirection: 'column',
md: {
lg: {
flexDirection: 'row',
overflow: 'hidden',
},
@@ -175,31 +190,65 @@ 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={{
@@ -209,19 +258,23 @@ export const EffectsConfiguration = ({
marginBottom: 0,
}}
>
{t('activateCamera')}
{t(isCameraGranted ? 'activateCamera' : 'permissionsCamera')}
</P>
</div>
)}
{processorPendingReveal && (
<div
className={css({
position: 'absolute',
right: '8px',
bottom: '8px',
})}
>
<Loader />
<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>
)}
</div>
@@ -242,7 +295,7 @@ export const EffectsConfiguration = ({
{hasFunnyEffectsAccess && (
<FunnyEffects
videoTrack={videoTrack}
isPending={processorPendingReveal}
isPending={processorPending}
onPending={setProcessorPending}
/>
)}
@@ -270,8 +323,8 @@ export const EffectsConfiguration = ({
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal}
isSelected={!selectedProcessor}
isDisabled={isDisabled}
>
<RiProhibited2Line />
</ToggleButton>
@@ -283,7 +336,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal}
isDisabled={isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
@@ -304,7 +357,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal}
isDisabled={isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
@@ -352,7 +405,7 @@ export const EffectsConfiguration = ({
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal}
isDisabled={isDisabled}
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)
@@ -0,0 +1,33 @@
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,
},
},
}
@@ -1,35 +0,0 @@
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
import { permissionsStore } from '@/stores/permissions'
export const useCannotUseDevice = (kind: MediaDeviceKind) => {
const {
isLoading,
isMicrophoneDenied,
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
} = useSnapshot(permissionsStore)
return useMemo(() => {
if (isLoading) return true
switch (kind) {
case 'audioinput':
case 'audiooutput': // audiooutput uses microphone permissions
return isMicrophoneDenied || isMicrophonePrompted
case 'videoinput':
return isCameraDenied || isCameraPrompted
default:
return false
}
}, [
kind,
isLoading,
isMicrophoneDenied,
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
])
}
@@ -1,79 +0,0 @@
import { useTelephony } from './useTelephony'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { getRouteUrl } from '@/navigation/getRouteUrl'
const COPY_SUCCESS_TIMEOUT = 3000
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
const telephony = useTelephony()
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
const [isCopied, setIsCopied] = useState(false)
const [isRoomUrlCopied, setIsRoomUrlCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), COPY_SUCCESS_TIMEOUT)
return () => clearTimeout(timeout)
}
}, [isCopied])
useEffect(() => {
if (isRoomUrlCopied) {
const timeout = setTimeout(
() => setIsRoomUrlCopied(false),
COPY_SUCCESS_TIMEOUT
)
return () => clearTimeout(timeout)
}
}, [isRoomUrlCopied])
const roomUrl = useMemo(() => {
return room?.slug ? getRouteUrl('room', room.slug) : ''
}, [room?.slug])
const hasTelephonyInfo = useMemo(() => {
return telephony.enabled && room?.pin_code
}, [telephony.enabled, room?.pin_code])
const content = useMemo(() => {
if (!roomUrl || !room) return ''
if (!hasTelephonyInfo) return roomUrl
return [
t('url', { roomUrl }),
t('numberAndPin', {
phoneNumber: telephony?.internationalPhoneNumber,
pinCode: formatPinCode(room.pin_code),
}),
].join('\n')
}, [roomUrl, hasTelephonyInfo, telephony, room, t])
const copyRoomToClipboard = async () => {
try {
await navigator.clipboard.writeText(content)
setIsCopied(true)
} catch (error) {
console.error(error)
}
}
const copyRoomUrlToClipboard = async () => {
try {
await navigator.clipboard.writeText(roomUrl)
setIsRoomUrlCopied(true)
} catch (error) {
console.error(error)
}
}
return {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
}
}
@@ -1,37 +0,0 @@
import {
RemixiconComponentType,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
RiVolumeDownLine,
RiVolumeMuteLine,
} from '@remixicon/react'
export interface DeviceIcons {
toggleOn: RemixiconComponentType
toggleOff: RemixiconComponentType
select: RemixiconComponentType
}
const ICONS: Record<MediaDeviceKind | 'default', DeviceIcons> = {
audioinput: {
toggleOn: RiMicLine,
toggleOff: RiMicOffLine,
select: RiMicLine,
},
videoinput: {
toggleOn: RiVideoOnLine,
toggleOff: RiVideoOffLine,
select: RiVideoOnLine,
},
audiooutput: {
toggleOn: RiVolumeDownLine,
toggleOff: RiVolumeMuteLine,
select: RiVolumeDownLine,
},
default: { toggleOn: RiMicLine, toggleOff: RiMicOffLine, select: RiMicLine },
}
export const useDeviceIcons = (kind: MediaDeviceKind): DeviceIcons =>
ICONS[kind] ?? ICONS.default
@@ -1,21 +0,0 @@
import { useMemo } from 'react'
import { Shortcut } from '@/features/shortcuts/types'
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
return useMemo<Shortcut | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
key: 'e',
ctrlKey: true,
}
case 'videoinput':
return {
key: 'd',
ctrlKey: true,
}
default:
return undefined
}
}, [kind])
}
@@ -1,8 +1,6 @@
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
import type { VideoResolution } from '@/stores/userChoices'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import type { VideoQuality } from 'livekit-client'
export function usePersistentUserChoices() {
const userChoicesSnap = useSnapshot(userChoicesStore)
@@ -24,12 +22,6 @@ export function usePersistentUserChoices() {
saveVideoInputDeviceId: (deviceId: string) => {
userChoicesStore.videoDeviceId = deviceId
},
saveVideoPublishResolution: (resolution: VideoResolution) => {
userChoicesStore.videoPublishResolution = resolution
},
saveVideoSubscribeQuality: (quality: VideoQuality) => {
userChoicesStore.videoSubscribeQuality = quality
},
saveUsername: (username: string) => {
userChoicesStore.username = username
},
@@ -1,58 +1,23 @@
import { LocalParticipant, Participant } from 'livekit-client'
import {
useParticipantAttribute,
useParticipants,
} from '@livekit/components-react'
import { useParticipantInfo } from '@livekit/components-react'
import { isLocal } from '@/utils/livekit'
import { useMemo } from 'react'
type useRaisedHandProps = {
participant: Participant
}
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
const { isHandRaised } = useRaisedHand({ participant })
const participants = useParticipants()
const positionInQueue = useMemo(() => {
if (!isHandRaised) return
return (
participants
.filter((p) => !!p.attributes.handRaisedAt)
.sort((a, b) => {
const dateA = new Date(a.attributes.handRaisedAt)
const dateB = new Date(b.attributes.handRaisedAt)
return dateA.getTime() - dateB.getTime()
})
.findIndex((p) => p.identity === participant.identity) + 1
)
}, [participants, participant, isHandRaised])
return {
positionInQueue,
firstInQueue: positionInQueue == 1,
}
}
export function useRaisedHand({ participant }: useRaisedHandProps) {
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
participant,
})
// fixme - refactor this part to rely on attributes
const { metadata } = useParticipantInfo({ participant })
const parsedMetadata = JSON.parse(metadata || '{}')
const isHandRaised = !!handRaisedAtAttribute
const toggleRaisedHand = async () => {
if (!isLocal(participant)) return
const localParticipant = participant as LocalParticipant
const attributes: Record<string, string> = {
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
const toggleRaisedHand = () => {
if (isLocal(participant)) {
parsedMetadata.raised = !parsedMetadata.raised
const localParticipant = participant as LocalParticipant
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
}
await localParticipant.setAttributes(attributes)
}
return { isHandRaised, toggleRaisedHand }
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand }
}
@@ -1,22 +0,0 @@
import { useEffect, useRef } from 'react'
export const useResolveInitiallyDefaultDeviceId = <
T extends { getDeviceId(): Promise<string | undefined> },
>(
currentId: string,
track: T | undefined,
save: (id: string) => void
) => {
const isInitiated = useRef(false)
useEffect(() => {
if (currentId !== 'default' || !track || isInitiated.current) return
const resolveDefaultDeviceId = async () => {
const actualDeviceId = await track.getDeviceId()
if (actualDeviceId && actualDeviceId !== 'default') {
isInitiated.current = true
save(actualDeviceId)
}
}
resolveDefaultDeviceId()
}, [currentId, track, save])
}
@@ -1,51 +0,0 @@
import { useEffect } from 'react'
import { usePersistentUserChoices } from './usePersistentUserChoices'
import { useRoomContext } from '@livekit/components-react'
import {
RemoteParticipant,
RemoteTrackPublication,
RoomEvent,
Track,
VideoQuality,
} from 'livekit-client'
/**
* This hook sets initial video quality for new participants as they join.
* LiveKit doesn't allow handling video quality preferences at the room level.
*/
export const useVideoResolutionSubscription = () => {
const {
userChoices: { videoSubscribeQuality },
} = usePersistentUserChoices()
const room = useRoomContext()
useEffect(() => {
if (!room) return
const handleTrackPublished = (
publication: RemoteTrackPublication,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_participant: RemoteParticipant
) => {
// By default, the maximum quality is set to high
if (
videoSubscribeQuality === undefined ||
videoSubscribeQuality === VideoQuality.HIGH
)
return
if (
publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare
) {
publication.setVideoQuality(videoSubscribeQuality)
}
}
room.on(RoomEvent.TrackPublished, handleTrackPublished)
return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished)
}
}, [room, videoSubscribeQuality])
}
@@ -3,23 +3,98 @@ import * as React from '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 = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enable the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({ onDeviceError }: ControlBarProps) {
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices()
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const barProps = {
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}
const isMobile = useIsMobile()
if (isMobile) {
return <MobileControlBar onDeviceError={onDeviceError} />
}
return <DesktopControlBar onDeviceError={onDeviceError} />
return (
<SettingsDialogProvider>
{isMobile ? (
<MobileControlBar {...barProps} />
) : (
<DesktopControlBar {...barProps} />
)}
</SettingsDialogProvider>
)
}
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
export interface ControlBarAuxProps {
onDeviceError: ControlBarProps['onDeviceError']
microphoneOnChange: (
enabled: boolean,
isUserInitiated: boolean
) => void | null
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
saveAudioInputDeviceId: (deviceId: string) => void
saveVideoInputDeviceId: (deviceId: string) => void
}
@@ -2,6 +2,7 @@ import { supportsScreenSharing } from '@livekit/components-core'
import { ControlBarAuxProps } from './ControlBar'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
import { HandToggle } from '../../components/controls/HandToggle'
@@ -10,12 +11,14 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
export function DesktopControlBar({
onDeviceError,
}: Readonly<ControlBarAuxProps>) {
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
return (
@@ -50,15 +53,27 @@ export function DesktopControlBar({
gap: '0.65rem',
})}
>
<AudioDevicesControl
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<VideoDeviceControl
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<ReactionsToggle />
{browserSupportsScreenSharing && (
@@ -4,6 +4,7 @@ import { ControlBarAuxProps } from './ControlBar'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { Button } from '@/primitives/Button'
@@ -18,22 +19,24 @@ import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { useSidePanel } from '../../hooks/useSidePanel'
import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
import { useConfig } from '@/api/useConfig'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export function MobileControlBar({
onDeviceError,
}: Readonly<ControlBarAuxProps>) {
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const { t } = useTranslation('rooms')
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel()
const { openSettingsDialog } = useSettingsDialog()
const { setDialogOpen } = useSettingsDialog()
const { data } = useConfig()
@@ -59,15 +62,27 @@ export function MobileControlBar({
})}
>
<LeaveButton />
<AudioDevicesControl
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<VideoDeviceControl
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<HandToggle />
<Button
@@ -152,7 +167,7 @@ export function MobileControlBar({
)}
<Button
onPress={() => {
openSettingsDialog()
setDialogOpen(true)
setIsMenuOpened(false)
}}
variant="primaryTextDark"
@@ -32,8 +32,6 @@ import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
const LayoutWrapper = styled(
'div',
@@ -79,7 +77,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useConnectionObserver()
useVideoResolutionSubscription()
const tracks = useTracks(
[
@@ -241,7 +238,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<RoomAudioRenderer />
<ConnectionStateToast />
<RecordingStateToast />
<SettingsDialogProvider />
</div>
)
}
@@ -0,0 +1,22 @@
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
}
@@ -4,21 +4,20 @@ import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Permissions } from '../components/Permissions'
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 }) => {
return (
<UserAware>
<Permissions />
{children}
</UserAware>
)
}
const BaseRoom = ({ children }: { children: ReactNode }) => (
<UserAware>
<PermissionErrorModal />
{children}
</UserAware>
)
export const Room = () => {
const { isLoggedIn } = useUser()
@@ -31,6 +30,7 @@ export const Room = () => {
const skipJoinScreen = isLoggedIn && mode === 'create'
useKeyboardShortcuts()
usePermissionsSync()
const clearRouterState = () => {
if (window?.history?.state) {
@@ -0,0 +1,22 @@
export const safeParseMetadata = (
metadataStr: string | null | undefined
): Record<string, unknown> => {
if (!metadataStr) {
return {}
}
try {
const parsed = JSON.parse(metadataStr)
// Ensure the result is an object
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
console.warn('Metadata parsed to non-object value:', parsed)
return {}
}
return parsed as Record<string, unknown>
} catch (error) {
console.error('Failed to parse metadata:', error)
return {}
}
}
@@ -19,5 +19,5 @@ export const parseConfigPhoneNumber = (
}
export function formatPinCode(pinCode?: string) {
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')}#`
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')} #`
}
@@ -9,16 +9,13 @@ import {
RiNotification3Line,
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -47,9 +44,7 @@ const tabPanelContainerStyle = css({
export type SettingsDialogExtended = Pick<
DialogProps,
'isOpen' | 'onOpenChange'
> & {
defaultSelectedTab?: SettingsDialogExtendedKey
}
>
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
// display only icon on small screen
@@ -60,11 +55,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
<Tabs
orientation="vertical"
className={tabsStyle}
defaultSelectedKey={props.defaultSelectedTab}
>
<Tabs orientation="vertical" className={tabsStyle}>
<div
className={tabListContainerStyle}
style={{
@@ -78,39 +69,30 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{t('dialog.heading')}
</Heading>
)}
<TabList border={false}>
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
<TabList border={false} aria-label="Chat log orientation example">
<Tab icon highlight id="1">
<RiAccountCircleLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
{isWideScreen && t('tabs.account')}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
<Tab icon highlight id="2">
<RiSpeakerLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
{isWideScreen && t('tabs.audio')}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
<RiVideoOnLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
<Tab icon highlight id="3">
<RiSettings3Line />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
{isWideScreen && t('tabs.general')}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
<Tab icon highlight id="4">
<RiNotification3Line />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
{isWideScreen && t('tabs.notifications')}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
<AccountTab
id={SettingsDialogExtendedKey.ACCOUNT}
onOpenChange={props.onOpenChange}
/>
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
<AccountTab id="1" onOpenChange={props.onOpenChange} />
<AudioTab id="2" />
<GeneralTab id="3" />
<NotificationsTab id="4" />
</div>
</Tabs>
</Dialog>
@@ -1,20 +0,0 @@
import { SettingsDialogExtended } from './SettingsDialogExtended'
import { useSnapshot } from 'valtio'
import { settingsStore } from '@/stores/settings'
export const SettingsDialogProvider = () => {
const { areSettingsOpen, defaultSelectedTab } = useSnapshot(settingsStore)
return (
<SettingsDialogExtended
isOpen={areSettingsOpen}
defaultSelectedTab={defaultSelectedTab}
onOpenChange={(v) => {
if (!v && settingsStore.defaultSelectedTab) {
settingsStore.defaultSelectedTab = undefined
}
settingsStore.areSettingsOpen = v
}}
/>
)
}
@@ -1,4 +1,4 @@
import { DialogProps, Field, Switch, Text } from '@/primitives'
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
@@ -9,11 +9,78 @@ import {
import { isSafari } from '@/utils/livekit'
import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { HStack } from '@/styled-system/jsx'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
import { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import posthog from 'posthog-js'
import { RowWrapper } from './layout/RowWrapper'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
type RowWrapperProps = {
heading: string
children: ReactNode[]
beta?: boolean
}
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -25,7 +92,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
const { localParticipant } = useRoomContext()
const {
userChoices: { noiseReductionEnabled, audioDeviceId, audioOutputDeviceId },
userChoices: { noiseReductionEnabled },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
saveAudioOutputDeviceId,
@@ -33,11 +100,17 @@ export const AudioTab = ({ id }: AudioTabProps) => {
const isSpeaking = useIsSpeaking(localParticipant)
const { devices: devicesOut, setActiveMediaDevice: setActiveMediaDeviceOut } =
useMediaDeviceSelect({ kind: 'audiooutput' })
const {
devices: devicesOut,
activeDeviceId: activeDeviceIdOut,
setActiveMediaDevice: setActiveMediaDeviceOut,
} = useMediaDeviceSelect({ kind: 'audiooutput' })
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
useMediaDeviceSelect({ kind: 'audioinput' })
const {
devices: devicesIn,
activeDeviceId: activeDeviceIdIn,
setActiveMediaDevice: setActiveMediaDeviceIn,
} = useMediaDeviceSelect({ kind: 'audioinput' })
const itemsOut: DeviceItems = devicesOut.map((d) => ({
value: d.deviceId,
@@ -62,6 +135,15 @@ export const AudioTab = ({ id }: AudioTabProps) => {
defaultSelectedKey: undefined,
}
// No API to directly query the default audio device; this function heuristically finds it.
// Returns the item with value 'default' if present; otherwise, returns the first item in the list.
const getDefaultSelectedKey = (items: DeviceItems) => {
if (!items || items.length === 0) return
const defaultItem =
items.find((item) => item.value === 'default') || items[0]
return defaultItem.value
}
const noiseReductionAvailable = useNoiseReductionAvailable()
return (
@@ -71,9 +153,11 @@ export const AudioTab = ({ id }: AudioTabProps) => {
type="select"
label={t('audio.microphone.label')}
items={itemsIn}
selectedKey={audioDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceIn(key as string)
defaultSelectedKey={
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
}
onSelectionChange={(key) => {
setActiveMediaDeviceIn(key as string)
saveAudioInputDeviceId(key as string)
}}
{...disabledProps}
@@ -97,9 +181,11 @@ export const AudioTab = ({ id }: AudioTabProps) => {
type="select"
label={t('audio.speakers.label')}
items={itemsOut}
selectedKey={audioOutputDeviceId}
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={async (key) => {
await setActiveMediaDeviceOut(key as string)
setActiveMediaDeviceOut(key as string)
saveAudioOutputDeviceId(key as string)
}}
{...disabledProps}
@@ -1,238 +0,0 @@
import { DialogProps, Field } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useCallback, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import {
createLocalVideoTrack,
LocalVideoTrack,
Track,
VideoPresets,
VideoQuality,
} from 'livekit-client'
import { BackgroundProcessorFactory } from '@/features/rooms/livekit/components/blur'
import { VideoResolution } from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper'
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
type DeviceItems = Array<{ value: string; label: string }>
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext()
const {
userChoices: {
videoDeviceId,
processorSerialized,
videoPublishResolution,
videoSubscribeQuality,
},
saveVideoInputDeviceId,
saveVideoPublishResolution,
saveVideoSubscribeQuality,
} = usePersistentUserChoices()
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null
)
const videoCallbackRef = useCallback((element: HTMLVideoElement | null) => {
setVideoElement(element)
}, [])
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
useMediaDeviceSelect({ kind: 'videoinput' })
const itemsIn: DeviceItems = devicesIn.map((d) => ({
value: d.deviceId,
label: d.label,
}))
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for camera permissions
// may raise an error. As a workaround, we infer camera permission status by checking if the list of camera input
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted camera access.
const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled
? {}
: {
placeholder: t('permissionsRequired'),
isDisabled: true,
}
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
const videoPublication = localParticipant.getTrackPublication(
Track.Source.Camera
)
const videoTrack = videoPublication?.track
if (videoTrack) {
saveVideoPublishResolution(key)
await videoTrack.restartTrack({
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
})
}
}
/**
* Updates video quality for all existing remote video tracks when user preference changes.
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
* so this function applies the selected quality to all existing remote video tracks.
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
*/
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.videoQuality !== selectedQuality) {
publication.setVideoQuality(selectedQuality)
}
})
})
}
useEffect(() => {
let videoTrack: LocalVideoTrack | null = null
const setUpVideoTrack = async () => {
if (videoElement) {
videoTrack = await createLocalVideoTrack({ deviceId: videoDeviceId })
videoTrack.attach(videoElement)
}
}
setUpVideoTrack()
return () => {
if (videoElement && videoTrack) {
videoTrack.detach()
videoTrack.stop()
}
}
}, [videoDeviceId, videoElement])
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}>
<Field
type="select"
label={t('camera.label')}
items={itemsIn}
selectedKey={videoDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceIn(key as string)
saveVideoInputDeviceId(key as string)
}}
{...disabledProps}
style={{
width: '100%',
}}
/>
<div
role="status"
aria-label={t(
`camera.previewAriaLabel.${localParticipant.isCameraEnabled ? 'enabled' : 'disabled'}`
)}
>
{localParticipant.isCameraEnabled ? (
<>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoCallbackRef}
width="160px"
height="56px"
style={{
display: !localParticipant.isCameraEnabled
? 'none'
: undefined,
}}
className={css({
transform: 'rotateY(180deg)',
height: '69px',
width: '160px',
})}
disablePictureInPicture
disableRemotePlayback
/>
</>
) : (
<span
className={css({
display: 'flex',
justifyContent: 'center',
textAlign: 'center',
})}
>
{t('camera.disabled')}
</span>
)}
</div>
</RowWrapper>
<RowWrapper heading={t('resolution.heading')}>
<Field
type="select"
label={t('resolution.publish.label')}
items={[
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
}}
style={{
width: '100%',
}}
/>
<></>
</RowWrapper>
<RowWrapper>
<Field
type="select"
label={t('resolution.subscribe.label')}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
}}
style={{
width: '100%',
}}
/>
<></>
</RowWrapper>
</TabPanel>
)
}
@@ -1,71 +0,0 @@
import { ReactNode } from 'react'
import { H } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
export type RowWrapperProps = {
heading?: string
children: ReactNode[]
beta?: boolean
}
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
export const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
{heading && (
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
)}
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
@@ -1,20 +0,0 @@
import { useSnapshot } from 'valtio/index'
import { settingsStore } from '@/stores/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
export const useSettingsDialog = () => {
const { areSettingsOpen } = useSnapshot(settingsStore)
const openSettingsDialog = (
defaultSelectedTab?: SettingsDialogExtendedKey
) => {
if (areSettingsOpen) return
if (defaultSelectedTab)
settingsStore.defaultSelectedTab = defaultSelectedTab
settingsStore.areSettingsOpen = true
}
return {
openSettingsDialog,
}
}
@@ -1,7 +0,0 @@
export enum SettingsDialogExtendedKey {
ACCOUNT = 'account',
AUDIO = 'audio',
VIDEO = 'video',
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
}
@@ -10,7 +10,7 @@ export const useKeyboardShortcuts = () => {
useEffect(() => {
// This approach handles basic shortcuts but isn't comprehensive.
// Issues might occur. First draft.
const onKeyDown = async (e: KeyboardEvent) => {
const onKeyDown = (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
@@ -20,7 +20,7 @@ export const useKeyboardShortcuts = () => {
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
e.preventDefault()
await shortcut()
shortcut()
}
window.addEventListener('keydown', onKeyDown)
@@ -5,7 +5,6 @@ export type useLongPressProps = {
onKeyDown: () => void
onKeyUp: () => void
longPressThreshold?: number
isDisabled?: boolean
}
export const useLongPress = ({
@@ -13,18 +12,10 @@ export const useLongPress = ({
onKeyDown,
onKeyUp,
longPressThreshold = 300,
isDisabled = false,
}: useLongPressProps) => {
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (isDisabled) {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = null
}
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code != keyCode || timeoutIdRef.current) return
timeoutIdRef.current = setTimeout(() => {
@@ -47,12 +38,8 @@ export const useLongPress = ({
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = null
}
}
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold, isDisabled])
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
return
}
@@ -5,22 +5,15 @@ import { Shortcut } from '@/features/shortcuts/types'
export type useRegisterKeyboardShortcutProps = {
shortcut?: Shortcut
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
handler: () => void
}
export const useRegisterKeyboardShortcut = ({
shortcut,
handler,
isDisabled = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!shortcut) return
const formattedKey = formatShortcutKey(shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
}
}, [handler, shortcut, isDisabled])
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
}, [handler, shortcut])
}
@@ -1,30 +0,0 @@
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
}
-4
View File
@@ -51,9 +51,5 @@
"ariaLabel": "Hinweis schließen",
"label": "OK"
}
},
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
}
}
+4 -9
View File
@@ -19,15 +19,10 @@
},
"laterMeetingDialog": {
"heading": "Ihre Zugangsdaten",
"description": "Teilen Sie diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten.",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert",
"copyUrl": "Meeting-Link kopieren",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
"description": "Senden Sie diesen Link an die Personen, die Sie zum Meeting einladen möchten. Sie können ohne ProConnect teilnehmen.",
"copy": "Meeting-Link kopieren",
"copied": "Link in die Zwischenablage kopiert",
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten."
},
"introSlider": {
"previous": {
+35 -57
View File
@@ -7,18 +7,13 @@
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
},
"selectDevice": {
"loading": "Laden…",
"select": "Wählen Sie einen Wert",
"permissionsNeeded": "Genehmigung erforderlich",
"settings": {
"audio": "Audioeinstellungen",
"video": "Videoeinstellungen"
"join": {
"selectDevice": {
"loading": "Lädt…",
"permissionNeeded": "Genehmigung erforderlich"
},
"effects": "Effekte anwenden",
"videoinput": {
"choose": "Kamera auswählen",
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"label": "Kamera",
@@ -26,17 +21,13 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
},
"audiooutput": {
"choose": "Lautsprecher auswählen",
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
}
},
"join": {
"choose": "Lautsprecher auswählen"
},
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
@@ -52,14 +43,10 @@
"errors": {
"usernameEmpty": "Ihr Name darf nicht leer sein"
},
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraStarting": "Kamera wird gestartet…",
"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?",
"permissionsButton": {
"cameraAndMicNotGranted": "Zugriff auf Kamera und Mikrofon erlauben",
"cameraNotGranted": "Zugriff auf Kamera erlauben"
},
"videoPreview": {
"enabled": "Videovorschau aktiviert",
"disabled": "Videovorschau deaktiviert"
@@ -77,18 +64,30 @@
"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": "Informationen kopieren",
"copyUrl": "Link kopieren",
"copied": "Informationen kopiert",
"copy": "Meeting-Link kopieren",
"copyButton": "Link kopieren",
"copied": "Link in die Zwischenablage kopiert",
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -103,30 +102,6 @@
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} darf Ihre Kamera nicht verwenden",
"microphone": "{{appTitle}} darf Ihr Mikrofon nicht verwenden",
"cameraAndMicrophone": "{{appTitle}} darf weder Ihr Mikrofon noch Ihre Kamera verwenden",
"default": "{{appTitle}} hat keine Berechtigung für bestimmte Zugriffe"
},
"body": {
"openMenu": {
"others": "Klicken Sie auf das Einstellungen-Symbol ICON_PLACEHOLDER in der Adressleiste Ihres Browsers",
"safari": "Klicken Sie auf das Menü 'Safari' und öffnen Sie 'Einstellungen für {{appDomain}}'."
},
"details": {
"camera": "Zugriff auf die Kamera erlauben",
"microphone": "Zugriff auf das Mikrofon erlauben",
"cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben",
"default": "Aktivieren Sie die erforderlichen Berechtigungen."
}
}
},
"permissionsButton": {
"tooltip": "Mehr Infos",
"ariaLabel": "Berechtigungsproblem. Mehr Infos anzeigen"
},
"error": {
"createRoom": {
"heading": "Authentifizierung erforderlich",
@@ -208,7 +183,10 @@
}
},
"effects": {
"activateCamera": "Ihre Kamera ist deaktiviert. Wählen Sie eine Option, um sie zu aktivieren.",
"activateCamera": "Kamera ist aus.",
"activateButton": "Kamera einschalten",
"permissionsCamera": "Bitte erlauben Sie der App den Zugriff auf Ihre Kamera, um fortzufahren.",
"permissionsButton": "Kamerazugriff erlauben",
"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",
@@ -280,9 +258,9 @@
"roomInformation": {
"title": "Verbindungsinformationen",
"button": {
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert"
"ariaLabel": "Kopiere deine Meeting-Adresse",
"copy": "Adresse kopieren",
"copied": "Adresse kopiert"
},
"phone": {
"call": "Rufen Sie an:",
-32
View File
@@ -30,37 +30,6 @@
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"video": {
"camera": {
"heading": "Kamera",
"label": "Wählen Sie Ihre Videoeingabe aus",
"disabled": "Kamera deaktiviert",
"previewAriaLabel": {
"enabled": "Videovorschau aktiviert",
"disabled": "Videovorschau deaktiviert"
}
},
"resolution": {
"heading": "Auflösung",
"publish": {
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
"items": {
"high": "Hohe Auflösung",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
},
"subscribe": {
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
"items": {
"high": "Hohe Auflösung (automatisch)",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
}
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"notifications": {
"heading": "Tonbenachrichtigungen",
"label": "Tonbenachrichtigungen für",
@@ -85,7 +54,6 @@
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen"
}
-4
View File
@@ -51,9 +51,5 @@
"ariaLabel": "Close the suggestion",
"label": "OK"
}
},
"clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
}
}
+4 -9
View File
@@ -19,15 +19,10 @@
},
"laterMeetingDialog": {
"heading": "Your connection details",
"description": "Share this information with the guests. They will be able to join the meeting without needing to sign in. This meeting is permanent and can be reused.",
"permissions": "People with this link do not need your permission to join this meeting.",
"copy": "Copy information",
"copied": "Information copied to clipboard",
"copyUrl": "Copy the meeting link",
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
"description": "Send this link to the people you want to invite to the meeting. They will be able to join without ProConnect.",
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
},
"introSlider": {
"previous": {
+37 -59
View File
@@ -7,18 +7,13 @@
"home": "Return to home",
"back": "Rejoin the meeting"
},
"selectDevice": {
"loading": "Loading…",
"select": "Select a value",
"permissionsNeeded": "Permission needed",
"settings": {
"audio": "Audio settings",
"video": "Video settings"
"join": {
"selectDevice": {
"loading": "Loading…",
"permissionNeeded": "Permission needed"
},
"effects": "Effects",
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"label": "Camera",
@@ -26,17 +21,13 @@
},
"audioinput": {
"choose": "Select microphone",
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"label": "Microphone"
},
"audiooutput": {
"choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed"
}
},
"join": {
"choose": "Select speakers"
},
"effects": {
"description": "Apply effects",
"title": "Effects",
@@ -52,14 +43,10 @@
"errors": {
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "Camera is disabled.",
"cameraStarting": "Camera is starting…",
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
"permissionsButton": {
"cameraAndMicNotGranted": "Allow access to camera and microphone",
"cameraNotGranted": "Allow access to camera"
},
"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"
@@ -77,18 +64,30 @@
"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 information",
"copyUrl": "Copy link",
"copied": "Information copied to clipboard",
"copy": "Copy the meeting link",
"copyButton": "Copy link",
"copied": "Link copied to clipboard",
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting.",
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
"permissions": "People with this link do not need your permission to join this meeting."
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -103,30 +102,6 @@
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} is not allowed to use your camera",
"microphone": "{{appTitle}} is not allowed to use your microphone",
"cameraAndMicrophone": "{{appTitle}} is not allowed to use your microphone or camera",
"default": "{{appTitle}} is not allowed to use certain permissions"
},
"body": {
"openMenu": {
"others": "Click on the settings icon ICON_PLACEHOLDER in your browsers address bar",
"safari": "Click the 'Safari' menu, and open 'Settings for {{appDomain}}'."
},
"details": {
"camera": "Allow access to the camera",
"microphone": "Allow access to the microphone",
"cameraAndMicrophone": "Allow access to the camera and microphone",
"default": "Enable the necessary permissions."
}
}
},
"permissionsButton": {
"tooltip": "More info",
"ariaLabel": "Permissions issue. Show more info"
},
"error": {
"createRoom": {
"heading": "Authentication Required",
@@ -208,7 +183,10 @@
}
},
"effects": {
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
"activateCamera": "Your camera is disabled.",
"activateButton": "Turn on camera",
"permissionsCamera": "Please allow the app to access your camera to continue.",
"permissionsButton": "Allow camera access",
"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",
@@ -280,9 +258,9 @@
"roomInformation": {
"title": "Connection Information",
"button": {
"ariaLabel": "Copy the information from your meeting",
"copy": "Copy information",
"copied": "Information copied"
"ariaLabel": "Copy your meeting address",
"copy": "Copy address",
"copied": "Address copied"
},
"phone": {
"call": "Call:",
-32
View File
@@ -30,37 +30,6 @@
},
"permissionsRequired": "Permissions required"
},
"video": {
"camera": {
"heading": "Camera",
"label": "Select your video input",
"disabled": "Camera disabled",
"previewAriaLabel": {
"enabled": "Video preview enabled",
"disabled": "Video preview disabled"
}
},
"resolution": {
"heading": "Resolution",
"publish": {
"label": "Select your sending resolution (max.)",
"items": {
"high": "High definition",
"medium": "Standard definition",
"low": "Low definition"
}
},
"subscribe": {
"label": "Select your reception resolution (max.)",
"items": {
"high": "High definition (automatic)",
"medium": "Standard definition",
"low": "Low definition"
}
}
},
"permissionsRequired": "Permissions required"
},
"notifications": {
"heading": "Sound notifications",
"label": "sound notifications for",
@@ -85,7 +54,6 @@
"tabs": {
"account": "Profile",
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications"
}
-4
View File
@@ -51,9 +51,5 @@
"ariaLabel": "Fermer la suggestion",
"label": "OK"
}
},
"clipboardContent": {
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
}
}
+4 -9
View File
@@ -19,15 +19,10 @@
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
"description": "Partagez ces informations avec les invités. Ils pourront rejoindre la réunion sans avoir besoin de se connecter. Cette réunion est permanente et peut être réutilisée.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"copy": "Copier les informations",
"copied": "Copiées dans le presse-papiers",
"copyUrl": "Copier le lien de la réunion",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
"description": "Envoyez ce lien aux personnes que vous souhaitez inviter à la réunion. Ils pourront la rejoindre sans ProConnect.",
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
},
"introSlider": {
"previous": {
+38 -60
View File
@@ -7,18 +7,13 @@
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
},
"selectDevice": {
"loading": "Chargement…",
"select": "Sélectionnez une valeur",
"permissionsNeeded": "Autorisations nécessaires",
"settings": {
"audio": "Paramètres audio",
"video": "Paramètres video"
"join": {
"selectDevice": {
"loading": "Chargement…",
"permissionNeeded": "Autorisations nécessaires"
},
"effects": "Effets d'arrière-plan",
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"label": "Webcam",
@@ -26,20 +21,16 @@
},
"audioinput": {
"choose": "Choisir le micro",
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"label": "Microphone"
},
"audiooutput": {
"choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
}
},
"join": {
"choose": "Choisir le haut parleur"
},
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière-plan",
"description": "Effets d'arrière plan",
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
@@ -52,7 +43,7 @@
"errors": {
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"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 ?",
@@ -60,10 +51,6 @@
"enabled": "Apercu vidéo activé",
"disabled": "Apercu vidéo désactivé"
},
"permissionsButton": {
"cameraAndMicNotGranted": "Autorisez l'accès à la caméra et au micro",
"cameraNotGranted": "Autorisez l'accès à la caméra"
},
"waiting": {
"title": "Demande de participation…",
"body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera"
@@ -77,18 +64,30 @@
"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 les informations",
"copyUrl": "Copier le lien",
"copied": "Copiées dans le presse-papiers",
"copy": "Copier le lien de la réunion",
"copyButton": "Copier le lien",
"copied": "Lien copié dans le presse-papiers",
"heading": "Votre réunion est prête",
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -103,30 +102,6 @@
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} n'est pas autorisé à utiliser votre caméra",
"microphone": "{{appTitle}} n'est pas autorisé à utiliser votre micro",
"cameraAndMicrophone": "{{appTitle}} n'est pas autorisé à utiliser votre micro ni votre caméra",
"default": "{{appTitle}} n'est pas autorisé à utiliser certaines fonctionnalités nécessaires."
},
"body": {
"openMenu": {
"others": "Cliquez sur l'icône des paramètres ICON_PLACEHOLDER dans la barre d'adresse de votre navigateur",
"safari": "Cliquez sur le menu \"Safari\", et ouvrez \"Paramètres pour {{appDomain}}\"."
},
"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",
"default": "Activez les autorisations nécessaires."
}
}
},
"permissionsButton": {
"tooltip": "Plus d'infos",
"ariaLabel": "Problème de permissions. Afficher plus d'infos"
},
"error": {
"createRoom": {
"heading": "Authentification requise",
@@ -198,7 +173,7 @@
"feedback": "Partager votre avis",
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Effets d'arrière-plan",
"effects": "Effets d'arrière plan",
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
@@ -208,7 +183,10 @@
}
},
"effects": {
"activateCamera": "Votre caméra est désactivée. Choisissez une option pour l'activer.",
"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",
"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",
@@ -280,9 +258,9 @@
"roomInformation": {
"title": "Informations de connexions",
"button": {
"ariaLabel": "Copier les informations de votre réunion",
"copy": "Copier les informations",
"copied": "Informations copiées"
"ariaLabel": "Copier l'adresse de votre réunion",
"copy": "Copier l'adresse",
"copied": "Adresse copiée"
},
"phone": {
"call": "Appelez le :",
@@ -462,7 +440,7 @@
"enable": "Épingler",
"disable": "Annuler l'épinglage"
},
"effects": "Effets d'arrière-plan",
"effects": "Effets d'arrière plan",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
-32
View File
@@ -30,37 +30,6 @@
},
"permissionsRequired": "Autorisations nécessaires"
},
"video": {
"camera": {
"heading": "Caméra",
"label": "Sélectionner votre entrée vidéo",
"disabled": "Caméra désactivée",
"previewAriaLabel": {
"enabled": "Aperçu vidéo activé",
"disabled": "Aperçu vidéo désactivé"
}
},
"resolution": {
"heading": "Résolution",
"publish": {
"label": "Sélectionner votre résolution d'envoi (max.)",
"items": {
"high": "Haute définition",
"medium": "Définition standard",
"low": "Basse définition"
}
},
"subscribe": {
"label": "Sélectionner votre résolution de réception (max.)",
"items": {
"high": "Haute définition (automatique)",
"medium": "Définition standard",
"low": "Basse définition"
}
}
},
"permissionsRequired": "Autorisations nécessaires"
},
"notifications": {
"heading": "Notifications sonores",
"label": "la notification sonore pour",
@@ -85,7 +54,6 @@
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"notifications": "Notifications"
}
-4
View File
@@ -50,9 +50,5 @@
"ariaLabel": "Sluit de suggestie",
"label": "OK"
}
},
"clipboardContent": {
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
}
}
+4 -9
View File
@@ -19,15 +19,10 @@
},
"laterMeetingDialog": {
"heading": "Uw verbindingsgegevens",
"description": "Deel deze informatie met de genodigden. Zij kunnen deelnemen aan de vergadering zonder zich aan te melden. Deze vergadering is permanent en kan hergebruikt worden.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"copy": "Informatie kopiëren",
"copied": "Informatie gekopieerd",
"copyUrl": "Kopieer de vergaderlink",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
}
"description": "Stuur deze link naar de mensen die u wilt uitnodigen voor de vergadering. Zij kunnen deelnemen zonder ProConnect.",
"copy": "Kopieer de vergaderlink",
"copied": "Link gekopieerd naar klembord",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
},
"introSlider": {
"previous": {
+37 -59
View File
@@ -7,18 +7,13 @@
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
},
"selectDevice": {
"loading": "Bezig met laden…",
"select": "Selecteer een waarde",
"permissionsNeeded": "Toestemming vereist",
"settings": {
"audio": "Audio-instellingen",
"video": "Video-instellingen"
"join": {
"selectDevice": {
"loading": "Laden…",
"permissionNeeded": "Toestemming vereist"
},
"effects": "Pas effecten toe",
"videoinput": {
"choose": "Selecteer camera",
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"label": "Camera",
@@ -26,17 +21,13 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
"label": "Microfoon"
},
"audiooutput": {
"choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
}
},
"join": {
"choose": "Selecteer luidsprekers"
},
"effects": {
"description": "Pas effecten toe",
"title": "Effecten",
@@ -52,14 +43,10 @@
"errors": {
"usernameEmpty": "Uw naam kan niet leeg zijn"
},
"cameraDisabled": "Camera is uitgeschakeld.",
"cameraStarting": "Camera wordt ingeschakeld…",
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
"permissionsButton": {
"cameraAndMicNotGranted": "Toegang tot camera en microfoon toestaan",
"cameraNotGranted": "Toegang tot camera toestaan"
},
"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"
@@ -77,18 +64,30 @@
"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": "Informatie kopiëren",
"copyUrl": "Kopieerlink",
"copied": "Informatie gekopieerd",
"copy": "Kopieer de vergaderlink",
"copyButton": "Kopieerlink",
"copied": "Link gekopieerd naar het klembord",
"heading": "Uw vergadering is klaar",
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
}
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -103,30 +102,6 @@
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} mag uw camera niet gebruiken",
"microphone": "{{appTitle}} mag uw microfoon niet gebruiken",
"cameraAndMicrophone": "{{appTitle}} mag uw microfoon en camera niet gebruiken",
"default": "{{appTitle}} heeft geen toestemming voor bepaalde rechten"
},
"body": {
"openMenu": {
"others": "Klik op het instellingenpictogram ICON_PLACEHOLDER in de adresbalk van uw browser",
"safari": "Klik op het menu 'Safari' en open 'Instellingen voor {{appDomain}}'."
},
"details": {
"camera": "Toegang tot de camera toestaan",
"microphone": "Toegang tot de microfoon toestaan",
"cameraAndMicrophone": "Toegang tot camera en microfoon toestaan",
"default": "Schakel de vereiste machtigingen in."
}
}
},
"permissionsButton": {
"tooltip": "Meer info",
"ariaLabel": "Probleem met machtigingen. Meer info weergeven"
},
"error": {
"createRoom": {
"heading": "Verificatie vereist",
@@ -208,7 +183,10 @@
}
},
"effects": {
"activateCamera": "Uw camera is uitgeschakeld. Kies een optie om deze in te schakelen.",
"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",
"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",
@@ -280,9 +258,9 @@
"roomInformation": {
"title": "Verbindingsinformatie",
"button": {
"ariaLabel": "Kopieer de informatie van uw vergadering",
"copy": "Informatie kopiëren",
"copied": "Informatie gekopieerd"
"ariaLabel": "Kopieer je vergaderadres",
"copy": "Adres kopiëren",
"copied": "Adres gekopieerd"
},
"phone": {
"call": "Bel:",
-32
View File
@@ -30,37 +30,6 @@
},
"permissionsRequired": "Machtigingen vereist"
},
"video": {
"camera": {
"heading": "Camera",
"label": "Selecteer uw video-ingang",
"disabled": "Camera uitgeschakeld",
"previewAriaLabel": {
"enabled": "Videovoorbeeld ingeschakeld",
"disabled": "Videovoorbeeld uitgeschakeld"
}
},
"resolution": {
"heading": "Resolutie",
"publish": {
"label": "Selecteer uw verzendresolutie (max.)",
"items": {
"high": "Hoge definitie",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
},
"subscribe": {
"label": "Selecteer uw ontvangstresolutie (max.)",
"items": {
"high": "Hoge definitie (automatisch)",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
}
},
"permissionsRequired": "Machtigingen vereist"
},
"notifications": {
"heading": "Geluidsmeldingen",
"label": "Geluidsmeldingen voor",
@@ -85,7 +54,6 @@
"tabs": {
"account": "Profiel",
"audio": "Audio",
"video": "Video",
"general": "Algemeen",
"notifications": "Meldingen"
}
+7 -25
View File
@@ -46,18 +46,6 @@ const StyledOverlayArrow = styled(OverlayArrow, {
transform: 'rotate(180deg) translateY(-1px)',
},
},
variants: {
variant: {
light: {},
dark: {
fill: 'primaryDark.50',
stroke: 'primaryDark.50',
},
},
},
defaultVariants: {
variant: 'light',
},
})
/**
@@ -68,8 +56,6 @@ const StyledOverlayArrow = styled(OverlayArrow, {
*/
export const Popover = ({
children,
variant = 'light',
withArrow = true,
...dialogProps
}: {
children: [
@@ -78,24 +64,20 @@ export const Popover = ({
| (({ close }: { close: () => void }) => ReactNode)
| ReactNode,
]
variant?: 'dark' | 'light'
withArrow?: boolean
} & Omit<DialogProps, 'children'>) => {
} & DialogProps) => {
const [trigger, popoverContent] = children
return (
<DialogTrigger>
{trigger}
<StyledPopover>
{withArrow && (
<StyledOverlayArrow variant={variant}>
<svg width={12} height={12} viewBox="0 0 12 12">
<path d="M0 0 L6 6 L12 0" />
</svg>
</StyledOverlayArrow>
)}
<StyledOverlayArrow>
<svg width={12} height={12} viewBox="0 0 12 12">
<path d="M0 0 L6 6 L12 0" />
</svg>
</StyledOverlayArrow>
<Dialog {...dialogProps}>
{({ close }) => (
<Box size="sm" type="popover" variant={variant}>
<Box size="sm" type="popover">
{typeof popoverContent === 'function'
? popoverContent({ close })
: popoverContent}
+11 -49
View File
@@ -6,14 +6,13 @@ import {
ListBox,
ListBoxItem,
Select as RACSelect,
SelectProps as RACSelectProps,
SelectProps,
SelectValue,
} from 'react-aria-components'
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { css } from '@/styled-system/css'
import type { Placement } from '@react-types/overlays'
const StyledButton = styled(Button, {
base: {
@@ -42,31 +41,6 @@ const StyledButton = styled(Button, {
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
},
},
variants: {
variant: {
light: {},
dark: {
backgroundColor: 'primaryDark.100',
fontWeight: 'medium !important',
color: 'white',
'&[data-pressed]': {
backgroundColor: 'primaryDark.900',
color: 'primaryDark.100',
},
'&[data-hovered]': {
backgroundColor: 'primaryDark.300',
color: 'white',
},
'&[data-selected]': {
backgroundColor: 'primaryDark.700 !important',
color: 'primaryDark.100 !important',
},
},
},
},
defaultVariants: {
variant: 'light',
},
})
const StyledSelectValue = styled(SelectValue, {
@@ -88,32 +62,23 @@ const StyledIcon = styled('div', {
},
})
export type SelectProps<T> = Omit<
RACSelectProps<object>,
'items' | 'label' | 'errors'
> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
placement?: Placement
variant?: 'light' | 'dark'
}
export const Select = <T extends string | number>({
label,
iconComponent,
items,
errors,
placement,
variant = 'light',
...props
}: SelectProps<T>) => {
}: 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 variant={variant}>
<StyledButton>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
@@ -125,16 +90,13 @@ export const Select = <T extends string | number>({
className={css({ flexShrink: 0 })}
/>
</StyledButton>
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
<StyledPopover>
<Box size="sm" type="popover" variant="control">
<ListBox>
{items.map((item) => (
<ListBoxItem
className={
menuRecipe({
extraPadding: true,
variant: variant,
}).item
menuRecipe({ extraPadding: true, variant: 'light' }).item
}
id={item.value}
key={item.value}
+6 -33
View File
@@ -22,15 +22,16 @@ export const StyledSwitch = styled(RACSwitch, {
borderRadius: '1.143rem',
transition: 'all 200ms, outline 200ms',
_before: {
willChange: 'transform',
content: '""',
display: 'block',
margin: '0.125rem',
width: '1.063rem',
height: '1.063rem',
borderRadius: '1.063rem',
background: 'primary.800',
transition: 'transform 200ms, background-color 200ms',
border: '2px solid',
borderColor: 'primary.800',
background: 'white',
transition: 'opacity 10ms',
transitionDelay: '0ms',
},
},
@@ -43,29 +44,14 @@ export const StyledSwitch = styled(RACSwitch, {
color: 'primary.800',
fontSize: '0.75rem',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1,
opacity: 0,
},
'& .cross': {
position: 'absolute',
display: 'block',
top: '50%',
left: '0.375rem',
transform: 'translateY(-50%)',
color: 'white',
fontSize: '0.70rem',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1,
opacity: 1,
transition: 'opacity 200ms',
transitionDelay: '0ms',
},
'&[data-selected] .indicator': {
borderColor: 'primary.800',
background: 'primary.800',
_before: {
border: 'none',
background: 'white',
transform: 'translateX(100%)',
},
@@ -75,11 +61,6 @@ export const StyledSwitch = styled(RACSwitch, {
transition: 'opacity 30ms',
transitionDelay: '150ms',
},
'&[data-selected] .cross': {
opacity: 0,
transition: 'opacity 10ms',
transitionDelay: '0ms',
},
'&[data-disabled] .indicator': {
borderColor: 'primary.200',
background: 'transparent',
@@ -87,9 +68,6 @@ export const StyledSwitch = styled(RACSwitch, {
background: 'primary.200',
},
},
'&[data-disabled] .cross': {
color: 'primary.500',
},
'&[data-focus-visible] .indicator': {
outline: '2px solid!',
outlineColor: 'focusRing!',
@@ -108,12 +86,7 @@ export type SwitchProps = StyledVariantProps<typeof StyledSwitch> &
export const Switch = ({ children, ...props }: SwitchProps) => (
<StyledSwitch {...props}>
<div className="indicator">
<span className="checkmark" aria-hidden="true">
</span>
<span className="cross" aria-hidden="true">
</span>
<span className="checkmark"></span>
</div>
{children}
</StyledSwitch>
+14 -29
View File
@@ -68,7 +68,6 @@ export const buttonRecipe = cva({
},
secondaryText: {
backgroundColor: 'transparent',
fontWeight: 'medium !important',
color: 'primary.800',
'&[data-hovered]': {
backgroundColor: 'greyscale.100',
@@ -229,6 +228,20 @@ 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',
@@ -251,20 +264,6 @@ export const buttonRecipe = cva({
color: 'error.300',
},
},
errorCircle: {
backgroundColor: 'error.500',
width: '56px',
height: '56px',
borderRadius: '100%',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'error.600',
},
'&[data-pressed]': {
backgroundColor: 'error.700',
color: 'error.200',
},
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
@@ -284,20 +283,6 @@ export const buttonRecipe = cva({
color: 'primary !important',
},
},
permission: {
position: 'relative',
// background: 'None !important',
borderRadius: '100%',
// border: 'none !important',
color: 'amber.500',
width: 'fit-content',
height: 'fit-content',
padding: '0 !important',
margin: '0 !important',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
},
invisible: {
true: {
@@ -4,6 +4,6 @@ type State = {
enabled: boolean
}
export const screenSharePreferenceStore = proxy<State>({
export const ScreenSharePreferenceStore = proxy<State>({
enabled: true,
})
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
export type ModalsState = {
permissions: boolean
}
export const modalsStore = proxy<ModalsState>({
permissions: false,
})
+4 -52
View File
@@ -1,61 +1,13 @@
import { proxy } from 'valtio'
import { derive } from 'derive-valtio'
type PermissionState =
| undefined
| 'granted'
| 'prompt'
| 'denied'
| 'unavailable'
type PermissionState = undefined | 'granted' | 'prompt' | 'denied'
type BaseState = {
type State = {
cameraPermission: PermissionState
microphonePermission: PermissionState
isLoading: boolean
isPermissionDialogOpen: boolean
}
type DerivedState = {
isCameraGranted: boolean
isMicrophoneGranted: boolean
isCameraDenied: boolean
isMicrophoneDenied: boolean
isCameraPrompted: boolean
isMicrophonePrompted: boolean
}
type State = BaseState & DerivedState
export const permissionsStore = proxy<BaseState>({
export const permissionStore = proxy<State>({
cameraPermission: undefined,
microphonePermission: undefined,
isLoading: true,
isPermissionDialogOpen: false,
}) as State
derive(
{
isCameraGranted: (get) =>
get(permissionsStore).cameraPermission == 'granted',
isMicrophoneGranted: (get) =>
get(permissionsStore).microphonePermission == 'granted',
isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
isMicrophoneDenied: (get) =>
get(permissionsStore).microphonePermission == 'denied',
isCameraPrompted: (get) =>
get(permissionsStore).cameraPermission == 'prompt',
isMicrophonePrompted: (get) =>
get(permissionsStore).microphonePermission == 'prompt',
},
{
proxy: permissionsStore,
}
)
export const openPermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = true
}
export const closePermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = false
}
})
-12
View File
@@ -1,12 +0,0 @@
import { proxy } from 'valtio'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
type State = {
areSettingsOpen: boolean
defaultSelectedTab?: SettingsDialogExtendedKey
}
export const settingsStore = proxy<State>({
areSettingsOpen: false,
defaultSelectedTab: undefined,
})
+1 -8
View File
@@ -5,24 +5,17 @@ import {
saveUserChoices,
LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-core'
import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
videoPublishResolution?: VideoResolution
videoSubscribeQuality?: VideoQuality
}
function getUserChoicesState(): LocalUserChoices {
return {
noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
videoPublishResolution: 'h720',
videoSubscribeQuality: VideoQuality.HIGH,
audioOutputDeviceId: 'default',
...loadUserChoices(),
}
}
-14
View File
@@ -1,14 +0,0 @@
/**
* FRAGILE: Splits translated text on placeholder to inject icons inline.
*
* Fragile because:
* - Relies on exact string matching - typos break it silently
* - Translators may accidentally modify/remove placeholders
* - No validation or error handling
*/
export const injectIconIntoTranslation = (
translation: string,
placeholder: string = 'ICON_PLACEHOLDER'
) => {
return translation.split(placeholder)
}
@@ -35,11 +35,14 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
@@ -78,7 +81,8 @@ backend:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
+1 -1
View File
@@ -1,4 +1,4 @@
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
livekit:
keys:
devkey: secret
@@ -35,11 +35,14 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
@@ -49,7 +52,6 @@ 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
@@ -69,8 +71,6 @@ backend:
SUMMARY_SERVICE_API_TOKEN: password
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
ROOM_TELEPHONY_ENABLED: True
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
@@ -79,7 +79,15 @@ backend:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
@@ -94,6 +102,13 @@ backend:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
@@ -1,4 +1,4 @@
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
livekit:
keys:
devkey: secret
+20 -2
View File
@@ -57,11 +57,14 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
@@ -100,7 +103,15 @@ backend:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
@@ -115,6 +126,13 @@ backend:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
+1 -1
View File
@@ -1,4 +1,4 @@
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
livekit:
keys:
devkey: secret

Some files were not shown because too many files have changed in this diff Show More