mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-16 05:28:53 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 116f79c3b3 | |||
| cc9dae66db | |||
| 8d000fc6d9 | |||
| b7abd0ae6e | |||
| 40e4f17c65 |
+6
-1
@@ -10,7 +10,9 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(frontend) introduce performance mode with auto-detection and telemetry
|
- 🚸(frontend) explain camera-in-use failures on the join screen
|
||||||
|
|
||||||
|
## [1.27.0] - 2026-08-14
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -25,6 +27,9 @@ and this project adheres to
|
|||||||
- 🐛(frontend) implement hysteresis band for the control bar layout
|
- 🐛(frontend) implement hysteresis band for the control bar layout
|
||||||
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
|
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
|
||||||
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
|
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
|
||||||
|
- 🐛(frontend) stop reporting screen-share denials as errors
|
||||||
|
- 🐛(frontend) generalize screen-share error modal beyond macOS
|
||||||
|
- 📈(frontend) stop double-reporting media device failures
|
||||||
|
|
||||||
## [1.26.0] - 2026-08-12
|
## [1.26.0] - 2026-08-12
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.26.0"
|
version = "1.27.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.26.0"
|
version = "1.27.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.26.0"
|
version = "1.27.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.26.0"
|
version = "1.27.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
export interface HardwareSnapshot {
|
|
||||||
/** navigator.hardwareConcurrency — logical CPU cores. */
|
|
||||||
cpu_cores: number | null
|
|
||||||
/** navigator.deviceMemory — RAM in GiB, bucketed by the browser (Chromium only). */
|
|
||||||
device_memory_gb: number | null
|
|
||||||
/** performance.memory.jsHeapSizeLimit in MB (Chromium only, non-standard). */
|
|
||||||
js_heap_limit_mb: number | null
|
|
||||||
/** performance.memory.usedJSHeapSize in MB (Chromium only, non-standard). */
|
|
||||||
js_heap_used_mb: number | null
|
|
||||||
/** Battery level 0..1 via navigator.getBattery() (Chromium only). */
|
|
||||||
battery_level: number | null
|
|
||||||
/** Whether the device is plugged in, via navigator.getBattery(). */
|
|
||||||
battery_charging: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const BATTERY_TIMEOUT_MS = 1_000
|
|
||||||
|
|
||||||
const toMb = (bytes: unknown): number | null =>
|
|
||||||
typeof bytes === 'number' ? Math.round(bytes / (1024 * 1024)) : null
|
|
||||||
|
|
||||||
export const collectHardwareSnapshot = async (): Promise<HardwareSnapshot> => {
|
|
||||||
const snapshot: HardwareSnapshot = {
|
|
||||||
cpu_cores: null,
|
|
||||||
device_memory_gb: null,
|
|
||||||
js_heap_limit_mb: null,
|
|
||||||
js_heap_used_mb: null,
|
|
||||||
battery_level: null,
|
|
||||||
battery_charging: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
snapshot.cpu_cores = navigator.hardwareConcurrency ?? null
|
|
||||||
|
|
||||||
const nav = navigator as Navigator & {
|
|
||||||
deviceMemory?: number
|
|
||||||
userAgentData?: { mobile?: boolean; platform?: string }
|
|
||||||
getBattery?: () => Promise<{ level: number; charging: boolean }>
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot.device_memory_gb = nav.deviceMemory ?? null
|
|
||||||
|
|
||||||
const memory = (
|
|
||||||
performance as Performance & {
|
|
||||||
memory?: { jsHeapSizeLimit?: number; usedJSHeapSize?: number }
|
|
||||||
}
|
|
||||||
).memory
|
|
||||||
snapshot.js_heap_limit_mb = toMb(memory?.jsHeapSizeLimit)
|
|
||||||
snapshot.js_heap_used_mb = toMb(memory?.usedJSHeapSize)
|
|
||||||
|
|
||||||
if (typeof nav.getBattery === 'function') {
|
|
||||||
// getBattery can hang on some platforms — don't let it delay the event.
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
||||||
const battery = await Promise.race([
|
|
||||||
nav.getBattery(),
|
|
||||||
new Promise<null>((resolve) => {
|
|
||||||
timeoutId = setTimeout(() => resolve(null), BATTERY_TIMEOUT_MS)
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
.catch(() => null)
|
|
||||||
.finally(() => clearTimeout(timeoutId))
|
|
||||||
if (battery) {
|
|
||||||
snapshot.battery_level = battery.level
|
|
||||||
snapshot.battery_charging = battery.charging
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// telemetry must never break the app
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshot
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,7 @@ export type LogCode =
|
|||||||
| 'livekit_room_error'
|
| 'livekit_room_error'
|
||||||
| 'device_switch_failure'
|
| 'device_switch_failure'
|
||||||
| 'permission_poll_failure'
|
| 'permission_poll_failure'
|
||||||
|
| 'media_devices_error_event'
|
||||||
// non-media families
|
// non-media families
|
||||||
| 'participant_mute_api_failure'
|
| 'participant_mute_api_failure'
|
||||||
| 'permissions_api_failure'
|
| 'permissions_api_failure'
|
||||||
@@ -30,7 +31,6 @@ export type LogCode =
|
|||||||
| 'clipboard_failure'
|
| 'clipboard_failure'
|
||||||
| 'fullscreen_failure'
|
| 'fullscreen_failure'
|
||||||
| 'publish_sources_failure'
|
| 'publish_sources_failure'
|
||||||
| 'performance_mode_failure'
|
|
||||||
| 'disconnect_failure'
|
| 'disconnect_failure'
|
||||||
| 'generic_failure'
|
| 'generic_failure'
|
||||||
|
|
||||||
@@ -137,7 +137,9 @@ export const captureMediaEvent = async (
|
|||||||
| 'media-device-topology'
|
| 'media-device-topology'
|
||||||
| 'media-device-success'
|
| 'media-device-success'
|
||||||
| 'device-not-found'
|
| 'device-not-found'
|
||||||
|
| 'device-in-use'
|
||||||
| 'permissions-denied'
|
| 'permissions-denied'
|
||||||
|
| 'screen-share-permission-denied'
|
||||||
| 'silent-mic-detected'
|
| 'silent-mic-detected'
|
||||||
| 'silent-mic-analyser-unavailable'
|
| 'silent-mic-analyser-unavailable'
|
||||||
| 'silent-mic-recovered'
|
| 'silent-mic-recovered'
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export enum ToastDuration {
|
|||||||
MEDIUM = 4000,
|
MEDIUM = 4000,
|
||||||
LONG = 5000,
|
LONG = 5000,
|
||||||
EXTRA_LONG = 7000,
|
EXTRA_LONG = 7000,
|
||||||
UNDO_WINDOW = 30000,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationDuration = {
|
export const NotificationDuration = {
|
||||||
@@ -16,5 +15,4 @@ export const NotificationDuration = {
|
|||||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||||
ROLE_CHANGED: ToastDuration.LONG,
|
ROLE_CHANGED: ToastDuration.LONG,
|
||||||
CPU_CONSTRAINED: ToastDuration.UNDO_WINDOW,
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -18,5 +18,4 @@ export enum NotificationType {
|
|||||||
RecordingSaving = 'recordingSaving',
|
RecordingSaving = 'recordingSaving',
|
||||||
PermissionsRemoved = 'permissionsRemoved',
|
PermissionsRemoved = 'permissionsRemoved',
|
||||||
RoleChanged = 'roleChanged',
|
RoleChanged = 'roleChanged',
|
||||||
CpuConstrained = 'cpuConstrained',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useToast } from 'react-aria'
|
|
||||||
import { useRef } from 'react'
|
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
import { type ToastProps } from './Toast'
|
|
||||||
import { HStack, VStack } from '@/styled-system/jsx'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Button, Text } from '@/primitives'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
|
||||||
import { disablePerformanceMode } from '@/stores/performanceMode'
|
|
||||||
import { captureEvent } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export function ToastCpuConstrained({ state, ...props }: Readonly<ToastProps>) {
|
|
||||||
const { t } = useTranslation('notifications', {
|
|
||||||
keyPrefix: 'cpuConstrained',
|
|
||||||
})
|
|
||||||
const ref = useRef(null)
|
|
||||||
const { toastProps, contentProps, closeButtonProps } = useToast(
|
|
||||||
props,
|
|
||||||
state,
|
|
||||||
ref
|
|
||||||
)
|
|
||||||
const toast = props.toast
|
|
||||||
|
|
||||||
const handleKeepQuality = () => {
|
|
||||||
captureEvent('cpu-constrained-degradation-cancelled')
|
|
||||||
disablePerformanceMode({ declinedAuto: true })
|
|
||||||
state.close(toast.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
|
||||||
<HStack alignItems="start" gap="0.5rem" padding={14}>
|
|
||||||
<VStack
|
|
||||||
justify="start"
|
|
||||||
alignItems="self-start"
|
|
||||||
{...contentProps}
|
|
||||||
maxWidth="370px"
|
|
||||||
gap="0.75rem"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
margin={false}
|
|
||||||
className={css({
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
overflowWrap: 'break-word',
|
|
||||||
whiteSpace: 'normal',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('message')}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="text"
|
|
||||||
className={css({
|
|
||||||
color: 'primary.300',
|
|
||||||
})}
|
|
||||||
onPress={() => handleKeepQuality()}
|
|
||||||
>
|
|
||||||
{t('keepQuality')}
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
|
||||||
<Button square size="sm" invisible {...closeButtonProps}>
|
|
||||||
<RiCloseLine size={18} color="white" />
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</StyledToastContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
|||||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||||
import { ToastCpuConstrained } from './ToastCpuConstrained'
|
|
||||||
|
|
||||||
interface ToastRegionProps extends AriaToastRegionProps {
|
interface ToastRegionProps extends AriaToastRegionProps {
|
||||||
state: ToastState<ToastData>
|
state: ToastState<ToastData>
|
||||||
@@ -75,9 +74,6 @@ const renderToast = (
|
|||||||
case NotificationType.RoleChanged:
|
case NotificationType.RoleChanged:
|
||||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||||
|
|
||||||
case NotificationType.CpuConstrained:
|
|
||||||
return <ToastCpuConstrained key={toast.key} toast={toast} state={state} />
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <Toast key={toast.key} toast={toast} state={state} />
|
return <Toast key={toast.key} toast={toast} state={state} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,6 @@ export const notifyAutoMutedOnJoin = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notifyCpuConstrained = () => {
|
|
||||||
toastQueue.add(
|
|
||||||
{
|
|
||||||
type: NotificationType.CpuConstrained,
|
|
||||||
},
|
|
||||||
{ timeout: NotificationDuration.CPU_CONSTRAINED }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const showLowerHandToast = (
|
export const showLowerHandToast = (
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import {
|
|
||||||
LocalTrackPublication,
|
|
||||||
LocalVideoTrack,
|
|
||||||
ParticipantEvent,
|
|
||||||
Track,
|
|
||||||
} from 'livekit-client'
|
|
||||||
|
|
||||||
import { captureEvent } from '@/features/analytics/telemetry'
|
|
||||||
import { collectHardwareSnapshot } from '@/features/analytics/hardware'
|
|
||||||
import { notifyCpuConstrained } from '@/features/notifications/utils'
|
|
||||||
import { isFireFox } from '@/utils/livekit'
|
|
||||||
import {
|
|
||||||
enablePerformanceMode,
|
|
||||||
performanceModeStore,
|
|
||||||
} from '@/stores/performanceMode'
|
|
||||||
|
|
||||||
export const CpuConstrainedObserver = () => {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const degradedTracksRef = useRef(new WeakSet<LocalVideoTrack>())
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const localParticipant = room.localParticipant
|
|
||||||
|
|
||||||
const handleCpuConstrained = (
|
|
||||||
track: LocalVideoTrack,
|
|
||||||
publication: LocalTrackPublication
|
|
||||||
) => {
|
|
||||||
const { enabled, userDeclinedAuto } = performanceModeStore
|
|
||||||
|
|
||||||
const shouldDegrade =
|
|
||||||
publication.source === Track.Source.Camera &&
|
|
||||||
!enabled &&
|
|
||||||
!userDeclinedAuto &&
|
|
||||||
!degradedTracksRef.current.has(track)
|
|
||||||
|
|
||||||
void collectHardwareSnapshot().then((hardware) => {
|
|
||||||
captureEvent('cpu-constrained', {
|
|
||||||
firefox: isFireFox(),
|
|
||||||
source: publication.source,
|
|
||||||
degraded: shouldDegrade,
|
|
||||||
trackOptions: publication.options,
|
|
||||||
performance_mode_enabled: enabled,
|
|
||||||
user_declined_auto: userDeclinedAuto,
|
|
||||||
...hardware,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!shouldDegrade) return
|
|
||||||
degradedTracksRef.current.add(track)
|
|
||||||
|
|
||||||
enablePerformanceMode('cpu')
|
|
||||||
notifyCpuConstrained()
|
|
||||||
}
|
|
||||||
|
|
||||||
localParticipant.on(
|
|
||||||
ParticipantEvent.LocalTrackCpuConstrained,
|
|
||||||
handleCpuConstrained
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
localParticipant.off(
|
|
||||||
ParticipantEvent.LocalTrackCpuConstrained,
|
|
||||||
handleCpuConstrained
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [room])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import {
|
|
||||||
LocalTrackPublication,
|
|
||||||
LocalVideoTrack,
|
|
||||||
ParticipantEvent,
|
|
||||||
Track,
|
|
||||||
TrackEvent,
|
|
||||||
} from 'livekit-client'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
import {
|
|
||||||
disablePerformanceMode,
|
|
||||||
performanceModeStore,
|
|
||||||
} from '@/stores/performanceMode'
|
|
||||||
import { degradeVideoTrack, restoreVideoTrack } from '../degradation'
|
|
||||||
|
|
||||||
/** Delay to re-apply degradation after restart to avoid racing LiveKit's encoding recompute. */
|
|
||||||
const REAPPLY_AFTER_RESTART_MS = 1_000
|
|
||||||
|
|
||||||
/** Syncs performance mode store state to outbound camera track encoding settings. */
|
|
||||||
export const PerformanceModeController = () => {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const { enabled } = useSnapshot(performanceModeStore)
|
|
||||||
|
|
||||||
// Manage degradation application and track lifecycle events
|
|
||||||
useEffect(() => {
|
|
||||||
const localParticipant = room.localParticipant
|
|
||||||
|
|
||||||
const getCameraTrack = () => {
|
|
||||||
const pub = localParticipant.getTrackPublication(Track.Source.Camera)
|
|
||||||
return pub?.track instanceof LocalVideoTrack ? pub.track : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const track = getCameraTrack()
|
|
||||||
|
|
||||||
// Restore track quality if performance mode is disabled
|
|
||||||
if (!enabled) {
|
|
||||||
if (track) {
|
|
||||||
restoreVideoTrack(track).catch((err) =>
|
|
||||||
reportError('performance_mode_failure', err, { action: 'restore' })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyDegradation = (t: LocalVideoTrack, action = 'degrade') => {
|
|
||||||
degradeVideoTrack(t).catch((err) =>
|
|
||||||
reportError('performance_mode_failure', err, { action })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-apply degradation after track restarts (e.g. device/resolution changes)
|
|
||||||
const watchTrackRestart = (t: LocalVideoTrack) => {
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout>
|
|
||||||
const onRestarted = () => {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
if (performanceModeStore.enabled) applyDegradation(t, 'reapply')
|
|
||||||
}, REAPPLY_AFTER_RESTART_MS)
|
|
||||||
}
|
|
||||||
t.on(TrackEvent.Restarted, onRestarted)
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
t.off(TrackEvent.Restarted, onRestarted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let unwatchRestart: (() => void) | undefined
|
|
||||||
|
|
||||||
if (track) {
|
|
||||||
applyDegradation(track)
|
|
||||||
unwatchRestart = watchTrackRestart(track)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply degradation to newly published camera tracks
|
|
||||||
const handlePublished = (pub: LocalTrackPublication) => {
|
|
||||||
if (
|
|
||||||
pub.source === Track.Source.Camera &&
|
|
||||||
pub.track instanceof LocalVideoTrack
|
|
||||||
) {
|
|
||||||
unwatchRestart?.()
|
|
||||||
applyDegradation(pub.track)
|
|
||||||
unwatchRestart = watchTrackRestart(pub.track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localParticipant.on(ParticipantEvent.LocalTrackPublished, handlePublished)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
localParticipant.off(
|
|
||||||
ParticipantEvent.LocalTrackPublished,
|
|
||||||
handlePublished
|
|
||||||
)
|
|
||||||
unwatchRestart?.()
|
|
||||||
}
|
|
||||||
}, [room, enabled])
|
|
||||||
|
|
||||||
// Reset auto (CPU-triggered) performance mode on unmount/leave room
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (performanceModeStore.trigger === 'cpu') {
|
|
||||||
disablePerformanceMode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { LocalVideoTrack } from 'livekit-client'
|
|
||||||
import { isFireFox } from '@/utils/livekit'
|
|
||||||
|
|
||||||
/** Target degraded encoding for layer 0 (~360p @ 15fps, ≤300kbps). */
|
|
||||||
export const DEGRADED_MAX_HEIGHT = 360
|
|
||||||
export const DEGRADED_MAX_FRAMERATE = 15
|
|
||||||
export const DEGRADED_MAX_BITRATE = 300_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Firefox ignores `active = false` on RTCRtpSender.
|
|
||||||
* We starve higher layers using LiveKit's sentinel workaround values instead.
|
|
||||||
*/
|
|
||||||
const FF_DISABLED_SCALE_DOWN = 4
|
|
||||||
const FF_DISABLED_MAX_BITRATE = 10
|
|
||||||
const FF_DISABLED_MAX_FRAMERATE = 2
|
|
||||||
|
|
||||||
type SavedEncoding = Pick<
|
|
||||||
RTCRtpEncodingParameters,
|
|
||||||
'active' | 'scaleResolutionDownBy' | 'maxBitrate' | 'maxFramerate'
|
|
||||||
>
|
|
||||||
|
|
||||||
/** Pre-degradation encoding snapshots keyed by track to prevent leaks. */
|
|
||||||
const savedEncodingsByTrack = new WeakMap<LocalVideoTrack, SavedEncoding[]>()
|
|
||||||
|
|
||||||
export const isTrackDegraded = (track: LocalVideoTrack) =>
|
|
||||||
savedEncodingsByTrack.has(track)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revertibly degrades local video quality without unpublishing.
|
|
||||||
* Avoids `prioritizePerformance()` because its internal flag disables dynacast permanently.
|
|
||||||
*
|
|
||||||
* - Layer 0: Capped to 360p / 15fps / 300kbps.
|
|
||||||
* - Other layers: Set to `active: false` (or starved on Firefox).
|
|
||||||
*/
|
|
||||||
export const degradeVideoTrack = async (track: LocalVideoTrack) => {
|
|
||||||
const sender = track.sender
|
|
||||||
if (!sender) {
|
|
||||||
throw new Error('sender not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = sender.getParameters()
|
|
||||||
if (!params.encodings || params.encodings.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot once so re-applications don't overwrite pristine encodings
|
|
||||||
if (!savedEncodingsByTrack.has(track)) {
|
|
||||||
savedEncodingsByTrack.set(
|
|
||||||
track,
|
|
||||||
params.encodings.map((e) => ({
|
|
||||||
active: e.active,
|
|
||||||
scaleResolutionDownBy: e.scaleResolutionDownBy,
|
|
||||||
maxBitrate: e.maxBitrate,
|
|
||||||
maxFramerate: e.maxFramerate,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const captureHeight =
|
|
||||||
track.mediaStreamTrack.getSettings().height ?? DEGRADED_MAX_HEIGHT
|
|
||||||
|
|
||||||
params.encodings = params.encodings.map((encoding, idx) => {
|
|
||||||
if (idx === 0) {
|
|
||||||
return {
|
|
||||||
...encoding,
|
|
||||||
active: true,
|
|
||||||
scaleResolutionDownBy: Math.max(
|
|
||||||
1,
|
|
||||||
Math.ceil(captureHeight / DEGRADED_MAX_HEIGHT)
|
|
||||||
),
|
|
||||||
maxFramerate: DEGRADED_MAX_FRAMERATE,
|
|
||||||
maxBitrate: Math.min(
|
|
||||||
encoding.maxBitrate ?? DEGRADED_MAX_BITRATE,
|
|
||||||
DEGRADED_MAX_BITRATE
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFireFox()) {
|
|
||||||
const starved: RTCRtpEncodingParameters = {
|
|
||||||
...encoding,
|
|
||||||
// Firefox workaround: active=false prevents LiveKit re-encodes, while starved values limit bitrate
|
|
||||||
active: false,
|
|
||||||
scaleResolutionDownBy: FF_DISABLED_SCALE_DOWN,
|
|
||||||
maxBitrate: FF_DISABLED_MAX_BITRATE,
|
|
||||||
maxFramerate: FF_DISABLED_MAX_FRAMERATE,
|
|
||||||
}
|
|
||||||
// LiveKit legacy property fallback for Firefox
|
|
||||||
;(starved as Record<string, unknown>).maxFrameRate =
|
|
||||||
FF_DISABLED_MAX_FRAMERATE
|
|
||||||
return starved
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...encoding, active: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
await sender.setParameters(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Restores encodings captured prior to degradation. */
|
|
||||||
export const restoreVideoTrack = async (track: LocalVideoTrack) => {
|
|
||||||
const saved = savedEncodingsByTrack.get(track)
|
|
||||||
savedEncodingsByTrack.delete(track)
|
|
||||||
|
|
||||||
const sender = track.sender
|
|
||||||
if (!saved || !sender) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = sender.getParameters()
|
|
||||||
if (!params.encodings || params.encodings.length !== saved.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params.encodings = params.encodings.map((encoding, idx) => {
|
|
||||||
const restored: RTCRtpEncodingParameters = {
|
|
||||||
...encoding,
|
|
||||||
...saved[idx],
|
|
||||||
}
|
|
||||||
// Clean up Firefox legacy property if set during degradation
|
|
||||||
;(restored as Record<string, unknown>).maxFrameRate = undefined
|
|
||||||
return restored
|
|
||||||
})
|
|
||||||
|
|
||||||
await sender.setParameters(params)
|
|
||||||
}
|
|
||||||
@@ -225,9 +225,10 @@ export const Conference = ({
|
|||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
const failure = MediaDeviceFailure.getFailure(e)
|
||||||
|
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||||
reportError('livekit_room_error', e, {
|
reportError('livekit_room_error', e, {
|
||||||
path: 'connect_publish',
|
path: 'connect_publish',
|
||||||
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
|
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onConnected={async () => {
|
onConnected={async () => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
|
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
|
||||||
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
||||||
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
||||||
import { SilentMicDetector } from './SilentMicDetector'
|
import { SilentMicDetector } from './SilentMicDetector'
|
||||||
@@ -218,12 +219,14 @@ const switchTrackDevice =
|
|||||||
function getPreviewMessages({
|
function getPreviewMessages({
|
||||||
cameraFound,
|
cameraFound,
|
||||||
cameraDenied,
|
cameraDenied,
|
||||||
|
cameraInUse,
|
||||||
micDenied,
|
micDenied,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoStarted,
|
videoStarted,
|
||||||
}: {
|
}: {
|
||||||
cameraFound: boolean
|
cameraFound: boolean
|
||||||
cameraDenied: boolean
|
cameraDenied: boolean
|
||||||
|
cameraInUse: boolean
|
||||||
micDenied: boolean
|
micDenied: boolean
|
||||||
videoEnabled: boolean
|
videoEnabled: boolean
|
||||||
videoStarted: boolean
|
videoStarted: boolean
|
||||||
@@ -235,6 +238,9 @@ function getPreviewMessages({
|
|||||||
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
||||||
return { hint: key, permissionsButtonLabel: key }
|
return { hint: key, permissionsButtonLabel: key }
|
||||||
}
|
}
|
||||||
|
if (cameraInUse) {
|
||||||
|
return { hint: 'cameraInUse', permissionsButtonLabel: null }
|
||||||
|
}
|
||||||
if (!videoEnabled) {
|
if (!videoEnabled) {
|
||||||
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
||||||
}
|
}
|
||||||
@@ -328,18 +334,20 @@ const VideoPreview = ({
|
|||||||
const cameraDenied = useCannotUseDevice('videoinput')
|
const cameraDenied = useCannotUseDevice('videoinput')
|
||||||
const micDenied = useCannotUseDevice('audioinput')
|
const micDenied = useCannotUseDevice('audioinput')
|
||||||
const cameraMissing = useDeviceMissing('videoinput')
|
const cameraMissing = useDeviceMissing('videoinput')
|
||||||
|
const cameraInUse = useDeviceInUse('videoinput')
|
||||||
|
|
||||||
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
||||||
|
|
||||||
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
||||||
cameraFound: !cameraMissing,
|
cameraFound: !cameraMissing,
|
||||||
cameraDenied,
|
cameraDenied,
|
||||||
|
cameraInUse,
|
||||||
micDenied,
|
micDenied,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoStarted,
|
videoStarted,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isError = cameraMissing || cameraDenied
|
const isError = cameraMissing || cameraDenied || cameraInUse
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.previewFrame}>
|
<div className={styles.previewFrame}>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { A, Button, Dialog, P } from '@/primitives'
|
import { A, Button, Dialog, P } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { getOS, type OS } from '@/utils/os'
|
||||||
|
|
||||||
|
const SCREEN_CAPTURE_SETTINGS_LINKS: Partial<Record<OS, string>> = {
|
||||||
|
macos:
|
||||||
|
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
||||||
|
windows: 'ms-settings:privacy-graphicscaptureprogrammatic',
|
||||||
|
}
|
||||||
|
|
||||||
// todo - refactor it into a generic system
|
// todo - refactor it into a generic system
|
||||||
export const ScreenShareErrorModal = ({
|
export const ScreenShareErrorModal = ({
|
||||||
@@ -11,7 +18,8 @@ export const ScreenShareErrorModal = ({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
||||||
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
|
const os = getOS()
|
||||||
|
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -26,15 +34,16 @@ export const ScreenShareErrorModal = ({
|
|||||||
<>
|
<>
|
||||||
<P>
|
<P>
|
||||||
{t('message')}{' '}
|
{t('message')}{' '}
|
||||||
{isMac && (
|
{settingsHref && (
|
||||||
<>
|
<>
|
||||||
{t('macInstructions')}{' '}
|
{t('settingsInstructions')}{' '}
|
||||||
<A
|
<A
|
||||||
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
href={settingsHref}
|
||||||
|
target="_blank"
|
||||||
color="primary"
|
color="primary"
|
||||||
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
|
||||||
>
|
>
|
||||||
{t('macSystemPreferences')}
|
{t(`settingsLabel.${os}`)}
|
||||||
</A>
|
</A>
|
||||||
.{' '}
|
.{' '}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+13
-23
@@ -9,18 +9,15 @@ import {
|
|||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
import { userChoicesStore } from '@/stores/userChoices'
|
||||||
import { performanceModeStore } from '@/stores/performanceMode'
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets initial video quality for new participants as they join.
|
||||||
|
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||||
|
*/
|
||||||
export const VideoResolutionSubscription = () => {
|
export const VideoResolutionSubscription = () => {
|
||||||
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
||||||
const { enabled: isPerformanceModeEnabled } =
|
|
||||||
useSnapshot(performanceModeStore)
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
const effectiveQuality = isPerformanceModeEnabled
|
|
||||||
? VideoQuality.LOW
|
|
||||||
: (videoSubscribeQuality ?? VideoQuality.HIGH)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return
|
if (!room) return
|
||||||
|
|
||||||
@@ -28,12 +25,18 @@ export const VideoResolutionSubscription = () => {
|
|||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
_participant: RemoteParticipant
|
_participant: RemoteParticipant
|
||||||
) => {
|
) => {
|
||||||
if (effectiveQuality === VideoQuality.HIGH) return
|
// By default, the maximum quality is set to high
|
||||||
|
if (
|
||||||
|
videoSubscribeQuality === undefined ||
|
||||||
|
videoSubscribeQuality === VideoQuality.HIGH
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if (
|
if (
|
||||||
publication.kind === Track.Kind.Video &&
|
publication.kind === Track.Kind.Video &&
|
||||||
publication.source !== Track.Source.ScreenShare
|
publication.source !== Track.Source.ScreenShare
|
||||||
) {
|
) {
|
||||||
publication.setVideoQuality(effectiveQuality)
|
publication.setVideoQuality(videoSubscribeQuality)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,20 +44,7 @@ export const VideoResolutionSubscription = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
}
|
}
|
||||||
}, [room, effectiveQuality])
|
}, [room, videoSubscribeQuality])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!room) return
|
|
||||||
|
|
||||||
room.remoteParticipants.forEach((participant) => {
|
|
||||||
participant.videoTrackPublications.forEach((publication) => {
|
|
||||||
if (publication.source === Track.Source.ScreenShare) return
|
|
||||||
if (publication.videoQuality !== effectiveQuality) {
|
|
||||||
publication.setVideoQuality(effectiveQuality)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [room, effectiveQuality])
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-9
@@ -20,6 +20,7 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
|||||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
|
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
|
||||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||||
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
||||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
@@ -97,21 +98,23 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
const deviceIcons = useDeviceIcons(kind)
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
const deviceMissing = useDeviceMissing(kind)
|
const deviceMissing = useDeviceMissing(kind)
|
||||||
|
const deviceInUse = useDeviceInUse(kind)
|
||||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||||
const silentMicWarning =
|
const silentMicWarning =
|
||||||
kind === 'audioinput' &&
|
kind === 'audioinput' &&
|
||||||
silentMicStatus === 'silent' &&
|
silentMicStatus === 'silent' &&
|
||||||
!cannotUseDevice &&
|
!cannotUseDevice &&
|
||||||
!deviceMissing
|
!deviceMissing &&
|
||||||
|
!deviceInUse
|
||||||
const deviceShortcut = useDeviceShortcut(kind)
|
const deviceShortcut = useDeviceShortcut(kind)
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const isRequestingPermission = useRef(false)
|
const isRequestingPermission = useRef(false)
|
||||||
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
|
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
|
||||||
|
|
||||||
const onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!enabled && deviceMissing) {
|
if (!enabled && deviceMissing) {
|
||||||
setShowDeviceNotFound(true)
|
setAlertError(MediaDeviceFailure.NotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!cannotUseDevice) {
|
if (!cannotUseDevice) {
|
||||||
@@ -185,10 +188,18 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
<PermissionNeededButton
|
<PermissionNeededButton
|
||||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||||
onPress={
|
onPress={
|
||||||
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
deviceMissing
|
||||||
|
? () => setAlertError(MediaDeviceFailure.NotFound)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{deviceInUse && (
|
||||||
|
<PermissionNeededButton
|
||||||
|
tooltip={t(`deviceInUse.${kind}`)}
|
||||||
|
onPress={() => setAlertError(MediaDeviceFailure.DeviceInUse)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{silentMicWarning && (
|
{silentMicWarning && (
|
||||||
<PermissionNeededButton
|
<PermissionNeededButton
|
||||||
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
||||||
@@ -207,9 +218,11 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
tooltip={
|
tooltip={
|
||||||
deviceMissing
|
deviceMissing
|
||||||
? t(`deviceNotFound.${kind}`)
|
? t(`deviceNotFound.${kind}`)
|
||||||
: cannotUseDevice
|
: deviceInUse
|
||||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
? t(`deviceInUse.${kind}`)
|
||||||
: toggleLabel
|
: cannotUseDevice
|
||||||
|
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||||
|
: toggleLabel
|
||||||
}
|
}
|
||||||
{...computedToggleButtonProps}
|
{...computedToggleButtonProps}
|
||||||
{...overrideToggleButtonProps}
|
{...overrideToggleButtonProps}
|
||||||
@@ -217,9 +230,9 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
<Icon />
|
<Icon />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
error={alertError}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
onClose={() => setShowDeviceNotFound(false)}
|
onClose={() => setAlertError(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { deviceInUseStore } from '@/stores/deviceInUse'
|
||||||
|
import { PERMISSION_BY_DEVICE_KIND } from '@/stores/permissions'
|
||||||
|
import { useCannotUseDevice } from './useCannotUseDevice'
|
||||||
|
import { useDeviceMissing } from './useDeviceMissing'
|
||||||
|
|
||||||
|
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
|
||||||
|
const inUse = useSnapshot(deviceInUseStore)
|
||||||
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
|
const deviceMissing = useDeviceMissing(kind)
|
||||||
|
|
||||||
|
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||||
|
if (!permissionKind || cannotUseDevice || deviceMissing) return false
|
||||||
|
return inUse[permissionKind]
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
noteSystemPermissionDenied,
|
noteSystemPermissionDenied,
|
||||||
type PermissionKind,
|
type PermissionKind,
|
||||||
} from '@/stores/permissions'
|
} from '@/stores/permissions'
|
||||||
|
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceInUse'
|
||||||
import { getOS } from '@/utils/os'
|
import { getOS } from '@/utils/os'
|
||||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
import {
|
import {
|
||||||
@@ -89,7 +90,15 @@ const onMediaPermissionError = (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
|
if (
|
||||||
|
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.DeviceInUse &&
|
||||||
|
path === 'join_preview'
|
||||||
|
) {
|
||||||
|
noteDeviceInUse(kind)
|
||||||
|
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reportError(
|
reportError(
|
||||||
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
||||||
e,
|
e,
|
||||||
@@ -97,6 +106,11 @@ const onMediaPermissionError = (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noteDeviceReady = (kind?: PermissionKind) => {
|
||||||
|
noteGumSuccess(kind)
|
||||||
|
clearDeviceInUse(kind)
|
||||||
|
}
|
||||||
|
|
||||||
// Module-level: effect dependencies, must be referentially stable.
|
// Module-level: effect dependencies, must be referentially stable.
|
||||||
const disableAudio = () => saveAudioInputEnabled(false)
|
const disableAudio = () => saveAudioInputEnabled(false)
|
||||||
const disableVideo = () => saveVideoInputEnabled(false)
|
const disableVideo = () => saveVideoInputEnabled(false)
|
||||||
@@ -114,7 +128,7 @@ export const requestDevicePermission = async (
|
|||||||
? await createLocalAudioTrack()
|
? await createLocalAudioTrack()
|
||||||
: await createLocalVideoTrack()
|
: await createLocalVideoTrack()
|
||||||
track.stop()
|
track.stop()
|
||||||
noteGumSuccess(PERMISSION_KIND[kind])
|
noteDeviceReady(PERMISSION_KIND[kind])
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
||||||
@@ -158,7 +172,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
video: true,
|
video: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
noteGumSuccess()
|
noteDeviceReady()
|
||||||
bothReady()
|
bothReady()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
@@ -180,7 +194,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
.getUserMedia({ audio: true })
|
.getUserMedia({ audio: true })
|
||||||
.then((stream) => {
|
.then((stream) => {
|
||||||
stopAll(stream)
|
stopAll(stream)
|
||||||
noteGumSuccess('microphone')
|
noteDeviceReady('microphone')
|
||||||
})
|
})
|
||||||
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||||
.finally(() =>
|
.finally(() =>
|
||||||
@@ -190,7 +204,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
.getUserMedia({ video: true })
|
.getUserMedia({ video: true })
|
||||||
.then((stream) => {
|
.then((stream) => {
|
||||||
stopAll(stream)
|
stopAll(stream)
|
||||||
noteGumSuccess('camera')
|
noteDeviceReady('camera')
|
||||||
})
|
})
|
||||||
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
||||||
.finally(() =>
|
.finally(() =>
|
||||||
@@ -227,7 +241,7 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
create()
|
create()
|
||||||
.then((newTrack) => {
|
.then((newTrack) => {
|
||||||
noteGumSuccess(permissionKind)
|
noteDeviceReady(permissionKind)
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
newTrack.stop()
|
newTrack.stop()
|
||||||
return
|
return
|
||||||
@@ -291,6 +305,8 @@ export function useJoinTracks(): {
|
|||||||
|
|
||||||
const { audioReady, videoReady } = useWarmupPermissions()
|
const { audioReady, videoReady } = useWarmupPermissions()
|
||||||
|
|
||||||
|
useEffect(() => () => clearDeviceInUse(), [])
|
||||||
|
|
||||||
const createAudio = useCallback(
|
const createAudio = useCallback(
|
||||||
() =>
|
() =>
|
||||||
createLocalAudioTrack({
|
createLocalAudioTrack({
|
||||||
|
|||||||
@@ -50,15 +50,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
||||||
const failure = MediaDeviceFailure.getFailure(error)
|
const failure = MediaDeviceFailure.getFailure(error)
|
||||||
if (!failure || !kind) return
|
if (!failure) return
|
||||||
|
if (failure != MediaDeviceFailure.Other) {
|
||||||
void captureMediaEvent('media-device-error', {
|
void captureMediaEvent('media-device-error', {
|
||||||
log_code: 'media_devices_error_event',
|
log_code: 'media_devices_error_event',
|
||||||
path: 'connect_publish',
|
path: 'connect_publish',
|
||||||
failure,
|
failure,
|
||||||
kind,
|
kind: kind ?? 'unknown',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
if (!kind) return
|
||||||
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||||
switch (failure) {
|
switch (failure) {
|
||||||
case MediaDeviceFailure.DeviceInUse:
|
case MediaDeviceFailure.DeviceInUse:
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { SidePanel } from '../components/SidePanel'
|
|||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
|
import { getOS } from '@/utils/os'
|
||||||
|
import { isFireFox } from '@/utils/livekit'
|
||||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
import { MediaStateObserver } from '../components/MediaStateObserver'
|
||||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
@@ -27,8 +29,6 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
|
|||||||
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
||||||
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
||||||
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
||||||
import { CpuConstrainedObserver } from '@/features/performance/components/CpuConstrainedObserver'
|
|
||||||
import { PerformanceModeController } from '@/features/performance/components/PerformanceModeController'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
@@ -38,6 +38,20 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
|||||||
SettingsComponent?: React.ComponentType
|
SettingsComponent?: React.ComponentType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getScreenSharePermissionDeniedScope = (
|
||||||
|
error: Error
|
||||||
|
): 'system' | 'user' | 'browser' | null => {
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
if (/by system/i.test(error.message)) return 'system'
|
||||||
|
if (/by user/i.test(error.message)) return 'user'
|
||||||
|
return 'browser'
|
||||||
|
}
|
||||||
|
if (error.name === 'NotFoundError' && isFireFox() && getOS() === 'macos') {
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
|
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
|
||||||
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
|
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
|
||||||
@@ -63,6 +77,34 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
|
|
||||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||||
|
|
||||||
|
const handleDeviceError = ({
|
||||||
|
source,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
source: Track.Source
|
||||||
|
error: Error
|
||||||
|
}) => {
|
||||||
|
if (source === Track.Source.ScreenShare) {
|
||||||
|
const scope = getScreenSharePermissionDeniedScope(error)
|
||||||
|
if (scope) {
|
||||||
|
if (scope === 'system') setIsShareErrorVisible(true)
|
||||||
|
void captureMediaEvent('screen-share-permission-denied', {
|
||||||
|
at: 'ControlBar.onDeviceError',
|
||||||
|
source,
|
||||||
|
error_name: error.name,
|
||||||
|
error_message: error.message,
|
||||||
|
denied_scope: scope,
|
||||||
|
os: getOS(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reportError('device_switch_failure', error, {
|
||||||
|
at: 'ControlBar.onDeviceError',
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RoomMetadataSynchronizer />
|
<RoomMetadataSynchronizer />
|
||||||
@@ -72,8 +114,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<MediaStateObserver />
|
<MediaStateObserver />
|
||||||
<ChatProvider />
|
<ChatProvider />
|
||||||
<VideoResolutionSubscription />
|
<VideoResolutionSubscription />
|
||||||
<PerformanceModeController />
|
|
||||||
<CpuConstrainedObserver />
|
|
||||||
<div
|
<div
|
||||||
className="lk-video-conference"
|
className="lk-video-conference"
|
||||||
{...props}
|
{...props}
|
||||||
@@ -96,21 +136,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<StageLayout />
|
<StageLayout />
|
||||||
)}
|
)}
|
||||||
</RoomContentArea>
|
</RoomContentArea>
|
||||||
<ControlBar
|
<ControlBar onDeviceError={handleDeviceError} />
|
||||||
onDeviceError={(e) => {
|
|
||||||
reportError('device_switch_failure', e.error, {
|
|
||||||
at: 'ControlBar.onDeviceError',
|
|
||||||
source: e.source,
|
|
||||||
})
|
|
||||||
if (
|
|
||||||
e.source == Track.Source.ScreenShare &&
|
|
||||||
e.error.toString() ==
|
|
||||||
'NotAllowedError: Permission denied by system'
|
|
||||||
) {
|
|
||||||
setIsShareErrorVisible(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<SidePanel />
|
<SidePanel />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ import {
|
|||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
import { RowWrapper } from './layout/RowWrapper'
|
import { RowWrapper } from './layout/RowWrapper'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import {
|
|
||||||
disablePerformanceMode,
|
|
||||||
enablePerformanceMode,
|
|
||||||
performanceModeStore,
|
|
||||||
} from '@/stores/performanceMode'
|
|
||||||
|
|
||||||
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
Pick<TabPanelProps, 'id'>
|
Pick<TabPanelProps, 'id'>
|
||||||
@@ -37,7 +32,7 @@ const EMPTY_PROPS = {}
|
|||||||
|
|
||||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||||
const { localParticipant } = useRoomContext()
|
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
@@ -46,9 +41,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
videoSubscribeQuality,
|
videoSubscribeQuality,
|
||||||
} = useSnapshot(userChoicesStore)
|
} = useSnapshot(userChoicesStore)
|
||||||
|
|
||||||
const { enabled: isPerformanceModeEnabled } =
|
|
||||||
useSnapshot(performanceModeStore)
|
|
||||||
|
|
||||||
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
|
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
@@ -93,6 +85,22 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(() => {
|
useEffect(() => {
|
||||||
let videoTrack: LocalVideoTrack | null = null
|
let videoTrack: LocalVideoTrack | null = null
|
||||||
|
|
||||||
@@ -209,13 +217,10 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
type="select"
|
type="select"
|
||||||
label={t('resolution.publish.label')}
|
label={t('resolution.publish.label')}
|
||||||
items={resolutionItems}
|
items={resolutionItems}
|
||||||
selectedKey={
|
selectedKey={videoPublishResolution}
|
||||||
isPerformanceModeEnabled ? 'h360' : videoPublishResolution
|
|
||||||
}
|
|
||||||
onSelectionChange={async (key) => {
|
onSelectionChange={async (key) => {
|
||||||
await handleVideoResolutionChange(key as VideoResolution)
|
await handleVideoResolutionChange(key as VideoResolution)
|
||||||
}}
|
}}
|
||||||
isDisabled={isPerformanceModeEnabled}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
@@ -227,43 +232,19 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
type="select"
|
type="select"
|
||||||
label={t('resolution.subscribe.label')}
|
label={t('resolution.subscribe.label')}
|
||||||
items={videoQualityItems}
|
items={videoQualityItems}
|
||||||
selectedKey={
|
selectedKey={videoSubscribeQuality?.toString()}
|
||||||
isPerformanceModeEnabled
|
|
||||||
? VideoQuality.LOW.toString()
|
|
||||||
: videoSubscribeQuality?.toString()
|
|
||||||
}
|
|
||||||
onSelectionChange={(key) => {
|
onSelectionChange={(key) => {
|
||||||
if (key == undefined) return
|
if (key == undefined) return
|
||||||
const selectedQuality = Number(String(key))
|
const selectedQuality = Number(String(key))
|
||||||
saveVideoSubscribeQuality(selectedQuality)
|
saveVideoSubscribeQuality(selectedQuality)
|
||||||
|
updateExistingRemoteVideoQuality(selectedQuality)
|
||||||
}}
|
}}
|
||||||
isDisabled={isPerformanceModeEnabled}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<></>
|
<></>
|
||||||
</RowWrapper>
|
</RowWrapper>
|
||||||
<RowWrapper heading={t('performance.heading')}>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={t('performance.label')}
|
|
||||||
description={t('performance.description')}
|
|
||||||
isSelected={isPerformanceModeEnabled}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (value) {
|
|
||||||
enablePerformanceMode('manual')
|
|
||||||
} else {
|
|
||||||
disablePerformanceMode()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
wrapperProps={{
|
|
||||||
noMargin: true,
|
|
||||||
fullWidth: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<></>
|
|
||||||
</RowWrapper>
|
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,6 @@
|
|||||||
"auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.",
|
"auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.",
|
||||||
"dismiss": "Stummschaltung aufheben"
|
"dismiss": "Stummschaltung aufheben"
|
||||||
},
|
},
|
||||||
"cpuConstrained": {
|
|
||||||
"message": "Ihr Gerät ist stark ausgelastet. Die Videoqualität wurde reduziert, um das Gespräch flüssig zu halten.",
|
|
||||||
"keepQuality": "Ursprüngliche Qualität beibehalten"
|
|
||||||
},
|
|
||||||
"reaction": {
|
"reaction": {
|
||||||
"description": "{{name}} hat mit {{emoji}} reagiert"
|
"description": "{{name}} hat mit {{emoji}} reagiert"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
||||||
},
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
||||||
|
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audioeinstellungen",
|
"audio": "Audioeinstellungen",
|
||||||
"video": "Videoeinstellungen"
|
"video": "Videoeinstellungen"
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||||
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
|
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
||||||
"cameraStarting": "Kamera wird gestartet…",
|
"cameraStarting": "Kamera wird gestartet…",
|
||||||
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
||||||
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
||||||
@@ -214,8 +219,11 @@
|
|||||||
"title": "Bildschirmfreigabe nicht möglich",
|
"title": "Bildschirmfreigabe nicht möglich",
|
||||||
"ariaLabel": "Bildschirmfreigabe nicht möglich",
|
"ariaLabel": "Bildschirmfreigabe nicht möglich",
|
||||||
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
|
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
|
||||||
"macInstructions": "Gehe zu den",
|
"settingsInstructions": "Gehe zu den",
|
||||||
"macSystemPreferences": "Systemeinstellungen",
|
"settingsLabel": {
|
||||||
|
"macos": "Systemeinstellungen",
|
||||||
|
"windows": "Windows-Datenschutzeinstellungen"
|
||||||
|
},
|
||||||
"helpLinkText": "Weitere Informationen findest du unter",
|
"helpLinkText": "Weitere Informationen findest du unter",
|
||||||
"helpLinkLabel": "Bildschirmfreigabeproblem",
|
"helpLinkLabel": "Bildschirmfreigabeproblem",
|
||||||
"closeButton": "Schließen",
|
"closeButton": "Schließen",
|
||||||
|
|||||||
@@ -70,11 +70,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"performance": {
|
|
||||||
"heading": "Leistung",
|
|
||||||
"label": "Leistung priorisieren",
|
|
||||||
"description": "Reduziert die Qualität Ihrer Videos, um Ihr Gerät zu entlasten. Wird bei Überlastung automatisch aktiviert. Deaktivieren Sie diese Funktion, um wieder die maximale Qualität zu erhalten."
|
|
||||||
},
|
|
||||||
"permissionsRequired": "Berechtigungen erforderlich"
|
"permissionsRequired": "Berechtigungen erforderlich"
|
||||||
},
|
},
|
||||||
"transcription": {
|
"transcription": {
|
||||||
|
|||||||
@@ -18,10 +18,6 @@
|
|||||||
"auto": "You have joined a large meeting. Your microphone has been automatically muted.",
|
"auto": "You have joined a large meeting. Your microphone has been automatically muted.",
|
||||||
"dismiss": "Unmute"
|
"dismiss": "Unmute"
|
||||||
},
|
},
|
||||||
"cpuConstrained": {
|
|
||||||
"message": "Your device is running low on processing power. Video quality has been reduced to keep the call smooth.",
|
|
||||||
"keepQuality": "Keep original quality"
|
|
||||||
},
|
|
||||||
"reaction": {
|
"reaction": {
|
||||||
"description": "{{name}} reacted with {{emoji}}"
|
"description": "{{name}} reacted with {{emoji}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"videoinput": "No camera detected. Check that it is properly wired.",
|
"videoinput": "No camera detected. Check that it is properly wired.",
|
||||||
"audioinput": "No microphone detected. Check that it is properly wired."
|
"audioinput": "No microphone detected. Check that it is properly wired."
|
||||||
},
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Camera unavailable: it is probably in use by another application or browser tab.",
|
||||||
|
"audioinput": "Microphone unavailable: it is probably in use by another application or browser tab."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audio settings",
|
"audio": "Audio settings",
|
||||||
"video": "Video settings"
|
"video": "Video settings"
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is disabled.",
|
"cameraDisabled": "Camera is disabled.",
|
||||||
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
||||||
|
"cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.",
|
||||||
"cameraStarting": "Camera is starting…",
|
"cameraStarting": "Camera is starting…",
|
||||||
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
"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?",
|
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
||||||
@@ -214,8 +219,11 @@
|
|||||||
"title": "Unable to share your screen",
|
"title": "Unable to share your screen",
|
||||||
"ariaLabel": "Unable to share your screen",
|
"ariaLabel": "Unable to share your screen",
|
||||||
"message": "Your browser may not be allowed to record the screen on your computer.",
|
"message": "Your browser may not be allowed to record the screen on your computer.",
|
||||||
"macInstructions": "Go to your",
|
"settingsInstructions": "Go to your",
|
||||||
"macSystemPreferences": "System Preferences",
|
"settingsLabel": {
|
||||||
|
"macos": "System Preferences",
|
||||||
|
"windows": "Windows privacy settings"
|
||||||
|
},
|
||||||
"helpLinkText": "To learn more, see",
|
"helpLinkText": "To learn more, see",
|
||||||
"helpLinkLabel": "Presentation issue",
|
"helpLinkLabel": "Presentation issue",
|
||||||
"closeButton": "Dismiss",
|
"closeButton": "Dismiss",
|
||||||
|
|||||||
@@ -70,11 +70,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"performance": {
|
|
||||||
"heading": "Performance",
|
|
||||||
"label": "Prioritize performance",
|
|
||||||
"description": "Reduces the quality of your videos to take the strain off your device. Automatically activates in case of overload. Disable it to restore maximum quality."
|
|
||||||
},
|
|
||||||
"permissionsRequired": "Permissions required"
|
"permissionsRequired": "Permissions required"
|
||||||
},
|
},
|
||||||
"transcription": {
|
"transcription": {
|
||||||
|
|||||||
@@ -18,10 +18,6 @@
|
|||||||
"auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.",
|
"auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.",
|
||||||
"dismiss": "Rétablir le micro"
|
"dismiss": "Rétablir le micro"
|
||||||
},
|
},
|
||||||
"cpuConstrained": {
|
|
||||||
"message": "Votre appareil manque de puissance. La qualité vidéo a été réduite pour préserver la fluidité de l'appel.",
|
|
||||||
"keepQuality": "Conserver la qualité d'origine"
|
|
||||||
},
|
|
||||||
"reaction": {
|
"reaction": {
|
||||||
"description": "{{name}} a reagi avec {{emoji}}"
|
"description": "{{name}} a reagi avec {{emoji}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||||
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
||||||
},
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.",
|
||||||
|
"audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Paramètres audio",
|
"audio": "Paramètres audio",
|
||||||
"video": "Paramètres video"
|
"video": "Paramètres video"
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "La caméra est désactivée.",
|
"cameraDisabled": "La caméra est désactivée.",
|
||||||
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||||
|
"cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.",
|
||||||
"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 ?",
|
"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 ?",
|
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
||||||
@@ -214,8 +219,11 @@
|
|||||||
"title": "Impossible de partager votre écran",
|
"title": "Impossible de partager votre écran",
|
||||||
"ariaLabel": "Impossible de partager votre écran",
|
"ariaLabel": "Impossible de partager votre écran",
|
||||||
"message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.",
|
"message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.",
|
||||||
"macInstructions": "Accèdez à vos",
|
"settingsInstructions": "Accédez à vos",
|
||||||
"macSystemPreferences": "Préférences système",
|
"settingsLabel": {
|
||||||
|
"macos": "Préférences système",
|
||||||
|
"windows": "paramètres de confidentialité Windows"
|
||||||
|
},
|
||||||
"helpLinkText": "Pour en savoir plus, consulter",
|
"helpLinkText": "Pour en savoir plus, consulter",
|
||||||
"helpLinkLabel": "Problème de présentation",
|
"helpLinkLabel": "Problème de présentation",
|
||||||
"closeButton": "Ignorer",
|
"closeButton": "Ignorer",
|
||||||
|
|||||||
@@ -70,11 +70,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"performance": {
|
|
||||||
"heading": "Performances",
|
|
||||||
"label": "Privilégier les performances",
|
|
||||||
"description": "Réduit la qualité de vos vidéos pour soulager votre appareil. S’active automatiquement en cas de surcharge. Désactivez-la pour retrouver la qualité maximale."
|
|
||||||
},
|
|
||||||
"permissionsRequired": "Autorisations nécessaires"
|
"permissionsRequired": "Autorisations nécessaires"
|
||||||
},
|
},
|
||||||
"transcription": {
|
"transcription": {
|
||||||
|
|||||||
@@ -18,10 +18,6 @@
|
|||||||
"auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.",
|
"auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.",
|
||||||
"dismiss": "Microfoon inschakelen"
|
"dismiss": "Microfoon inschakelen"
|
||||||
},
|
},
|
||||||
"cpuConstrained": {
|
|
||||||
"message": "Uw apparaat is zwaar belast. De videokwaliteit is verlaagd om het gesprek soepel te laten verlopen.",
|
|
||||||
"keepQuality": "Oorspronkelijke kwaliteit behouden"
|
|
||||||
},
|
|
||||||
"reaction": {
|
"reaction": {
|
||||||
"description": "{{name}} reageerde met {{emoji}}"
|
"description": "{{name}} reageerde met {{emoji}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||||
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
||||||
},
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
||||||
|
"audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audio-instellingen",
|
"audio": "Audio-instellingen",
|
||||||
"video": "Video-instellingen"
|
"video": "Video-instellingen"
|
||||||
@@ -67,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||||
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||||
|
"cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
||||||
"cameraStarting": "Camera wordt ingeschakeld…",
|
"cameraStarting": "Camera wordt ingeschakeld…",
|
||||||
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
||||||
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
||||||
@@ -214,8 +219,11 @@
|
|||||||
"title": "Kan uw scherm niet delen",
|
"title": "Kan uw scherm niet delen",
|
||||||
"ariaLabel": "Kan uw scherm niet delen",
|
"ariaLabel": "Kan uw scherm niet delen",
|
||||||
"message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.",
|
"message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.",
|
||||||
"macInstructions": "Ga naar uw",
|
"settingsInstructions": "Ga naar uw",
|
||||||
"macSystemPreferences": "Systeemvoorkeuren",
|
"settingsLabel": {
|
||||||
|
"macos": "Systeemvoorkeuren",
|
||||||
|
"windows": "Windows-privacyinstellingen"
|
||||||
|
},
|
||||||
"helpLinkText": "Meer informatie, zie",
|
"helpLinkText": "Meer informatie, zie",
|
||||||
"helpLinkLabel": "Presentatieprobleem",
|
"helpLinkLabel": "Presentatieprobleem",
|
||||||
"closeButton": "Negeren",
|
"closeButton": "Negeren",
|
||||||
|
|||||||
@@ -70,11 +70,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"performance": {
|
|
||||||
"heading": "Prestaties",
|
|
||||||
"label": "Prestaties prioriteren",
|
|
||||||
"description": "Vermindert de kwaliteit van je video's om je apparaat te ontlasten. Wordt automatisch geactiveerd bij overbelasting. Schakel het uit om de maximale kwaliteit te herstellen."
|
|
||||||
},
|
|
||||||
"permissionsRequired": "Machtigingen vereist"
|
"permissionsRequired": "Machtigingen vereist"
|
||||||
},
|
},
|
||||||
"transcription": {
|
"transcription": {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
import type { PermissionKind } from './permissions'
|
||||||
|
|
||||||
|
export const deviceInUseStore = proxy<Record<PermissionKind, boolean>>({
|
||||||
|
camera: false,
|
||||||
|
microphone: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
|
||||||
|
|
||||||
|
export const noteDeviceInUse = (kind?: PermissionKind) => {
|
||||||
|
for (const k of kind ? [kind] : ALL_KINDS) {
|
||||||
|
deviceInUseStore[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearDeviceInUse = (kind?: PermissionKind) => {
|
||||||
|
for (const k of kind ? [kind] : ALL_KINDS) {
|
||||||
|
deviceInUseStore[k] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { proxy } from 'valtio'
|
|
||||||
|
|
||||||
export type PerformanceModeTrigger = 'cpu' | 'manual'
|
|
||||||
|
|
||||||
export const performanceModeStore = proxy<{
|
|
||||||
enabled: boolean
|
|
||||||
trigger: PerformanceModeTrigger | null
|
|
||||||
userDeclinedAuto: boolean
|
|
||||||
}>({
|
|
||||||
enabled: false,
|
|
||||||
trigger: null,
|
|
||||||
userDeclinedAuto: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const enablePerformanceMode = (trigger: PerformanceModeTrigger) => {
|
|
||||||
if (performanceModeStore.enabled) return
|
|
||||||
performanceModeStore.enabled = true
|
|
||||||
performanceModeStore.trigger = trigger
|
|
||||||
}
|
|
||||||
|
|
||||||
export const disablePerformanceMode = ({
|
|
||||||
declinedAuto = false,
|
|
||||||
}: { declinedAuto?: boolean } = {}) => {
|
|
||||||
if (declinedAuto) {
|
|
||||||
performanceModeStore.userDeclinedAuto = true
|
|
||||||
}
|
|
||||||
if (!performanceModeStore.enabled) return
|
|
||||||
performanceModeStore.enabled = false
|
|
||||||
performanceModeStore.trigger = null
|
|
||||||
}
|
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.0",
|
"@html-to/text-cli": "0.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.26.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user