Compare commits

..

4 Commits

Author SHA1 Message Date
Florent Chehab 13c75697c7 (summary) add hostname to analytics properties
This helps track down what was the source of events.
This can be usefull when checking perf of different workers for instance.
2026-08-14 14:45:18 +02:00
lebaudantoine 8d000fc6d9 📈(frontend) stop double-reporting media device failures
Only report `Other` `MediaDeviceFailure` cases as they genuinely
need investigation.

Make sure we do not report the same situation both as a media event
and as a media exception when it is already handled.
2026-08-14 14:04:35 +02:00
lebaudantoine b7abd0ae6e 🐛(frontend) generalize screen-share error modal beyond macOS
The screen-share error modal was tailored to macOS and did not work
correctly on other operating systems.

Make it OS-aware so it also handles Windows properly, showing the
right guidance for each platform.

Also open the OS settings link in a new tab, so the user is not
disconnected from the ongoing meeting when following it.
2026-08-14 11:22:40 +02:00
lebaudantoine 40e4f17c65 🐛(frontend) stop reporting screen-share denials as errors
Add a small helper that classifies a `getDisplayMedia` failure as a
user, browser, or OS permission denial, or returns null when it is
a genuine error.

Chromium reports denials with explicit, non-localized messages:

* "Permission denied by user" when the user cancels or dismisses
  the source picker.
* "Permission denied by system" when the OS blocks capture (e.g.
  the macOS Screen Recording privacy setting).
* Plain "Permission denied" for browser-level blocks (site
  settings, enterprise policy, permissions-policy).

Firefox and Safari use generic `NotAllowedError` messages, which
fall into the "browser" bucket.

Firefox additionally does not map macOS Screen Recording (TCC)
blocks to `NotAllowedError`: the OS silently returns no capturable
sources, so `getDisplayMedia` rejects with `NotFoundError` ("The
object can not be found here."). Same quirk as the mic/cam OS blocks
handled in `useWatchMediaDeviceErrors` via `isLikelySystemNotFound`.

Behavior on a denied screen-share permission:

* Denials are expected outcomes (picker cancelled by the user, OS
  privacy settings, enterprise policy…) and no longer surface as
  exceptions in error tracking; capture an analytics event instead.
* Only OS-level blocks get the modal, since it explains how to
  unblock them.
2026-08-14 11:22:40 +02:00
31 changed files with 138 additions and 638 deletions
+4 -4
View File
@@ -8,14 +8,11 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) introduce performance mode with auto-detection and telemetry
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
- ✨(summary) add hostname to analytics properties
### Fixed
@@ -25,6 +22,9 @@ and this project adheres to
- 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(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,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'
| 'device_switch_failure'
| 'permission_poll_failure'
| 'media_devices_error_event'
// non-media families
| 'participant_mute_api_failure'
| 'permissions_api_failure'
@@ -30,7 +31,6 @@ export type LogCode =
| 'clipboard_failure'
| 'fullscreen_failure'
| 'publish_sources_failure'
| 'performance_mode_failure'
| 'disconnect_failure'
| 'generic_failure'
@@ -138,6 +138,7 @@ export const captureMediaEvent = async (
| 'media-device-success'
| 'device-not-found'
| 'permissions-denied'
| 'screen-share-permission-denied'
| 'silent-mic-detected'
| 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered'
@@ -3,7 +3,6 @@ export enum ToastDuration {
MEDIUM = 4000,
LONG = 5000,
EXTRA_LONG = 7000,
UNDO_WINDOW = 30000,
}
export const NotificationDuration = {
@@ -16,5 +15,4 @@ export const NotificationDuration = {
REACTION_RECEIVED: ToastDuration.SHORT,
RECORDING_REQUESTED: ToastDuration.LONG,
ROLE_CHANGED: ToastDuration.LONG,
CPU_CONSTRAINED: ToastDuration.UNDO_WINDOW,
} as const
@@ -18,5 +18,4 @@ export enum NotificationType {
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
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 { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
import { ToastCpuConstrained } from './ToastCpuConstrained'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -75,9 +74,6 @@ const renderToast = (
case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
case NotificationType.CpuConstrained:
return <ToastCpuConstrained key={toast.key} toast={toast} state={state} />
default:
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 = (
participant: Participant,
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',
})}
onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
reportError('livekit_room_error', e, {
path: 'connect_publish',
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
})
}}
onConnected={async () => {
@@ -1,6 +1,13 @@
import { A, Button, Dialog, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
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
export const ScreenShareErrorModal = ({
@@ -11,7 +18,8 @@ export const ScreenShareErrorModal = ({
onClose: () => void
}) => {
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 (
<Dialog
@@ -26,15 +34,16 @@ export const ScreenShareErrorModal = ({
<>
<P>
{t('message')}{' '}
{isMac && (
{settingsHref && (
<>
{t('macInstructions')}{' '}
{t('settingsInstructions')}{' '}
<A
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
href={settingsHref}
target="_blank"
color="primary"
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
>
{t('macSystemPreferences')}
{t(`settingsLabel.${os}`)}
</A>
.{' '}
</>
@@ -9,18 +9,15 @@ import {
} from 'livekit-client'
import { useSnapshot } from 'valtio'
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 = () => {
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
const { enabled: isPerformanceModeEnabled } =
useSnapshot(performanceModeStore)
const room = useRoomContext()
const effectiveQuality = isPerformanceModeEnabled
? VideoQuality.LOW
: (videoSubscribeQuality ?? VideoQuality.HIGH)
useEffect(() => {
if (!room) return
@@ -28,12 +25,18 @@ export const VideoResolutionSubscription = () => {
publication: RemoteTrackPublication,
_participant: RemoteParticipant
) => {
if (effectiveQuality === VideoQuality.HIGH) return
// 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(effectiveQuality)
publication.setVideoQuality(videoSubscribeQuality)
}
}
@@ -41,20 +44,7 @@ export const VideoResolutionSubscription = () => {
return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished)
}
}, [room, effectiveQuality])
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])
}, [room, videoSubscribeQuality])
return null
}
@@ -50,15 +50,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
useEffect(() => {
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
const failure = MediaDeviceFailure.getFailure(error)
if (!failure || !kind) return
void captureMediaEvent('media-device-error', {
log_code: 'media_devices_error_event',
path: 'connect_publish',
failure,
kind,
})
if (!failure) return
if (failure != MediaDeviceFailure.Other) {
void captureMediaEvent('media-device-error', {
log_code: 'media_devices_error_event',
path: 'connect_publish',
failure,
kind: kind ?? 'unknown',
})
}
if (!kind) return
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) {
case MediaDeviceFailure.DeviceInUse:
@@ -11,7 +11,9 @@ import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
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 { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
@@ -27,8 +29,6 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { CpuConstrainedObserver } from '@/features/performance/components/CpuConstrainedObserver'
import { PerformanceModeController } from '@/features/performance/components/PerformanceModeController'
/**
* @public
@@ -38,6 +38,20 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
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.
* 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 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 (
<>
<RoomMetadataSynchronizer />
@@ -72,8 +114,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<MediaStateObserver />
<ChatProvider />
<VideoResolutionSubscription />
<PerformanceModeController />
<CpuConstrainedObserver />
<div
className="lk-video-conference"
{...props}
@@ -96,21 +136,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<StageLayout />
)}
</RoomContentArea>
<ControlBar
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)
}
}}
/>
<ControlBar onDeviceError={handleDeviceError} />
<SidePanel />
</>
)}
@@ -22,11 +22,6 @@ import {
} from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper'
import { useSnapshot } from 'valtio'
import {
disablePerformanceMode,
enablePerformanceMode,
performanceModeStore,
} from '@/stores/performanceMode'
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -37,7 +32,7 @@ const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant } = useRoomContext()
const { localParticipant, remoteParticipants } = useRoomContext()
const {
videoDeviceId,
@@ -46,9 +41,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
videoSubscribeQuality,
} = useSnapshot(userChoicesStore)
const { enabled: isPerformanceModeEnabled } =
useSnapshot(performanceModeStore)
const [videoElement, setVideoElement] = useState<HTMLVideoElement | 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(() => {
let videoTrack: LocalVideoTrack | null = null
@@ -209,13 +217,10 @@ export const VideoTab = ({ id }: VideoTabProps) => {
type="select"
label={t('resolution.publish.label')}
items={resolutionItems}
selectedKey={
isPerformanceModeEnabled ? 'h360' : videoPublishResolution
}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
}}
isDisabled={isPerformanceModeEnabled}
style={{
width: '100%',
}}
@@ -227,43 +232,19 @@ export const VideoTab = ({ id }: VideoTabProps) => {
type="select"
label={t('resolution.subscribe.label')}
items={videoQualityItems}
selectedKey={
isPerformanceModeEnabled
? VideoQuality.LOW.toString()
: videoSubscribeQuality?.toString()
}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
}}
isDisabled={isPerformanceModeEnabled}
style={{
width: '100%',
}}
/>
<></>
</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>
)
}
@@ -18,10 +18,6 @@
"auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.",
"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": {
"description": "{{name}} hat mit {{emoji}} reagiert"
},
+5 -2
View File
@@ -214,8 +214,11 @@
"title": "Bildschirmfreigabe nicht möglich",
"ariaLabel": "Bildschirmfreigabe nicht möglich",
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
"macInstructions": "Gehe zu den",
"macSystemPreferences": "Systemeinstellungen",
"settingsInstructions": "Gehe zu den",
"settingsLabel": {
"macos": "Systemeinstellungen",
"windows": "Windows-Datenschutzeinstellungen"
},
"helpLinkText": "Weitere Informationen findest du unter",
"helpLinkLabel": "Bildschirmfreigabeproblem",
"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"
},
"transcription": {
@@ -18,10 +18,6 @@
"auto": "You have joined a large meeting. Your microphone has been automatically muted.",
"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": {
"description": "{{name}} reacted with {{emoji}}"
},
+5 -2
View File
@@ -214,8 +214,11 @@
"title": "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.",
"macInstructions": "Go to your",
"macSystemPreferences": "System Preferences",
"settingsInstructions": "Go to your",
"settingsLabel": {
"macos": "System Preferences",
"windows": "Windows privacy settings"
},
"helpLinkText": "To learn more, see",
"helpLinkLabel": "Presentation issue",
"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"
},
"transcription": {
@@ -18,10 +18,6 @@
"auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.",
"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": {
"description": "{{name}} a reagi avec {{emoji}}"
},
+5 -2
View File
@@ -214,8 +214,11 @@
"title": "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.",
"macInstructions": "Accèdez à vos",
"macSystemPreferences": "Préférences système",
"settingsInstructions": "Accédez à vos",
"settingsLabel": {
"macos": "Préférences système",
"windows": "paramètres de confidentialité Windows"
},
"helpLinkText": "Pour en savoir plus, consulter",
"helpLinkLabel": "Problème de présentation",
"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. Sactive automatiquement en cas de surcharge. Désactivez-la pour retrouver la qualité maximale."
},
"permissionsRequired": "Autorisations nécessaires"
},
"transcription": {
@@ -18,10 +18,6 @@
"auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.",
"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": {
"description": "{{name}} reageerde met {{emoji}}"
},
+5 -2
View File
@@ -214,8 +214,11 @@
"title": "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.",
"macInstructions": "Ga naar uw",
"macSystemPreferences": "Systeemvoorkeuren",
"settingsInstructions": "Ga naar uw",
"settingsLabel": {
"macos": "Systeemvoorkeuren",
"windows": "Windows-privacyinstellingen"
},
"helpLinkText": "Meer informatie, zie",
"helpLinkLabel": "Presentatieprobleem",
"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"
},
"transcription": {
@@ -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
}
+6
View File
@@ -1,6 +1,7 @@
"""Analytics classes."""
import json
import socket
import time
from collections import Counter
from functools import lru_cache
@@ -43,6 +44,11 @@ class Analytics:
if self.is_disabled:
return
# We add hostname to help track down the source of events
properties = properties or {}
if not properties.get("hostname"):
properties = {**properties, "hostname": socket.gethostname()}
try:
self._client.capture(
event_name, distinct_id=distinct_id, properties=properties