Compare commits

..

2 Commits

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

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

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

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

Before

Width:  |  Height:  |  Size: 7.1 KiB

@@ -1,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()
@@ -154,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) =>
@@ -169,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(
{
@@ -186,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])
@@ -1,18 +1,12 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
LiveKitRoom,
usePersistentUserChoices,
} from '@livekit/components-react'
import { LiveKitRoom } from '@livekit/components-react'
import {
DisconnectReason,
MediaDeviceFailure,
Room,
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
VideoPresets,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -35,20 +29,18 @@ import { isFireFox } from '@/utils/livekit'
export const Conference = ({
roomId,
userConfig,
initialRoomData,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
userChoices: LocalUserChoices
}
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
@@ -87,38 +79,22 @@ 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,
},
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
}
// 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,
])
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -249,6 +225,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>
@@ -2,12 +2,13 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { HStack, VStack } from '@/styled-system/jsx'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import {
@@ -26,12 +27,7 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useSnapshot } from 'valtio'
import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
import { ToggleDevice } from './join/ToggleDevice'
import { SelectDevice } from './join/SelectDevice'
import { useResolveDefaultDeviceId } from '../livekit/hooks/useResolveDefaultDevice'
import { isSafari } from '@/utils/livekit'
import { LocalUserChoices } from '@/stores/userChoices'
const onError = (e: Error) => console.error('ERROR', e)
@@ -76,23 +72,45 @@ const Effects = ({
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
</Dialog>
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
zIndex: '1',
})}
>
<RiImageCircleAiFill size={24} />
</Button>
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
>
<RiImageCircleAiFill size={24} />
</Button>
</div>
<div
className={css({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '20%',
backgroundImage:
'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(255,255,255,0) 100%)',
borderBottomRadius: '1rem',
})}
/>
</>
)
}
export const Join = ({
enterRoom,
onSubmit,
roomId,
}: {
enterRoom: () => void
onSubmit: (choices: LocalUserChoices) => void
roomId: string
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -102,13 +120,11 @@ export const Join = ({
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
},
saveAudioInputEnabled,
saveAudioOutputDeviceId,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
@@ -116,14 +132,23 @@ export const Join = ({
saveProcessorSerialized,
} = usePersistentUserChoices()
const [processor, setProcessor] = useState(
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
)
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
{
audio: { deviceId: audioDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
video: { deviceId: videoDeviceId },
},
onError
)
@@ -144,20 +169,13 @@ export const Join = ({
[tracks]
)
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveDefaultDeviceId(audioDeviceId, audioTrack, saveAudioInputDeviceId)
useResolveDefaultDeviceId(videoDeviceId, videoTrack, saveVideoInputDeviceId)
const videoEl = useRef(null)
const isVideoInitiated = useRef(false)
useEffect(() => {
const videoElement = videoEl.current as HTMLVideoElement | null
const handleVideoLoaded = () => {
if (videoElement) {
isVideoInitiated.current = true
videoElement.style.opacity = '1'
}
}
@@ -170,14 +188,29 @@ export const Join = ({
return () => {
videoTrack?.detach()
if (videoElement) {
videoElement.removeEventListener('loadedmetadata', handleVideoLoaded)
videoElement.style.opacity = '0'
}
isVideoInitiated.current = false
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
}
}, [videoTrack, videoEnabled])
const enterRoom = useCallback(() => {
onSubmit({
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processorSerialized: processor?.serialize(),
})
}, [
onSubmit,
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processor,
])
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
@@ -236,48 +269,15 @@ export const Join = ({
enterRoom()
}
const permissions = useSnapshot(permissionsStore)
const isCameraDeniedOrPrompted =
permissions.isCameraDenied || permissions.isCameraPrompted
const isMicrophoneDeniedOrPrompted =
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
const hintMessage = useMemo(() => {
if (isCameraDeniedOrPrompted) {
return isMicrophoneDeniedOrPrompted
? 'cameraAndMicNotGranted'
: 'cameraNotGranted'
// This hook is used to setup the persisted user choice processor on initialization.
// So it's on purpose that processor is not included in the deps.
// We just want to wait for the videoTrack to be loaded to apply the default processor.
useEffect(() => {
if (processor && videoTrack && !videoTrack.getProcessor()) {
videoTrack.setProcessor(processor)
}
if (!videoEnabled) {
return 'cameraDisabled'
}
if (!isVideoInitiated.current) {
return 'cameraStarting'
}
if (videoTrack && videoEnabled) {
return ''
}
}, [
videoTrack,
videoEnabled,
isCameraDeniedOrPrompted,
isMicrophoneDeniedOrPrompted,
])
const permissionsButtonLabel = useMemo(() => {
if (!isMicrophoneDeniedOrPrompted && !isCameraDeniedOrPrompted) {
return null
}
if (isCameraDeniedOrPrompted && isMicrophoneDeniedOrPrompted) {
return 'cameraAndMicNotGranted'
}
if (isCameraDeniedOrPrompted && !isMicrophoneDeniedOrPrompted) {
return 'cameraNotGranted'
}
return null
}, [isMicrophoneDeniedOrPrompted, isCameraDeniedOrPrompted])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack])
const renderWaitingState = () => {
switch (status) {
@@ -339,7 +339,6 @@ export const Join = ({
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
@@ -362,284 +361,140 @@ export const Join = ({
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
flexDirection: 'column',
flexGrow: 1,
gap: { base: '1rem', sm: '2rem', lg: 0 },
lg: {
flexDirection: 'row',
alignItems: 'center',
},
})}
>
<div
className={css({
display: 'flex',
height: 'auto',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
gap: '2rem',
padding: '0 2rem',
flexDirection: 'column',
minWidth: 0,
maxWidth: '764px',
width: '100%',
lg: {
height: '540px',
flexGrow: 1,
flexDirection: 'row',
width: 'auto',
height: '570px',
},
})}
>
<div
className={css({
display: 'inline-flex',
flexDirection: 'column',
flexGrow: 1,
minWidth: 0,
flexShrink: { base: 0, sm: 1 },
width: '100%',
lg: {
width: '740px',
},
})}
>
<div
className={css({
borderRadius: '1rem',
flex: '0 1',
minWidth: '320px',
margin: {
base: '0.5rem',
sm: '1rem',
lg: '1rem 0.5rem 1rem 1rem',
},
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
height: 'auto',
aspectRatio: 16 / 9,
'--tw-shadow':
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
'--tw-shadow-colored':
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
boxShadow:
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
backgroundColor: 'black',
})}
>
<div
className={css({
position: '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',
width: '100%',
height: 'fit-content',
aspectRatio: '16 / 9',
})}
>
<div
{videoTrack && videoEnabled ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="1280"
height="720"
className={css({
backgroundColor: 'black',
position: 'absolute',
boxSizing: 'border-box',
top: 0,
display: 'block',
width: '100%',
height: '100%',
overflow: 'hidden',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
borderRadius: '1rem',
})}
>
<div
aria-label={t(
`videoPreview.${videoEnabled ? 'enabled' : 'disabled'}`
)}
role="status"
className={css({
position: 'absolute',
top: 0,
width: '100%',
})}
>
<div
className={css({
width: '100%',
height: 'auto',
aspectRatio: '16 / 9',
overflow: 'hidden',
position: 'absolute',
top: '-2px',
left: '-2px',
pointerEvents: 'none',
transform: 'scale(1.02)',
})}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoEl}
width="1280"
height="720"
style={{
display:
!videoEnabled || isCameraDeniedOrPrompted
? 'none'
: undefined,
}}
className={css({
position: 'absolute',
transform: 'rotateY(180deg)',
opacity: 0,
height: '100%',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
})}
disablePictureInPicture
disableRemotePlayback
/>
</div>
</div>
<div
role="alert"
className={css({
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
justifyContent: 'center',
textAlign: 'center',
alignItems: 'center',
padding: '0.24rem',
boxSizing: 'border-box',
gap: '1rem',
})}
>
<p
className={css({
fontWeight: '400',
fontSize: { base: '1rem', sm: '1.25rem', lg: '1.5rem' },
textWrap: 'balance',
color: 'white',
})}
>
{hintMessage && t(hintMessage)}
</p>
{isCameraDeniedOrPrompted && (
<Button
size="sm"
variant="tertiary"
onPress={openPermissionsDialog}
>
{t(`permissionsButton.${permissionsButtonLabel}`)}
</Button>
)}
</div>
</div>
<div
className={css({
position: 'absolute',
bottom: '1rem',
zIndex: '1',
display: 'flex',
gap: '1rem',
justifyContent: 'center',
left: '50%',
transform: 'translateX(-50%)',
})}
>
<ToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
<ToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
</div>
<div
className={css({
position: 'absolute',
right: '1rem',
bottom: '1rem',
zIndex: '1',
})}
>
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
</div>
</div>
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '2%',
width: '80%',
marginX: 'auto',
})}
>
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="audioinput"
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
disablePictureInPicture
disableRemotePlayback
/>
</div>
{!isSafari() && (
) : (
<div
className={css({
width: '30%',
width: '100%',
height: '100%',
color: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<SelectDevice
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
<p
className={css({
fontSize: '24px',
fontWeight: '300',
})}
>
{!videoEnabled && t('cameraDisabled')}
{videoEnabled && !videoTrack && t('cameraStarting')}
</p>
</div>
)}
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="videoinput"
id={videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={audioDeviceId}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={videoDeviceId}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
</HStack>
</div>
<div
className={css({
width: '100%',
flexShrink: 0,
padding: '0',
sm: {
width: '448px',
padding: '0 3rem 9rem 3rem',
},
})}
>
{renderWaitingState()}
</div>
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0 448px',
position: 'relative',
margin: '1rem 1rem 1rem 0.5rem',
})}
>
{renderWaitingState()}
</div>
</div>
</Screen>
@@ -1,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>
)
}
@@ -1,143 +0,0 @@
import {
RemixiconComponentType,
RiMicLine,
RiVideoOnLine,
RiVolumeDownLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useEffect, useMemo } from 'react'
import { Select } from '@/primitives/Select'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type DeviceItems = Array<{ value: string; label: string }>
type DeviceConfig = {
icon: RemixiconComponentType
}
type SelectDeviceProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
}
type SelectDevicePermissionsProps = SelectDeviceProps & {
config: DeviceConfig
}
const SelectDevicePermissions = ({
id,
kind,
config,
onSubmit,
}: SelectDevicePermissionsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
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])
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length === 0}
items={items}
iconComponent={config?.icon}
placeholder={
items.length === 0
? t('selectDevice.loading')
: t('selectDevice.select')
}
selectedKey={id || activeDeviceId}
onSelectionChange={(key) => {
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
/>
)
}
export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const permissions = useSnapshot(permissionsStore)
const config = useMemo<DeviceConfig | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
icon: RiMicLine,
}
case 'audiooutput':
return {
icon: RiVolumeDownLine,
}
case 'videoinput':
return {
icon: RiVideoOnLine,
}
}
}, [kind])
const isPermissionDeniedOrPrompted = useMemo(() => {
if (kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
if (kind == 'audiooutput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
return false
}, [kind, permissions])
if (!config) return null
if (isPermissionDeniedOrPrompted || permissions.isLoading) {
return (
<Select
aria-label={t(`${kind}.permissionsNeeded`)}
label=""
isDisabled={true}
items={[]}
iconComponent={config?.icon}
placeholder={t('selectDevice.permissionsNeeded')}
/>
)
}
return (
<SelectDevicePermissions
id={id}
onSubmit={onSubmit}
kind={kind}
config={config}
/>
)
}
@@ -1,78 +0,0 @@
import { UseTrackToggleProps } from '@livekit/components-react'
import { ToggleDevice as BaseToggleDevice } from '../../livekit/components/controls/ToggleDevice'
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../livekit/config/ToggleDeviceConfig'
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useCallback, useMemo, useState } from 'react'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type ToggleDeviceProps<T extends ToggleSource> = UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack
source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
}
export const ToggleDevice = <T extends ToggleSource>({
track,
onChange,
...props
}: ToggleDeviceProps<T>) => {
const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
const [isTrackEnabled, setIsTrackEnabled] = useState(
props.initialState ?? false
)
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
if (config.kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (config.kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [config, permissions])
const toggle = useCallback(async () => {
if (!track) {
console.error('Track is undefined.')
return
}
try {
if (isTrackEnabled) {
setIsTrackEnabled(false)
onChange?.(false, true)
await track.mute()
} else {
setIsTrackEnabled(true)
onChange?.(true, true)
await track.unmute()
}
} catch (error) {
console.error('Failed to toggle track:', error)
}
}, [track, onChange, isTrackEnabled])
return (
<BaseToggleDevice
enabled={isTrackEnabled}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggle={toggle}
config={config}
variant="whiteCircle"
errorVariant="errorCircle"
toggleButtonProps={{
groupPosition: undefined,
}}
/>
)
}
@@ -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,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
@@ -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()
@@ -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>
)
}
@@ -5,7 +5,14 @@ import {
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import {
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import {
LocalAudioTrack,
LocalVideoTrack,
@@ -13,25 +20,64 @@ import {
VideoCaptureOptions,
} from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect, useMemo } from 'react'
import { useEffect } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../config/ToggleDeviceConfig'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: ToggleSource
source: SelectToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
@@ -46,7 +92,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = TOGGLE_DEVICE_CONFIG[props.source]
const config = selectToggleDeviceConfig[props.source]
if (!config) {
throw new Error('Invalid source')
}
@@ -55,18 +101,6 @@ export const SelectToggleDevice = <T extends ToggleSource>({
const { userChoices } = usePersistentUserChoices()
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
switch (config.kind) {
case 'audioinput':
return (
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
)
case 'videoinput':
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [permissions, config.kind])
const toggle = () => {
if (props.source === Track.Source.Camera) {
/**
@@ -127,7 +161,6 @@ export const SelectToggleDevice = <T extends ToggleSource>({
config={config}
variant={variant}
toggle={toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{
...(hideMenu
? {
@@ -139,18 +172,13 @@ export const SelectToggleDevice = <T extends ToggleSource>({
{!hideMenu && (
<Menu variant={menuVariant}>
<Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !isPermissionDeniedOrPrompted
? variant
: 'error2'
}
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowUpSLine />
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
@@ -3,7 +3,7 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
@@ -13,16 +13,12 @@ import {
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../config/ToggleDeviceConfig'
export type ToggleDeviceProps = {
enabled: boolean
isPermissionDeniedOrPrompted?: boolean
toggle: () => void
config: ToggleDeviceConfig
config: SelectToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
@@ -31,9 +27,7 @@ export const ToggleDevice = ({
enabled,
toggle,
variant = 'primaryDark',
errorVariant = 'error2',
toggleButtonProps,
isPermissionDeniedOrPrompted,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -62,7 +56,7 @@ export const ToggleDevice = ({
return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [enabled, kind, shortcut, t])
const Icon = enabled && !isPermissionDeniedOrPrompted ? iconOn : iconOff
const Icon = enabled ? iconOn : iconOff
const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) {
@@ -70,29 +64,18 @@ export const ToggleDevice = ({
}
return (
<div style={{ position: 'relative' }}>
{isPermissionDeniedOrPrompted && <PermissionNeededButton />}
<ToggleButton
isSelected={!enabled}
variant={
enabled && !isPermissionDeniedOrPrompted ? variant : errorVariant
}
shySelected
onPress={() =>
isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle()
}
aria-label={toggleLabel}
tooltip={
isPermissionDeniedOrPrompted
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
</div>
<ToggleButton
isSelected={!enabled}
variant={enabled ? variant : 'error2'}
shySelected
onPress={() => toggle()}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
)
}
@@ -1,52 +0,0 @@
import { Track } from 'livekit-client'
import {
RemixiconComponentType,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Shortcut } from '@/features/shortcuts/types'
export type ToggleSource = Exclude<
Track.Source,
| Track.Source.ScreenShareAudio
| Track.Source.Unknown
| Track.Source.ScreenShare
>
export type ToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type ToggleDeviceConfigMap = {
[key in ToggleSource]: ToggleDeviceConfig
}
export const TOGGLE_DEVICE_CONFIG = {
[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,
},
},
} as const satisfies ToggleDeviceConfigMap
@@ -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,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)
@@ -18,18 +16,9 @@ export function usePersistentUserChoices() {
saveAudioInputDeviceId: (deviceId: string) => {
userChoicesStore.audioDeviceId = deviceId
},
saveAudioOutputDeviceId: (deviceId: string) => {
userChoicesStore.audioOutputDeviceId = deviceId
},
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,18 +0,0 @@
import { useEffect } from 'react'
export const useResolveDefaultDeviceId = <
T extends { getDeviceId(): Promise<string | undefined> },
>(
currentId: string,
track: T | undefined,
save: (id: string) => void
) => {
useEffect(() => {
if (currentId !== 'default' || !track) return
const resolveDefaultDeviceId = async () => {
const actualDeviceId = await track.getDeviceId()
if (actualDeviceId && actualDeviceId !== 'default') 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])
}
@@ -32,7 +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'
const LayoutWrapper = styled(
'div',
@@ -78,7 +77,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useConnectionObserver()
useVideoResolutionSubscription()
const tracks = useTracks(
[
+15 -18
View File
@@ -1,28 +1,21 @@
import { ReactNode, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { useLocation, useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Permissions } from '../components/Permissions'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
const BaseRoom = ({ children }: { children: ReactNode }) => {
return (
<UserAware>
<Permissions />
{children}
</UserAware>
)
}
import { LocalUserChoices } from '@/stores/userChoices'
export const Room = () => {
const { isLoggedIn } = useUser()
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const { roomId } = useParams()
const [location, setLocation] = useLocation()
@@ -55,21 +48,25 @@ export const Room = () => {
return <ErrorScreen />
}
if (!hasSubmittedEntry && !skipJoinScreen) {
if (!userConfig && !skipJoinScreen) {
return (
<BaseRoom>
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
</BaseRoom>
<UserAware>
<Join onSubmit={setUserConfig} roomId={roomId} />
</UserAware>
)
}
return (
<BaseRoom>
<UserAware>
<Conference
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</BaseRoom>
</UserAware>
)
}
@@ -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,13 +9,11 @@ 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'
@@ -71,7 +69,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{t('dialog.heading')}
</Heading>
)}
<TabList border={false}>
<TabList border={false} aria-label="Chat log orientation example">
<Tab icon highlight id="1">
<RiAccountCircleLine />
{isWideScreen && t('tabs.account')}
@@ -81,14 +79,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{isWideScreen && t('tabs.audio')}
</Tab>
<Tab icon highlight id="3">
<RiVideoOnLine />
{isWideScreen && t('tabs.video')}
</Tab>
<Tab icon highlight id="4">
<RiSettings3Line />
{isWideScreen && t('tabs.general')}
</Tab>
<Tab icon highlight id="5">
<Tab icon highlight id="4">
<RiNotification3Line />
{isWideScreen && t('tabs.notifications')}
</Tab>
@@ -97,9 +91,8 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<div className={tabPanelContainerStyle}>
<AccountTab id="1" onOpenChange={props.onOpenChange} />
<AudioTab id="2" />
<VideoTab id="3" />
<GeneralTab id="4" />
<NotificationsTab id="5" />
<GeneralTab id="3" />
<NotificationsTab id="4" />
</div>
</Tabs>
</Dialog>
@@ -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'>
@@ -28,7 +95,6 @@ export const AudioTab = ({ id }: AudioTabProps) => {
userChoices: { noiseReductionEnabled },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const isSpeaking = useIsSpeaking(localParticipant)
@@ -117,10 +183,9 @@ export const AudioTab = ({ id }: AudioTabProps) => {
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={(key) => {
onSelectionChange={async (key) =>
setActiveMediaDeviceOut(key as string)
saveAudioOutputDeviceId(key as string)
}}
}
{...disabledProps}
style={{
minWidth: 0,
@@ -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>
</>
)
}
-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": {
+8 -57
View File
@@ -8,14 +8,8 @@
"back": "Dem Meeting erneut beitreten"
},
"join": {
"selectDevice": {
"loading": "Laden…",
"select": "Wählen Sie einen Wert",
"permissionsNeeded": "Genehmigung erforderlich"
},
"videoinput": {
"choose": "Kamera auswählen",
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"label": "Kamera",
@@ -23,15 +17,10 @@
},
"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"
},
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
@@ -48,17 +37,7 @@
"usernameEmpty": "Ihr Name darf nicht leer sein"
},
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraStarting": "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"
},
"cameraStarting": "Kamera wird gestartet.",
"waiting": {
"title": "Beitrittsanfrage wird gesendet...",
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
@@ -74,16 +53,12 @@
},
"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": {
@@ -98,30 +73,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",
@@ -275,9 +226,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": {
+8 -57
View File
@@ -8,14 +8,8 @@
"back": "Rejoin the meeting"
},
"join": {
"selectDevice": {
"loading": "Loading…",
"select": "Select a value",
"permissionsNeeded": "Permission needed"
},
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"label": "Camera",
@@ -23,15 +17,10 @@
},
"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"
},
"effects": {
"description": "Apply effects",
"title": "Effects",
@@ -48,17 +37,7 @@
"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"
},
"videoPreview": {
"enabled": "Video preview enabled",
"disabled": "Video preview disabled"
},
"cameraStarting": "Camera is starting.",
"waiting": {
"title": "Requesting to join...",
"body": "You will be able to join this call when someone authorizes you"
@@ -74,16 +53,12 @@
},
"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": {
@@ -98,30 +73,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",
@@ -275,9 +226,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": {
+9 -58
View File
@@ -8,14 +8,8 @@
"back": "Réintégrer la réunion"
},
"join": {
"selectDevice": {
"loading": "Chargement…",
"select": "Sélectionnez une valeur",
"permissionsNeeded": "Autorisations nécessaires"
},
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"label": "Webcam",
@@ -23,15 +17,10 @@
},
"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"
},
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière plan",
@@ -48,17 +37,7 @@
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraStarting": "La caméra va démarrer",
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
"videoPreview": {
"enabled": "Apercu vidéo activé",
"disabled": "Apercu vidéo désactivé"
},
"permissionsButton": {
"cameraAndMicNotGranted": "Autorisez l'accès à la caméra et au micro",
"cameraNotGranted": "Autorisez l'accès à la caméra"
},
"cameraStarting": "La caméra va démarrer.",
"waiting": {
"title": "Demande de participation…",
"body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera"
@@ -74,16 +53,12 @@
},
"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": {
@@ -98,30 +73,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",
@@ -275,9 +226,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 :",
-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": {
+8 -57
View File
@@ -8,14 +8,8 @@
"back": "Sluit weer bij de vergadering aan"
},
"join": {
"selectDevice": {
"loading": "Bezig met laden…",
"select": "Selecteer een waarde",
"permissionsNeeded": "Toestemming vereist"
},
"videoinput": {
"choose": "Selecteer camera",
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"label": "Camera",
@@ -23,15 +17,10 @@
},
"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"
},
"effects": {
"description": "Pas effecten toe",
"title": "Effecten",
@@ -48,17 +37,7 @@
"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"
},
"videoPreview": {
"enabled": "Videovoorbeeld ingeschakeld",
"disabled": "Videovoorbeeld uitgeschakeld"
},
"cameraStarting": "Camera wordt ingeschakeld.",
"waiting": {
"title": "Verzoek tot deelname...",
"body": "U kunt deelnemen aan dit gesprek wanneer iemand u toestemming geeft"
@@ -74,16 +53,12 @@
},
"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": {
@@ -98,30 +73,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",
@@ -275,9 +226,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"
}
+2 -22
View File
@@ -1,6 +1,6 @@
import { type ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
import { RiArrowDropDownLine } from '@remixicon/react'
import {
Button,
ListBox,
@@ -12,14 +12,12 @@ import {
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { css } from '@/styled-system/css'
const StyledButton = styled(Button, {
base: {
width: 'full',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingY: 0.125,
paddingX: 0.25,
border: '1px solid',
@@ -55,40 +53,22 @@ const StyledSelectValue = styled(SelectValue, {
},
})
const StyledIcon = styled('div', {
base: {
marginRight: '0.35rem',
flexShrink: 0,
},
})
export const Select = <T extends string | number>({
label,
iconComponent,
items,
errors,
...props
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
}) => {
const IconComponent = iconComponent
return (
<RACSelect {...props}>
{label}
<StyledButton>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
</StyledIcon>
)}
<StyledSelectValue />
<RiArrowDropDownLine
aria-hidden="true"
className={css({ flexShrink: 0 })}
/>
<RiArrowDropDownLine aria-hidden="true" />
</StyledButton>
<StyledPopover>
<Box size="sm" type="popover" variant="control">
@@ -68,7 +68,6 @@ export const buttonRecipe = cva({
},
secondaryText: {
backgroundColor: 'transparent',
fontWeight: 'medium !important',
color: 'primary.800',
'&[data-hovered]': {
backgroundColor: 'greyscale.100',
@@ -251,20 +250,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 +269,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: {
-61
View File
@@ -1,61 +0,0 @@
import { proxy } from 'valtio'
import { derive } from 'derive-valtio'
type PermissionState =
| undefined
| 'granted'
| 'prompt'
| 'denied'
| 'unavailable'
type BaseState = {
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>({
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
}
-9
View File
@@ -5,24 +5,15 @@ 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,
...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)
}
@@ -40,6 +40,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
{{- with .Values.livekit.keys }}
@@ -40,6 +40,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
{{- with .Values.livekit.keys }}
@@ -69,8 +72,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
@@ -62,6 +62,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
{{- with .Values.livekit.keys }}
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.10
version: 0.0.9
-4
View File
@@ -21,7 +21,6 @@
| `ingress.path` | Path to use for the Ingress | `/` |
| `ingress.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingress.tls.secretName` | Secret name for TLS config | `nil` |
| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingress.customBackends` | Add custom backends to ingress | `[]` |
@@ -31,7 +30,6 @@
| `ingressAdmin.path` | Path to use for the Ingress | `/admin` |
| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressAdmin.tls.secretName` | Secret name for TLS config | `nil` |
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` |
@@ -165,7 +163,6 @@
| `posthog.ingress.path` | URL path prefix for the ingress routes (e.g., /) | `/` |
| `posthog.ingress.hosts` | Additional hostnames array to be included in the ingress | `[]` |
| `posthog.ingress.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
| `posthog.ingress.tls.secretName` | Secret name for TLS config | `nil` |
| `posthog.ingress.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
| `posthog.ingress.customBackends` | Custom backend service configurations for the ingress | `[]` |
| `posthog.ingress.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
@@ -175,7 +172,6 @@
| `posthog.ingressAssets.path` | URL path prefix for the ingress routes (e.g., /) | `/static` |
| `posthog.ingressAssets.hosts` | Additional hostnames array to be included in the ingress | `[]` |
| `posthog.ingressAssets.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
| `posthog.ingressAssets.tls.secretName` | Secret name for TLS config | `nil` |
| `posthog.ingressAssets.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
| `posthog.ingressAssets.customBackends` | Custom backend service configurations for the ingress | `[]` |
| `posthog.ingressAssets.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
{{- if .Values.ingress.tls.enabled }}
tls:
{{- if .Values.ingress.host }}
- secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
- secretName: {{ $fullName }}-tls
hosts:
- {{ .Values.ingress.host | quote }}
{{- end }}
@@ -30,7 +30,6 @@ spec:
tls:
{{- if .Values.ingressAdmin.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingressAdmin.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressAdmin.host | quote }}
{{- end }}
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
{{- if .Values.posthog.ingress.tls.enabled }}
tls:
{{- if .Values.posthog.ingress.host }}
- secretName: {{ .Values.posthog.ingress.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
- secretName: {{ $fullName }}-posthog-tls
hosts:
- {{ .Values.posthog.ingress.host | quote }}
{{- end }}
@@ -29,7 +29,7 @@ spec:
{{- if .Values.posthog.ingressAssets.tls.enabled }}
tls:
{{- if .Values.posthog.ingressAssets.host }}
- secretName: {{ .Values.posthog.ingressAssets.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
- secretName: {{ $fullName }}-posthog-tls
hosts:
- {{ .Values.posthog.ingressAssets.host | quote }}
{{- end }}
+1 -9
View File
@@ -38,12 +38,10 @@ ingress:
hosts: []
# - chart-example.local
## @param ingress.tls.enabled Weather to enable TLS for the Ingress
## @param ingress.tls.secretName Secret name for TLS config
## @skip ingress.tls.additional
## @extra ingress.tls.additional[].secretName Secret name for additional TLS config
## @extra ingress.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
additional: []
@@ -64,12 +62,10 @@ ingressAdmin:
hosts: [ ]
# - chart-example.local
## @param ingressAdmin.tls.enabled Weather to enable TLS for the Ingress
## @param ingressAdmin.tls.secretName Secret name for TLS config
## @skip ingressAdmin.tls.additional
## @extra ingressAdmin.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressAdmin.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
additional: []
@@ -91,8 +87,8 @@ ingressMedia:
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
secretName: null
additional: []
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
@@ -362,7 +358,6 @@ posthog:
## @param posthog.ingress.path URL path prefix for the ingress routes (e.g., /)
## @param posthog.ingress.hosts Additional hostnames array to be included in the ingress
## @param posthog.ingress.tls.enabled Enable or disable TLS/HTTPS for the ingress
## @param posthog.ingress.tls.secretName Secret name for TLS config
## @param posthog.ingress.tls.additional Additional TLS configurations for extra hosts/certificates
## @param posthog.ingress.customBackends Custom backend service configurations for the ingress
## @param posthog.ingress.annotations Additional Kubernetes annotations to apply to the ingress
@@ -373,7 +368,6 @@ posthog:
path: /
hosts: [ ]
tls:
secretName: null
enabled: true
additional: [ ]
@@ -386,7 +380,6 @@ posthog:
## @param posthog.ingressAssets.path URL path prefix for the ingress routes (e.g., /)
## @param posthog.ingressAssets.hosts Additional hostnames array to be included in the ingress
## @param posthog.ingressAssets.tls.enabled Enable or disable TLS/HTTPS for the ingress
## @param posthog.ingressAssets.tls.secretName Secret name for TLS config
## @param posthog.ingressAssets.tls.additional Additional TLS configurations for extra hosts/certificates
## @param posthog.ingressAssets.customBackends Custom backend service configurations for the ingress
## @param posthog.ingressAssets.annotations Additional Kubernetes annotations to apply to the ingress
@@ -397,7 +390,6 @@ posthog:
path: /static
hosts: [ ]
tls:
secretName: null
enabled: true
additional: [ ]