Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 486c737156 | |||
| 7b0c5ee300 | |||
| 56bea3cedc | |||
| 51ed277941 | |||
| f46a5dc157 | |||
| 4f4b4d3231 | |||
| 25a39a1fb6 | |||
| a5003b55e3 | |||
| bcf551a25c | |||
| c1c2d0260d | |||
| e80b9c2485 | |||
| 04f7412307 | |||
| ba422110f8 | |||
| a83afdbb0c | |||
| a5d8aae293 | |||
| 8a3a0d5759 | |||
| aa8362c470 | |||
| c2586a392c | |||
| ea3f0cc59e | |||
| 9aa9342054 | |||
| ca38c4851f | |||
| a97895c383 | |||
| e88aeedbf1 | |||
| 5f32a9e6a3 | |||
| aa09c15ecc | |||
| 7ec3e481ff | |||
| 42107f4698 | |||
| a49893696b | |||
| 6f3339fbdc | |||
| f17e0a3ba0 | |||
| 2367750395 | |||
| 5eb70384e3 | |||
| 22c68da2af | |||
| ebf676529f | |||
| 40cedba8ae | |||
| 59e0643dde | |||
| 7b89395f42 | |||
| 3d047b5124 | |||
| 89b1190bb4 | |||
| 8f28a46a5f | |||
| ac88c046dc | |||
| b7f55ac35d | |||
| 329a729bdc | |||
| 2215b621f4 | |||
| c330ec6ff4 | |||
| 8245270f28 | |||
| 9728603f72 | |||
| 803c94a80c | |||
| fd90d0b830 | |||
| f380d0342d | |||
| 4cdf257b6a | |||
| 338f8d8a69 | |||
| ab4f415d23 | |||
| e926b407b1 | |||
| 4e655a0a64 | |||
| d2bde299be | |||
| 9b44ed0974 | |||
| aeaa9b7ffd | |||
| da73424f72 | |||
| 2b9b977f57 | |||
| 355db6ef9a | |||
| 890e043d29 | |||
| 9eb412758a | |||
| fa27afdfdf | |||
| cb8b415ef9 | |||
| 7c6182cc4e | |||
| 2d47e90a1a | |||
| 56ec2dd8cb | |||
| 361de29780 | |||
| e4d5ca64b9 | |||
| bd139a1ef9 | |||
| f682f8feb3 | |||
| 5f1d59c753 | |||
| c45b91dc58 | |||
| 0e72f61650 | |||
| 4fae3c6c47 | |||
| 120bcdc720 | |||
| f1b20d7981 | |||
| 95190ec690 | |||
| adb99cc5d9 | |||
| 201069aa4c | |||
| b6a5b1a805 | |||
| f3af637fd6 | |||
| de3a5aa404 | |||
| 5e9d20e685 | |||
| b54445739a | |||
| 7c67bacd94 | |||
| 1fd1b184ee | |||
| adb517a336 | |||
| eec9ff9f26 | |||
| e0258a1765 | |||
| 872ce1ecc6 | |||
| e2c3b745ca |
@@ -82,6 +82,7 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -186,6 +187,7 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
@@ -85,6 +85,13 @@ clean_old_images('localhost:5001/meet-livekit')
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
|
||||
Generated
+22
-11
@@ -13,10 +13,12 @@
|
||||
"@livekit/track-processors": "0.5.7",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@react-types/overlays": "3.9.0",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.0",
|
||||
"i18next": "25.3.1",
|
||||
@@ -24,7 +26,7 @@
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.15.2",
|
||||
"livekit-client": "2.15.5",
|
||||
"posthog-js": "1.256.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.10.1",
|
||||
@@ -3377,12 +3379,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/overlays": {
|
||||
"version": "3.8.16",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.8.16.tgz",
|
||||
"integrity": "sha512-Aj9jIFwALk9LiOV/s3rVie+vr5qWfaJp/6aGOuc2StSNDTHvj1urSAr3T0bT8wDlkrqnlS4JjEGE40ypfOkbAA==",
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
|
||||
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-types/shared": "^3.30.0"
|
||||
"@react-types/shared": "^3.31.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -3438,9 +3440,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.30.0.tgz",
|
||||
"integrity": "sha512-COIazDAx1ncDg046cTJ8SFYsX8aS3lB/08LDnbkH/SkdYrFPWDlXMrO/sUam8j1WWM+PJ+4d1mj7tODIKNiFog==",
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -5294,6 +5296,15 @@
|
||||
"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",
|
||||
@@ -7449,9 +7460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.15.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.2.tgz",
|
||||
"integrity": "sha512-hf0A0JFN7M0iVGZxMfTk6a3cW7TNTVdqxkykjKBweORlqhQX1ITVloh6aLvplLZOxpkUE5ZVLz1DeS3+ERglog==",
|
||||
"version": "2.15.5",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.5.tgz",
|
||||
"integrity": "sha512-zn36akmDlqZxlrTOUgYXtxtj35HQ44aJ+mgKat9BTSPiZru4RjEHOtp8RJE6jGoN2miJlWiOeEKHB2+ae3YrSw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
"@livekit/track-processors": "0.5.7",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@react-types/overlays": "3.9.0",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.0",
|
||||
"i18next": "25.3.1",
|
||||
@@ -29,7 +31,7 @@
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.15.2",
|
||||
"livekit-client": "2.15.5",
|
||||
"posthog-js": "1.256.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.10.1",
|
||||
|
||||
@@ -1,100 +1,187 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
|
||||
import { Bold, 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 = ({
|
||||
roomId,
|
||||
room,
|
||||
...dialogProps
|
||||
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home')
|
||||
const roomUrl = getRouteUrl('room', roomId)
|
||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isCopied])
|
||||
const roomUrl = room && getRouteUrl('room', room?.slug)
|
||||
const telephony = useTelephony()
|
||||
|
||||
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={!!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')
|
||||
) : (
|
||||
<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',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
<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',
|
||||
}}
|
||||
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',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 (
|
||||
@@ -153,7 +154,7 @@ export const Home = () => {
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
@@ -202,7 +203,7 @@ export const Home = () => {
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoomId(data.slug)
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
@@ -251,8 +252,8 @@ export const Home = () => {
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
roomId={laterRoomId ?? ''}
|
||||
onOpenChange={() => setLaterRoomId(null)}
|
||||
room={laterRoom}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
|
||||
@@ -98,6 +98,7 @@ export const MainNotificationToast = () => {
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: notification.type,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LiveKitRoom } from '@livekit/components-react'
|
||||
import {
|
||||
LiveKitRoom,
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import {
|
||||
DisconnectReason,
|
||||
MediaDeviceFailure,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
RoomOptions,
|
||||
supportsAdaptiveStream,
|
||||
supportsDynacast,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
@@ -31,18 +35,20 @@ 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])
|
||||
@@ -93,15 +99,23 @@ export const Conference = ({
|
||||
},
|
||||
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,
|
||||
])
|
||||
@@ -235,7 +249,6 @@ 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 } from '@/primitives'
|
||||
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
|
||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { Heading, Dialog } from 'react-aria-components'
|
||||
import { Text, text } from '@/primitives/Text'
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
RiFileCopyLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo } 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, {
|
||||
@@ -34,24 +39,27 @@ const StyledRACDialog = styled(Dialog, {
|
||||
},
|
||||
})
|
||||
|
||||
export const InviteDialog = ({
|
||||
roomId,
|
||||
...dialogProps
|
||||
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const roomUrl = getRouteUrl('room', roomId)
|
||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const roomData = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isCopied])
|
||||
const telephony = useTelephony()
|
||||
|
||||
const isTelephonyReadyForUse = useMemo(() => {
|
||||
return telephony?.enabled && roomData?.pin_code
|
||||
}, [telephony?.enabled, roomData?.pin_code])
|
||||
|
||||
const {
|
||||
isCopied,
|
||||
copyRoomToClipboard,
|
||||
isRoomUrlCopied,
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(roomData)
|
||||
|
||||
return (
|
||||
<StyledRACDialog {...dialogProps}>
|
||||
<StyledRACDialog {...props}>
|
||||
{({ close }) => (
|
||||
<VStack
|
||||
alignItems="left"
|
||||
@@ -60,7 +68,7 @@ export const InviteDialog = ({
|
||||
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
||||
{t('shareDialog.heading')}
|
||||
{t('heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
@@ -68,7 +76,7 @@ export const InviteDialog = ({
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
dialogProps.onClose?.()
|
||||
props.onClose?.()
|
||||
close()
|
||||
}}
|
||||
aria-label={t('closeDialog')}
|
||||
@@ -76,49 +84,125 @@ export const InviteDialog = ({
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<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>
|
||||
<P>{t('description')}</P>
|
||||
{isTelephonyReadyForUse ? (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.200',
|
||||
borderRadius: '50%',
|
||||
padding: '4px',
|
||||
marginTop: '1rem',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<RiSpam2Fill
|
||||
size={22}
|
||||
<div
|
||||
className={css({
|
||||
fill: 'primary.500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
/>
|
||||
>
|
||||
<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>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('shareDialog.permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</StyledRACDialog>
|
||||
|
||||
@@ -2,19 +2,26 @@ import { useTranslation } from 'react-i18next'
|
||||
import { usePreviewTracks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createLocalVideoTrack,
|
||||
createLocalAudioTrack,
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
import {
|
||||
EffectsConfiguration,
|
||||
EffectsConfigurationProps,
|
||||
} from '../livekit/components/effects/EffectsConfiguration'
|
||||
import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice'
|
||||
import { ToggleDevice } from '../livekit/components/controls/Device/ToggleDevice'
|
||||
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
@@ -27,7 +34,11 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import type { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
@@ -72,45 +83,23 @@ const Effects = ({
|
||||
</Text>
|
||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
||||
</Dialog>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: '0',
|
||||
padding: '1rem',
|
||||
zIndex: '1',
|
||||
})}
|
||||
<Button
|
||||
variant="whiteCircle"
|
||||
onPress={openDialog}
|
||||
tooltip={t('description')}
|
||||
aria-label={t('description')}
|
||||
>
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
<RiImageCircleAiFill size={24} />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
enterRoom,
|
||||
roomId,
|
||||
}: {
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
enterRoom: () => void
|
||||
roomId: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
@@ -120,11 +109,13 @@ export const Join = ({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
username,
|
||||
},
|
||||
saveAudioInputEnabled,
|
||||
saveAudioOutputDeviceId,
|
||||
saveVideoInputEnabled,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
@@ -132,28 +123,44 @@ export const Join = ({
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const [processor, setProcessor] = useState(
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
|
||||
)
|
||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}, [
|
||||
processor,
|
||||
saveProcessorSerialized,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(processor?.serialize()),
|
||||
])
|
||||
if (initialUserChoices.current === null) {
|
||||
initialUserChoices.current = {
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
username,
|
||||
}
|
||||
}
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
audio: { deviceId: audioDeviceId },
|
||||
video: { deviceId: videoDeviceId },
|
||||
audio: !!initialUserChoices.current &&
|
||||
initialUserChoices.current?.audioEnabled && {
|
||||
deviceId: initialUserChoices.current.audioDeviceId,
|
||||
},
|
||||
video: !!initialUserChoices.current &&
|
||||
initialUserChoices.current?.videoEnabled && {
|
||||
deviceId: initialUserChoices.current.videoDeviceId,
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
processorSerialized
|
||||
),
|
||||
},
|
||||
},
|
||||
onError
|
||||
)
|
||||
|
||||
const videoTrack = useMemo(
|
||||
const [dynamicVideoTrack, setDynamicVideoTrack] =
|
||||
useState<LocalVideoTrack | null>(null)
|
||||
const [dynamicAudioTrack, setDynamicAudioTrack] =
|
||||
useState<LocalAudioTrack | null>(null)
|
||||
|
||||
const previewVideoTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Video
|
||||
@@ -161,56 +168,129 @@ export const Join = ({
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const audioTrack = useMemo(
|
||||
const previewAudioTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Audio
|
||||
)[0] as LocalVideoTrack,
|
||||
)[0] as LocalAudioTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
/*
|
||||
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
|
||||
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
|
||||
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const createVideoTrack = async () => {
|
||||
try {
|
||||
const track = await createLocalVideoTrack({
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
processorSerialized
|
||||
),
|
||||
})
|
||||
setDynamicVideoTrack(track)
|
||||
} catch (error) {
|
||||
onError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
videoEnabled &&
|
||||
!initialUserChoices.current?.videoEnabled &&
|
||||
!previewVideoTrack &&
|
||||
!dynamicVideoTrack
|
||||
) {
|
||||
createVideoTrack()
|
||||
}
|
||||
}, [
|
||||
videoEnabled,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
previewVideoTrack,
|
||||
dynamicVideoTrack,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const createAudioTrack = async () => {
|
||||
try {
|
||||
const track = await createLocalAudioTrack({
|
||||
deviceId: { exact: audioDeviceId },
|
||||
})
|
||||
setDynamicAudioTrack(track)
|
||||
} catch (error) {
|
||||
onError(error as Error)
|
||||
}
|
||||
}
|
||||
if (
|
||||
audioEnabled &&
|
||||
!initialUserChoices.current?.audioEnabled &&
|
||||
!previewAudioTrack &&
|
||||
!dynamicAudioTrack
|
||||
) {
|
||||
createAudioTrack()
|
||||
}
|
||||
}, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack])
|
||||
|
||||
// Cleanup dynamic tracks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dynamicVideoTrack?.stop()
|
||||
}
|
||||
}, [dynamicVideoTrack])
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dynamicAudioTrack?.stop()
|
||||
}
|
||||
}, [dynamicAudioTrack])
|
||||
|
||||
// Final tracks (dynamic takes precedence over preview)
|
||||
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
||||
const audioTrack = dynamicAudioTrack || previewAudioTrack
|
||||
|
||||
// LiveKit by default populates device choices with "default" value.
|
||||
// Instead, use the current device id used by the preview track as a default
|
||||
useResolveInitiallyDefaultDeviceId(
|
||||
audioDeviceId,
|
||||
audioTrack,
|
||||
saveAudioInputDeviceId
|
||||
)
|
||||
useResolveInitiallyDefaultDeviceId(
|
||||
videoDeviceId,
|
||||
videoTrack,
|
||||
saveVideoInputDeviceId
|
||||
)
|
||||
|
||||
const videoEl = useRef(null)
|
||||
const isVideoInitiated = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoEl.current as HTMLVideoElement | null
|
||||
|
||||
const handleVideoLoaded = () => {
|
||||
if (videoElement) {
|
||||
isVideoInitiated.current = true
|
||||
videoElement.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
if (videoElement && videoTrack && videoEnabled) {
|
||||
videoTrack.unmute()
|
||||
videoTrack.attach(videoElement)
|
||||
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
|
||||
}
|
||||
|
||||
return () => {
|
||||
videoTrack?.detach()
|
||||
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
|
||||
if (videoElement) {
|
||||
videoElement.removeEventListener('loadedmetadata', handleVideoLoaded)
|
||||
videoElement.style.opacity = '0'
|
||||
}
|
||||
isVideoInitiated.current = false
|
||||
}
|
||||
}, [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
|
||||
@@ -269,15 +349,43 @@ export const Join = ({
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
// 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)
|
||||
const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput')
|
||||
const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput')
|
||||
|
||||
const hintMessage = useMemo(() => {
|
||||
if (isCameraDeniedOrPrompted) {
|
||||
return isMicrophoneDeniedOrPrompted
|
||||
? 'cameraAndMicNotGranted'
|
||||
: 'cameraNotGranted'
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoTrack])
|
||||
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])
|
||||
|
||||
const renderWaitingState = () => {
|
||||
switch (status) {
|
||||
@@ -339,6 +447,7 @@ export const Join = ({
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
@@ -361,141 +470,315 @@ 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: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
height: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2rem',
|
||||
padding: '0 2rem',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
maxWidth: '764px',
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
width: 'auto',
|
||||
height: '570px',
|
||||
height: '540px',
|
||||
flexGrow: 1,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
lg: {
|
||||
width: '740px',
|
||||
},
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
flexShrink: { base: 0, sm: 1 },
|
||||
})}
|
||||
>
|
||||
<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',
|
||||
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',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{videoTrack && videoEnabled ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
ref={videoEl}
|
||||
width="1280"
|
||||
height="720"
|
||||
className={css({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transform: 'rotateY(180deg)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
borderRadius: '1rem',
|
||||
})}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
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
|
||||
className={css({
|
||||
backgroundColor: 'black',
|
||||
position: 'absolute',
|
||||
boxSizing: 'border-box',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
<div
|
||||
aria-label={t(
|
||||
`videoPreview.${videoEnabled ? 'enabled' : 'disabled'}`
|
||||
)}
|
||||
role="status"
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
fontWeight: '300',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{!videoEnabled && t('cameraDisabled')}
|
||||
{videoEnabled && !videoTrack && t('cameraStarting')}
|
||||
</p>
|
||||
<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
|
||||
kind="audioinput"
|
||||
context="join"
|
||||
enabled={audioEnabled}
|
||||
toggle={async () => {
|
||||
saveAudioInputEnabled(!audioEnabled)
|
||||
if (audioEnabled) {
|
||||
await audioTrack?.mute()
|
||||
} else {
|
||||
await audioTrack?.unmute()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ToggleDevice
|
||||
kind="videoinput"
|
||||
context="join"
|
||||
enabled={videoEnabled}
|
||||
toggle={async () => {
|
||||
saveVideoInputEnabled(!videoEnabled)
|
||||
if (videoEnabled) {
|
||||
await videoTrack?.mute()
|
||||
} else {
|
||||
await videoTrack?.unmute()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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={async (id) => {
|
||||
try {
|
||||
saveAudioInputDeviceId(id)
|
||||
if (audioTrack) {
|
||||
await audioTrack.setDeviceId({ exact: id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch microphone device', err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!isSafari() && (
|
||||
<div
|
||||
className={css({
|
||||
width: '30%',
|
||||
})}
|
||||
>
|
||||
<SelectDevice
|
||||
kind="audiooutput"
|
||||
id={audioOutputDeviceId}
|
||||
onSubmit={saveAudioOutputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
|
||||
<div
|
||||
className={css({
|
||||
width: '30%',
|
||||
})}
|
||||
>
|
||||
<SelectDevice
|
||||
kind="videoinput"
|
||||
id={videoDeviceId}
|
||||
onSubmit={async (id) => {
|
||||
try {
|
||||
saveVideoInputDeviceId(id)
|
||||
if (videoTrack) {
|
||||
await await videoTrack.setDeviceId({ exact: id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch camera device', err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HStack justify="center" padding={1.5}>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
initialState={audioEnabled}
|
||||
track={audioTrack}
|
||||
initialDeviceId={audioDeviceId}
|
||||
onChange={(enabled) => saveAudioInputEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
initialState={videoEnabled}
|
||||
track={videoTrack}
|
||||
initialDeviceId={videoDeviceId}
|
||||
onChange={(enabled) => saveVideoInputEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
padding: '0',
|
||||
sm: {
|
||||
width: '448px',
|
||||
padding: '0 3rem 9rem 3rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{renderWaitingState()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
flex: '0 0 448px',
|
||||
position: 'relative',
|
||||
margin: '1rem 1rem 1rem 0.5rem',
|
||||
})}
|
||||
>
|
||||
{renderWaitingState()}
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
)
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Dialog, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { usePermissions } from '../hooks/usePermissions'
|
||||
import { useModal } from '../hooks/useModal'
|
||||
|
||||
export const PermissionErrorModal = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'permissionErrorDialog' })
|
||||
const permissions = usePermissions()
|
||||
const { isOpen, close } = useModal('permissions')
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOpen() &&
|
||||
permissions.isCameraGranted &&
|
||||
permissions.isMicrophoneGranted
|
||||
) {
|
||||
close()
|
||||
}
|
||||
}, [permissions, isOpen, close])
|
||||
|
||||
// Use to split the translation in half to inject an inline icon
|
||||
const icon_placeholder = 'ICON_PLACEHOLDER'
|
||||
const [openMenuFirstPart, openMenuSecondPart] = t('body.openMenu', {
|
||||
icon_placeholder,
|
||||
}).split(icon_placeholder)
|
||||
|
||||
const permissionLabel = useMemo(() => {
|
||||
if (permissions.isMicrophoneDenied && permissions.isCameraDenied) {
|
||||
return 'cameraAndMicrophone'
|
||||
} else if (permissions.isCameraDenied) {
|
||||
return 'camera'
|
||||
} else if (permissions.isMicrophoneDenied) {
|
||||
return 'microphone'
|
||||
} else {
|
||||
return 'default'
|
||||
}
|
||||
}, [permissions])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen()}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title={''}
|
||||
aria-label={t(`heading.${permissionLabel}`, {
|
||||
app_title: `${import.meta.env.VITE_APP_TITLE}`,
|
||||
})}
|
||||
onClose={() => close()}
|
||||
>
|
||||
<HStack>
|
||||
<img
|
||||
src="/assets/camera_mic_permission.svg"
|
||||
alt=""
|
||||
className={css({
|
||||
minWidth: '290px',
|
||||
minHeight: '290px',
|
||||
maxWidth: '290px',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '400px',
|
||||
})}
|
||||
>
|
||||
<H lvl={2}>
|
||||
{t(`heading.${permissionLabel}`, {
|
||||
app_title: `${import.meta.env.VITE_APP_TITLE}`,
|
||||
})}
|
||||
</H>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
<li>
|
||||
{openMenuFirstPart}
|
||||
<span
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||
>
|
||||
<RiEqualizer2Line />
|
||||
</span>
|
||||
{openMenuSecondPart}
|
||||
</li>
|
||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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,22 +0,0 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { ModalsState, modalsStore } from '@/stores/modals'
|
||||
|
||||
interface UseModalReturn {
|
||||
isOpen: () => boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const useModal = (name: keyof ModalsState): UseModalReturn => {
|
||||
const modalsSnap = useSnapshot(modalsStore)
|
||||
|
||||
const isOpen = (): boolean => {
|
||||
return modalsSnap[name] as boolean
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open: () => (modalsStore[name] = true),
|
||||
close: () => (modalsStore[name] = false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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?.()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
|
||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useSize } from '../hooks/useResizeObserver'
|
||||
@@ -18,7 +18,7 @@ export const FullScreenShareWarning = ({
|
||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { width: containerWidth } = useSize(warningContainerRef)
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
|
||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||
|
||||
const isFullScreenSharing = useMemo(() => {
|
||||
if (trackReference?.source !== 'screen_share') return false
|
||||
@@ -62,7 +62,7 @@ export const FullScreenShareWarning = ({
|
||||
}
|
||||
|
||||
const handleDismissWarning = () => {
|
||||
ScreenSharePreferenceStore.enabled = false
|
||||
screenSharePreferenceStore.enabled = false
|
||||
}
|
||||
|
||||
if (!shouldShowWarning) return null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
@@ -8,24 +8,22 @@ 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"
|
||||
@@ -53,9 +51,9 @@ export const Info = () => {
|
||||
})}
|
||||
>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
{roomUrl}
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{telephony?.enabled && data?.pin_code && (
|
||||
{isTelephonyReadyForUse && (
|
||||
<>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
<Bold>{t('roomInformation.phone.call')}</Bold> (
|
||||
@@ -72,10 +70,7 @@ export const Info = () => {
|
||||
size="sm"
|
||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||
aria-label={t('roomInformation.button.ariaLabel')}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="copy-info-sidepannel"
|
||||
style={{
|
||||
marginLeft: '-8px',
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from './ToggleDevice'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import Source = Track.Source
|
||||
import * as React from 'react'
|
||||
import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
type AudioDevicesControlProps = Omit<
|
||||
UseTrackToggleProps<Source.Microphone>,
|
||||
'source' | 'onChange'
|
||||
> & {
|
||||
hideMenu?: boolean
|
||||
}
|
||||
|
||||
export const AudioDevicesControl = ({
|
||||
hideMenu,
|
||||
...props
|
||||
}: AudioDevicesControlProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const {
|
||||
userChoices: { audioDeviceId, audioOutputDeviceId },
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
saveAudioOutputDeviceId,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
|
||||
[saveAudioInputEnabled]
|
||||
)
|
||||
|
||||
const trackProps = useTrackToggle({
|
||||
source: Source.Microphone,
|
||||
onChange,
|
||||
...props,
|
||||
})
|
||||
|
||||
const kind = 'audioinput'
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
})}
|
||||
>
|
||||
<ToggleDevice
|
||||
{...trackProps}
|
||||
kind={kind}
|
||||
toggle={trackProps.toggle as () => Promise<void>}
|
||||
overrideToggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
groupPosition: undefined,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Popover variant="dark" withArrow={false}>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={
|
||||
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
|
||||
}
|
||||
>
|
||||
<RiArrowUpSLine />
|
||||
</Button>
|
||||
{({ close }) => (
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '36rem',
|
||||
padding: '0.15rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<SelectDevice
|
||||
context="room"
|
||||
kind={kind}
|
||||
id={audioDeviceId}
|
||||
onSubmit={saveAudioInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<SelectDevice
|
||||
context="room"
|
||||
kind="audiooutput"
|
||||
id={audioOutputDeviceId}
|
||||
onSubmit={saveAudioOutputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
<SettingsButton
|
||||
settingTab={SettingsDialogExtendedKey.AUDIO}
|
||||
onPress={close}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Select, SelectProps } from '@/primitives/Select'
|
||||
import { Placement } from '@react-types/overlays'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
||||
|
||||
type DeviceItems = Array<{ value: string; label: string }>
|
||||
|
||||
type SelectDeviceContext = {
|
||||
variant?: 'light' | 'dark'
|
||||
placement?: Placement
|
||||
}
|
||||
|
||||
type SelectDeviceProps = {
|
||||
id?: string
|
||||
onSubmit?: (id: string) => void
|
||||
kind: MediaDeviceKind
|
||||
context?: 'join' | 'room'
|
||||
}
|
||||
|
||||
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
||||
Pick<SelectProps<T>, 'placement' | 'variant' | 'iconComponent'>
|
||||
|
||||
const SelectDevicePermissions = <T extends string | number>({
|
||||
id,
|
||||
kind,
|
||||
onSubmit,
|
||||
iconComponent,
|
||||
...props
|
||||
}: SelectDevicePermissionsProps<T>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
|
||||
|
||||
const items: DeviceItems = devices
|
||||
.filter((d) => !!d.deviceId)
|
||||
.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))
|
||||
|
||||
/**
|
||||
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
|
||||
* Auto-selects the only available audio output device when currently on 'default'
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
kind !== 'audiooutput' ||
|
||||
items.length !== 1 ||
|
||||
items[0].value === 'default' ||
|
||||
activeDeviceId !== 'default'
|
||||
)
|
||||
return
|
||||
onSubmit?.(items[0].value)
|
||||
setActiveMediaDevice(items[0].value)
|
||||
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
|
||||
|
||||
const selectedKey = id || activeDeviceId
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`${kind}.choose`)}
|
||||
label=""
|
||||
isDisabled={items.length === 0}
|
||||
items={items}
|
||||
iconComponent={iconComponent}
|
||||
placeholder={items.length === 0 ? t('loading') : t('select')}
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={(key) => {
|
||||
if (key === selectedKey) return
|
||||
onSubmit?.(key as string)
|
||||
setActiveMediaDevice(key as string)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectDevice = ({
|
||||
id,
|
||||
onSubmit,
|
||||
kind,
|
||||
context = 'join',
|
||||
}: SelectDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const contextProps = useMemo<SelectDeviceContext>(() => {
|
||||
if (context == 'room') {
|
||||
return { variant: 'dark', placement: 'top' }
|
||||
}
|
||||
return {}
|
||||
}, [context])
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
|
||||
if (cannotUseDevice) {
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`${kind}.permissionsNeeded`)}
|
||||
label=""
|
||||
isDisabled={true}
|
||||
items={[]}
|
||||
placeholder={t('permissionsNeeded')}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectDevicePermissions
|
||||
id={id}
|
||||
onSubmit={onSubmit}
|
||||
kind={kind}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { RiSettings3Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
|
||||
export const SettingsButton = ({
|
||||
settingTab,
|
||||
onPress,
|
||||
}: {
|
||||
settingTab: SettingsDialogExtendedKey
|
||||
onPress?: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
square
|
||||
tooltip={t(`settings.${settingTab}`)}
|
||||
aria-label={t(`settings.${settingTab}`)}
|
||||
variant="primaryDark"
|
||||
onPress={() => {
|
||||
openSettingsDialog(settingTab)
|
||||
onPress?.()
|
||||
}}
|
||||
>
|
||||
<RiSettings3Line size={24} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useLocalParticipant,
|
||||
useMaybeRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
||||
|
||||
type ToggleDeviceStyleProps = {
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
toggleButtonProps?: Partial<ToggleButtonProps>
|
||||
}
|
||||
|
||||
export type ToggleDeviceProps<T extends ToggleSource> = {
|
||||
enabled: boolean
|
||||
toggle: (
|
||||
forceState?: boolean,
|
||||
captureOptions?: CaptureOptionsBySource<T>
|
||||
) => Promise<void | boolean | undefined>
|
||||
context?: 'room' | 'join'
|
||||
kind: 'audioinput' | 'videoinput'
|
||||
overrideToggleButtonProps?: Partial<ToggleButtonProps>
|
||||
}
|
||||
|
||||
export const ToggleDevice = <T extends ToggleSource>({
|
||||
kind,
|
||||
enabled,
|
||||
toggle,
|
||||
context = 'room',
|
||||
overrideToggleButtonProps,
|
||||
}: ToggleDeviceProps<T>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const {
|
||||
variant,
|
||||
errorVariant,
|
||||
toggleButtonProps: computedToggleButtonProps,
|
||||
} = useMemo<ToggleDeviceStyleProps>(() => {
|
||||
if (context === 'join') {
|
||||
return {
|
||||
variant: 'whiteCircle',
|
||||
errorVariant: 'errorCircle',
|
||||
toggleButtonProps: {
|
||||
groupPosition: undefined,
|
||||
},
|
||||
} as ToggleDeviceStyleProps
|
||||
}
|
||||
return {
|
||||
variant: 'primaryDark',
|
||||
errorVariant: 'error2',
|
||||
toggleButtonProps: {
|
||||
groupPosition: 'left',
|
||||
},
|
||||
} as ToggleDeviceStyleProps
|
||||
}, [context])
|
||||
|
||||
const [pushToTalk, setPushToTalk] = useState(false)
|
||||
|
||||
const onKeyDown = () => {
|
||||
if (pushToTalk || enabled) return
|
||||
toggle()
|
||||
setPushToTalk(true)
|
||||
}
|
||||
const onKeyUp = () => {
|
||||
if (!pushToTalk) return
|
||||
toggle()
|
||||
setPushToTalk(false)
|
||||
}
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: deviceShortcut,
|
||||
handler: async () => await toggle(),
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
useLongPress({
|
||||
keyCode: kind === 'audioinput' ? 'Space' : undefined,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
|
||||
const toggleLabel = useMemo(() => {
|
||||
const label = t(enabled ? 'disable' : 'enable', {
|
||||
keyPrefix: `selectDevice.${kind}`,
|
||||
})
|
||||
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
|
||||
}, [enabled, kind, deviceShortcut, t])
|
||||
|
||||
const Icon =
|
||||
enabled && !cannotUseDevice ? deviceIcons.toggleOn : deviceIcons.toggleOff
|
||||
|
||||
const roomContext = useMaybeRoomContext()
|
||||
if (kind === 'audioinput' && pushToTalk && roomContext) {
|
||||
return <ActiveSpeakerWrapper />
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{cannotUseDevice && <PermissionNeededButton />}
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
variant={enabled && !cannotUseDevice ? variant : errorVariant}
|
||||
shySelected
|
||||
onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={
|
||||
cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
}
|
||||
{...computedToggleButtonProps}
|
||||
{...overrideToggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveSpeakerWrapper = () => {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
|
||||
import { Track, VideoCaptureOptions } from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from './ToggleDevice'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { BackgroundProcessorFactory } from '../../blur'
|
||||
import Source = Track.Source
|
||||
import * as React from 'react'
|
||||
import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
square
|
||||
tooltip={t('effects')}
|
||||
aria-label={t('effects')}
|
||||
variant="primaryDark"
|
||||
onPress={() => {
|
||||
if (!isEffectsOpen) toggleEffects()
|
||||
onPress()
|
||||
}}
|
||||
>
|
||||
<RiImageCircleAiFill size={24} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type VideoDeviceControlProps = Omit<
|
||||
UseTrackToggleProps<Source.Camera>,
|
||||
'source' | 'onChange'
|
||||
> & {
|
||||
hideMenu?: boolean
|
||||
}
|
||||
|
||||
export const VideoDeviceControl = ({
|
||||
hideMenu,
|
||||
...props
|
||||
}: VideoDeviceControlProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
|
||||
usePersistentUserChoices()
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
|
||||
[saveVideoInputEnabled]
|
||||
)
|
||||
|
||||
const trackProps = useTrackToggle({
|
||||
source: Source.Camera,
|
||||
onChange,
|
||||
...props,
|
||||
})
|
||||
|
||||
const kind = 'videoinput'
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
|
||||
const toggleWithProcessor = async () => {
|
||||
/**
|
||||
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
|
||||
* Before, we had the following bug:
|
||||
* 1 - Configure a processor on join screen
|
||||
* 2 - Turn off camera on join screen
|
||||
* 3 - Join the room
|
||||
* 4 - Turn on the camera
|
||||
* 5 - No processor is applied to the camera
|
||||
* Expected: The processor is applied.
|
||||
*
|
||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||
*/
|
||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||
userChoices.processorSerialized
|
||||
)
|
||||
|
||||
const toggle = trackProps.toggle as (
|
||||
forceState: boolean,
|
||||
captureOptions: VideoCaptureOptions
|
||||
) => Promise<void>
|
||||
|
||||
await toggle(!trackProps.enabled, {
|
||||
processor: processor,
|
||||
} as VideoCaptureOptions)
|
||||
}
|
||||
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
})}
|
||||
>
|
||||
<ToggleDevice
|
||||
{...trackProps}
|
||||
kind={kind}
|
||||
toggle={toggleWithProcessor}
|
||||
overrideToggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
groupPosition: undefined,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Popover variant="dark" withArrow={false}>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={
|
||||
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
|
||||
}
|
||||
>
|
||||
<RiArrowUpSLine />
|
||||
</Button>
|
||||
{({ close }) => (
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '36rem',
|
||||
padding: '0.15rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<SelectDevice
|
||||
context="room"
|
||||
kind={kind}
|
||||
id={userChoices.videoDeviceId}
|
||||
onSubmit={saveVideoInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
<EffectsButton onPress={close} />
|
||||
<SettingsButton
|
||||
settingTab={SettingsDialogExtendedKey.VIDEO}
|
||||
onPress={close}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -2,16 +2,16 @@ import { RiSettings3Line } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSettingsDialog } from '../SettingsDialogContext'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
|
||||
export const SettingsMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => setDialogOpen(true)}
|
||||
onAction={() => openSettingsDialog()}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useMediaDeviceSelect,
|
||||
useTrackToggle,
|
||||
UseTrackToggleProps,
|
||||
} from '@livekit/components-react'
|
||||
import { Button, Menu, MenuList } from '@/primitives'
|
||||
import {
|
||||
RemixiconComponentType,
|
||||
RiArrowDownSLine,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
VideoCaptureOptions,
|
||||
} from 'livekit-client'
|
||||
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { useEffect } from 'react'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
import { BackgroundProcessorFactory } from '../blur'
|
||||
|
||||
export type ToggleSource = Exclude<
|
||||
Track.Source,
|
||||
Track.Source.ScreenShareAudio | Track.Source.Unknown
|
||||
>
|
||||
|
||||
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
|
||||
|
||||
export type SelectToggleDeviceConfig = {
|
||||
kind: MediaDeviceKind
|
||||
iconOn: RemixiconComponentType
|
||||
iconOff: RemixiconComponentType
|
||||
shortcut?: Shortcut
|
||||
longPress?: Shortcut
|
||||
}
|
||||
|
||||
type SelectToggleDeviceConfigMap = {
|
||||
[key in SelectToggleSource]: SelectToggleDeviceConfig
|
||||
}
|
||||
|
||||
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
|
||||
[Track.Source.Microphone]: {
|
||||
kind: 'audioinput',
|
||||
iconOn: RiMicLine,
|
||||
iconOff: RiMicOffLine,
|
||||
shortcut: {
|
||||
key: 'd',
|
||||
ctrlKey: true,
|
||||
},
|
||||
longPress: {
|
||||
key: 'Space',
|
||||
},
|
||||
},
|
||||
[Track.Source.Camera]: {
|
||||
kind: 'videoinput',
|
||||
iconOn: RiVideoOnLine,
|
||||
iconOff: RiVideoOffLine,
|
||||
shortcut: {
|
||||
key: 'e',
|
||||
ctrlKey: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type SelectToggleDeviceProps<T extends ToggleSource> =
|
||||
UseTrackToggleProps<T> & {
|
||||
track?: LocalAudioTrack | LocalVideoTrack | undefined
|
||||
initialDeviceId?: string
|
||||
onActiveDeviceChange: (deviceId: string) => void
|
||||
source: SelectToggleSource
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
menuVariant?: 'dark' | 'light'
|
||||
hideMenu?: boolean
|
||||
}
|
||||
|
||||
export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
track,
|
||||
initialDeviceId,
|
||||
onActiveDeviceChange,
|
||||
hideMenu,
|
||||
variant = 'primaryDark',
|
||||
menuVariant = 'light',
|
||||
...props
|
||||
}: SelectToggleDeviceProps<T>) => {
|
||||
const config = selectToggleDeviceConfig[props.source]
|
||||
if (!config) {
|
||||
throw new Error('Invalid source')
|
||||
}
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
const trackProps = useTrackToggle(props)
|
||||
|
||||
const { userChoices } = usePersistentUserChoices()
|
||||
|
||||
const toggle = () => {
|
||||
if (props.source === Track.Source.Camera) {
|
||||
/**
|
||||
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
|
||||
* Before, we had the following bug:
|
||||
* 1 - Configure a processor on join screen
|
||||
* 2 - Turn off camera on join screen
|
||||
* 3 - Join the room
|
||||
* 4 - Turn on the camera
|
||||
* 5 - No processor is applied to the camera
|
||||
* Expected: The processor is applied.
|
||||
*
|
||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||
*/
|
||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||
userChoices.processorSerialized
|
||||
)
|
||||
|
||||
const toggle = trackProps.toggle as (
|
||||
forceState: boolean,
|
||||
captureOptions: VideoCaptureOptions
|
||||
) => Promise<void>
|
||||
|
||||
toggle(!trackProps.enabled, {
|
||||
processor: processor,
|
||||
} as VideoCaptureOptions)
|
||||
} else {
|
||||
trackProps.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({ kind: config.kind, track })
|
||||
|
||||
/**
|
||||
* When providing only track outside of a room context, activeDeviceId is undefined.
|
||||
* So we need to initialize it with the initialDeviceId.
|
||||
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialDeviceId !== undefined) {
|
||||
setActiveMediaDevice(initialDeviceId)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setActiveMediaDevice])
|
||||
|
||||
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
})}
|
||||
>
|
||||
<ToggleDevice
|
||||
{...trackProps}
|
||||
config={config}
|
||||
variant={variant}
|
||||
toggle={toggle}
|
||||
toggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
groupPosition: undefined,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Menu variant={menuVariant}>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={trackProps.enabled ? variant : 'error2'}
|
||||
>
|
||||
<RiArrowDownSLine />
|
||||
</Button>
|
||||
<MenuList
|
||||
items={devices.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))}
|
||||
selectedItem={activeDeviceId}
|
||||
onAction={(value) => {
|
||||
setActiveMediaDevice(value as string)
|
||||
onActiveDeviceChange(value as string)
|
||||
}}
|
||||
variant={menuVariant}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
|
||||
const SettingsDialogContext = createContext<
|
||||
| {
|
||||
dialogOpen: boolean
|
||||
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const SettingsDialogProvider: React.FC<{
|
||||
children: React.ReactNode
|
||||
}> = ({ children }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
return (
|
||||
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
|
||||
{children}
|
||||
<SettingsDialogExtended
|
||||
isOpen={dialogOpen}
|
||||
onOpenChange={(v) => !v && setDialogOpen(false)}
|
||||
/>
|
||||
</SettingsDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useSettingsDialog = () => {
|
||||
const context = useContext(SettingsDialogContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useSettingsDialog must be used within a SettingsDialogProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useLocalParticipant,
|
||||
useMaybeRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export type ToggleDeviceProps = {
|
||||
enabled: boolean
|
||||
toggle: () => void
|
||||
config: SelectToggleDeviceConfig
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
toggleButtonProps?: Partial<ToggleButtonProps>
|
||||
}
|
||||
|
||||
export const ToggleDevice = ({
|
||||
config,
|
||||
enabled,
|
||||
toggle,
|
||||
variant = 'primaryDark',
|
||||
toggleButtonProps,
|
||||
}: ToggleDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { kind, shortcut, iconOn, iconOff, longPress } = config
|
||||
|
||||
const [pushToTalk, setPushToTalk] = useState(false)
|
||||
|
||||
const onKeyDown = () => {
|
||||
if (pushToTalk || enabled) return
|
||||
toggle()
|
||||
setPushToTalk(true)
|
||||
}
|
||||
const onKeyUp = () => {
|
||||
if (!pushToTalk) return
|
||||
toggle()
|
||||
setPushToTalk(false)
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
|
||||
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
|
||||
|
||||
const toggleLabel = useMemo(() => {
|
||||
const label = t(enabled ? 'disable' : 'enable', {
|
||||
keyPrefix: `join.${kind}`,
|
||||
})
|
||||
return shortcut ? appendShortcutLabel(label, shortcut) : label
|
||||
}, [enabled, kind, shortcut, t])
|
||||
|
||||
const Icon = enabled ? iconOn : iconOff
|
||||
|
||||
const context = useMaybeRoomContext()
|
||||
if (kind === 'audioinput' && pushToTalk && context) {
|
||||
return <ActiveSpeakerWrapper />
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
variant={enabled ? variant : 'error2'}
|
||||
shySelected
|
||||
onPress={() => toggle()}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={toggleLabel}
|
||||
groupPosition="left"
|
||||
{...toggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveSpeakerWrapper = () => {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useMemo } from 'react'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
|
||||
export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||
const {
|
||||
isLoading,
|
||||
isMicrophoneDenied,
|
||||
isMicrophonePrompted,
|
||||
isCameraDenied,
|
||||
isCameraPrompted,
|
||||
} = useSnapshot(permissionsStore)
|
||||
|
||||
return useMemo(() => {
|
||||
if (isLoading) return true
|
||||
|
||||
switch (kind) {
|
||||
case 'audioinput':
|
||||
case 'audiooutput': // audiooutput uses microphone permissions
|
||||
return isMicrophoneDenied || isMicrophonePrompted
|
||||
case 'videoinput':
|
||||
return isCameraDenied || isCameraPrompted
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, [
|
||||
kind,
|
||||
isLoading,
|
||||
isMicrophoneDenied,
|
||||
isMicrophonePrompted,
|
||||
isCameraDenied,
|
||||
isCameraPrompted,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
RemixiconComponentType,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
RiVolumeDownLine,
|
||||
RiVolumeMuteLine,
|
||||
} from '@remixicon/react'
|
||||
|
||||
export interface DeviceIcons {
|
||||
toggleOn: RemixiconComponentType
|
||||
toggleOff: RemixiconComponentType
|
||||
select: RemixiconComponentType
|
||||
}
|
||||
|
||||
const ICONS: Record<MediaDeviceKind | 'default', DeviceIcons> = {
|
||||
audioinput: {
|
||||
toggleOn: RiMicLine,
|
||||
toggleOff: RiMicOffLine,
|
||||
select: RiMicLine,
|
||||
},
|
||||
videoinput: {
|
||||
toggleOn: RiVideoOnLine,
|
||||
toggleOff: RiVideoOffLine,
|
||||
select: RiVideoOnLine,
|
||||
},
|
||||
audiooutput: {
|
||||
toggleOn: RiVolumeDownLine,
|
||||
toggleOff: RiVolumeMuteLine,
|
||||
select: RiVolumeDownLine,
|
||||
},
|
||||
default: { toggleOn: RiMicLine, toggleOff: RiMicOffLine, select: RiMicLine },
|
||||
}
|
||||
|
||||
export const useDeviceIcons = (kind: MediaDeviceKind): DeviceIcons =>
|
||||
ICONS[kind] ?? ICONS.default
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
|
||||
return useMemo<Shortcut | undefined>(() => {
|
||||
switch (kind) {
|
||||
case 'audioinput':
|
||||
return {
|
||||
key: 'e',
|
||||
ctrlKey: true,
|
||||
}
|
||||
case 'videoinput':
|
||||
return {
|
||||
key: 'd',
|
||||
ctrlKey: true,
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}, [kind])
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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)
|
||||
@@ -16,9 +18,18 @@ 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
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const useResolveInitiallyDefaultDeviceId = <
|
||||
T extends { getDeviceId(): Promise<string | undefined> },
|
||||
>(
|
||||
currentId: string,
|
||||
track: T | undefined,
|
||||
save: (id: string) => void
|
||||
) => {
|
||||
const isInitiated = useRef(false)
|
||||
useEffect(() => {
|
||||
if (currentId !== 'default' || !track || isInitiated.current) return
|
||||
const resolveDefaultDeviceId = async () => {
|
||||
const actualDeviceId = await track.getDeviceId()
|
||||
if (actualDeviceId && actualDeviceId !== 'default') {
|
||||
isInitiated.current = true
|
||||
save(actualDeviceId)
|
||||
}
|
||||
}
|
||||
resolveDefaultDeviceId()
|
||||
}, [currentId, track, save])
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usePersistentUserChoices } from './usePersistentUserChoices'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RemoteParticipant,
|
||||
RemoteTrackPublication,
|
||||
RoomEvent,
|
||||
Track,
|
||||
VideoQuality,
|
||||
} from 'livekit-client'
|
||||
|
||||
/**
|
||||
* This hook sets initial video quality for new participants as they join.
|
||||
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||
*/
|
||||
export const useVideoResolutionSubscription = () => {
|
||||
const {
|
||||
userChoices: { videoSubscribeQuality },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
const handleTrackPublished = (
|
||||
publication: RemoteTrackPublication,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_participant: RemoteParticipant
|
||||
) => {
|
||||
// By default, the maximum quality is set to high
|
||||
if (
|
||||
videoSubscribeQuality === undefined ||
|
||||
videoSubscribeQuality === VideoQuality.HIGH
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
publication.kind === Track.Kind.Video &&
|
||||
publication.source !== Track.Source.ScreenShare
|
||||
) {
|
||||
publication.setVideoQuality(videoSubscribeQuality)
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
}
|
||||
}, [room, videoSubscribeQuality])
|
||||
}
|
||||
@@ -3,98 +3,23 @@ import * as React from 'react'
|
||||
|
||||
import { MobileControlBar } from './MobileControlBar'
|
||||
import { DesktopControlBar } from './DesktopControlBar'
|
||||
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
|
||||
/** @public */
|
||||
export type ControlBarControls = {
|
||||
microphone?: boolean
|
||||
camera?: boolean
|
||||
chat?: boolean
|
||||
screenShare?: boolean
|
||||
leave?: boolean
|
||||
settings?: boolean
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||
variation?: 'minimal' | 'verbose' | 'textOnly'
|
||||
controls?: ControlBarControls
|
||||
/**
|
||||
* If `true`, the user's device choices will be persisted.
|
||||
* This will enable the user to have the same device choices when they rejoin the room.
|
||||
* @defaultValue true
|
||||
* @alpha
|
||||
*/
|
||||
saveUserChoices?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ControlBar` prefab gives the user the basic user interface to control their
|
||||
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
|
||||
*
|
||||
* @remarks
|
||||
* This component is build with other LiveKit components like `TrackToggle`,
|
||||
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveKitRoom>
|
||||
* <ControlBar />
|
||||
* </LiveKitRoom>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
||||
const {
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputEnabled,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const microphoneOnChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
|
||||
[saveAudioInputEnabled]
|
||||
)
|
||||
|
||||
const cameraOnChange = React.useCallback(
|
||||
(enabled: boolean, isUserInitiated: boolean) =>
|
||||
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
|
||||
[saveVideoInputEnabled]
|
||||
)
|
||||
|
||||
const barProps = {
|
||||
onDeviceError,
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<SettingsDialogProvider>
|
||||
{isMobile ? (
|
||||
<MobileControlBar {...barProps} />
|
||||
) : (
|
||||
<DesktopControlBar {...barProps} />
|
||||
)}
|
||||
</SettingsDialogProvider>
|
||||
)
|
||||
if (isMobile) {
|
||||
return <MobileControlBar onDeviceError={onDeviceError} />
|
||||
}
|
||||
return <DesktopControlBar onDeviceError={onDeviceError} />
|
||||
}
|
||||
|
||||
export interface ControlBarAuxProps {
|
||||
onDeviceError: ControlBarProps['onDeviceError']
|
||||
microphoneOnChange: (
|
||||
enabled: boolean,
|
||||
isUserInitiated: boolean
|
||||
) => void | null
|
||||
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
|
||||
saveAudioInputDeviceId: (deviceId: string) => void
|
||||
saveVideoInputDeviceId: (deviceId: string) => void
|
||||
}
|
||||
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import { ControlBarAuxProps } from './ControlBar'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
||||
import { Track } from 'livekit-client'
|
||||
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
|
||||
import { HandToggle } from '../../components/controls/HandToggle'
|
||||
@@ -11,14 +10,12 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}: ControlBarAuxProps) {
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
return (
|
||||
@@ -53,27 +50,15 @@ export function DesktopControlBar({
|
||||
gap: '0.65rem',
|
||||
})}
|
||||
>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
<AudioDevicesControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
menuVariant="dark"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
onChange={cameraOnChange}
|
||||
<VideoDeviceControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
menuVariant="dark"
|
||||
/>
|
||||
<ReactionsToggle />
|
||||
{browserSupportsScreenSharing && (
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ControlBarAuxProps } from './ControlBar'
|
||||
import React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
||||
import { Track } from 'livekit-client'
|
||||
import { HandToggle } from '../../components/controls/HandToggle'
|
||||
import { Button } from '@/primitives/Button'
|
||||
@@ -19,24 +18,22 @@ import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { LinkButton } from '@/primitives'
|
||||
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
|
||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
microphoneOnChange,
|
||||
cameraOnChange,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
}: ControlBarAuxProps) {
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
@@ -62,27 +59,15 @@ export function MobileControlBar({
|
||||
})}
|
||||
>
|
||||
<LeaveButton />
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
onChange={microphoneOnChange}
|
||||
<AudioDevicesControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
onChange={cameraOnChange}
|
||||
<VideoDeviceControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<HandToggle />
|
||||
<Button
|
||||
@@ -167,7 +152,7 @@ export function MobileControlBar({
|
||||
)}
|
||||
<Button
|
||||
onPress={() => {
|
||||
setDialogOpen(true)
|
||||
openSettingsDialog()
|
||||
setIsMenuOpened(false)
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
|
||||
@@ -32,6 +32,8 @@ import { RecordingStateToast } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -77,6 +79,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
|
||||
useConnectionObserver()
|
||||
useVideoResolutionSubscription()
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
@@ -238,6 +241,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<RoomAudioRenderer />
|
||||
<ConnectionStateToast />
|
||||
<RecordingStateToast />
|
||||
<SettingsDialogProvider />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { ReactNode, useEffect, useState } from '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'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
|
||||
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<UserAware>
|
||||
<Permissions />
|
||||
{children}
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
|
||||
export const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const { userChoices: existingUserChoices } = usePersistentUserChoices()
|
||||
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
|
||||
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
|
||||
|
||||
const { roomId } = useParams()
|
||||
const [location, setLocation] = useLocation()
|
||||
@@ -48,25 +55,21 @@ export const Room = () => {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
if (!userConfig && !skipJoinScreen) {
|
||||
if (!hasSubmittedEntry && !skipJoinScreen) {
|
||||
return (
|
||||
<UserAware>
|
||||
<Join onSubmit={setUserConfig} roomId={roomId} />
|
||||
</UserAware>
|
||||
<BaseRoom>
|
||||
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
|
||||
</BaseRoom>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<BaseRoom>
|
||||
<Conference
|
||||
initialRoomData={initialRoomData}
|
||||
roomId={roomId}
|
||||
mode={mode}
|
||||
userConfig={{
|
||||
...existingUserChoices,
|
||||
...userConfig,
|
||||
}}
|
||||
/>
|
||||
</UserAware>
|
||||
</BaseRoom>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,16 @@ import {
|
||||
RiNotification3Line,
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
import { GeneralTab } from './tabs/GeneralTab'
|
||||
import { AudioTab } from './tabs/AudioTab'
|
||||
import { VideoTab } from './tabs/VideoTab'
|
||||
import { useRef } from 'react'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -44,7 +47,9 @@ const tabPanelContainerStyle = css({
|
||||
export type SettingsDialogExtended = Pick<
|
||||
DialogProps,
|
||||
'isOpen' | 'onOpenChange'
|
||||
>
|
||||
> & {
|
||||
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||
}
|
||||
|
||||
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
// display only icon on small screen
|
||||
@@ -55,7 +60,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
|
||||
return (
|
||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||
<Tabs orientation="vertical" className={tabsStyle}>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
className={tabsStyle}
|
||||
defaultSelectedKey={props.defaultSelectedTab}
|
||||
>
|
||||
<div
|
||||
className={tabListContainerStyle}
|
||||
style={{
|
||||
@@ -69,30 +78,39 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
{t('dialog.heading')}
|
||||
</Heading>
|
||||
)}
|
||||
<TabList border={false} aria-label="Chat log orientation example">
|
||||
<Tab icon highlight id="1">
|
||||
<TabList border={false}>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
|
||||
<RiAccountCircleLine />
|
||||
{isWideScreen && t('tabs.account')}
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id="2">
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
|
||||
<RiSpeakerLine />
|
||||
{isWideScreen && t('tabs.audio')}
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id="3">
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
|
||||
<RiVideoOnLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
|
||||
<RiSettings3Line />
|
||||
{isWideScreen && t('tabs.general')}
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id="4">
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
|
||||
<RiNotification3Line />
|
||||
{isWideScreen && t('tabs.notifications')}
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
<AccountTab id="1" onOpenChange={props.onOpenChange} />
|
||||
<AudioTab id="2" />
|
||||
<GeneralTab id="3" />
|
||||
<NotificationsTab id="4" />
|
||||
<AccountTab
|
||||
id={SettingsDialogExtendedKey.ACCOUNT}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
|
||||
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SettingsDialogExtended } from './SettingsDialogExtended'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { settingsStore } from '@/stores/settings'
|
||||
|
||||
export const SettingsDialogProvider = () => {
|
||||
const { areSettingsOpen, defaultSelectedTab } = useSnapshot(settingsStore)
|
||||
|
||||
return (
|
||||
<SettingsDialogExtended
|
||||
isOpen={areSettingsOpen}
|
||||
defaultSelectedTab={defaultSelectedTab}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && settingsStore.defaultSelectedTab) {
|
||||
settingsStore.defaultSelectedTab = undefined
|
||||
}
|
||||
settingsStore.areSettingsOpen = v
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
|
||||
import { DialogProps, Field, Switch, Text } from '@/primitives'
|
||||
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import {
|
||||
@@ -9,78 +9,11 @@ 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 { ReactNode } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import posthog from 'posthog-js'
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
import posthog from 'posthog-js'
|
||||
import { RowWrapper } from './layout/RowWrapper'
|
||||
|
||||
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
@@ -92,24 +25,19 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
const { localParticipant } = useRoomContext()
|
||||
|
||||
const {
|
||||
userChoices: { noiseReductionEnabled },
|
||||
userChoices: { noiseReductionEnabled, audioDeviceId, audioOutputDeviceId },
|
||||
saveAudioInputDeviceId,
|
||||
saveNoiseReductionEnabled,
|
||||
saveAudioOutputDeviceId,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
|
||||
const {
|
||||
devices: devicesOut,
|
||||
activeDeviceId: activeDeviceIdOut,
|
||||
setActiveMediaDevice: setActiveMediaDeviceOut,
|
||||
} = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||
const { devices: devicesOut, setActiveMediaDevice: setActiveMediaDeviceOut } =
|
||||
useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||
|
||||
const {
|
||||
devices: devicesIn,
|
||||
activeDeviceId: activeDeviceIdIn,
|
||||
setActiveMediaDevice: setActiveMediaDeviceIn,
|
||||
} = useMediaDeviceSelect({ kind: 'audioinput' })
|
||||
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
|
||||
useMediaDeviceSelect({ kind: 'audioinput' })
|
||||
|
||||
const itemsOut: DeviceItems = devicesOut.map((d) => ({
|
||||
value: d.deviceId,
|
||||
@@ -134,15 +62,6 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
defaultSelectedKey: undefined,
|
||||
}
|
||||
|
||||
// No API to directly query the default audio device; this function heuristically finds it.
|
||||
// Returns the item with value 'default' if present; otherwise, returns the first item in the list.
|
||||
const getDefaultSelectedKey = (items: DeviceItems) => {
|
||||
if (!items || items.length === 0) return
|
||||
const defaultItem =
|
||||
items.find((item) => item.value === 'default') || items[0]
|
||||
return defaultItem.value
|
||||
}
|
||||
|
||||
const noiseReductionAvailable = useNoiseReductionAvailable()
|
||||
|
||||
return (
|
||||
@@ -152,11 +71,9 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
type="select"
|
||||
label={t('audio.microphone.label')}
|
||||
items={itemsIn}
|
||||
defaultSelectedKey={
|
||||
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
|
||||
}
|
||||
onSelectionChange={(key) => {
|
||||
setActiveMediaDeviceIn(key as string)
|
||||
selectedKey={audioDeviceId}
|
||||
onSelectionChange={async (key) => {
|
||||
await setActiveMediaDeviceIn(key as string)
|
||||
saveAudioInputDeviceId(key as string)
|
||||
}}
|
||||
{...disabledProps}
|
||||
@@ -180,12 +97,11 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
type="select"
|
||||
label={t('audio.speakers.label')}
|
||||
items={itemsOut}
|
||||
defaultSelectedKey={
|
||||
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
|
||||
}
|
||||
onSelectionChange={async (key) =>
|
||||
setActiveMediaDeviceOut(key as string)
|
||||
}
|
||||
selectedKey={audioOutputDeviceId}
|
||||
onSelectionChange={async (key) => {
|
||||
await setActiveMediaDeviceOut(key as string)
|
||||
saveAudioOutputDeviceId(key as string)
|
||||
}}
|
||||
{...disabledProps}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { settingsStore } from '@/stores/settings'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
export const useSettingsDialog = () => {
|
||||
const { areSettingsOpen } = useSnapshot(settingsStore)
|
||||
|
||||
const openSettingsDialog = (
|
||||
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||
) => {
|
||||
if (areSettingsOpen) return
|
||||
if (defaultSelectedTab)
|
||||
settingsStore.defaultSelectedTab = defaultSelectedTab
|
||||
settingsStore.areSettingsOpen = true
|
||||
}
|
||||
|
||||
return {
|
||||
openSettingsDialog,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum SettingsDialogExtendedKey {
|
||||
ACCOUNT = 'account',
|
||||
AUDIO = 'audio',
|
||||
VIDEO = 'video',
|
||||
GENERAL = 'general',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const useKeyboardShortcuts = () => {
|
||||
useEffect(() => {
|
||||
// This approach handles basic shortcuts but isn't comprehensive.
|
||||
// Issues might occur. First draft.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const onKeyDown = async (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey } = e
|
||||
if (!key) return
|
||||
const shortcutKey = formatShortcutKey({
|
||||
@@ -20,7 +20,7 @@ export const useKeyboardShortcuts = () => {
|
||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut) return
|
||||
e.preventDefault()
|
||||
shortcut()
|
||||
await shortcut()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
@@ -5,6 +5,7 @@ export type useLongPressProps = {
|
||||
onKeyDown: () => void
|
||||
onKeyUp: () => void
|
||||
longPressThreshold?: number
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useLongPress = ({
|
||||
@@ -12,10 +13,18 @@ export const useLongPress = ({
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
longPressThreshold = 300,
|
||||
isDisabled = false,
|
||||
}: useLongPressProps) => {
|
||||
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isDisabled) {
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current)
|
||||
timeoutIdRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code != keyCode || timeoutIdRef.current) return
|
||||
timeoutIdRef.current = setTimeout(() => {
|
||||
@@ -38,8 +47,12 @@ export const useLongPress = ({
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current)
|
||||
timeoutIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
|
||||
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold, isDisabled])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,15 +5,22 @@ import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
export type useRegisterKeyboardShortcutProps = {
|
||||
shortcut?: Shortcut
|
||||
handler: () => void
|
||||
handler: () => Promise<void | boolean | undefined> | void
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useRegisterKeyboardShortcut = ({
|
||||
shortcut,
|
||||
handler,
|
||||
isDisabled = false,
|
||||
}: useRegisterKeyboardShortcutProps) => {
|
||||
useEffect(() => {
|
||||
if (!shortcut) return
|
||||
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
|
||||
}, [handler, shortcut])
|
||||
const formattedKey = formatShortcutKey(shortcut)
|
||||
if (isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
} else {
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
}
|
||||
}, [handler, shortcut, isDisabled])
|
||||
}
|
||||
|
||||
@@ -51,5 +51,9 @@
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Ihre Zugangsdaten",
|
||||
"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."
|
||||
"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:"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
|
||||
@@ -7,9 +7,18 @@
|
||||
"home": "Zur Startseite zurückkehren",
|
||||
"back": "Dem Meeting erneut beitreten"
|
||||
},
|
||||
"join": {
|
||||
"selectDevice": {
|
||||
"loading": "Laden…",
|
||||
"select": "Wählen Sie einen Wert",
|
||||
"permissionsNeeded": "Genehmigung erforderlich",
|
||||
"settings": {
|
||||
"audio": "Audioeinstellungen",
|
||||
"video": "Videoeinstellungen"
|
||||
},
|
||||
"effects": "Effekte anwenden",
|
||||
"videoinput": {
|
||||
"choose": "Kamera auswählen",
|
||||
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
|
||||
"disable": "Kamera deaktivieren",
|
||||
"enable": "Kamera aktivieren",
|
||||
"label": "Kamera",
|
||||
@@ -17,10 +26,17 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Mikrofon auswählen",
|
||||
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
|
||||
"disable": "Mikrofon deaktivieren",
|
||||
"enable": "Mikrofon aktivieren",
|
||||
"label": "Mikrofon"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Lautsprecher auswählen",
|
||||
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Effekte anwenden",
|
||||
"title": "Effekte",
|
||||
@@ -37,7 +53,17 @@
|
||||
"usernameEmpty": "Ihr Name darf nicht leer sein"
|
||||
},
|
||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||
"cameraStarting": "Kamera wird gestartet.",
|
||||
"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"
|
||||
},
|
||||
"waiting": {
|
||||
"title": "Beitrittsanfrage wird gesendet...",
|
||||
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
|
||||
@@ -51,30 +77,18 @@
|
||||
"body": "Niemand hat auf Ihre Anfrage reagiert"
|
||||
}
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
"heading": {
|
||||
"camera": "{{app_title}} hat keine Berechtigung, Ihre Kamera zu verwenden",
|
||||
"microphone": "{{app_title}} hat keine Berechtigung, Ihr Mikrofon zu verwenden",
|
||||
"cameraAndMicrophone": "{{app_title}} hat keine Berechtigung, Ihr Mikrofon und Ihre Kamera zu verwenden",
|
||||
"default": "{{app_title}} hat keine Berechtigung für bestimmte Zugriffe"
|
||||
},
|
||||
"body": {
|
||||
"openMenu": "Klicken Sie auf das Einstellungssymbol {{icon_placeholder}} in der Adressleiste Ihres Browsers",
|
||||
"details": {
|
||||
"camera": "Zugriff auf die Kamera erlauben",
|
||||
"microphone": "Zugriff auf das Mikrofon erlauben",
|
||||
"cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
|
||||
"shareDialog": {
|
||||
"copy": "Meeting-Link kopieren",
|
||||
"copyButton": "Link kopieren",
|
||||
"copied": "Link in die Zwischenablage kopiert",
|
||||
"copy": "Informationen kopieren",
|
||||
"copyUrl": "Link kopieren",
|
||||
"copied": "Informationen 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."
|
||||
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"mediaErrorDialog": {
|
||||
"DeviceInUse": {
|
||||
@@ -89,6 +103,30 @@
|
||||
},
|
||||
"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",
|
||||
@@ -242,9 +280,9 @@
|
||||
"roomInformation": {
|
||||
"title": "Verbindungsinformationen",
|
||||
"button": {
|
||||
"ariaLabel": "Kopiere deine Meeting-Adresse",
|
||||
"copy": "Adresse kopieren",
|
||||
"copied": "Adresse kopiert"
|
||||
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
|
||||
"copy": "Informationen kopieren",
|
||||
"copied": "Informationen kopiert"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
|
||||
@@ -30,6 +30,37 @@
|
||||
},
|
||||
"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",
|
||||
@@ -54,6 +85,7 @@
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "Allgemein",
|
||||
"notifications": "Benachrichtigungen"
|
||||
}
|
||||
|
||||
@@ -51,5 +51,9 @@
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Your connection details",
|
||||
"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."
|
||||
"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:"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
|
||||
@@ -7,9 +7,18 @@
|
||||
"home": "Return to home",
|
||||
"back": "Rejoin the meeting"
|
||||
},
|
||||
"join": {
|
||||
"selectDevice": {
|
||||
"loading": "Loading…",
|
||||
"select": "Select a value",
|
||||
"permissionsNeeded": "Permission needed",
|
||||
"settings": {
|
||||
"audio": "Audio settings",
|
||||
"video": "Video settings"
|
||||
},
|
||||
"effects": "Effects",
|
||||
"videoinput": {
|
||||
"choose": "Select camera",
|
||||
"permissionsNeeded": "Select camera - permission needed",
|
||||
"disable": "Disable camera",
|
||||
"enable": "Enable camera",
|
||||
"label": "Camera",
|
||||
@@ -17,10 +26,17 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Select microphone",
|
||||
"permissionsNeeded": "Select microphone - permission needed",
|
||||
"disable": "Disable microphone",
|
||||
"enable": "Enable microphone",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Select speaker",
|
||||
"permissionsNeeded": "Select speaker - permission needed"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Apply effects",
|
||||
"title": "Effects",
|
||||
@@ -37,7 +53,17 @@
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
"cameraStarting": "Camera is starting.",
|
||||
"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"
|
||||
},
|
||||
"waiting": {
|
||||
"title": "Requesting to join...",
|
||||
"body": "You will be able to join this call when someone authorizes you"
|
||||
@@ -51,30 +77,18 @@
|
||||
"body": "No one responded to your request"
|
||||
}
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
"heading": {
|
||||
"camera": "{{app_title}} is not allowed to use your camera",
|
||||
"microphone": "{{app_title}} is not allowed to use your microphone",
|
||||
"cameraAndMicrophone": "{{app_title}} is not allowed to use your microphone or camera",
|
||||
"default": "{{app_title}} is not allowed to use certain permissions"
|
||||
},
|
||||
"body": {
|
||||
"openMenu": "Click the settings icon {{icon_placeholder}} in your browser's address bar",
|
||||
"details": {
|
||||
"camera": "Allow access to the camera",
|
||||
"microphone": "Allow access to the microphone",
|
||||
"cameraAndMicrophone": "Allow access to the camera and microphone"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
"copy": "Copy the meeting link",
|
||||
"copyButton": "Copy link",
|
||||
"copied": "Link copied to clipboard",
|
||||
"copy": "Copy information",
|
||||
"copyUrl": "Copy link",
|
||||
"copied": "Information 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."
|
||||
"permissions": "People with this link do not need your permission to join this meeting.",
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"mediaErrorDialog": {
|
||||
"DeviceInUse": {
|
||||
@@ -89,6 +103,30 @@
|
||||
},
|
||||
"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 browser’s 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",
|
||||
@@ -242,9 +280,9 @@
|
||||
"roomInformation": {
|
||||
"title": "Connection Information",
|
||||
"button": {
|
||||
"ariaLabel": "Copy your meeting address",
|
||||
"copy": "Copy address",
|
||||
"copied": "Address copied"
|
||||
"ariaLabel": "Copy the information from your meeting",
|
||||
"copy": "Copy information",
|
||||
"copied": "Information copied"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
|
||||
@@ -30,6 +30,37 @@
|
||||
},
|
||||
"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",
|
||||
@@ -54,6 +85,7 @@
|
||||
"tabs": {
|
||||
"account": "Profile",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"notifications": "Notifications"
|
||||
}
|
||||
|
||||
@@ -51,5 +51,9 @@
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Vos informations de connexion",
|
||||
"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."
|
||||
"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 :"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
|
||||
@@ -7,9 +7,18 @@
|
||||
"home": "Retourner à l'accueil",
|
||||
"back": "Réintégrer la réunion"
|
||||
},
|
||||
"join": {
|
||||
"selectDevice": {
|
||||
"loading": "Chargement…",
|
||||
"select": "Sélectionnez une valeur",
|
||||
"permissionsNeeded": "Autorisations nécessaires",
|
||||
"settings": {
|
||||
"audio": "Paramètres audio",
|
||||
"video": "Paramètres video"
|
||||
},
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"videoinput": {
|
||||
"choose": "Choisir la webcam",
|
||||
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
|
||||
"disable": "Désactiver la webcam",
|
||||
"enable": "Activer la webcam",
|
||||
"label": "Webcam",
|
||||
@@ -17,13 +26,20 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Choisir le micro",
|
||||
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
|
||||
"disable": "Désactiver le micro",
|
||||
"enable": "Activer le micro",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Choisir le haut-parleur",
|
||||
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"heading": "Rejoindre la réunion ?",
|
||||
"effects": {
|
||||
"description": "Effets d'arrière plan",
|
||||
"description": "Effets d'arrière-plan",
|
||||
"title": "Effets",
|
||||
"subTitle": "Paramétrez les effets de votre caméra."
|
||||
},
|
||||
@@ -37,7 +53,17 @@
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
},
|
||||
"cameraDisabled": "La caméra est désactivée.",
|
||||
"cameraStarting": "La caméra va démarrer.",
|
||||
"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"
|
||||
},
|
||||
"waiting": {
|
||||
"title": "Demande de participation…",
|
||||
"body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera"
|
||||
@@ -51,30 +77,18 @@
|
||||
"body": "Personne n'a répondu à votre demande de participation à l'appel"
|
||||
}
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
"heading": {
|
||||
"camera": "{{app_title}} n'est pas autorisé à utiliser votre caméra",
|
||||
"microphone": "{{app_title}} n'est pas autorisé à utiliser votre micro",
|
||||
"cameraAndMicrophone": "{{app_title}} n'est pas autorisé à utiliser votre micro ni votre caméra",
|
||||
"default": "{{app_title}} n'est pas autorisé à utiliser certaines permissions"
|
||||
},
|
||||
"body": {
|
||||
"openMenu": "Cliquez sur l'icône des paramètres {{icon_placeholder}} dans la barre d'adresse de votre navigateur",
|
||||
"details": {
|
||||
"camera": "Autorisez l'accès à la caméra",
|
||||
"microphone": "Autorisez l'accès au microphone",
|
||||
"cameraAndMicrophone": "Autorisez l'accès à la caméra et au microphone"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
"copy": "Copier le lien de la réunion",
|
||||
"copyButton": "Copier le lien",
|
||||
"copied": "Lien copié dans le presse-papiers",
|
||||
"copy": "Copier les informations",
|
||||
"copyUrl": "Copier le lien",
|
||||
"copied": "Copiées dans le presse-papiers",
|
||||
"heading": "Votre réunion est prête",
|
||||
"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."
|
||||
"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 :"
|
||||
}
|
||||
},
|
||||
"mediaErrorDialog": {
|
||||
"DeviceInUse": {
|
||||
@@ -89,6 +103,30 @@
|
||||
},
|
||||
"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",
|
||||
@@ -160,7 +198,7 @@
|
||||
"feedback": "Partager votre avis",
|
||||
"settings": "Paramètres",
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Effets d'arrière plan",
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"switchCamera": "Changer de caméra",
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
@@ -242,9 +280,9 @@
|
||||
"roomInformation": {
|
||||
"title": "Informations de connexions",
|
||||
"button": {
|
||||
"ariaLabel": "Copier l'adresse de votre réunion",
|
||||
"copy": "Copier l'adresse",
|
||||
"copied": "Adresse copiée"
|
||||
"ariaLabel": "Copier les informations de votre réunion",
|
||||
"copy": "Copier les informations",
|
||||
"copied": "Informations copiées"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
@@ -424,7 +462,7 @@
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
},
|
||||
"effects": "Effets d'arrière plan",
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"fullScreen": "Plein écran"
|
||||
},
|
||||
|
||||
@@ -30,6 +30,37 @@
|
||||
},
|
||||
"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",
|
||||
@@ -54,6 +85,7 @@
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Vidéo",
|
||||
"general": "Général",
|
||||
"notifications": "Notifications"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,9 @@
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Uw verbindingsgegevens",
|
||||
"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."
|
||||
"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:"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
|
||||
@@ -7,9 +7,18 @@
|
||||
"home": "Keer terug naar het hoofdscherm",
|
||||
"back": "Sluit weer bij de vergadering aan"
|
||||
},
|
||||
"join": {
|
||||
"selectDevice": {
|
||||
"loading": "Bezig met laden…",
|
||||
"select": "Selecteer een waarde",
|
||||
"permissionsNeeded": "Toestemming vereist",
|
||||
"settings": {
|
||||
"audio": "Audio-instellingen",
|
||||
"video": "Video-instellingen"
|
||||
},
|
||||
"effects": "Pas effecten toe",
|
||||
"videoinput": {
|
||||
"choose": "Selecteer camera",
|
||||
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
|
||||
"disable": "Camera uitschakelen",
|
||||
"enable": "Camera inschakelen",
|
||||
"label": "Camera",
|
||||
@@ -17,10 +26,17 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Selecteer microfoon",
|
||||
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
||||
"disable": "Microfoon dempen",
|
||||
"enable": "Microfoon dempen opheffen",
|
||||
"label": "Microfoon"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Selecteer luidspreker",
|
||||
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Pas effecten toe",
|
||||
"title": "Effecten",
|
||||
@@ -37,7 +53,17 @@
|
||||
"usernameEmpty": "Uw naam kan niet leeg zijn"
|
||||
},
|
||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||
"cameraStarting": "Camera wordt ingeschakeld.",
|
||||
"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"
|
||||
},
|
||||
"waiting": {
|
||||
"title": "Verzoek tot deelname...",
|
||||
"body": "U kunt deelnemen aan dit gesprek wanneer iemand u toestemming geeft"
|
||||
@@ -51,30 +77,18 @@
|
||||
"body": "Niemand heeft gereageerd op uw verzoek om deel te nemen aan het gesprek"
|
||||
}
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
"heading": {
|
||||
"camera": "{{app_title}} heeft geen toestemming om je camera te gebruiken",
|
||||
"microphone": "{{app_title}} heeft geen toestemming om je microfoon te gebruiken",
|
||||
"cameraAndMicrophone": "{{app_title}} heeft geen toestemming om je microfoon en camera te gebruiken",
|
||||
"default": "{{app_title}} heeft geen toestemming voor bepaalde rechten"
|
||||
},
|
||||
"body": {
|
||||
"openMenu": "Klik op het instellingenpictogram {{icon_placeholder}} in de adresbalk van je browser",
|
||||
"details": {
|
||||
"camera": "Sta toegang tot de camera toe",
|
||||
"microphone": "Sta toegang tot de microfoon toe",
|
||||
"cameraAndMicrophone": "Sta toegang tot de camera en microfoon toe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
|
||||
"shareDialog": {
|
||||
"copy": "Kopieer de vergaderlink",
|
||||
"copyButton": "Kopieerlink",
|
||||
"copied": "Link gekopieerd naar het klembord",
|
||||
"copy": "Informatie kopiëren",
|
||||
"copyUrl": "Kopieerlink",
|
||||
"copied": "Informatie gekopieerd",
|
||||
"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."
|
||||
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
|
||||
"phone": {
|
||||
"call": "Bel:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"mediaErrorDialog": {
|
||||
"DeviceInUse": {
|
||||
@@ -89,6 +103,30 @@
|
||||
},
|
||||
"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",
|
||||
@@ -242,9 +280,9 @@
|
||||
"roomInformation": {
|
||||
"title": "Verbindingsinformatie",
|
||||
"button": {
|
||||
"ariaLabel": "Kopieer je vergaderadres",
|
||||
"copy": "Adres kopiëren",
|
||||
"copied": "Adres gekopieerd"
|
||||
"ariaLabel": "Kopieer de informatie van uw vergadering",
|
||||
"copy": "Informatie kopiëren",
|
||||
"copied": "Informatie gekopieerd"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Bel:",
|
||||
|
||||
@@ -30,6 +30,37 @@
|
||||
},
|
||||
"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",
|
||||
@@ -54,6 +85,7 @@
|
||||
"tabs": {
|
||||
"account": "Profiel",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "Algemeen",
|
||||
"notifications": "Meldingen"
|
||||
}
|
||||
|
||||
@@ -46,6 +46,18 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
||||
transform: 'rotate(180deg) translateY(-1px)',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
light: {},
|
||||
dark: {
|
||||
fill: 'primaryDark.50',
|
||||
stroke: 'primaryDark.50',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -56,6 +68,8 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
||||
*/
|
||||
export const Popover = ({
|
||||
children,
|
||||
variant = 'light',
|
||||
withArrow = true,
|
||||
...dialogProps
|
||||
}: {
|
||||
children: [
|
||||
@@ -64,20 +78,24 @@ export const Popover = ({
|
||||
| (({ close }: { close: () => void }) => ReactNode)
|
||||
| ReactNode,
|
||||
]
|
||||
} & DialogProps) => {
|
||||
variant?: 'dark' | 'light'
|
||||
withArrow?: boolean
|
||||
} & Omit<DialogProps, 'children'>) => {
|
||||
const [trigger, popoverContent] = children
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{trigger}
|
||||
<StyledPopover>
|
||||
<StyledOverlayArrow>
|
||||
<svg width={12} height={12} viewBox="0 0 12 12">
|
||||
<path d="M0 0 L6 6 L12 0" />
|
||||
</svg>
|
||||
</StyledOverlayArrow>
|
||||
{withArrow && (
|
||||
<StyledOverlayArrow variant={variant}>
|
||||
<svg width={12} height={12} viewBox="0 0 12 12">
|
||||
<path d="M0 0 L6 6 L12 0" />
|
||||
</svg>
|
||||
</StyledOverlayArrow>
|
||||
)}
|
||||
<Dialog {...dialogProps}>
|
||||
{({ close }) => (
|
||||
<Box size="sm" type="popover">
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
{typeof popoverContent === 'function'
|
||||
? popoverContent({ close })
|
||||
: popoverContent}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { RiArrowDropDownLine } from '@remixicon/react'
|
||||
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
|
||||
import {
|
||||
Button,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Select as RACSelect,
|
||||
SelectProps,
|
||||
SelectProps as RACSelectProps,
|
||||
SelectValue,
|
||||
} from 'react-aria-components'
|
||||
import { Box } from './Box'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { css } from '@/styled-system/css'
|
||||
import type { Placement } from '@react-types/overlays'
|
||||
|
||||
const StyledButton = styled(Button, {
|
||||
base: {
|
||||
width: 'full',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingY: 0.125,
|
||||
paddingX: 0.25,
|
||||
border: '1px solid',
|
||||
@@ -39,6 +42,31 @@ const StyledButton = styled(Button, {
|
||||
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
light: {},
|
||||
dark: {
|
||||
backgroundColor: 'primaryDark.100',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'white',
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'primaryDark.900',
|
||||
color: 'primaryDark.100',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primaryDark.300',
|
||||
color: 'white',
|
||||
},
|
||||
'&[data-selected]': {
|
||||
backgroundColor: 'primaryDark.700 !important',
|
||||
color: 'primaryDark.100 !important',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledSelectValue = styled(SelectValue, {
|
||||
@@ -53,30 +81,60 @@ const StyledSelectValue = styled(SelectValue, {
|
||||
},
|
||||
})
|
||||
|
||||
export const Select = <T extends string | number>({
|
||||
label,
|
||||
items,
|
||||
errors,
|
||||
...props
|
||||
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
|
||||
const StyledIcon = styled('div', {
|
||||
base: {
|
||||
marginRight: '0.35rem',
|
||||
flexShrink: 0,
|
||||
},
|
||||
})
|
||||
|
||||
export type SelectProps<T> = Omit<
|
||||
RACSelectProps<object>,
|
||||
'items' | 'label' | 'errors'
|
||||
> & {
|
||||
iconComponent?: RemixiconComponentType
|
||||
label: ReactNode
|
||||
items: Array<{ value: T; label: ReactNode }>
|
||||
errors?: ReactNode
|
||||
}) => {
|
||||
placement?: Placement
|
||||
variant?: 'light' | 'dark'
|
||||
}
|
||||
|
||||
export const Select = <T extends string | number>({
|
||||
label,
|
||||
iconComponent,
|
||||
items,
|
||||
errors,
|
||||
placement,
|
||||
variant = 'light',
|
||||
...props
|
||||
}: SelectProps<T>) => {
|
||||
const IconComponent = iconComponent
|
||||
return (
|
||||
<RACSelect {...props}>
|
||||
{label}
|
||||
<StyledButton>
|
||||
<StyledButton variant={variant}>
|
||||
{!!IconComponent && (
|
||||
<StyledIcon>
|
||||
<IconComponent size={18} />
|
||||
</StyledIcon>
|
||||
)}
|
||||
<StyledSelectValue />
|
||||
<RiArrowDropDownLine aria-hidden="true" />
|
||||
<RiArrowDropDownLine
|
||||
aria-hidden="true"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
</StyledButton>
|
||||
<StyledPopover>
|
||||
<Box size="sm" type="popover" variant="control">
|
||||
<StyledPopover placement={placement}>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
<ListBox>
|
||||
{items.map((item) => (
|
||||
<ListBoxItem
|
||||
className={
|
||||
menuRecipe({ extraPadding: true, variant: 'light' }).item
|
||||
menuRecipe({
|
||||
extraPadding: true,
|
||||
variant: variant,
|
||||
}).item
|
||||
}
|
||||
id={item.value}
|
||||
key={item.value}
|
||||
|
||||
@@ -22,16 +22,15 @@ export const StyledSwitch = styled(RACSwitch, {
|
||||
borderRadius: '1.143rem',
|
||||
transition: 'all 200ms, outline 200ms',
|
||||
_before: {
|
||||
willChange: 'transform',
|
||||
content: '""',
|
||||
display: 'block',
|
||||
margin: '0.125rem',
|
||||
width: '1.063rem',
|
||||
height: '1.063rem',
|
||||
borderRadius: '1.063rem',
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.800',
|
||||
background: 'white',
|
||||
transition: 'opacity 10ms',
|
||||
background: 'primary.800',
|
||||
transition: 'transform 200ms, background-color 200ms',
|
||||
transitionDelay: '0ms',
|
||||
},
|
||||
},
|
||||
@@ -44,14 +43,29 @@ export const StyledSwitch = styled(RACSwitch, {
|
||||
color: 'primary.800',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
opacity: 0,
|
||||
},
|
||||
'& .cross': {
|
||||
position: 'absolute',
|
||||
display: 'block',
|
||||
top: '50%',
|
||||
left: '0.375rem',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'white',
|
||||
fontSize: '0.70rem',
|
||||
fontWeight: 'bold',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
opacity: 1,
|
||||
transition: 'opacity 200ms',
|
||||
transitionDelay: '0ms',
|
||||
},
|
||||
'&[data-selected] .indicator': {
|
||||
borderColor: 'primary.800',
|
||||
background: 'primary.800',
|
||||
_before: {
|
||||
border: 'none',
|
||||
background: 'white',
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
@@ -61,6 +75,11 @@ export const StyledSwitch = styled(RACSwitch, {
|
||||
transition: 'opacity 30ms',
|
||||
transitionDelay: '150ms',
|
||||
},
|
||||
'&[data-selected] .cross': {
|
||||
opacity: 0,
|
||||
transition: 'opacity 10ms',
|
||||
transitionDelay: '0ms',
|
||||
},
|
||||
'&[data-disabled] .indicator': {
|
||||
borderColor: 'primary.200',
|
||||
background: 'transparent',
|
||||
@@ -68,6 +87,9 @@ export const StyledSwitch = styled(RACSwitch, {
|
||||
background: 'primary.200',
|
||||
},
|
||||
},
|
||||
'&[data-disabled] .cross': {
|
||||
color: 'primary.500',
|
||||
},
|
||||
'&[data-focus-visible] .indicator': {
|
||||
outline: '2px solid!',
|
||||
outlineColor: 'focusRing!',
|
||||
@@ -86,7 +108,12 @@ export type SwitchProps = StyledVariantProps<typeof StyledSwitch> &
|
||||
export const Switch = ({ children, ...props }: SwitchProps) => (
|
||||
<StyledSwitch {...props}>
|
||||
<div className="indicator">
|
||||
<span className="checkmark">✓</span>
|
||||
<span className="checkmark" aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
<span className="cross" aria-hidden="true">
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</StyledSwitch>
|
||||
|
||||
@@ -68,6 +68,7 @@ export const buttonRecipe = cva({
|
||||
},
|
||||
secondaryText: {
|
||||
backgroundColor: 'transparent',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'primary.800',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'greyscale.100',
|
||||
@@ -250,6 +251,20 @@ 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',
|
||||
@@ -269,6 +284,20 @@ 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: {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
export type ModalsState = {
|
||||
permissions: boolean
|
||||
}
|
||||
|
||||
export const modalsStore = proxy<ModalsState>({
|
||||
permissions: false,
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
}
|
||||
+1
-1
@@ -4,6 +4,6 @@ type State = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export const ScreenSharePreferenceStore = proxy<State>({
|
||||
export const screenSharePreferenceStore = proxy<State>({
|
||||
enabled: true,
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
type State = {
|
||||
areSettingsOpen: boolean
|
||||
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||
}
|
||||
|
||||
export const settingsStore = proxy<State>({
|
||||
areSettingsOpen: false,
|
||||
defaultSelectedTab: undefined,
|
||||
})
|
||||
@@ -5,15 +5,24 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* FRAGILE: Splits translated text on placeholder to inject icons inline.
|
||||
*
|
||||
* Fragile because:
|
||||
* - Relies on exact string matching - typos break it silently
|
||||
* - Translators may accidentally modify/remove placeholders
|
||||
* - No validation or error handling
|
||||
*/
|
||||
export const injectIconIntoTranslation = (
|
||||
translation: string,
|
||||
placeholder: string = 'ICON_PLACEHOLDER'
|
||||
) => {
|
||||
return translation.split(placeholder)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ backend:
|
||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_HOST: postgres
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
@@ -78,8 +78,7 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
python manage.py migrate --no-input
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
|
||||
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
|
||||
livekit:
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
@@ -35,7 +35,7 @@ backend:
|
||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_HOST: postgres
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
@@ -69,6 +69,8 @@ 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
|
||||
|
||||
|
||||
@@ -77,15 +79,7 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
python manage.py migrate --no-input
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
@@ -100,13 +94,6 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
|
||||
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
|
||||
livekit:
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
@@ -57,7 +57,7 @@ backend:
|
||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_HOST: postgres
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
@@ -100,15 +100,7 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
python manage.py migrate --no-input
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
@@ -123,13 +115,6 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
|
||||
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
|
||||
livekit:
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: kc-postgres
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-postgresql
|
||||
port: 5432
|
||||
protocol: TCP
|
||||
targetPort: tcp-postgresql
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: kc-postgresql
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
serviceName: "kc-postgres"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: kc-postgresql
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: pg
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: tcp-postgresql
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: pass
|
||||
- name: POSTGRES_USER
|
||||
value: dinum
|
||||
- name: POSTGRES_DB
|
||||
value: keycloak
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: keycloak
|
||||
spec:
|
||||
rules:
|
||||
- host: "keycloak.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: keycloak
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- hosts:
|
||||
- keycloak.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-keycloak
|
||||
port: 8080
|
||||
protocol: TCP
|
||||
targetPort: tcp-keycloak
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: realm
|
||||
data:
|
||||
meet.json: |
|
||||
{{ .Values.realm | indent 4 }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: keycloak
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
serviceName: "keycloak"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: keycloak
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: keycloak
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
args:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname=keycloak.127.0.0.1.nip.io
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: tcp-keycloak
|
||||
env:
|
||||
- name: KEYCLOAK_ADMIN
|
||||
value: admin
|
||||
- name: KEYCLOAK_ADMIN_PASSWORD
|
||||
value: admin
|
||||
- name: PROXY_ADDRESS_FORWARDING
|
||||
value: 'true'
|
||||
- name: KC_DB_URL_HOST
|
||||
value: kc_postgresql
|
||||
- name: KC_DB_URL_DATABASE
|
||||
value: keycloak
|
||||
- name: KC_DB_PASSWORD
|
||||
value: pass
|
||||
- name: KC_DB_USERNAME
|
||||
value: dinum
|
||||
- name: KC_DB_SCHEMA
|
||||
value: public
|
||||
volumeMounts:
|
||||
- name: realm
|
||||
mountPath: "/opt/keycloak/data/import"
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: realm
|
||||
configMap:
|
||||
name: realm
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: minio
|
||||
spec:
|
||||
rules:
|
||||
- host: "minio.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: minio
|
||||
port:
|
||||
number: 9001
|
||||
tls:
|
||||
- hosts:
|
||||
- minio.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: client
|
||||
port: 9000
|
||||
protocol: TCP
|
||||
targetPort: 9000
|
||||
- name: console
|
||||
port: 9001
|
||||
protocol: TCP
|
||||
targetPort: 9001
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
spec:
|
||||
containers:
|
||||
- name: minio
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
minio server --console-address :9001 /data
|
||||
env:
|
||||
- name: MINIO_ROOT_USER
|
||||
value: meet
|
||||
- name: MINIO_ROOT_PASSWORD
|
||||
value: password
|
||||
image: "minio/minio"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: client
|
||||
- containerPort: 9001
|
||||
name: console
|
||||
volumeMounts:
|
||||
- mountPath: /data
|
||||
name: data
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir:
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: minio-bucket
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mc
|
||||
image: minio/mc
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0
|
||||
restartPolicy: Never
|
||||
backoffLimit: 1
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-postgresql
|
||||
port: 5432
|
||||
protocol: TCP
|
||||
targetPort: tcp-postgresql
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgresql
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
serviceName: "postgres"
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: postgresql
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: pg
|
||||
image: postgres:16-alpine
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: [ "pg_isready", "-U", "dinum", "-d", "meet", "-h", "127.0.0.1" ]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: [ "pg_isready", "-U", "dinum", "-d", "meet", "-h", "127.0.0.1" ]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: tcp-postgresql
|
||||
env:
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: pass
|
||||
- name: POSTGRES_USER
|
||||
value: dinum
|
||||
- name: POSTGRES_DB
|
||||
value: meet
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis-master
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: tcp-redis
|
||||
port: 6379
|
||||
protocol: TCP
|
||||
targetPort: tcp-redis
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: redis
|
||||
data:
|
||||
redis.conf: |
|
||||
bind 0.0.0.0
|
||||
port 6379
|
||||
user default on >pass ~* &* +@all
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
args:
|
||||
- redis-server
|
||||
- /usr/local/etc/redis/redis.conf
|
||||
image: "redis:8.2-alpine"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: tcp-redis
|
||||
volumeMounts:
|
||||
- name: redis
|
||||
mountPath: "/usr/local/etc/redis"
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: redis
|
||||
configMap:
|
||||
name: redis
|
||||
+2
-99
@@ -13,109 +13,10 @@ environments:
|
||||
- env.d/{{ .Environment.Name }}/values.secrets.yaml
|
||||
|
||||
repositories:
|
||||
- name: bitnami
|
||||
url: registry-1.docker.io/bitnamicharts
|
||||
oci: true
|
||||
- name: livekit
|
||||
url: https://helm.livekit.io
|
||||
|
||||
releases:
|
||||
- name: postgres
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||
missingFileHandler: Warn
|
||||
namespace: {{ .Namespace }}
|
||||
chart: bitnami/postgresql
|
||||
version: 13.1.5
|
||||
values:
|
||||
- auth:
|
||||
username: dinum
|
||||
password: pass
|
||||
database: meet
|
||||
- tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
|
||||
- name: keycloak
|
||||
installed: {{ or (eq .Environment.Name "dev-keycloak") (eq .Environment.Name "dev-dinum") | toYaml }}
|
||||
missingFileHandler: Warn
|
||||
namespace: {{ .Namespace }}
|
||||
chart: bitnami/keycloak
|
||||
version: 17.3.6
|
||||
values:
|
||||
- postgresql:
|
||||
auth:
|
||||
username: keycloak
|
||||
password: keycloak
|
||||
database: keycloak
|
||||
- extraEnvVars:
|
||||
- name: KEYCLOAK_EXTRA_ARGS
|
||||
value: "--import-realm"
|
||||
- name: KC_HOSTNAME_URL
|
||||
value: https://keycloak.127.0.0.1.nip.io
|
||||
- extraVolumes:
|
||||
- name: import
|
||||
configMap:
|
||||
name: meet-keycloak
|
||||
- extraVolumeMounts:
|
||||
- name: import
|
||||
mountPath: /opt/bitnami/keycloak/data/import/
|
||||
- auth:
|
||||
adminUser: su
|
||||
adminPassword: su
|
||||
- proxy: edge
|
||||
- ingress:
|
||||
enabled: true
|
||||
hostname: keycloak.127.0.0.1.nip.io
|
||||
- extraDeploy:
|
||||
- apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: meet-keycloak
|
||||
data:
|
||||
meet.json: |
|
||||
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 14 }}
|
||||
|
||||
- name: minio
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||
namespace: {{ .Namespace }}
|
||||
missingFileHandler: Warn
|
||||
chart: bitnami/minio
|
||||
version: 12.10.10
|
||||
values:
|
||||
- auth:
|
||||
rootUser: meet
|
||||
rootPassword: password
|
||||
- provisioning:
|
||||
enabled: true
|
||||
buckets:
|
||||
- name: meet-media-storage
|
||||
versioning: true
|
||||
- ingress:
|
||||
enabled: true
|
||||
hostname: minio-console.127.0.0.1.nip.io
|
||||
servicePort: 9001
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
kubernetes.io/ingress.class: nginx
|
||||
extraVolumes:
|
||||
- name: mkcert
|
||||
secret:
|
||||
secretName: mkcert
|
||||
extraVolumeMounts:
|
||||
- mountPath: /certs/CAs/
|
||||
name: mkcert
|
||||
|
||||
- name: redis
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||
missingFileHandler: Warn
|
||||
namespace: {{ .Namespace }}
|
||||
chart: bitnami/redis
|
||||
version: 18.19.2
|
||||
values:
|
||||
- auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
|
||||
- name: extra
|
||||
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||
missingFileHandler: Warn
|
||||
@@ -129,6 +30,8 @@ releases:
|
||||
enablePermanentRedirect: {{ .Values | get "enablePermanentRedirect" "False"}}
|
||||
oldDomain: {{ .Values | get "oldDomain" "demo.com" }}
|
||||
newDomain: {{ .Values | get "newDomain" "demo.com" }}
|
||||
- realm: |
|
||||
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 8 }}
|
||||
|
||||
- name: meet
|
||||
version: {{ .Values.version }}
|
||||
|
||||
@@ -17,9 +17,18 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
celery_worker:
|
||||
container_name: celery_worker
|
||||
container_name: celery_worker_transcribe
|
||||
build: .
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe_queue
|
||||
volumes:
|
||||
- .:/app
|
||||
depends_on:
|
||||
- redis
|
||||
- app
|
||||
celery_summarize_worker:
|
||||
container_name: celery_worker_summarize
|
||||
build: .
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize_queue
|
||||
volumes:
|
||||
- .:/app
|
||||
depends_on:
|
||||
|
||||
@@ -6,12 +6,13 @@ from typing import Optional
|
||||
from celery.result import AsyncResult
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.celery_worker import (
|
||||
process_audio_transcribe_summarize,
|
||||
process_audio_transcribe_summarize_v2,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
class TaskCreation(BaseModel):
|
||||
"""Task data."""
|
||||
@@ -45,7 +46,8 @@ async def create_task(request: TaskCreation):
|
||||
request.room,
|
||||
request.recording_date,
|
||||
request.recording_time,
|
||||
]
|
||||
],
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
|
||||
return {"id": task.id, "message": "Task created"}
|
||||
|
||||
@@ -48,6 +48,24 @@ class Analytics:
|
||||
except Exception as e:
|
||||
raise AnalyticsException("Failed to capture analytics event") from e
|
||||
|
||||
def is_feature_enabled(
|
||||
self, feature_name: str, distinct_id: str = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a feature flag is enabled in PostHog for a dinstinct_id.
|
||||
"""
|
||||
if self.is_disabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Check feature flag {feature_name} for user {distinct_id}"
|
||||
)
|
||||
return self._client.feature_enabled(feature_name, distinct_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking feature flag {feature_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_analytics():
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from celery import Celery
|
||||
from celery import signals
|
||||
import sentry_sdk
|
||||
from summary.core.config import get_settings
|
||||
import summary.core.celery_signals
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
celery = Celery(
|
||||
__name__,
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
broker_connection_retry_on_startup=True,
|
||||
)
|
||||
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_sentry(**_kwargs):
|
||||
"""Initialize sentry."""
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
@@ -0,0 +1,26 @@
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from celery import signals
|
||||
from summary.core.config import get_settings
|
||||
|
||||
analytics = get_analytics()
|
||||
settings = get_settings()
|
||||
metadata_manager = MetadataManager()
|
||||
|
||||
|
||||
@signals.task_prerun.connect
|
||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
task_args = args or []
|
||||
metadata_manager.create(task_id, task_args)
|
||||
|
||||
|
||||
@signals.task_retry.connect
|
||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect
|
||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Celery summarize workers."""
|
||||
|
||||
from celery import Celery, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
import openai
|
||||
from requests import Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.prompt import (
|
||||
PROMPT_SYSTEM_PLAN,
|
||||
PROMPT_SYSTEM_TLDR,
|
||||
PROMPT_SYSTEM_PART,
|
||||
PROMPT_USER_PART,
|
||||
PROMPT_SYSTEM_CLEANING,
|
||||
PROMPT_SYSTEM_NEXT_STEP,
|
||||
)
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.celery_app import celery
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
|
||||
|
||||
def post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = create_retry_session()
|
||||
session.headers.update({"Authorization": f"Bearer {settings.webhook_api_token}"})
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def LLM_call(client, system_prompt, user_prompt, retry=2):
|
||||
|
||||
data = {
|
||||
"model": settings.resume_llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(**data)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error("LLM call failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@celery.task(
|
||||
bind=True, autoretry_for=[Exception], max_retries=3, queue=settings.summarize_queue
|
||||
)
|
||||
def summarize_transcription(self, transcript: str, email: str, sub: str, title: str):
|
||||
logger.info("Starting summarization task")
|
||||
|
||||
logger.info("Initiating summarize client")
|
||||
|
||||
client_summary = openai.OpenAI(
|
||||
base_url=settings.resume_endpoint, api_key=settings.resume_api_key
|
||||
)
|
||||
|
||||
tldr = LLM_call(client_summary, PROMPT_SYSTEM_TLDR, transcript)
|
||||
|
||||
logger.info("TLDR generated")
|
||||
|
||||
parts = LLM_call(client_summary, PROMPT_SYSTEM_PLAN, transcript)
|
||||
logger.info("Plan generated")
|
||||
|
||||
parts = parts.split("\n")
|
||||
parts = [x for x in parts if x.strip() != ""]
|
||||
logger.info("Empty parts removed")
|
||||
|
||||
parts_summarized = []
|
||||
for part in parts:
|
||||
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
|
||||
logger.info("Summarizing part: %s", part)
|
||||
parts_summarized.append(
|
||||
LLM_call(
|
||||
client_summary, PROMPT_SYSTEM_PART, prompt_user_part.format(part=part)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("Parts summarized")
|
||||
|
||||
raw_summary = "\n\n".join(parts_summarized)
|
||||
|
||||
next_steps = LLM_call(client_summary, PROMPT_SYSTEM_NEXT_STEP, transcript)
|
||||
logger.info("Next steps generated")
|
||||
cleaned_summary = LLM_call(client_summary, PROMPT_SYSTEM_CLEANING, raw_summary)
|
||||
logger.info("Summary cleaned")
|
||||
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
|
||||
|
||||
data = {
|
||||
"title": title + " - Summary",
|
||||
"content": summary,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
||||
logger.debug("Response body: %s", response.text)
|
||||
@@ -19,33 +19,18 @@ from requests import Session, exceptions
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.prompt import get_instructions
|
||||
from summary.core.celery_summarize_worker import summarize_transcription
|
||||
from summary.core.celery_app import celery
|
||||
from summary.core.celery_signals import metadata_manager
|
||||
from summary.core.analytics import get_analytics
|
||||
|
||||
settings = get_settings()
|
||||
analytics = get_analytics()
|
||||
|
||||
metadata_manager = MetadataManager()
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
celery = Celery(
|
||||
__name__,
|
||||
broker=settings.celery_broker_url,
|
||||
backend=settings.celery_result_backend,
|
||||
broker_connection_retry_on_startup=True,
|
||||
)
|
||||
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_sentry(**_kwargs):
|
||||
"""Initialize sentry."""
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
|
||||
|
||||
DEFAULT_EMPTY_TRANSCRIPTION = """
|
||||
**Aucun contenu audio n’a été détecté dans votre transcription.**
|
||||
@@ -137,25 +122,6 @@ def post_with_retries(url, data):
|
||||
session.close()
|
||||
|
||||
|
||||
@signals.task_prerun.connect
|
||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
task_args = args or []
|
||||
metadata_manager.create(task_id, task_args)
|
||||
|
||||
|
||||
@signals.task_retry.connect
|
||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect
|
||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
|
||||
|
||||
@celery.task(max_retries=settings.celery_max_retries)
|
||||
def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
@@ -209,7 +175,7 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
||||
|
||||
instructions = get_instructions(transcription)
|
||||
summary_response = openai_client.chat.completions.create(
|
||||
model=settings.openai_llm_model, messages=instructions
|
||||
model=settings.resume_llm_model, messages=instructions
|
||||
)
|
||||
|
||||
summary = summary_response.choices[0].message.content
|
||||
@@ -236,6 +202,7 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
@@ -353,5 +320,10 @@ def process_audio_transcribe_summarize_v2(
|
||||
logger.debug("Response body: %s", response.text)
|
||||
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
|
||||
# TODO - integrate summarize the transcript and create a new document.
|
||||
if not analytics.is_feature_enabled("summary-enabled", distinct_id=sub):
|
||||
logger.info("Summary generation skipped (feature flag disabled).")
|
||||
else:
|
||||
logger.info("Queuing summary generation task.")
|
||||
summarize_transcription.apply_async(
|
||||
args=[formatted_transcription, email, sub, title], queue=settings.summarize_queue
|
||||
)
|
||||
|
||||
@@ -35,8 +35,10 @@ class Settings(BaseSettings):
|
||||
openai_api_key: str
|
||||
openai_base_url: str = "https://api.openai.com/v1"
|
||||
openai_asr_model: str = "whisper-1"
|
||||
openai_llm_model: str = "gpt-4o"
|
||||
resume_llm_model: str = "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
|
||||
openai_max_retries: int = 0
|
||||
resume_api_key: str
|
||||
resume_endpoint: str
|
||||
|
||||
# Webhook-related settings
|
||||
webhook_max_retries: int = 2
|
||||
@@ -66,7 +68,9 @@ class Settings(BaseSettings):
|
||||
task_tracker_redis_url: str = "redis://redis/0"
|
||||
task_tracker_prefix: str = "task_metadata:"
|
||||
|
||||
|
||||
# Queue redis
|
||||
summarize_queue: str = "summarize_queue"
|
||||
transcribe_queue: str = "transcribe_queue"
|
||||
@lru_cache
|
||||
def get_settings():
|
||||
"""Load and cache application settings."""
|
||||
|
||||
@@ -50,3 +50,34 @@ def get_instructions(transcript):
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
|
||||
PROMPT_SYSTEM_TLDR = """Tu es un agent dont le rôle est de créer un TL;DR (résumé très concis) d'un compte rendu de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est de rédiger un résumé concis et structuré, en te concentrant uniquement sur les informations essentielles et pertinentes. Tu répondras en un paragraphe structuré (3 à 6 phrases), sans rien ajouter d'autre. Tu répondras dans le format suivant sans rien ajouter d'autre:
|
||||
### Résumé TL;DR
|
||||
|
||||
[Résumé concis et structuré]"""
|
||||
|
||||
PROMPT_SYSTEM_PLAN = """Ta tâche est de diviser le contenu du transcript en sujets concrets correspondant aux grands axes discutés durant la réunion. Ne crée pas de catégories génériques. Les titres doivent être courts, précis et représentatifs des échanges. Veille à ce que chaque sujet soit distinct et qu’aucun thème ne soit répété. Tu te limitera à 5 ou 6 sujets maximum.
|
||||
L'introducion, ordre du jour, conclusion etc seront rajoutés a posteriori. Tu répondra dans le format suivant sans rien ajouter d'autre:
|
||||
"Titre du sujet 1
|
||||
Titre du sujet 2
|
||||
Titre du sujet 3
|
||||
..."
|
||||
"""
|
||||
|
||||
PROMPT_SYSTEM_PART = """Tu es un agent dont le rôle est de créer une partie du résumé d'un compte rendu de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript, et le titre du sujet correspondant. Ta tâche est de rédiger un résumé concis de cette partie et uniquement cette partie, en te concentrant uniquement sur les informations essentielles et pertinentes. Le résumé de chaque partie doit tenir en 4 à 6 phrases maximum, sans entrer dans les détails mineurs. Tu répondra dans le format suivant :
|
||||
### Titre du sujet [Traduire ce titre selon la langue du transcript]
|
||||
[Résumé concis et structuré de la partie du transcript]
|
||||
"""
|
||||
|
||||
PROMPT_USER_PART = """Titre de la partie à résumer : {part}
|
||||
|
||||
Transcript complet :
|
||||
{transcript}"""
|
||||
|
||||
PROMPT_SYSTEM_CLEANING = """Tu es un agent dont le rôle est de nettoyer un résumé de compte rendu de réunion. Tu recevras en entrée le résumé brut, potentiellement avec des erreurs de formatage, des incohérences ou des redondances. Ta tâche est de corriger les erreurs de formatage, d'améliorer la clarté et la cohérence du texte, et de t'assurer que le résumé est bien structuré et facile à lire. Ton but principal est de retirer les redondances et les répétitions. Assure la cohérence entre les titres, et homogénéise le style d’écriture entre les parties. Supprime les doublons d’informations entre les parties si présents. Si certaines parties sont plus secondaires, tu peux les fusionner ou les réduire en 1 à 2 phrases. Mets en avant les points centraux qui ont fait l’objet de décisions ou d’actions. Tu répondra uniquement avec le résumé sans rien ajouter d'autre"""
|
||||
|
||||
PROMPT_SYSTEM_NEXT_STEP = """Tu es un agent dont le rôle est d'extraire les prochaines étapes d'un transcript de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est d'identifier et lister toutes les actions à entreprendre, en indiquant la ou les personnes assignées et en précisant les échéances si elles sont mentionnées. Ne retiens que les actions concrètes et à venir. Ignore les remarques générales ou les constats sans suite. Les actions doivent suivre ce format strict :
|
||||
### Prochaines étapes
|
||||
|
||||
- [ ] [Action à effectuer] Assignée à : [Nom], Échéance : [Date si mentionnée]"""
|
||||
|
||||
Reference in New Issue
Block a user