Compare commits

...

34 Commits

Author SHA1 Message Date
lebaudantoine a1ea05222d 🔖(minor) bump release to 1.26.0 2026-08-12 14:55:24 +02:00
lebaudantoine 6c4f0632b8 ️(frontend) fix accessibility issue in the hint paragraph
Refactor the hint paragraph markup and semantics to resolve an
accessibility issue flagged on it, so assistive technologies expose
it correctly to users.
2026-08-12 14:52:09 +02:00
lebaudantoine ff7a1a4f33 🐛(telemetry) tag in-room permission failures with their own path
Since `ToggleDevice` renders on both the join screen and in the
room, `requestDevicePermission` was reporting in-room denials
through the join-preview handler, inflating the `join_preview_failure`
funnel.

Rename `onJoinPreviewError` to `onMediaPermissionError` and thread
a `path` parameter through, derived from `ToggleDevice`'s existing
`context` prop. In-room failures are now reported under a new
`room_media_failure` code, keeping `join_preview_failure` intact
for existing dashboards.
2026-08-12 14:52:09 +02:00
lebaudantoine 7d1ce5f215 🐛(frontend) filter expected user actions from PiP error reporting
The Picture-in-Picture error handler was reporting every error to
PostHog, including the ones triggered when the user intentionally
closes or cancels the PiP window.

Only report unexpected errors, so PostHog no longer receives noise
from normal user interactions.
2026-08-12 14:52:09 +02:00
lebaudantoine 22ab89994b (frontend) add a silent-microphone watcher on join and room screens
Introduce a watcher that listens to the microphone stream and detects
when it stays silent, which is often a sign of an underlying issue:
missing OS permissions, a faulty device, or a hardware lock (e.g. a
physical mute switch).

Wire the watcher on both the join and room screens, so users get a
signal that something is off before it turns into an actual meeting
problem.
2026-08-12 14:52:09 +02:00
lebaudantoine e1a28f315d 🚸(frontend) guide users when the OS blocks browser media access
Introduce a new handling flow for the case where the operating
system itself is blocking browser access to the microphone or
camera, rather than the browser's own permission.

Detect the situation and surface guidance to the user, so they know
they need to allow the browser to access their microphone/camera in
the OS settings.

Only a minority of users are impacted, but the failure mode is very
confusing when it happens. Hopefully this reduces the amount of
support requests around it.
2026-08-12 14:52:09 +02:00
lebaudantoine dffcb83fff 🐛(frontend) display the meeting id in the join screen page title
Fix a minor issue on the join screen: the page title was missing the
meeting id, even though the hook's documentation stated it should be
included.

Align the actual behavior with the documented one so the meeting id
now shows up in the browser tab title.
2026-08-12 14:52:09 +02:00
lebaudantoine c838229ec9 📈(frontend) snapshot media devices on the happy path
Also snapshot the state of media devices when the user successfully
joins a meeting, not only when something goes wrong. This gives us
the baseline needed to compute meaningful ratios — for example, the
share of users who join a meeting without granting permissions, or
without a microphone or camera available.

Without a happy-path measurement, the current error-only data has no
denominator to compare against.
2026-08-12 14:52:09 +02:00
lebaudantoine ab40ec365d ♻️(frontend) prefer captureMediaEvent over reportError when no-op
Switch calls to `reportError` over to `captureMediaEvent` when the
underlying situation is not an engineering issue to investigate but
rather a media-related event worth tracking (e.g. no camera or
microphone available on the user's device).

`reportError` stays reserved for actual errors that warrant an
engineer's attention.
2026-08-12 14:52:09 +02:00
lebaudantoine 199c0297d4 🐛(frontend) handle missing device errors gracefully
Handle the "requested device not found" error surfaced in production
when users arrive without a microphone or camera available on their
computer. Some devices also have a hardware button that physically
locks the microphone and makes it invisible to the browser.

Instead of failing loudly, surface a clearer state to the user so
they can still proceed with whatever device is actually available.
2026-08-12 14:52:09 +02:00
lebaudantoine 089db20a2e ⚗️(frontend) capture console.error in PostHog
Forward `console.error` calls to PostHog on top of the existing
exception capture.

This is experimental: the goal is to gather more information about
buggy situations that do not surface as thrown exceptions today.
May be reverted or filtered depending on the signal-to-noise ratio.
2026-08-12 14:52:09 +02:00
lebaudantoine f0c08bea92 📈(frontend) track media kind on join screen exceptions
When a media exception is raised on the join screen, include the
kind of media involved (microphone or camera) in the tracking event,
so we can tell which device is actually failing without having to
correlate other signals.
2026-08-12 14:52:09 +02:00
lebaudantoine 03e90b6178 🐛(frontend) fix double-counted pageviews in PostHog
Pageviews were being counted twice in PostHog. Refactor the way
pageviews are computed to follow PostHog's documented recommended
pattern.

Verified locally by connecting PostHog to localhost and confirming
that only a single pageview event is emitted per navigation.
2026-08-12 14:52:09 +02:00
lebaudantoine c53a2f8af4 💄(frontend) hide the ProConnect button on narrow viewports
Hide the ProConnect button (only used by the Dinum frontend) when
the device viewport is not wide enough to display it cleanly, so it
does not overflow or break the layout on smaller screens.
2026-08-12 14:52:09 +02:00
lebaudantoine 7461cd28ce 🐛(frontend) only show the effect button when the track is defined
Guard the effect button so it only renders when the track exists.
This prevents the frontend build from failing when TypeScript
rightly flagged the possibility of an undefined track being passed
to the effect logic.
2026-08-12 14:52:09 +02:00
lebaudantoine 5723f29cef (frontend) prompt for permissions when toggling a denied device
When a user clicks the microphone or camera toggle while the
corresponding permission is denied, trigger a permission prompt via
`getUserMedia` instead of silently doing nothing.

This gives users a clear path back to granting access without having
to dig into the browser settings themselves.
2026-08-12 14:52:09 +02:00
lebaudantoine 68a5e84f5d ♻️(frontend) simplify preview track lifecycle and permission prompt
Vendor `usePreviewTracks` from LiveKit. The only reason we kept the
upstream hook was to trigger a single combined permission prompt for
both microphone and camera at once, but it also tied the lifecycle
of the two tracks together, which made preview handling harder than
it needed to be.

Simplify the track lifecycle: instantiate each preview track once,
and drop the dynamic fallback that came with the shared hook.

To still get a single combined prompt, trigger a dedicated
`getUserMedia` call for mic + camera on entry, and release the
resulting tracks as soon as the user answers the prompt.

Known limitation: if the user denies both mic and camera at that
first prompt, the app will prompt again per device type on later
attempts, instead of asking once again for both. Acceptable trade-off
for now.
2026-08-12 14:52:09 +02:00
lebaudantoine b84ee74ee2 ♻️(frontend) reorganize the Join component
Restructure the code inside the Join component to factorize related
pieces and group them more consistently.

This does not change behavior; it just makes the component easier to
read and maintain.
2026-08-12 14:52:09 +02:00
lebaudantoine 0dd2478c3e ♻️(frontend) extract lobby logic into a dedicated component
Extract all the lobby-related logic from the Join component into a
dedicated component.

This makes the Join component easier to maintain and pushes the
lobby state down closer to where it is actually used, avoiding
unnecessary re-renders higher up.
2026-08-12 14:52:09 +02:00
lebaudantoine 8f27b89d21 (frontend) add a sound tester to the output select menu
Add a sound tester next to the selected output device in the speaker
select menu, so users can play a test sound and confirm they picked
the right speaker.

Inspired by the microphone gauge added previously, and requested by
users.
2026-08-12 14:52:09 +02:00
lebaudantoine b780d2845a (frontend) add an audio gauge to the microphone select menu
Add an audio level gauge next to the selected microphone in the mic
select menu, so users can see at a glance whether their microphone
is actually picking up sound.

Inspired by Google Meet's mic picker, and requested by users.
2026-08-12 14:52:09 +02:00
lebaudantoine 751d029ac9 🔧(frontend) sync persisted device ids with the actual selected devices
Now that the exact deviceId constraint has been dropped, the browser
can pick a different device than the one persisted in localStorage
(for example when the persisted device is no longer available).

Sync the persisted ids in localStorage with the device id that was
actually selected on the started track, so the local cache stays
consistent with what the app is really using.
2026-08-12 14:52:09 +02:00
lebaudantoine aaa51a4457 ️(frontend) revert old permission-toggle hotfix
Revert the old hotfix that allowed users to toggle their microphone
or camera while permissions were not granted, which then triggered
a `getUserMedia` call to prompt for them.

Now that the permission store is properly kept in sync with the
browser, this workaround is no longer needed as-is. The intended
behavior will be reimplemented cleanly in a later commit.
2026-08-12 14:52:09 +02:00
lebaudantoine c8a3ef6f61 🐛(frontend) fix permission store regression
`derive-valtio` was broken by a recent update, which cascaded into
various regressions in the permission store.

Take the opportunity to also refactor how permissions are handled.
The store is now a pure cache with a single writer: every signal
re-reads the browser via `syncPermissions()`, and the browser stays
the only source of truth.

Re-sync triggers, all event-driven (no polling):

* `devicechange`: granting permission reveals device labels/ids, so
  it fires on grant in every browser, including Safari. This
  replaces the previous 500ms Safari polling. Denials are still
  caught by the concurrent `getUserMedia` rejection through
  `notePermissionDeniedFromGum`.
* Window focus: covers the return from the browser or system
  permission UI.
* Permissions API `change` events, where the query is supported.
2026-08-12 14:52:09 +02:00
lebaudantoine 5d50671b3c 🔥(frontend) remove buggy device-id resolution code
Remove the current device-id resolution code that was buggy and
failed to resolve the device id correctly.

A replacement will be introduced in upcoming commits.
2026-08-12 14:52:09 +02:00
lebaudantoine 8615bf879c 📈(frontend) capture media diagnostics on media errors
Attach a media diagnostics snapshot to the room event handler for
media exceptions. The snapshot captures the state of the user's
setup at the moment of the error (available devices, permission
state, active tracks, etc.), so support has enough context to
troubleshoot user issues without asking them to reproduce.
2026-08-12 14:52:09 +02:00
lebaudantoine 186d16c46f 🐛(frontend) drop exact deviceId constraint on dynamic track creation
Dynamic track creation used an exact deviceId constraint based on
the device id persisted in localStorage. If that device was no
longer available on reconnect, the browser raised a DOMException
instead of falling back to another device.

Drop the exact constraint so the browser can pick any available
device when the persisted one is gone.
2026-08-12 14:52:09 +02:00
lebaudantoine fb3ee56702 ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
Move the remaining direct `posthog.capture` calls behind the
telemetry module, so PostHog is only referenced from a single place.

Call sites now use the telemetry API instead of touching PostHog
directly, making it easier to swap the backend later without
changing every call site.
2026-08-12 14:52:09 +02:00
lebaudantoine 48c0cb320e ♻️(frontend) encapsulate error tracking behind a telemetry module
Introduce a telemetry module that exposes a `reportError` helper.
Under the hood it forwards errors to PostHog, but the module is the
only place that knows about PostHog.

Replace `console.error` calls used for error reporting with
`reportError`, so the codebase now goes through a single, consistent
API for telemetry.

This normalizes how errors are reported and makes it straightforward
to swap PostHog for another backend later on, without touching every
call site.
2026-08-12 14:52:09 +02:00
lebaudantoine d810c9e0de 🐛(frontend) drop resize listener in useIsMobileBrowser
`isMobileBrowser()` only reads `navigator.userAgent`, which does
not change during the lifetime of the document, so the previous
`resize` listener never had anything meaningful to update.

It did, however, dispatch `setIsMobile` on components rendered into
a Document Picture-in-Picture window (e.g. the reactions toolbar).
When the PiP window had already been closed, Firefox threw
"can't access dead object".

Compute the value once and skip the listener entirely.

Fix 019cb315-d827-73f2-b1cc-74e4dd71e982
2026-08-12 14:52:09 +02:00
lebaudantoine 134d9a188f 🐛(frontend) gate blur on WebGL2 transformer support
`ProcessorWrapper.isSupported` reports pipeline support but not
whether the WebGL2 transformer is available. On browsers where it
is not (e.g. Chrome/Edge on Windows with WebGL2 disabled by a GPU
blocklist), toggling blur throws at runtime.

Update `supportsBackgroundProcessors()` to check both, so the UI
only exposes blur when it can actually run.

fix 019f8e3b-f035-73e2-9d6a-d0dd2d0a1163
2026-08-12 14:52:09 +02:00
lebaudantoine ea7188059d 🐛(frontend) guard getRouteUrl('room', slug) against missing slug
InviteDialog.tsx and Info.tsx were the last call sites calling
getRouteUrl('room', slug) without a slug guard, unlike every other
caller (e.g. useCopyRoomToClipboard).

Compute roomUrl only when the slug exists (undefined in
InviteDialog, '' in Info to keep its unguarded .replace safe).
Guarding at the call site preserves the "no room data yet" state
instead of returning a bogus "/" URL from room.to.

Fix 019fd616-f158-7771-8cff-bac3090b8449
2026-08-12 14:52:09 +02:00
lebaudantoine b8958e6e87 🐛(frontend) unmount PiP portal synchronously on pagehide
When the PiP window closes, the browser destroys its document right
after `pagehide`. If the portal unmount is left to React's async
scheduling, it commits against a dead document and `removeChild`
throws "NotFoundError", crashing the app.

Subscribe `PictureInPicturePortal` to the Valtio store with
`sync: true`, and use `flushSync` in `usePictureInPicture` on
teardown so React unmounts the portal while the PiP document is
still alive.

Fix 019f42cf-86a9-7ad2-8e64-81b004ddc5de
2026-08-12 14:52:09 +02:00
lebaudantoine 23bb3c39d0 🐛(frontend) normalize thrown values into proper Error instances
LiveKit can surface raw DOM events (for example WebSocket "error"
events, whose only enumerable key is `isTrusted`) instead of Error
instances.

When such a value ends up being captured, our error reporting logs
it as "Event: Event captured as exception with keys: isTrusted",
which is unhelpful and hides the real cause.

Add a small helper that normalizes any unknown thrown or emitted
value into a proper Error, preserving the original payload as
context.

Fixes 01997b9a-db63-7fc2-8fe4-f21dd7fd608d.
2026-08-12 14:52:09 +02:00
96 changed files with 3117 additions and 1335 deletions
+28
View File
@@ -8,6 +8,34 @@ and this project adheres to
## [Unreleased]
## [1.26.0] - 2026-08-12
### Added
- 📈(frontend) capture media diagnostics on media errors
- ✨(frontend) add an audio gauge to the microphone select menu
- ✨(frontend) add a sound tester to the output select menu
- ✨(frontend) prompt for permissions when toggling a denied device
- ⚗️(frontend) capture console.error in PostHog
- 📈(frontend) snapshot media devices on the happy path
- 🚸(frontend) guide users when the OS blocks browser media access
- ✨(frontend) add a silent-microphone watcher on join and room screens
### Changed
- ♻️(frontend) encapsulate error tracking behind a telemetry module
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
- 🔧(frontend) sync persisted device ids with the actual selected devices
- 💄(frontend) hide the ProConnect button on narrow viewports
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
### Fixed
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
- 🐛(frontend) fix permission store regression
- 🐛(frontend) handle missing device errors gracefully
- 🐛(frontend) display the meeting id in the join screen page title
## [1.25.2] - 2026-08-06
### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.25.2"
version = "1.26.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.25.2"
version = "1.26.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.25.2"
version = "1.26.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.25.2"
version = "1.26.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -12
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.25.2",
"version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.25.2",
"version": "1.26.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
@@ -22,7 +22,6 @@
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.3.1",
@@ -4710,15 +4709,6 @@
"node": ">= 0.8"
}
},
"node_modules/derive-valtio": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0-rc.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.25.2",
"version": "1.26.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -29,7 +29,6 @@
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.3.1",
+5 -1
View File
@@ -2,6 +2,7 @@ import { Button } from '@/primitives'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { reportError } from '@/features/analytics/telemetry'
export const SoundTester = () => {
const { t } = useTranslation('settings')
@@ -15,7 +16,10 @@ export const SoundTester = () => {
try {
await audioRef?.current?.setSinkId(deviceId)
} catch (error) {
console.error(`Error setting sinkId: ${error}`)
reportError(
'device_switch_failure',
new Error(`Error setting sinkId: ${error}`)
)
}
}
updateActiveId(activeDeviceId)
@@ -1,15 +1,7 @@
import { useEffect } from 'react'
import { useLocation } from 'wouter'
import { type PostHog } from 'posthog-js'
import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser'
let posthog: PostHog | null = null
const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
import { getPosthog } from '../utils'
export const startAnalyticsSession = (data: ApiUser) => {
getPosthog().then((ph) => {
@@ -38,7 +30,6 @@ export const useAnalytics = ({
flags_api_host,
isDisabled,
}: useAnalyticsProps) => {
const [location] = useLocation()
const { user } = useUser()
useEffect(() => {
@@ -49,6 +40,13 @@ export const useAnalytics = ({
api_host: host,
flags_api_host: flags_api_host,
person_profiles: 'always',
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true,
},
})
})
}, [id, host, flags_api_host, isDisabled])
@@ -58,12 +56,5 @@ export const useAnalytics = ({
startAnalyticsSession(user)
}, [user])
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
useEffect(() => {
getPosthog().then((ph) => {
ph.capture('$pageview')
})
}, [location])
return null
}
@@ -0,0 +1,148 @@
import { getPosthog } from './utils'
export const captureEvent = (
event: string,
props?: Record<string, unknown>
) => {
void getPosthog()
.then((ph) => {
ph.capture(event, props)
})
.catch(() => {
/* telemetry must never break the app */
})
if (import.meta.env.DEV) {
console.warn(`[telemetry] ${event}`, props)
}
}
export type LogCode =
// media
| 'join_preview_failure'
| 'room_media_failure'
| 'livekit_room_error'
| 'device_switch_failure'
| 'permission_poll_failure'
// non-media families
| 'participant_mute_api_failure'
| 'permissions_api_failure'
| 'effects_processor_failure'
| 'clipboard_failure'
| 'fullscreen_failure'
| 'publish_sources_failure'
| 'disconnect_failure'
| 'generic_failure'
export const reportError = (
logCode: LogCode,
error: unknown,
extraInfo: Record<string, unknown> = {}
): void => {
const e = error instanceof Error ? error : new Error(String(error))
void getPosthog()
.then((ph) => {
ph.captureException(e, {
log_code: logCode,
error_name: e.name,
error_message: e.message,
...extraInfo,
})
})
.catch(() => {})
if (import.meta.env.DEV) {
console.warn(`[${logCode}]`, e, extraInfo)
}
}
export interface DeviceSnapshot {
cam_count: number
mic_count: number
out_count: number
labels_visible: boolean
saved_cam_present: boolean | null
saved_mic_present: boolean | null
saved_video_device_id_set: boolean
saved_audio_device_id_set: boolean
audio_enabled: boolean | null
video_enabled: boolean | null
cam_permission: PermissionState | 'unknown'
mic_permission: PermissionState | 'unknown'
}
/** Reads the persisted LiveKit user choices without importing the store. */
const readPersistedChoices = (): {
videoDeviceId?: string
audioDeviceId?: string
videoEnabled?: boolean
audioEnabled?: boolean
} => {
try {
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
} catch {
return {}
}
}
const queryPermission = async (
name: 'camera' | 'microphone'
): Promise<PermissionState | 'unknown'> => {
try {
const status = await navigator.permissions.query({
name: name as PermissionName,
})
return status.state
} catch {
return 'unknown'
}
}
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
const choices = readPersistedChoices()
let devices: MediaDeviceInfo[] = []
try {
devices = await navigator.mediaDevices.enumerateDevices()
} catch {
/* snapshot stays partial */
}
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
const present = (k: MediaDeviceKind, id?: string) =>
id ? ofKind(k).some((d) => d.deviceId === id) : null
const [cam_permission, mic_permission] = await Promise.all([
queryPermission('camera'),
queryPermission('microphone'),
])
return {
cam_count: ofKind('videoinput').length,
mic_count: ofKind('audioinput').length,
out_count: ofKind('audiooutput').length,
labels_visible: devices.some((d) => !!d.label),
saved_cam_present: present('videoinput', choices.videoDeviceId),
saved_mic_present: present('audioinput', choices.audioDeviceId),
saved_video_device_id_set: !!choices.videoDeviceId,
saved_audio_device_id_set: !!choices.audioDeviceId,
audio_enabled: choices.audioEnabled ?? null,
video_enabled: choices.videoEnabled ?? null,
cam_permission,
mic_permission,
}
}
export const captureMediaEvent = async (
event:
| 'media-device-error'
| 'media-acquisition'
| 'media-device-topology'
| 'media-device-success'
| 'device-not-found'
| 'permissions-denied'
| 'silent-mic-detected'
| 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered'
| 'visit-room'
| 'connection-event',
props: Record<string, unknown>
) => {
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
}
@@ -0,0 +1,8 @@
import type { PostHog } from 'posthog-js'
let posthog: PostHog | null = null
export const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
@@ -15,6 +15,7 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen'
import { reportError } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -160,7 +161,9 @@ const Home = () => {
window.location.replace(data.external_home_url)
} catch (error) {
setRedirectFailed(true)
console.error('Site is not reachable:', error)
reportError('generic_failure', error, {
context: 'Site is not reachable:',
})
}
}
}
@@ -4,6 +4,7 @@ import { NotificationDuration } from './NotificationDuration'
import type { Participant } from 'livekit-client'
import type { NotificationPayload } from './NotificationPayload'
import type { RecordingMode } from '@/features/recording'
import { reportError } from '@/features/analytics/telemetry'
export const notifyAutoMutedOnJoin = () => {
toastQueue.add(
@@ -55,7 +56,9 @@ export const decodeNotificationDataReceived = (
return parsed as NotificationPayload
} catch (error) {
// Handle errors appropriately for your application
console.error('Failed to decode notification payload:', error)
reportError('generic_failure', error, {
context: 'Failed to decode notification payload:',
})
return
}
}
@@ -1,5 +1,6 @@
import type { Participant } from 'livekit-client'
import { useLowerHandParticipant } from './lowerHandParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant()
@@ -11,7 +12,9 @@ export const useLowerHandParticipants = () => {
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while lowering hands :', error)
reportError('generic_failure', error, {
context: 'An error occurred while lowering hands :',
})
throw new Error('An error occurred while lowering hands.', {
cause: error,
})
@@ -1,6 +1,7 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
import { reportError } from '@/features/analytics/telemetry'
export const useParticipantRole = () => {
const data = useRoomData()
@@ -22,8 +23,11 @@ export const useParticipantRole = () => {
}),
})
} catch (error) {
console.error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
reportError(
'generic_failure',
new Error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
)
}
}
@@ -10,6 +10,7 @@ import {
} from '../../participants/api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry'
export const POLL_INTERVAL_MS = 1000
@@ -87,7 +88,7 @@ export const useWaitingParticipants = () => {
await refetchWaiting()
} catch (e) {
console.error(e)
reportError('generic_failure', e)
setListEnabled(true)
}
}
@@ -7,7 +7,9 @@ import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
sync: true,
})
const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root')
@@ -19,7 +21,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
}
}, [])
if (!container) return null
if (!container || !container.isConnected) return null
return createPortal(
/**
@@ -1,7 +1,9 @@
import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
@@ -59,21 +61,29 @@ export const usePictureInPicture = () => {
if (!IS_PIP_SUPPORTED) return null
if (isOpen) return null
let pipWindow: Window
try {
const pipWindow =
pipWindow =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).documentPictureInPicture.requestWindow({
width,
height,
})
} catch {
// Avoid unhandled rejections if the user blocks or closes the request.
return null
}
try {
initializeTitleAndLanguage(pipWindow, t('title'))
initializePortalContainer(pipWindow)
syncStyles(pipWindow)
const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) {
documentPictureInPictureStore.window = null
flushSync(() => {
documentPictureInPictureStore.window = null
})
}
}
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
@@ -82,8 +92,10 @@ export const usePictureInPicture = () => {
})
documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) {
// Avoid unhandled rejections if the user blocks or closes the request.
console.error('Failed to open Picture-in-Picture window', error)
reportError('generic_failure', error, {
context: 'pip_init_failure',
})
pipWindow.close()
return null
}
},
@@ -16,7 +16,6 @@ import {
notifyRecordingSaveInProgress,
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
@@ -29,6 +28,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { FeatureFlags } from '@/features/analytics/enums'
import { LimitDescription } from './LimitDescription'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -63,7 +63,7 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingRequested,
})
posthog.capture('screen-recording-requested', {})
captureEvent('screen-recording-requested', {})
}
const handleScreenRecording = async () => {
@@ -100,13 +100,15 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
posthog.capture('screen-recording-started', {
captureEvent('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
}
} catch (error) {
console.error('Failed to handle recording:', error)
reportError('generic_failure', error, {
context: 'Failed to handle recording:',
})
}
}
@@ -17,7 +17,6 @@ import {
useNotifyParticipants,
notifyRecordingSaveInProgress,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox.tsx'
@@ -35,6 +34,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitDescription } from './LimitDescription'
import { openSettingsDialog } from '@/stores/settings'
import { captureEvent, reportError } from '@/features/analytics/telemetry'
export const TranscriptSidePanel = () => {
const { data } = useConfig()
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionRequested,
})
posthog.capture('transcript-requested', {})
captureEvent('transcript-requested', {})
}
const handleTranscript = async () => {
@@ -121,13 +121,15 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {
captureEvent('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
reportError('generic_failure', error, {
context: 'Failed to handle transcript:',
})
}
}
@@ -1,5 +1,6 @@
import { useRoomInfo } from '@livekit/components-react'
import { useMemo } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useRoomMetadata = () => {
const { metadata } = useRoomInfo()
@@ -8,7 +9,9 @@ export const useRoomMetadata = () => {
try {
return JSON.parse(metadata)
} catch (error) {
console.error('Failed to parse room metadata:', error)
reportError('generic_failure', error, {
context: 'Failed to parse room metadata:',
})
return undefined
}
} else {
@@ -9,6 +9,7 @@ import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipant = () => {
const apiRoomData = useRoomData()
@@ -31,7 +32,10 @@ export const useMuteParticipant = () => {
// Guard against undefined token for non-admin users
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
console.error('Cannot mute participant: missing auth token')
reportError(
'participant_mute_api_failure',
new Error('Cannot mute participant: missing auth token')
)
return
}
@@ -53,8 +57,11 @@ export const useMuteParticipant = () => {
}
)
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
reportError(
'participant_mute_api_failure',
new Error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
)
return
}
@@ -65,8 +72,11 @@ export const useMuteParticipant = () => {
destinationIdentities: [participant.identity],
})
} catch (e) {
console.error(
`Failed to notify muted participant ${participant.identity}: ${e}`
reportError(
'participant_mute_api_failure',
new Error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
)
}
@@ -1,5 +1,6 @@
import type { Participant } from 'livekit-client'
import { useMuteParticipant } from './muteParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipants = () => {
const { muteParticipant } = useMuteParticipant()
@@ -11,7 +12,9 @@ export const useMuteParticipants = () => {
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while muting participants :', error)
reportError('participant_mute_api_failure', error, {
context: 'An error occurred while muting participants :',
})
throw new Error('An error occurred while muting participants.', {
cause: error,
})
@@ -1,6 +1,7 @@
import type { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
export const useParticipantPermissions = () => {
@@ -32,8 +33,11 @@ export const useParticipantPermissions = () => {
}),
})
} catch (error) {
console.error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
reportError(
'permissions_api_failure',
new Error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
)
}
}
@@ -1,5 +1,6 @@
import type { Participant, Track } from 'livekit-client'
import { useParticipantPermissions } from './updateParticipantPermissions'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
export const useUpdateParticipantsPermissions = () => {
@@ -15,7 +16,9 @@ export const useUpdateParticipantsPermissions = () => {
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while updating permissions :', error)
reportError('permissions_api_failure', error, {
context: 'An error occurred while updating permissions :',
})
throw new Error('An error occurred while updating permissions.', {
cause: error,
})
@@ -25,8 +25,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
@@ -37,6 +36,7 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
export const Conference = ({
roomId,
@@ -47,7 +47,6 @@ export const Conference = ({
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
@@ -57,8 +56,8 @@ export const Conference = ({
const { username } = useSnapshot(userStore)
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
void captureMediaEvent('visit-room', { slug: roomId })
}, [roomId])
const fetchKey = [keys.room, roomId]
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
@@ -170,14 +169,6 @@ export const Conference = ({
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
const isMobile = useIsMobile()
const hasAutoMutedRef = useRef(false)
@@ -235,7 +226,10 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important',
})}
onError={(e) => {
posthog.captureException(e)
reportError('livekit_room_error', e, {
path: 'connect_publish',
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
})
}}
onConnected={async () => {
if (!apiConfig) return
@@ -295,18 +289,10 @@ export const Conference = ({
return
}
}}
onMediaDeviceFailure={(e, kind) => {
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
setMediaDeviceError({ error: e, kind })
}
}}
>
<WatchMediaDeviceErrors />
<VideoConference />
{!isMobile && <InviteDialog mode={mode} />}
<MediaDeviceErrorAlert
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
<PictureInPictureConference />
</LiveKitRoom>
</Screen>
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug)
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
const telephony = useTelephony()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field'
import { Form, Text } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
import { saveUsername, userStore } from '@/stores/user'
import { fetchRoom } from '../api/fetchRoom'
import { ApiAccessLevel } from '../api/ApiRoom'
import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
import { useLobby } from '../hooks/useLobby'
export const Lobby = ({
roomId,
enterRoom,
}: {
roomId: string
enterRoom: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { data: configData } = useConfig()
const { isLoggedIn, user } = useUser()
const { username } = useSnapshot(userStore)
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
// 3. State is manually updated via queryClient when a waiting participant is accepted
// 4. No automatic refetching or revalidation occurs during this period
const {
data: roomData,
error,
isError,
refetch: refetchRoom,
} = useQuery({
queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
retry: false,
enabled: false,
})
useEffect(() => {
if (isError && error?.statusCode == 404) {
// The room component will handle the room creation if the user is authenticated
enterRoom()
}
}, [isError, error, enterRoom])
const handleAccepted = (response: ApiRequestEntry) => {
queryClient.setQueryData([keys.room, roomId], {
...roomData,
livekit: response.livekit,
})
enterRoom()
}
const { status, startWaiting } = useLobby({
roomId,
username: username || user?.full_name || 'anonymous',
onAccepted: handleAccepted,
})
const { openLoginHint } = useLoginHint()
const handleSubmit = async () => {
const { data } = await refetchRoom()
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
if (data?.access_level == ApiAccessLevel.TRUSTED) {
openLoginHint()
}
startWaiting()
return
}
enterRoom()
}
switch (status) {
case ApiLobbyStatus.TIMEOUT:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('timeoutInvite.title')}
</H>
<Text as="p" variant="note">
{t('timeoutInvite.body')}
</Text>
</VStack>
)
case ApiLobbyStatus.DENIED:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('denied.title')}
</H>
<Text as="p" variant="note">
{t('denied.body')}
</Text>
</VStack>
)
case ApiLobbyStatus.WAITING:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('waiting.title')}
</H>
<Text
as="p"
variant="note"
className={css({ marginBottom: '1.5rem' })}
>
{t('waiting.body')}
</Text>
<Spinner />
</VStack>
)
default:
return (
<Form
onSubmit={handleSubmit}
submitLabel={t('joinLabel')}
submitButtonProps={{
fullWidth: true,
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
{(!isLoggedIn ||
configData?.authenticated_users_can_edit_display_name) && (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
id="input-name"
defaultValue={username || user?.full_name}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
</VStack>
</Form>
)
}
}
@@ -1,13 +1,114 @@
import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
import { css } from '@/styled-system/css'
import { Dialog, H } from '@/primitives'
import { Button, Dialog, H, P } from '@/primitives'
import { RiEqualizer2Line } from '@remixicon/react'
import { useEffect, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
import {
closePermissionsDialog,
closeSystemPermissionsDialog,
permissionsStore,
} from '@/stores/permissions'
import { useTranslation } from 'react-i18next'
import { injectIconIntoTranslation } from '@/utils/translation'
import { isSafari } from '@/utils/livekit'
import { type OS, getOS } from '@/utils/os'
type StepsOs = 'macos' | 'windows' | 'android' | 'other'
const STEPS_OS: Record<OS, StepsOs> = {
macos: 'macos',
windows: 'windows',
android: 'android',
linux: 'other',
other: 'other',
}
const getSystemSettingsUrl = (os: OS, label: string): string | null => {
if (os === 'macos') {
if (label === 'camera')
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'
if (label === 'microphone')
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
return 'x-apple.systempreferences:com.apple.preference.security?Privacy'
}
if (os === 'windows') {
if (label === 'camera') return 'ms-settings:privacy-webcam'
if (label === 'microphone') return 'ms-settings:privacy-microphone'
return 'ms-settings:privacy'
}
return null
}
const SystemPermissions = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'systemPermissionDialog' })
const permissions = useSnapshot(permissionsStore)
const os = useMemo(() => getOS() || 'other', [])
const label = useMemo(() => {
if (permissions.microphoneSystemDenied && permissions.cameraSystemDenied) {
return 'cameraAndMicrophone'
}
if (permissions.cameraSystemDenied) return 'camera'
return 'microphone'
}, [permissions])
const isOpen = permissions.isSystemPermissionDialogOpen
// Auto-close once access works again (the user fixed the OS settings).
useEffect(() => {
if (
isOpen &&
!permissions.microphoneSystemDenied &&
!permissions.cameraSystemDenied
) {
closeSystemPermissionsDialog()
}
}, [isOpen, permissions])
const device = t(`device.${label}`)
const settingsUrl = getSystemSettingsUrl(os, label)
return (
<Dialog
isOpen={isOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${label}`)}
onClose={closeSystemPermissionsDialog}
>
<div
className={css({
maxWidth: '500px',
})}
>
<H lvl={1}>{t(`heading.${label}`)}</H>
<P>{t('intro', { device })}</P>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
{Array.from({ length: 2 }, (_, index) => (
<li key={index}>
{t(`steps.${STEPS_OS[os] || 'other'}.${index + 1}`, { device })}
</li>
))}
</ol>
{settingsUrl && (
<div className={css({ marginTop: '2rem' })}>
<Button
variant="primary"
size="sm"
onPress={() => {
window.open(settingsUrl, '_blank')
}}
>
{t('openSettings')}
</Button>
</div>
)}
</div>
</Dialog>
)
}
/**
* Singleton component - ensures permissions sync runs only once across the app.
@@ -65,68 +166,74 @@ export const Permissions = () => {
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
return (
<Dialog
isOpen={permissions.isPermissionDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
})}
onClose={closePermissionsDialog}
>
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
<>
<SystemPermissions />
<Dialog
isOpen={permissions.isPermissionDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
})}
onClose={closePermissionsDialog}
>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
width: '100%',
minHeight: '290px',
maxWidth: '290px',
})}
/>
<div
className={css({
maxWidth: '400px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
})}
>
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
appTitle,
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
width: '100%',
minHeight: '290px',
maxWidth: '290px',
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{isSafari() ? (
t('body.openMenu.safari', {
appDomain: window.origin.replace('https://', ''),
})
) : (
<>
{descriptionBeforeIcon}
<span
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
<RiEqualizer2Line />
</span>
{descriptionAfterIcon}
</>
)}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
/>
<div
className={css({
maxWidth: '400px',
})}
>
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
appTitle,
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{isSafari() ? (
t('body.openMenu.safari', {
appDomain: window.origin.replace('https://', ''),
})
) : (
<>
{descriptionBeforeIcon}
<span
style={{
display: 'inline-block',
verticalAlign: 'middle',
}}
>
<RiEqualizer2Line />
</span>
{descriptionAfterIcon}
</>
)}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
</div>
</div>
</div>
</Dialog>
</Dialog>
</>
)
}
@@ -1,13 +1,12 @@
import { Button, H, Input, Text, TextArea } from '@/primitives'
import { Button, H, Text, TextArea } from '@/primitives'
import { useEffect, useMemo, useState } from 'react'
import { cva } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import type { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { CandidateInfo } from '@/stores/connectionObserver'
import { captureEvent } from '@/features/analytics/telemetry'
const Card = styled('div', {
base: {
@@ -72,11 +71,9 @@ const labelRecipe = cva({
})
const OpenFeedback = ({
posthog,
onNext,
metadata,
}: {
posthog: PostHog
onNext: () => void
metadata?: Record<string, unknown>
}) => {
@@ -90,7 +87,7 @@ const OpenFeedback = ({
const onSubmit = () => {
try {
posthog.capture('open-feedback', {
captureEvent('open-feedback', {
feedback,
...metadata,
})
@@ -141,12 +138,10 @@ const OpenFeedback = ({
}
const RateQuality = ({
posthog,
onNext,
metadata,
maxRating = 5,
}: {
posthog: PostHog
onNext: () => void
metadata?: Record<string, unknown>
maxRating?: number
@@ -160,7 +155,7 @@ const RateQuality = ({
const onSubmit = () => {
try {
posthog.capture('quality-rating', {
captureEvent('quality-rating', {
rating: selectedRating,
...metadata,
})
@@ -243,67 +238,6 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
)
}
const AuthenticationMessage = ({
onNext,
posthog,
}: {
onNext: () => void
posthog: PostHog
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'authenticationMessage' })
const [email, setEmail] = useState('')
const onSubmit = () => {
posthog.people.set({ unsafe_email: email })
onNext()
}
return (
<Card
style={{
maxWidth: '380px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<H lvl={3}>{t('heading')}</H>
<Input
id="emailInput"
name="email"
placeholder={t('placeholder')}
required
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
marginBottom: '1rem',
}}
/>
<VStack gap="0.5">
<Button
variant="primary"
size="sm"
fullWidth
isDisabled={!email}
onPress={onSubmit}
>
{t('submit')}
</Button>
<Button
invisible
variant="secondary"
size="sm"
fullWidth
onPress={onNext}
>
{t('ignore')}
</Button>
</VStack>
</Card>
)
}
type RatingMetadata = {
room_id?: string
pc_publisher?: CandidateInfo
@@ -318,12 +252,6 @@ export const Rating = ({
metadata: RatingMetadata
}) => {
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const posthog = usePostHog()
const isUserAnonymous = useMemo(() => {
return posthog.get_property('$user_state') == 'anonymous'
}, [posthog])
const [step, setStep] = useState(0)
const sessionId = useMemo(() => crypto.randomUUID(), [])
@@ -339,37 +267,14 @@ export const Rating = ({
if (!isAnalyticsEnabled) return
if (step == 0) {
return (
<RateQuality
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
}
if (step == 1) {
return (
<OpenFeedback
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
}
if (step == 2) {
return isUserAnonymous ? (
<AuthenticationMessage
posthog={posthog}
onNext={() => setStep(step + 1)}
/>
) : (
<ConfirmationMessage onNext={() => setStep(0)} />
)
}
if (step == 3) {
return <ConfirmationMessage onNext={() => setStep(0)} />
}
}
@@ -0,0 +1,123 @@
import { useEffect, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { createAudioAnalyser, LocalAudioTrack } from 'livekit-client'
import { useLocalParticipant } from '@livekit/components-react'
import { reportMicSample, silentMicStore } from '@/stores/silentMic'
import { captureMediaEvent } from '@/features/analytics/telemetry'
import { useIsTrackMuted } from '../livekit/hooks/useIsTrackMuted'
// A live microphone always has a noise floor; only a signal pinned to
// zero counts as silent (no audio data flowing at all).
const SILENT_VOLUME_EPSILON = 0.0001
const TICK_MS = 1_000
type SilentMicContext = 'join' | 'room'
const ActiveDetector = ({
track,
context,
}: {
track: LocalAudioTrack
context: SilentMicContext
}) => {
const isMuted = useIsTrackMuted(track)
// The interval reads through refs so state updates never re-arm the
// timer or the analyser.
const mutedRef = useRef(isMuted)
mutedRef.current = isMuted
const contextRef = useRef(context)
contextRef.current = context
useEffect(() => {
let audioAnalyser: ReturnType<typeof createAudioAnalyser>
try {
audioAnalyser = createAudioAnalyser(track, {
fftSize: 256,
smoothingTimeConstant: 0.7,
})
} catch {
void captureMediaEvent('silent-mic-analyser-unavailable', {
context: contextRef.current,
})
return
}
const { analyser, calculateVolume, cleanup } = audioAnalyser
const tick = () => {
// Zero volume is only evidence of silence when audio data is
// actually flowing. Skip the sample when:
// - the mic is intentionally muted;
// - the tab is backgrounded (suspended AudioContext reads as zero);
// - the AudioContext is not running yet — Chrome keeps it
// 'suspended' until a user gesture on pages loaded without
// activation, and a suspended analyser reports zeros for a
// perfectly healthy microphone.
if (
mutedRef.current ||
document.visibilityState !== 'visible' ||
analyser.context.state !== 'running'
) {
return
}
const result = reportMicSample({
trackId: track.mediaStreamTrack?.id,
silent: calculateVolume() <= SILENT_VOLUME_EPSILON,
deltaMs: TICK_MS,
})
if (result === 'silent-detected') {
void captureMediaEvent('silent-mic-detected', {
context: contextRef.current,
media_stream_track_muted: track.mediaStreamTrack?.muted ?? null,
})
} else if (result === 'recovered') {
void captureMediaEvent('silent-mic-recovered', {
context: contextRef.current,
})
}
}
const interval = window.setInterval(tick, TICK_MS)
return () => {
window.clearInterval(interval)
void cleanup()
}
}, [track])
return null
}
/**
* One-shot silent-mic check (see stores/silentMic.ts). Renders nothing;
* mounts the volume watcher only while the check is still undecided so
* the analyser goes away as soon as the outcome is known.
*/
export const SilentMicDetector = ({
track,
context,
}: {
track?: LocalAudioTrack
context: SilentMicContext
}) => {
const { status } = useSnapshot(silentMicStore)
if ((status !== 'watching' && status !== 'silent') || !track) {
return null
}
return (
<ActiveDetector
key={track.mediaStreamTrack?.id}
track={track}
context={context}
/>
)
}
/** Room-side variant: watches the published local microphone track. */
export const RoomSilentMicDetector = () => {
const { microphoneTrack } = useLocalParticipant()
const track =
microphoneTrack?.track instanceof LocalAudioTrack
? microphoneTrack.track
: undefined
return <SilentMicDetector track={track} context="room" />
}
@@ -0,0 +1,64 @@
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { Button, Dialog, H, P } from '@/primitives'
import {
closeSilentMicDialog,
discardSilentMicDetection,
silentMicStore,
} from '@/stores/silentMic'
/**
* Opened from the "!" badge on the microphone toggle when the silent-mic
* check tripped (see stores/silentMic.ts). Explains the likely causes
* and lets the user opt out of the detection for good.
*/
export const SilentMicDialog = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'silentMic.dialog' })
const { isDialogOpen } = useSnapshot(silentMicStore)
return (
<Dialog
isOpen={isDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t('title')}
onClose={closeSilentMicDialog}
>
<div
className={css({
maxWidth: '500px',
})}
>
<H lvl={1}>{t('title')}</H>
<P>{t('intro')}</P>
<ul className={css({ listStyle: 'disc', paddingLeft: '24px' })}>
<li>{t('causes.system')}</li>
<li>{t('causes.hardware')}</li>
<li>{t('causes.wrongDevice')}</li>
</ul>
<P>{t('hint')}</P>
<div
className={css({
marginTop: '1.5rem',
display: 'flex',
gap: '1rem',
flexWrap: 'wrap',
})}
>
<Button variant="primary" size="sm" onPress={closeSilentMicDialog}>
{t('close')}
</Button>
<Button
variant="tertiary"
size="sm"
onPress={discardSilentMicDetection}
>
{t('discard')}
</Button>
</div>
</div>
</Dialog>
)
}
@@ -0,0 +1,11 @@
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { useWatchMediaDeviceErrors } from '../livekit/hooks/useWatchMediaDeviceErrors'
/**
* Single place responsible for the room's media device errors — mounts the
* watcher and renders the resulting user-facing alert.
*/
export const WatchMediaDeviceErrors = () => {
const { error, kind, clear } = useWatchMediaDeviceErrors()
return <MediaDeviceErrorAlert error={error} kind={kind} onClose={clear} />
}
@@ -0,0 +1,19 @@
import { useEffect } from 'react'
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
export function useWatchDeviceAvailability() {
useEffect(() => {
if (!navigator.mediaDevices) return
syncDeviceAvailability()
navigator.mediaDevices.addEventListener(
'devicechange',
syncDeviceAvailability
)
return () => {
navigator.mediaDevices.removeEventListener(
'devicechange',
syncDeviceAvailability
)
}
}, [])
}
@@ -1,160 +1,36 @@
import { useEffect } from 'react'
import { permissionsStore } from '@/stores/permissions'
import { isSafari } from '@/utils/livekit'
const POLLING_TIME = 500
import { syncPermissions } from '@/stores/permissions'
export const useWatchPermissions = () => {
useEffect(() => {
let cleanup: (() => void) | undefined
let intervalId: ReturnType<typeof setTimeout> | undefined
let isCancelled = false
const sync = () => void syncPermissions()
sync()
const checkPermissions = async () => {
try {
if (!navigator.permissions) {
if (!isCancelled) {
permissionsStore.cameraPermission = 'unavailable'
permissionsStore.microphonePermission = 'unavailable'
}
return
}
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
window.addEventListener('focus', sync)
const [cameraPermission, microphonePermission] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
/**
* Safari Permission API Limitation Workaround
*
* Safari has a known issue where permission change events are not reliably fired
* when users interact with permission prompts. This is documented in Apple's forums:
* https://developer.apple.com/forums/thread/757353
*
* The problem:
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
* - This leaves the UI in an inconsistent state showing outdated permission status
*
* The solution:
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
* - Continue polling until both permissions are no longer in 'prompt' state
* - This ensures we catch permission changes even when Safari fails to fire events
*
* This polling is Safari-specific and only activates when needed to minimize performance impact.
*/
if (
isSafari() &&
(cameraPermission.state === 'prompt' ||
microphonePermission.state === 'prompt')
) {
// Start polling every 1 second if either permission is in 'prompt' state
if (!intervalId) {
intervalId = setInterval(async () => {
try {
const [updatedCamera, updatedMicrophone] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
const cameraChanged =
permissionsStore.cameraPermission !== updatedCamera.state
const microphoneChanged =
permissionsStore.microphonePermission !==
updatedMicrophone.state
if (cameraChanged) {
permissionsStore.cameraPermission = updatedCamera.state
}
if (microphoneChanged) {
permissionsStore.microphonePermission =
updatedMicrophone.state
}
if (
updatedCamera.state !== 'prompt' &&
updatedMicrophone.state !== 'prompt'
) {
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
console.error('Error polling permissions:', error)
}
}
}, POLLING_TIME)
}
}
permissionsStore.cameraPermission = cameraPermission.state
permissionsStore.microphonePermission = microphonePermission.state
const handleCameraChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.cameraPermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
const handleMicrophoneChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.microphonePermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
cameraPermission.addEventListener('change', handleCameraChange)
microphonePermission.addEventListener('change', handleMicrophoneChange)
cleanup = () => {
cameraPermission.removeEventListener('change', handleCameraChange)
microphonePermission.removeEventListener(
'change',
handleMicrophoneChange
)
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
console.error('Error checking permissions:', error)
}
} finally {
if (!isCancelled) {
permissionsStore.isLoading = false
}
}
let statuses: PermissionStatus[] = []
let cancelled = false
if (navigator.permissions) {
Promise.all([
navigator.permissions.query({ name: 'camera' as PermissionName }),
navigator.permissions.query({ name: 'microphone' as PermissionName }),
])
.then((results) => {
if (cancelled) return
statuses = results
statuses.forEach((s) => s.addEventListener('change', sync))
})
.catch(() => {
// Query unsupported: devicechange/focus + gUM outcomes cover it.
})
}
checkPermissions()
return () => {
isCancelled = true
cleanup?.()
cancelled = true
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
window.removeEventListener('focus', sync)
statuses.forEach((s) => s.removeEventListener('change', sync))
}
}, [])
}
@@ -13,6 +13,7 @@ import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { useEffect } from 'react'
import { closeSidePanel } from '@/stores/layout'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { reportError } from '@/features/analytics/telemetry'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -205,7 +206,7 @@ export const Admin = () => {
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
}).catch((e) => console.error(e))
}).catch((e) => reportError('generic_failure', e))
}
items={[
{
@@ -11,10 +11,10 @@ import { DisconnectReason, RoomEvent } from 'livekit-client'
import { userPreferencesStore } from '@/stores/userPreferences'
import { connectionObserverStore } from '@/stores/connectionObserver'
import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { isMobileBrowser } from '@livekit/components-core'
import { FeatureFlags } from '@/features/analytics/enums'
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
const CANDIDATE_POLL_INTERVAL_MS = 5000
@@ -182,23 +182,23 @@ export const ConnectionObserver = () => {
// total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now()
posthog.capture('connection-event')
void captureMediaEvent('connection-event', {})
}
const handleReconnect = () => {
posthog.capture('reconnect-event')
captureEvent('reconnect-event')
}
const handleReconnected = () => {
posthog.capture('reconnected-event')
captureEvent('reconnected-event')
}
const handleSignalingConnect = () => {
posthog.capture('signaling-connect-event')
captureEvent('signaling-connect-event')
}
const handleSignalingReconnect = () => {
posthog.capture('signaling-reconnect-event')
captureEvent('signaling-reconnect-event')
}
const handleDisconnect = (
@@ -206,7 +206,7 @@ export const ConnectionObserver = () => {
) => {
const connectionEndTime = Date.now()
posthog.capture('disconnect-event', {
captureEvent('disconnect-event', {
// Calculate total session duration from first connection to final disconnect
// This duration is sensitive to refreshing the page.
sessionDuration: connectionStartTimeRef.current
@@ -14,7 +14,7 @@ export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
const roomUrl = data?.slug ? getRouteUrl('room', data.slug) : ''
const telephony = useTelephony()
@@ -0,0 +1,20 @@
import { useLocalParticipant } from '@livekit/components-react'
import type { LocalTrack } from 'livekit-client'
import { useSyncTrackDeviceId } from '../hooks/useSyncTrackDeviceId'
import {
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} from '@/stores/userChoices'
export const SyncDevicePreferences = () => {
const { cameraTrack, microphoneTrack } = useLocalParticipant()
useSyncTrackDeviceId(
cameraTrack?.track as LocalTrack | undefined,
saveVideoInputDeviceId
)
useSyncTrackDeviceId(
microphoneTrack?.track as LocalTrack | undefined,
saveAudioInputDeviceId
)
return null
}
@@ -1,5 +1,4 @@
import type { ProcessorOptions, Track } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
ImageSegmenter,
@@ -18,6 +17,7 @@ import {
type ProcessorType,
MEDIAPIPE_PATH_WASM,
} from '.'
import { captureEvent } from '@/features/analytics/telemetry.ts'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -100,7 +100,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.initSegmenter()
this._initWorker()
posthog.capture('firefox-blurring-init')
captureEvent('firefox-blurring-init', {})
}
_initVirtualBackgroundImage() {
@@ -1,5 +1,4 @@
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
FaceLandmarker,
@@ -16,6 +15,7 @@ import {
ProcessorType,
MEDIAPIPE_PATH_WASM,
} from '.'
import { captureEvent } from '@/features/analytics/telemetry'
const PROCESSING_WIDTH = 256 * 3
const PROCESSING_HEIGHT = 144 * 3
@@ -101,7 +101,7 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
await this.initFaceLandmarker()
this._initWorker()
posthog.capture('face-landmarks-init')
captureEvent('face-landmarks-init', {})
}
_initWorker() {
@@ -1,4 +1,7 @@
import { ProcessorWrapper } from '@livekit/track-processors'
import {
ProcessorWrapper,
supportsBackgroundProcessors,
} from '@livekit/track-processors'
import type { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
@@ -34,7 +37,9 @@ export class BackgroundProcessorFactory {
}
static isSupported() {
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
return (
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
)
}
static getProcessor(
@@ -45,7 +50,7 @@ export class BackgroundProcessorFactory {
if (!isBlur && !isVirtual) return undefined
if (ProcessorWrapper.isSupported) {
if (supportsBackgroundProcessors()) {
return new UnifiedBackgroundTrackProcessor(config)
}
@@ -4,6 +4,7 @@ import { RiCameraSwitchLine } from '@remixicon/react'
import { useEffect, useState } from 'react'
import type { ButtonProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { reportError } from '@/features/analytics/telemetry'
enum FacingMode {
USER = 'user',
@@ -103,7 +104,11 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
setActiveMediaDevice(device.deviceId)
setFacingMode(target)
} else {
console.error('Cannot get user device with facingMode ' + target)
reportError(
'device_switch_failure',
new Error('Cannot get user device with facingMode ' + target),
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
)
}
}
return (
@@ -1,8 +1,12 @@
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import {
useLocalParticipant,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import { Track } from 'livekit-client'
import { LocalAudioTrack, Track } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
@@ -51,6 +55,12 @@ export const AudioDevicesControl = ({
...props,
})
const { microphoneTrack } = useLocalParticipant()
const localAudioTrack =
microphoneTrack?.track instanceof LocalAudioTrack
? microphoneTrack.track
: undefined
const kind = 'audioinput'
const cannotUseDevice = useCannotUseDevice(kind)
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
@@ -111,6 +121,7 @@ export const AudioDevicesControl = ({
context="room"
kind={kind}
id={audioDeviceId}
track={localAudioTrack}
onSubmit={saveAudioInputDeviceId}
/>
</div>
@@ -0,0 +1,132 @@
import { LocalAudioTrack } from 'livekit-client'
import { useTrackVolume } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Text } from '@/primitives'
import { useIsTrackMuted } from '../../../hooks/useIsTrackMuted'
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 0.25rem',
marginTop: '0.5rem',
borderTop: '1px solid',
minHeight: '2.5rem',
},
variants: {
theme: {
light: {
borderColor: 'gray.200',
color: 'greyscale.600',
},
dark: {
borderColor: 'primaryDark.300',
color: 'rgba(255 255 255 / 0.7)',
},
},
},
})
const StyledGaugeContainer = styled('div', {
base: {
flexGrow: 1,
height: '0.375rem',
borderRadius: '0.1875rem',
overflow: 'hidden',
},
variants: {
theme: {
light: {
backgroundColor: 'greyscale.250',
},
dark: {
backgroundColor: 'rgba(255 255 255 / 0.25)',
},
},
},
})
const StyledGauge = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: 'inherit',
transformOrigin: 'left center',
transform: 'scaleX(0)',
transition: 'transform 0.06s linear',
},
variants: {
theme: {
light: {
backgroundColor: 'primary.500',
},
dark: {
backgroundColor: 'primaryDark.800',
},
},
},
})
type Theme = 'light' | 'dark'
type AudioLevelGaugeProps = {
track?: LocalAudioTrack
variant?: Theme
}
const LevelBar = ({
track,
theme,
}: {
track: LocalAudioTrack
theme: Theme
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const volume = useTrackVolume(track, {
fftSize: 256,
smoothingTimeConstant: 0.7,
})
const level = Math.min(1, volume)
return (
<>
<RiMicLine size={18} aria-hidden="true" />
<StyledGaugeContainer
theme={theme}
role="img"
aria-label={t('audioinput.level')}
>
<StyledGauge theme={theme} style={{ transform: `scaleX(${level})` }} />
</StyledGaugeContainer>
</>
)
}
export const AudioLevelGauge = ({
track,
variant = 'light',
}: AudioLevelGaugeProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const isMuted = useIsTrackMuted(track)
const showMutedHint = !track || isMuted
return (
<StyledContainer theme={variant}>
{showMutedHint ? (
<>
<RiMicOffLine size={18} aria-hidden="true" />
<Text variant="bodyXsMedium">{t('audioinput.muteTest')}</Text>
</>
) : (
<LevelBar
key={track.mediaStreamTrack?.id}
track={track}
theme={variant}
/>
)}
</StyledContainer>
)
}
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiVolumeUpLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
// no track: the test plays a bundled file through the selected sink, and
// following `sinkId` mid-playback re-routes it live. No permission involved.
type Theme = 'light' | 'dark'
const BUTTON_VARIANT = {
light: 'quaternaryText',
dark: 'primaryTextDark',
} as const
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
paddingTop: '0.5rem',
marginTop: '0.5rem',
borderTop: '1px solid',
},
variants: {
theme: {
light: {
borderColor: 'gray.200',
},
dark: {
borderColor: 'primaryDark.300',
},
},
},
})
const StyledButtonContent = styled('span', {
base: {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 'full',
paddingX: '1.625rem',
'& > svg': {
position: 'absolute',
left: 0,
},
},
})
type OutputSoundTesterProps = {
/** The device the test should play through (the select's current key). */
sinkId?: string
variant?: Theme
}
export const OutputSoundTester = ({
sinkId,
variant = 'light',
}: OutputSoundTesterProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const latestSinkIdRef = useRef(sinkId)
latestSinkIdRef.current = sinkId
const stopPlayback = useCallback(() => {
const audio = audioRef.current
if (audio) {
audio.pause()
audio.currentTime = 0
}
setIsPlaying(false)
}, [])
useEffect(() => {
if (!sinkId || !canTestAudioOutput()) return
audioRef.current?.setSinkId(sinkId).catch(() => {
// Re-routing failed (stale or unplugged device): stop the test rather
// than keep playing through the previous sink.
if (latestSinkIdRef.current === sinkId) {
stopPlayback()
}
})
}, [sinkId, stopPlayback])
useEffect(() => {
const audio = audioRef.current
return () => audio?.pause()
}, [])
return (
<StyledContainer theme={variant}>
<Button
variant={BUTTON_VARIANT[variant]}
size="sm"
fullWidth
isDisabled={isPlaying}
onPress={async () => {
const audio = audioRef.current
if (!audio) return
try {
// Confirm routing before starting: a no-op when already routed,
// but rejects on a stale device id, so the test never plays
// through the wrong sink.
if (sinkId && canTestAudioOutput()) {
await audio.setSinkId(sinkId)
}
await audio.play()
setIsPlaying(true)
} catch {
stopPlayback()
}
}}
>
<StyledButtonContent>
<RiVolumeUpLine size={18} aria-hidden />
{isPlaying ? t('audiooutput.testing') : t('audiooutput.test')}
</StyledButtonContent>
</Button>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src="sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)}
/>
</StyledContainer>
)
}
@@ -4,8 +4,17 @@ import { openPermissionsDialog } from '@/stores/permissions'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
export const PermissionNeededButton = () => {
type PermissionNeededButtonProps = {
tooltip?: string
onPress?: () => void
}
export const PermissionNeededButton = ({
tooltip,
onPress,
}: PermissionNeededButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
const label = tooltip ?? t('tooltip')
return (
<div
className={css({
@@ -17,9 +26,9 @@ export const PermissionNeededButton = () => {
})}
>
<Button
aria-label={t('ariaLabel')}
tooltip={t('tooltip')}
onPress={() => openPermissionsDialog()}
aria-label={tooltip ? label : t('ariaLabel')}
tooltip={label}
onPress={onPress ?? (() => openPermissionsDialog())}
variant="permission"
>
<div
@@ -4,7 +4,12 @@ import { useEffect, useMemo } from 'react'
import { Select, SelectProps } from '@/primitives/Select'
import type { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
import type { LocalAudioTrack } from 'livekit-client'
import { AudioLevelGauge } from './AudioLevelGauge'
import { OutputSoundTester } from './OutputSoundTester'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
type DeviceItems = Array<{ value: string; label: string }>
@@ -18,6 +23,7 @@ type SelectDeviceProps = {
onSubmit?: (id: string) => void
kind: MediaDeviceKind
context?: 'join' | 'room'
track?: LocalAudioTrack
}
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
@@ -28,6 +34,7 @@ const SelectDevicePermissions = <T extends string | number>({
kind,
onSubmit,
iconComponent,
track,
...props
}: SelectDevicePermissionsProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -74,6 +81,16 @@ const SelectDevicePermissions = <T extends string | number>({
await setActiveMediaDevice(key as string)
onSubmit?.(key as string)
}}
menuFooter={
kind === 'audioinput' ? (
<AudioLevelGauge track={track} variant={props.variant} />
) : kind === 'audiooutput' && canTestAudioOutput() ? (
<OutputSoundTester
sinkId={selectedKey as string}
variant={props.variant}
/>
) : undefined
}
{...props}
/>
)
@@ -84,6 +101,7 @@ export const SelectDevice = ({
onSubmit,
kind,
context = 'join',
track,
}: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -96,6 +114,25 @@ export const SelectDevice = ({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
if (deviceMissing) {
return (
<Select
aria-label={t(`NotFound.title.${kind}`, {
keyPrefix: 'mediaErrorDialog',
})}
label=""
isDisabled={true}
items={[]}
placeholder={t(`NotFound.title.${kind}`, {
keyPrefix: 'mediaErrorDialog',
})}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
if (cannotUseDevice) {
return (
@@ -116,6 +153,7 @@ export const SelectDevice = ({
id={id}
onSubmit={onSubmit}
kind={kind}
track={track}
iconComponent={deviceIcons.select}
{...contextProps}
/>
@@ -1,7 +1,7 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useMemo, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
@@ -12,10 +12,16 @@ import {
useMaybeRoomContext,
useRoomContext,
} from '@livekit/components-react'
import { MediaDeviceFailure } from 'livekit-client'
import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceErrorAlert'
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
import { useSnapshot } from 'valtio'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type {
@@ -90,9 +96,45 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
const { status: silentMicStatus } = useSnapshot(silentMicStore)
const silentMicWarning =
kind === 'audioinput' &&
silentMicStatus === 'silent' &&
!cannotUseDevice &&
!deviceMissing
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false)
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
const onPress = async () => {
if (!enabled && deviceMissing) {
setShowDeviceNotFound(true)
return
}
if (!cannotUseDevice) {
toggle()
return
}
if (isRequestingPermission.current) return
isRequestingPermission.current = true
try {
const granted = await requestDevicePermission(
kind,
context === 'join' ? 'join_preview' : 'room'
)
if (granted) {
toggle()
} else {
openPermissionsDialog(kind)
}
} finally {
isRequestingPermission.current = false
}
}
useRegisterKeyboardShortcut({
id: deviceShortcut?.id,
handler: async () => {
@@ -139,7 +181,20 @@ export const ToggleDevice = <T extends ToggleSource>({
return (
<div style={{ position: 'relative' }}>
{cannotUseDevice && <PermissionNeededButton />}
{(cannotUseDevice || deviceMissing) && (
<PermissionNeededButton
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
onPress={
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
}
/>
)}
{silentMicWarning && (
<PermissionNeededButton
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
onPress={openSilentMicDialog}
/>
)}
<ToggleButton
isSelected={!enabled}
isDisabled={isDisabled}
@@ -147,23 +202,25 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
}
shySelected
onPress={() => {
if (cannotUseDevice) {
openPermissionsDialog(kind)
}
toggle()
}}
onPress={onPress}
aria-label={toggleLabel}
tooltip={
cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
deviceMissing
? t(`deviceNotFound.${kind}`)
: cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps}
{...overrideToggleButtonProps}
>
<Icon />
</ToggleButton>
<MediaDeviceErrorAlert
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
kind={kind}
onClose={() => setShowDeviceNotFound(false)}
/>
</div>
)
}
@@ -3,6 +3,7 @@ import { Button } from '@/primitives'
import { RiPhoneFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ConnectionState } from 'livekit-client'
import { reportError } from '@/features/analytics/telemetry'
export const LeaveButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
@@ -15,11 +16,11 @@ export const LeaveButton = () => {
tooltip={t('leave')}
aria-label={t('leave')}
onPress={() => {
room
.disconnect(true)
.catch((e) =>
console.error('An error occurred while disconnecting:', e)
)
room.disconnect(true).catch((e) =>
reportError('disconnect_failure', e, {
context: 'An error occurred while disconnecting:',
})
)
}}
data-attr="controls-leave"
>
@@ -33,6 +33,7 @@ import { useConfig } from '@/api/useConfig.ts'
import { proxy, useSnapshot } from 'valtio'
import { Spinner } from '@/primitives/Spinner.tsx'
import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices'
import { reportError } from '@/features/analytics/telemetry'
enum BlurRadius {
NONE = 0,
@@ -238,7 +239,9 @@ export const EffectsConfiguration = ({
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
reportError('effects_processor_failure', error, {
context: 'Error applying effect:',
})
} finally {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
@@ -5,6 +5,7 @@ import { RiGlassesLine, RiGoblet2Fill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
import type { LocalVideoTrack } from 'livekit-client'
import { reportError } from '@/features/analytics/telemetry'
export type FunnyEffectsProps = {
videoTrack: LocalVideoTrack
@@ -55,7 +56,9 @@ export const FunnyEffects = ({
await videoTrack.setProcessor(newProcessor)
}
} catch (e) {
console.error('could not update processor', e)
reportError('effects_processor_failure', e, {
context: 'could not update processor',
})
} finally {
onPending(false)
}
@@ -9,6 +9,8 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
microphoneSystemDenied,
cameraSystemDenied,
} = useSnapshot(permissionsStore)
return useMemo(() => {
@@ -17,9 +19,11 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
switch (kind) {
case 'audioinput':
case 'audiooutput': // audiooutput uses microphone permissions
return isMicrophoneDenied || isMicrophonePrompted
return (
isMicrophoneDenied || isMicrophonePrompted || microphoneSystemDenied
)
case 'videoinput':
return isCameraDenied || isCameraPrompted
return isCameraDenied || isCameraPrompted || cameraSystemDenied
default:
return false
@@ -31,5 +35,7 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
microphoneSystemDenied,
cameraSystemDenied,
])
}
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { reportError } from '@/features/analytics/telemetry'
const COPY_SUCCESS_TIMEOUT = 3000
@@ -58,7 +59,9 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
await navigator.clipboard.writeText(content)
setIsCopied(true)
} catch (error) {
console.error(error)
reportError('clipboard_failure', error, {
context: 'copy_room_content',
})
}
}
@@ -67,7 +70,9 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
await navigator.clipboard.writeText(roomUrl)
setIsRoomUrlCopied(true)
} catch (error) {
console.error(error)
reportError('clipboard_failure', error, {
context: 'copy_room_url',
})
}
}
@@ -0,0 +1,27 @@
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { permissionsStore } from '@/stores/permissions'
/**
* enumerateDevices() may hide OS/app-blocked devices on Firefox Android.
* Only report "missing" when no permission block explains the absence,
* so the permission UI takes precedence.
*/
export const useDeviceMissing = (kind: MediaDeviceKind): boolean => {
const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore)
const {
cameraSystemDenied,
microphoneSystemDenied,
isCameraDenied,
isMicrophoneDenied,
} = useSnapshot(permissionsStore)
switch (kind) {
case 'videoinput':
return !hasCamera && !cameraSystemDenied && !isCameraDenied
case 'audioinput':
return !hasMicrophone && !microphoneSystemDenied && !isMicrophoneDenied
default:
return false
}
}
@@ -4,6 +4,7 @@
import { useMemo, useState } from 'react'
import { type TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { reportError } from '@/features/analytics/telemetry'
export function useFullScreen({
trackRef,
@@ -56,7 +57,9 @@ export function useFullScreen({
await docEl.msRequestFullscreen()
}
} catch (error) {
console.error('Error entering fullscreen:', error)
reportError('fullscreen_failure', error, {
context: 'Error entering fullscreen:',
})
}
}
@@ -70,7 +73,9 @@ export function useFullScreen({
await document.msExitFullscreen()
}
} catch (error) {
console.error('Error exiting fullscreen:', error)
reportError('fullscreen_failure', error, {
context: 'Error exiting fullscreen:',
})
}
}
@@ -0,0 +1,24 @@
import { type LocalAudioTrack, TrackEvent } from 'livekit-client'
import { useEffect, useState } from 'react'
export const useIsTrackMuted = (track?: LocalAudioTrack) => {
const [isMuted, setIsMuted] = useState(() => track?.isMuted ?? true)
useEffect(() => {
if (!track) {
setIsMuted(true)
return
}
setIsMuted(track.isMuted)
const onMuted = () => setIsMuted(true)
const onUnmuted = () => setIsMuted(false)
track.on(TrackEvent.Muted, onMuted)
track.on(TrackEvent.Unmuted, onUnmuted)
return () => {
track.off(TrackEvent.Muted, onMuted)
track.off(TrackEvent.Unmuted, onUnmuted)
}
}, [track])
return isMuted
}
@@ -0,0 +1,336 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSnapshot } from 'valtio'
import {
createLocalAudioTrack,
createLocalVideoTrack,
type LocalAudioTrack,
type LocalVideoTrack,
MediaDeviceFailure,
TrackEvent,
} from 'livekit-client'
import { BackgroundProcessorFactory } from '../components/blur'
import {
classifyPermissionError,
isLikelySystemNotFound,
isSystemPermissionError,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind,
} from '@/stores/permissions'
import { getOS } from '@/utils/os'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import {
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveVideoInputDeviceId,
saveVideoInputEnabled,
userChoicesStore,
} from '@/stores/userChoices'
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
const VOICE_AUDIO_CONSTRAINTS = {
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
sampleRate: 48000,
channelCount: 1,
sampleSize: 16,
} as const
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
audioinput: 'microphone',
videoinput: 'camera',
}
type MediaPath = 'join_preview' | 'room'
const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview'
) => {
if (
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
) {
void classifyPermissionError(e, kind).then((scope) => {
if (scope === 'system') {
noteSystemPermissionDenied(kind)
} else {
notePermissionDeniedFromGum(kind)
}
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: scope,
os: getOS(),
})
})
return
}
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(e, kind).then((system) => {
if (system) {
noteSystemPermissionDenied(kind)
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: 'system',
os: getOS(),
})
return
}
captureMediaEvent('device-not-found', { path, kind })
})
return
}
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
{ path, kind }
)
}
// Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false)
const stopAll = (stream: MediaStream) =>
stream.getTracks().forEach((track) => track.stop())
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview'
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
noteGumSuccess(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
return false
}
}
type WarmupState = {
audioReady: boolean
videoReady: boolean
}
/**
* Requests camera and microphone once on mount (one combined call → at
* most one browser dialog) and releases them immediately. Readiness is
* per kind: track acquisition must wait for its own kind to be settled
* to avoid a second dialog, but a microphone that stalls or fails (e.g.
* OS-level block on Firefox) must not hold the camera hostage — that is
* how LiveKit behaves in-room (independent per-kind acquisitions).
*/
function useWarmupPermissions(): WarmupState {
const [state, setState] = useState<WarmupState>({
audioReady: false,
videoReady: false,
})
const started = useRef(false)
useEffect(() => {
if (started.current) {
return
}
started.current = true
const bothReady = () => setState({ audioReady: true, videoReady: true })
const warmup = async () => {
try {
stopAll(
await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
})
)
noteGumSuccess()
bothReady()
} catch (error) {
if (
MediaDeviceFailure.getFailure(error as Error) ===
MediaDeviceFailure.PermissionDenied &&
!isSystemPermissionError(error)
) {
// Retrying after a dismissal would show a second dialog.
onMediaPermissionError(error as Error)
bothReady()
return
}
// Combined requests fail atomically (e.g. missing webcam fails the
// mic too, and an OS-level block on one device fails both) — retry
// per kind to know which device is affected; permission is settled
// at the browser level, no dialog risk. The retries run in parallel
// and settle readiness independently.
void navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
stopAll(stream)
noteGumSuccess('microphone')
})
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
.finally(() =>
setState((current) => ({ ...current, audioReady: true }))
)
void navigator.mediaDevices
.getUserMedia({ video: true })
.then((stream) => {
stopAll(stream)
noteGumSuccess('camera')
})
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
.finally(() =>
setState((current) => ({ ...current, videoReady: true }))
)
}
}
warmup()
}, [])
return state
}
function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
ready,
enabled,
create,
permissionKind,
onFailure,
}: {
ready: boolean
enabled: boolean
create: () => Promise<T>
permissionKind: PermissionKind
onFailure: () => void
}): T | null {
const [track, setTrack] = useState<T | null>(null)
// Acquire.
useEffect(() => {
if (!ready || !enabled || track) {
return
}
let cancelled = false
create()
.then((newTrack) => {
noteGumSuccess(permissionKind)
if (cancelled) {
newTrack.stop()
return
}
setTrack(newTrack)
})
.catch((error) => {
onMediaPermissionError(error as Error, permissionKind)
onFailure()
})
return () => {
cancelled = true
}
}, [ready, enabled, track, create, permissionKind, onFailure])
// Release on toggle-off so the LED turns off.
useEffect(() => {
if (!enabled && track) {
track.stop()
setTrack(null)
}
}, [enabled, track])
// Track ended externally (permission revoked, device unplugged):
// disable instead of re-acquiring, so no unsolicited dialog.
useEffect(() => {
if (!track) {
return
}
const handleEnded = () => {
setTrack(null)
onFailure()
}
track.on(TrackEvent.Ended, handleEnded)
return () => {
track.off(TrackEvent.Ended, handleEnded)
}
}, [track, onFailure])
// Release on unmount or replacement.
useEffect(() => {
return () => {
track?.stop()
}
}, [track])
return track
}
export function useJoinTracks(): {
audioTrack: LocalAudioTrack | undefined
videoTrack: LocalVideoTrack | undefined
} {
const {
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
processorConfig,
} = useSnapshot(userChoicesStore)
const { audioReady, videoReady } = useWarmupPermissions()
const createAudio = useCallback(
() =>
createLocalAudioTrack({
deviceId: audioDeviceId,
...VOICE_AUDIO_CONSTRAINTS,
}),
[audioDeviceId]
)
const createVideo = useCallback(
() =>
createLocalVideoTrack({
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
}),
[videoDeviceId, processorConfig]
)
const audioTrack = useLocalTrack({
ready: audioReady,
enabled: audioEnabled,
create: createAudio,
permissionKind: 'microphone',
onFailure: disableAudio,
})
const videoTrack = useLocalTrack({
ready: videoReady,
enabled: videoEnabled,
create: createVideo,
permissionKind: 'camera',
onFailure: disableVideo,
})
useSyncTrackDeviceId(audioTrack ?? undefined, saveAudioInputDeviceId)
useSyncTrackDeviceId(videoTrack ?? undefined, saveVideoInputDeviceId)
return {
audioTrack: audioTrack ?? undefined,
videoTrack: videoTrack ?? undefined,
}
}
@@ -1,6 +1,7 @@
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const usePermissionsManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
@@ -28,7 +29,9 @@ export const usePermissionsManager = () => {
return { configuration: newConfiguration }
} catch (error) {
console.error('Failed to update muting permission:', error)
reportError('permissions_api_failure', error, {
context: 'Failed to update muting permission:',
})
return { success: false, error }
}
},
@@ -12,6 +12,7 @@ import {
NotificationType,
useNotifyParticipants,
} from '@/features/notifications'
import { reportError } from '@/features/analytics/telemetry'
export const updatePublishSources = (
currentSources: Source[],
@@ -108,7 +109,9 @@ export const usePublishSourcesManager = () => {
return { configuration: newConfiguration }
} catch (error) {
console.error(`Failed to update ${sources}:`, error)
reportError('publish_sources_failure', error, {
context: `Failed to update ${sources}:`,
})
return { success: false, error }
}
},
@@ -8,6 +8,7 @@ import {
import { isLocal } from '@/utils/livekit'
import { useMemo } from 'react'
import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand'
import { reportError } from '@/features/analytics/telemetry'
type useRaisedHandProps = {
participant: Participant
@@ -79,9 +80,9 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
try {
await raiseHand(!isHandRaised)
} catch (e) {
console.error(
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
)
reportError('generic_failure', e, {
context: 'toggle_raised_hand',
})
}
}
@@ -1,22 +0,0 @@
import { useEffect, useRef } from 'react'
export const useResolveInitiallyDefaultDeviceId = <
T extends { getDeviceId(): Promise<string | undefined> },
>(
currentId: string,
track: T | undefined,
save: (id: string) => void
) => {
const isInitiated = useRef(false)
useEffect(() => {
if (currentId !== 'default' || !track || isInitiated.current) return
const resolveDefaultDeviceId = async () => {
const actualDeviceId = await track.getDeviceId()
if (actualDeviceId && actualDeviceId !== 'default') {
isInitiated.current = true
save(actualDeviceId)
}
}
resolveDefaultDeviceId()
}, [currentId, track, save])
}
@@ -1,28 +1,18 @@
import { useTitle } from 'hoofd'
import { useRoomData } from './useRoomData'
import { useMemo } from 'react'
const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? ''
/**
* Updates the browser tab title with the room name to help users easily find
* Updates the browser tab title with the room id to help users easily find
* the meeting tab among many open tabs. Works on both the join screen and
* once connected.
*/
export const useRoomPageTitle = () => {
const roomData = useRoomData()
export const useRoomPageTitle = (roomId?: string) => {
const pageTitle = useMemo(() => {
if (!roomData) {
return APP_TITLE
}
const roomLabel = roomData.name || roomData.slug || ''
if (!roomLabel) return APP_TITLE
return `${APP_TITLE} - ${roomLabel} `
}, [roomData])
if (!roomId) return APP_TITLE
return `${APP_TITLE} - ${roomId}`
}, [roomId])
useTitle(pageTitle)
}
@@ -0,0 +1,40 @@
import { useEffect } from 'react'
import { TrackEvent, type LocalTrack } from 'livekit-client'
/**
* Keeps the persisted preference aligned with the track's actual device.
* The track is the source of truth, not the store.
*
* Syncs on mount and TrackEvent.Restarted, covering acquisition/restart,
* setDeviceId switches, unmute re-acquisition, and browser fallback.
*
* Skip persisting the raw "default" alias: it means "follow the OS default"
* and resolving it would pin the preference to a concrete device.
*/
export const useSyncTrackDeviceId = (
track: LocalTrack | undefined,
save: (deviceId: string) => void
) => {
useEffect(() => {
if (!track) return
let cancelled = false
const sync = () => {
track
.getDeviceId(false)
.then((deviceId) => {
if (!cancelled && deviceId) {
save(deviceId)
}
})
.catch(() => {
// A track without settings (ended, screen share) has no id to sync.
})
}
sync()
track.on(TrackEvent.Restarted, sync)
return () => {
cancelled = true
track.off(TrackEvent.Restarted, sync)
}
}, [track, save])
}
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
import {
PERMISSION_BY_DEVICE_KIND,
type PermissionDeniedScope,
type PermissionKind,
classifyPermissionError,
isLikelySystemNotFound,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
} from '@/stores/permissions'
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
import { captureMediaEvent } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
type MediaDeviceAlert = {
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
const capturePermissionsDenied = (
scope: PermissionDeniedScope,
kind?: PermissionKind
) =>
captureMediaEvent('permissions-denied', {
path: 'connect_publish',
kind,
denied_scope: scope,
os: getOS(),
})
/**
* Single owner of the room's media device errors
* (RoomEvent.MediaDevicesError): telemetry, the user-facing alert, device
* availability refresh, and browser- vs OS-level permission classification.
* Listens to the raw room event because onMediaDeviceFailure only exposes
* LiveKit's mapped enum, and the raw error is needed to tell a browser-level
* denial from an OS-level one (Chromium's "Permission denied by system",
* Firefox/macOS's NotFoundError).
*/
export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
clear: () => void
} => {
const room = useRoomContext()
const [alert, setAlert] = useState<MediaDeviceAlert>(NO_ALERT)
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,
})
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) {
case MediaDeviceFailure.DeviceInUse:
setAlert({ error: failure, kind })
break
case MediaDeviceFailure.NotFound:
void syncDeviceAvailability()
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(error, permissionKind).then((system) => {
if (system) {
noteSystemPermissionDenied(permissionKind)
void capturePermissionsDenied('system', permissionKind)
} else {
setAlert({ error: failure, kind })
}
})
break
case MediaDeviceFailure.PermissionDenied:
void classifyPermissionError(error, permissionKind).then((scope) => {
void capturePermissionsDenied(scope, permissionKind)
if (scope === 'system') {
noteSystemPermissionDenied(permissionKind)
} else {
notePermissionDeniedFromGum(permissionKind)
}
})
break
default:
break
}
}
room.on(RoomEvent.MediaDevicesError, onDeviceError)
return () => {
room.off(RoomEvent.MediaDevicesError, onDeviceError)
}
}, [room])
const clear = useCallback(() => setAlert(NO_ALERT), [])
return { ...alert, clear }
}
@@ -11,9 +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 { MediaStateObserver } from '../components/MediaStateObserver'
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
@@ -25,6 +25,8 @@ import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder
import { StageLayout } from '@/features/layout/components/StageLayout'
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'
/**
* @public
@@ -53,7 +55,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
* @public
*/
export function VideoConference({ ...props }: VideoConferenceProps) {
useRoomPageTitle()
useNoiseReduction()
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
@@ -64,6 +65,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<>
<RoomMetadataSynchronizer />
<ConnectionObserver />
<SyncDevicePreferences />
<RoomSilentMicDetector />
<MediaStateObserver />
<ChatProvider />
<VideoResolutionSubscription />
@@ -91,7 +94,10 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
</RoomContentArea>
<ControlBar
onDeviceError={(e) => {
console.error(e)
reportError('device_switch_failure', e.error, {
at: 'ControlBar.onDeviceError',
source: e.source,
})
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
@@ -7,6 +7,7 @@ import { useUser } from '@/features/auth/api/useUser'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Permissions } from '../components/Permissions'
import { SilentMicDialog } from '../components/SilentMicDialog'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import {
isRoomValid,
@@ -14,11 +15,14 @@ import {
} from '@/features/rooms/utils/isRoomValid'
import { useConfig } from '@/api/useConfig.ts'
import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
const BaseRoom = ({ children }: { children: ReactNode }) => {
return (
<UserAware>
<Permissions />
<SilentMicDialog />
{children}
</UserAware>
)
@@ -36,12 +40,15 @@ const Room = () => {
const { data } = useConfig()
useRoomPageTitle(roomId)
useEffect(() => {
const shouldSilenceLogs = data?.silence_livekit_debug_logs || false
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
}, [data?.silence_livekit_debug_logs])
useKeyboardShortcuts()
useWatchDeviceAvailability()
const clearRouterState = () => {
if (window?.history?.state) {
@@ -0,0 +1,3 @@
export const canTestAudioOutput = () =>
typeof HTMLMediaElement !== 'undefined' &&
'setSinkId' in HTMLMediaElement.prototype // Safari: no output routing
@@ -5,6 +5,7 @@ import { useUser } from '@/features/auth/api/useUser'
import { Spinner } from '@/primitives/Spinner'
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
import { PopupWindow } from '../utils/PopupWindow'
import { reportError } from '@/features/analytics/telemetry'
const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow()
@@ -52,7 +53,9 @@ const CreatePopup = () => {
popupWindow.close()
})
} catch (error) {
console.error('Failed to create meeting room:', error)
reportError('generic_failure', error, {
context: 'Failed to create meeting room:',
})
}
}
if (isLoggedIn && callbackId) {
@@ -15,6 +15,7 @@ import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
@@ -108,7 +109,7 @@ const SettingsPopup = () => {
patchRoom({
roomId: roomSlug,
room: { configuration: newConfiguration },
}).catch((e) => console.error(e))
}).catch((e) => reportError('generic_failure', e))
}
const updateSource = (sources: Source[], enabled: boolean) => {
@@ -329,7 +330,7 @@ const SettingsPopup = () => {
patchRoom({
roomId: roomSlug,
room: { access_level: value as ApiAccessLevel },
}).catch((e) => console.error(e))
}).catch((e) => reportError('generic_failure', e))
}
items={[
{
@@ -1,5 +1,6 @@
import { authUrl } from '@/features/auth/utils/authUrl'
import { PopupMessageType, CallbackCreationRoomData } from './types'
import { reportError } from '@/features/analytics/telemetry'
export class PopupWindow {
private sendMessageToManager(
@@ -9,7 +10,7 @@ export class PopupWindow {
callback?: () => void
) {
if (!window.opener) {
console.error('No manager window found')
reportError('generic_failure', new Error('No manager window found'))
window.close()
return
}
@@ -11,6 +11,7 @@ import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
import { saveUsername } from '@/stores/user'
import { logout } from '@/features/auth/utils/logout'
import { useConfig } from '@/api/useConfig'
import { reportError } from '@/features/analytics/telemetry'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -35,9 +36,9 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
saveUsername(name)
onOpenChange?.(false) // only close on success
} catch (error) {
console.error(
`Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}`
)
reportError('generic_failure', error, {
context: 'rename_participant',
})
}
}
const handleOnCancel = () => {
@@ -11,7 +11,6 @@ import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
import posthog from 'posthog-js'
import { RowWrapper } from './layout/RowWrapper'
import { useSnapshot } from 'valtio'
import {
@@ -20,6 +19,7 @@ import {
saveNoiseReductionEnabled,
userChoicesStore,
} from '@/stores/userChoices'
import { captureEvent } from '@/features/analytics/telemetry'
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -128,7 +128,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
isSelected={noiseReductionEnabled}
onChange={(v) => {
saveNoiseReductionEnabled(v)
if (v) posthog.capture('noise-reduction-init')
if (v) captureEvent('noise-reduction-init')
}}
>
{t('audio.noiseReduction.label')}
+7 -1
View File
@@ -169,7 +169,13 @@ export const Header = () => {
!isTermsOfService &&
!loginButtonDisabledByUrl && (
<>
<LoginButton proConnectHint={false} />
<div
className={css({
display: { base: 'none', xsm: 'block' },
})}
>
<LoginButton proConnectHint={false} />
</div>
<LoginHint />
</>
)}
+67 -2
View File
@@ -12,6 +12,10 @@
"loading": "Laden…",
"select": "Wähle einen Wert",
"permissionsNeeded": "Berechtigung erforderlich",
"deviceNotFound": {
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
},
"settings": {
"audio": "Audioeinstellungen",
"video": "Videoeinstellungen"
@@ -29,6 +33,8 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"level": "Mikrofon-Eingangspegel",
"muteTest": "Aktivieren Sie Ihr Mikrofon, um es zu testen",
"permissionsNeeded": "Mikrofon auswählen Berechtigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
@@ -38,7 +44,9 @@
},
"audiooutput": {
"choose": "Audioausgabe auswählen",
"permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich"
"permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich",
"test": "Lautsprecher testen",
"testing": "Testton wird abgespielt…"
}
},
"join": {
@@ -58,6 +66,7 @@
"usernameEmpty": "Dein Name darf nicht leer sein"
},
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"cameraStarting": "Kamera wird gestartet…",
"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?",
@@ -113,6 +122,16 @@
"videoinput": "Deine Kamera wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du deine Kamera hier verwenden möchtest, schließe den anderen Tab oder die App."
}
},
"NotFound": {
"title": {
"audioinput": "Kein Mikrofon erkannt",
"videoinput": "Keine Kamera erkannt"
},
"body": {
"audioinput": "Auf deinem Gerät wurde kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist und nicht durch einen Schalter stummgeschaltet wird, und versuche es dann erneut.",
"videoinput": "Auf deinem Gerät wurde keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist und nicht durch eine Abdeckung oder einen Schalter blockiert wird, und versuche es dann erneut."
}
},
"close": "OK"
},
"permissionErrorDialog": {
@@ -135,6 +154,53 @@
}
}
},
"systemPermissionDialog": {
"heading": {
"camera": "Erlauben Sie Ihrem Browser, Ihre Kamera zu verwenden",
"microphone": "Erlauben Sie Ihrem Browser, Ihr Mikrofon zu verwenden",
"cameraAndMicrophone": "Erlauben Sie Ihrem Browser, Ihre Kamera und Ihr Mikrofon zu verwenden"
},
"device": {
"camera": "Kamera",
"microphone": "Mikrofon",
"cameraAndMicrophone": "Kamera und Ihr Mikrofon"
},
"intro": "Ihr Browser hat bereits die Berechtigung, aber die Systemeinstellungen Ihres Computers blockieren den Zugriff auf Ihr(e) {{device}}.",
"steps": {
"macos": {
"1": "Öffnen Sie in den Systemeinstellungen „Datenschutz & Sicherheit“.",
"2": "Erlauben Sie Ihrem Browser den Zugriff auf Ihr(e) {{device}}."
},
"windows": {
"1": "Öffnen Sie in den Windows-Einstellungen „Datenschutz und Sicherheit“.",
"2": "Aktivieren Sie den Zugriff auf {{device}} und erlauben Sie Desktop-Apps die Nutzung."
},
"android": {
"1": "Öffnen Sie in den Android-Einstellungen „Apps“ und wählen Sie Ihren Browser aus.",
"2": "Erlauben Sie unter „Berechtigungen“ den Zugriff auf Ihr(e) {{device}} und laden Sie die Seite anschließend neu."
},
"other": {
"1": "Erlauben Sie Ihrem Browser in den Datenschutzeinstellungen Ihres Systems den Zugriff auf Ihr(e) {{device}}.",
"2": "Versuchen Sie es anschließend erneut."
}
},
"openSettings": "Systemeinstellungen öffnen"
},
"silentMic": {
"tooltip": "Ihr Mikrofon ist aktiviert, aber es wird kein Ton erkannt. Klicken Sie für weitere Informationen.",
"dialog": {
"title": "Kein Ton erkannt",
"intro": "Ihr Mikrofon ist aktiviert, aber seit einiger Zeit wird kein Ton erkannt. Überprüfen Sie Folgendes:",
"causes": {
"system": "Der Zugriff auf das Mikrofon wird möglicherweise durch die Datenschutzeinstellungen Ihres Systems blockiert.",
"hardware": "Möglicherweise ist ein Stummschalter aktiviert oder das Mikrofon ist nicht angeschlossen oder defekt.",
"wrongDevice": "Möglicherweise ist das falsche Mikrofon ausgewählt. Wählen Sie im Mikrofonmenü ein anderes aus."
},
"hint": "Sprechen Sie erneut, nachdem Sie diese Punkte überprüft haben. Die Warnung verschwindet, sobald ein Ton erkannt wird.",
"close": "Verstanden",
"discard": "Erkennung eines stummen Mikrofons deaktivieren"
}
},
"permissionsButton": {
"tooltip": "Mehr Infos",
"ariaLabel": "Problem mit Berechtigungen. Mehr Infos anzeigen"
@@ -326,7 +392,6 @@
}
}
},
"faceLandmarks": {
"title": "Visuelle Effekte",
"glasses": {
+67 -1
View File
@@ -12,6 +12,10 @@
"loading": "Loading…",
"select": "Select a value",
"permissionsNeeded": "Permission needed",
"deviceNotFound": {
"videoinput": "No camera detected. Check that it is properly wired.",
"audioinput": "No microphone detected. Check that it is properly wired."
},
"settings": {
"audio": "Audio settings",
"video": "Video settings"
@@ -29,6 +33,8 @@
},
"audioinput": {
"choose": "Select microphone",
"level": "Microphone input level",
"muteTest": "Turn on your microphone to test it",
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
@@ -38,7 +44,9 @@
},
"audiooutput": {
"choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed"
"permissionsNeeded": "Select speaker - permission needed",
"test": "Test speakers",
"testing": "Playing test sound…"
}
},
"join": {
@@ -58,6 +66,7 @@
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "Camera is disabled.",
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
"cameraStarting": "Camera is starting…",
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
@@ -113,6 +122,16 @@
"videoinput": "Your camera is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here."
}
},
"NotFound": {
"title": {
"audioinput": "No microphone detected",
"videoinput": "No camera detected"
},
"body": {
"audioinput": "No microphone was detected on your device. Check that it is properly plugged in and not disabled by a mute switch, then try again.",
"videoinput": "No camera was detected on your device. Check that it is properly plugged in and not blocked by a cover or switch, then try again."
}
},
"close": "OK"
},
"permissionErrorDialog": {
@@ -135,6 +154,53 @@
}
}
},
"systemPermissionDialog": {
"heading": {
"camera": "Allow your browser to use your camera",
"microphone": "Allow your browser to use your microphone",
"cameraAndMicrophone": "Allow your browser to use your camera and microphone"
},
"device": {
"camera": "camera",
"microphone": "microphone",
"cameraAndMicrophone": "camera and microphone"
},
"intro": "Your browser already has permission, but your computer's system settings are blocking access to your {{device}}.",
"steps": {
"macos": {
"1": "In System Settings, open “Privacy & Security”.",
"2": "Allow your browser to access your {{device}}."
},
"windows": {
"1": "In Windows Settings, open “Privacy & security”.",
"2": "Turn on {{device}} access and allow desktop apps to use it."
},
"android": {
"1": "In Android Settings, open “Apps” and select your browser.",
"2": "In “Permissions”, allow access to the {{device}}, then reload this page."
},
"other": {
"1": "In your system's privacy settings, allow your browser to access your {{device}}.",
"2": "Then try again."
}
},
"openSettings": "Open system settings"
},
"silentMic": {
"tooltip": "Your microphone is on, but no sound is being detected. Click to learn more.",
"dialog": {
"title": "No sound detected",
"intro": "Your microphone is on, but we haven't detected any sound for a while. Check the following:",
"causes": {
"system": "Your system's privacy settings may be blocking microphone access.",
"hardware": "A mute switch may be turned on, or your microphone may be disconnected or faulty.",
"wrongDevice": "The wrong microphone may be selected. Try choosing another one from the microphone menu."
},
"hint": "Speak again after checking these settings. This warning will disappear as soon as sound is detected.",
"close": "Got it",
"discard": "Turn off silent microphone detection"
}
},
"permissionsButton": {
"tooltip": "More info",
"ariaLabel": "Permissions issue. Show more info"
+67 -1
View File
@@ -12,6 +12,10 @@
"loading": "Chargement…",
"select": "Sélectionnez une valeur",
"permissionsNeeded": "Autorisations nécessaires",
"deviceNotFound": {
"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é."
},
"settings": {
"audio": "Paramètres audio",
"video": "Paramètres video"
@@ -29,6 +33,8 @@
},
"audioinput": {
"choose": "Choisir le micro",
"level": "Niveau d'entrée du micro",
"muteTest": "Activez votre micro pour le tester",
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
@@ -38,7 +44,9 @@
},
"audiooutput": {
"choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires",
"test": "Tester les haut-parleurs",
"testing": "Lecture du son de test…"
}
},
"join": {
@@ -58,6 +66,7 @@
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
"cameraStarting": "La caméra va démarrer…",
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
@@ -113,6 +122,16 @@
"videoinput": "Votre caméra est déjà utilisée dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici."
}
},
"NotFound": {
"title": {
"audioinput": "Aucun microphone détecté",
"videoinput": "Aucune caméra détectée"
},
"body": {
"audioinput": "Aucun microphone n'a été détecté sur votre appareil. Vérifiez qu'il est bien branché et qu'aucun bouton ne le coupe, puis réessayez.",
"videoinput": "Aucune caméra n'a été détectée sur votre appareil. Vérifiez qu'elle est bien branchée et qu'aucun cache ou bouton ne la bloque, puis réessayez."
}
},
"close": "OK"
},
"permissionErrorDialog": {
@@ -135,6 +154,53 @@
}
}
},
"systemPermissionDialog": {
"heading": {
"camera": "Autorisez votre navigateur à utiliser votre caméra",
"microphone": "Autorisez votre navigateur à utiliser votre micro",
"cameraAndMicrophone": "Autorisez votre navigateur à utiliser votre caméra et votre micro"
},
"device": {
"camera": "caméra",
"microphone": "micro",
"cameraAndMicrophone": "caméra et votre micro"
},
"intro": "Votre navigateur a déjà l'autorisation, mais les réglages de votre ordinateur bloquent l'accès à votre {{device}}.",
"steps": {
"macos": {
"1": "Dans les réglages Système, ouvrez « Confidentialité et sécurité ».",
"2": "Autorisez votre navigateur à accéder à votre {{device}}."
},
"windows": {
"1": "Dans les paramètres Windows, ouvrez « Confidentialité et sécurité ».",
"2": "Activez l'accès à votre {{device}} et autorisez les applications de bureau à l'utiliser."
},
"android": {
"1": "Dans les paramètres Android, ouvrez « Applications » puis sélectionnez votre navigateur.",
"2": "Dans « Autorisations », autorisez l'accès à votre {{device}}, puis rechargez la page."
},
"other": {
"1": "Dans les paramètres de confidentialité de votre système, autorisez votre navigateur à accéder à votre {{device}}.",
"2": "Puis réessayez."
}
},
"openSettings": "Ouvrir les réglages système"
},
"silentMic": {
"tooltip": "Votre micro est activé, mais aucun son n'est détecté. Cliquez pour en savoir plus.",
"dialog": {
"title": "Aucun son détecté",
"intro": "Votre micro est activé, mais aucun son n'est détecté depuis un moment. Vérifiez les points suivants :",
"causes": {
"system": "L'accès au micro est peut-être bloqué dans les réglages de confidentialité de votre système.",
"hardware": "Un bouton ou interrupteur coupe peut-être le micro, ou celui-ci est débranché ou défectueux.",
"wrongDevice": "Un autre micro est peut-être sélectionné. Essayez d'en choisir un autre dans le menu du micro."
},
"hint": "Parlez à nouveau après avoir vérifié ces points. L'avertissement disparaîtra dès qu'un son sera détecté.",
"close": "Compris",
"discard": "Désactiver cette détection"
}
},
"permissionsButton": {
"tooltip": "Plus d'infos",
"ariaLabel": "Problème de permissions. Afficher plus d'infos"
+67 -1
View File
@@ -12,6 +12,10 @@
"loading": "Bezig met laden…",
"select": "Selecteer een waarde",
"permissionsNeeded": "Toestemming vereist",
"deviceNotFound": {
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
},
"settings": {
"audio": "Audio-instellingen",
"video": "Video-instellingen"
@@ -29,6 +33,8 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"level": "Microfoon-ingangsniveau",
"muteTest": "Zet je microfoon aan om deze te testen",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
@@ -38,7 +44,9 @@
},
"audiooutput": {
"choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist",
"test": "Luidsprekers testen",
"testing": "Testgeluid wordt afgespeeld…"
}
},
"join": {
@@ -58,6 +66,7 @@
"usernameEmpty": "Uw naam kan niet leeg zijn"
},
"cameraDisabled": "Camera is uitgeschakeld.",
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"cameraStarting": "Camera wordt ingeschakeld…",
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
@@ -113,6 +122,16 @@
"videoinput": "Je camera wordt al gebruikt door een ander tabblad of een andere toepassing, waardoor deze niet geactiveerd kan worden in dit videogesprek. Sluit indien gewenst het andere tabblad of de toepassing om je camera hier te gebruiken."
}
},
"NotFound": {
"title": {
"audioinput": "Geen microfoon gedetecteerd",
"videoinput": "Geen camera gedetecteerd"
},
"body": {
"audioinput": "Er is geen microfoon gedetecteerd op je apparaat. Controleer of deze goed is aangesloten en niet door een schakelaar is gedempt, en probeer het opnieuw.",
"videoinput": "Er is geen camera gedetecteerd op je apparaat. Controleer of deze goed is aangesloten en niet door een afdekking of schakelaar wordt geblokkeerd, en probeer het opnieuw."
}
},
"close": "OK"
},
"permissionErrorDialog": {
@@ -135,6 +154,53 @@
}
}
},
"systemPermissionDialog": {
"heading": {
"camera": "Geef je browser toestemming om je camera te gebruiken",
"microphone": "Geef je browser toestemming om je microfoon te gebruiken",
"cameraAndMicrophone": "Geef je browser toestemming om je camera en microfoon te gebruiken"
},
"device": {
"camera": "camera",
"microphone": "microfoon",
"cameraAndMicrophone": "camera en microfoon"
},
"intro": "Je browser heeft al toestemming, maar de systeeminstellingen van je computer blokkeren de toegang tot je {{device}}.",
"steps": {
"macos": {
"1": "Open in Systeeminstellingen “Privacy en beveiliging”.",
"2": "Geef je browser toegang tot je {{device}}."
},
"windows": {
"1": "Open in de Windows-instellingen “Privacy en beveiliging”.",
"2": "Zet {{device}}-toegang aan en sta desktop-apps toe deze te gebruiken."
},
"android": {
"1": "Open in de Android-instellingen “Apps” en kies je browser.",
"2": "Sta bij “Machtigingen” toegang tot je {{device}} toe en herlaad daarna de pagina."
},
"other": {
"1": "Geef je browser in de privacy-instellingen van je systeem toegang tot je {{device}}.",
"2": "Probeer het daarna opnieuw."
}
},
"openSettings": "Systeeminstellingen openen"
},
"silentMic": {
"tooltip": "Je microfoon staat aan, maar er wordt geen geluid gedetecteerd. Klik voor meer informatie.",
"dialog": {
"title": "Geen geluid gedetecteerd",
"intro": "Je microfoon staat aan, maar er is al een tijdje geen geluid gedetecteerd. Controleer het volgende:",
"causes": {
"system": "De privacyinstellingen van je systeem blokkeren mogelijk de toegang tot je microfoon.",
"hardware": "Mogelijk staat er een mute-schakelaar aan, of is je microfoon niet aangesloten of defect.",
"wrongDevice": "Mogelijk is de verkeerde microfoon geselecteerd. Kies een andere microfoon in het microfoonmenu."
},
"hint": "Spreek opnieuw nadat je dit hebt gecontroleerd. De waarschuwing verdwijnt zodra er geluid wordt gedetecteerd.",
"close": "Begrepen",
"discard": "Detectie van een stille microfoon uitschakelen"
}
},
"permissionsButton": {
"tooltip": "Meer info",
"ariaLabel": "Probleem met machtigingen. Meer info weergeven"
-3
View File
@@ -112,9 +112,6 @@ export const Checkbox = ({
const descriptionId = useId()
if (isInvalid !== undefined) {
console.error(
'Checkbox: passing isInvalid is not supported, use the validate prop instead'
)
return null
}
+50 -43
View File
@@ -99,6 +99,7 @@ export type SelectProps<T> = Omit<
errors?: ReactNode
placement?: Placement
variant?: 'light' | 'dark'
menuFooter?: ReactNode
}
export const Select = <T extends string | number>({
@@ -108,56 +109,62 @@ export const Select = <T extends string | number>({
errors,
placement,
variant = 'light',
menuFooter,
...props
}: SelectProps<T>) => {
const IconComponent = iconComponent
const { t } = useTranslation('global')
return (
<RACSelect {...props}>
{label}
<StyledButton variant={variant}>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
</StyledIcon>
)}
<StyledSelectValue />
<RiArrowDropDownLine
aria-hidden="true"
className={css({ flexShrink: 0 })}
/>
</StyledButton>
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
<ListBox>
{items.map((item) => (
<ListBoxItem
className={
menuRecipe({
extraPadding: true,
variant: variant,
}).item
}
id={item.value}
key={item.value}
textValue={
typeof item.label === 'string' ? item.label : undefined
}
>
{({ isSelected }) => (
<>
{item.label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
{({ isOpen }) => (
<>
{label}
<StyledButton variant={variant}>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
</StyledIcon>
)}
<StyledSelectValue />
<RiArrowDropDownLine
aria-hidden="true"
className={css({ flexShrink: 0 })}
/>
</StyledButton>
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
<ListBox>
{items.map((item) => (
<ListBoxItem
className={
menuRecipe({
extraPadding: true,
variant: variant,
}).item
}
id={item.value}
key={item.value}
textValue={
typeof item.label === 'string' ? item.label : undefined
}
>
{({ isSelected }) => (
<>
{item.label}
{isSelected && (
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
)}
</>
)}
</>
)}
</ListBoxItem>
))}
</ListBox>
</Box>
</StyledPopover>
{errors}
</ListBoxItem>
))}
</ListBox>
{isOpen && menuFooter}
</Box>
</StyledPopover>
{errors}
</>
)}
</RACSelect>
)
}
+4 -4
View File
@@ -1,6 +1,7 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio'
import { reportError } from '@/features/analytics/telemetry'
export type UiFont =
| 'default'
@@ -154,10 +155,9 @@ function getAccessibilityState(): AccessibilityState {
return DEFAULT_STATE
} catch (error: unknown) {
console.error(
'[AccessibilityStore] Failed to parse stored settings:',
error
)
reportError('generic_failure', error, {
context: '[AccessibilityStore] Failed to parse stored settings:',
})
return DEFAULT_STATE
}
}
@@ -0,0 +1,38 @@
import { proxy } from 'valtio'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
// Device presence (not permission): enumerateDevices() exposes kinds
// before any grant. Optimistic defaults until the first sync.
export const deviceAvailabilityStore = proxy({
hasCamera: true,
hasMicrophone: true,
synced: false,
})
export const syncDeviceAvailability = async (): Promise<void> => {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
const hasCamera = devices.some((d) => d.kind === 'videoinput')
const hasMicrophone = devices.some((d) => d.kind === 'audioinput')
// Report each absence once, not on every devicechange.
const firstSync = !deviceAvailabilityStore.synced
if (!hasCamera && (firstSync || deviceAvailabilityStore.hasCamera)) {
void captureMediaEvent('device-not-found', { kind: 'videoinput' })
}
if (
!hasMicrophone &&
(firstSync || deviceAvailabilityStore.hasMicrophone)
) {
void captureMediaEvent('device-not-found', { kind: 'audioinput' })
}
deviceAvailabilityStore.hasCamera = hasCamera
deviceAvailabilityStore.hasMicrophone = hasMicrophone
deviceAvailabilityStore.synced = true
} catch (error) {
reportError('permissions_api_failure', error as Error, {
context: 'enumerateDevices for device availability',
})
}
}
+4 -4
View File
@@ -3,6 +3,7 @@ import { proxyMap } from 'valtio/utils'
import { deserializeToProxyMap, serializeProxyMap } from '@/utils/valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry'
type State = {
soundNotifications: Map<NotificationType, boolean>
@@ -41,10 +42,9 @@ function getNotificationsState(): State {
),
}
} catch (error: unknown) {
console.error(
'[NotificationsStore] Failed to parse stored settings:',
error
)
reportError('generic_failure', error, {
context: '[NotificationsStore] Failed to parse stored settings:',
})
return DEFAULT_STATE
}
}
+263 -33
View File
@@ -1,22 +1,18 @@
import { proxy } from 'valtio'
import { derive } from 'derive-valtio'
import { isFireFox, isMacintosh } from '@/utils/livekit'
import { isAndroid } from '@/utils/os'
type PermissionState =
| undefined
| 'granted'
| 'prompt'
| 'denied'
| 'unavailable'
type PermissionState = undefined | 'granted' | 'prompt' | 'denied'
type BaseState = {
type State = {
cameraPermission: PermissionState
microphonePermission: PermissionState
cameraSystemDenied: boolean
microphoneSystemDenied: boolean
isLoading: boolean
isPermissionDialogOpen: boolean
isSystemPermissionDialogOpen: boolean
requestOrigin?: 'audioinput' | 'videoinput'
}
type DerivedState = {
isCameraGranted: boolean
isMicrophoneGranted: boolean
isCameraDenied: boolean
@@ -25,42 +21,276 @@ type DerivedState = {
isMicrophonePrompted: boolean
}
type State = BaseState & DerivedState
export const permissionsStore = proxy<BaseState>({
export const permissionsStore = proxy<State>({
cameraPermission: undefined,
microphonePermission: undefined,
cameraSystemDenied: false,
microphoneSystemDenied: false,
isLoading: true,
isPermissionDialogOpen: false,
isSystemPermissionDialogOpen: false,
requestOrigin: undefined,
}) as State
derive(
{
isCameraGranted: (get) =>
get(permissionsStore).cameraPermission == 'granted',
isMicrophoneGranted: (get) =>
get(permissionsStore).microphonePermission == 'granted',
isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
isMicrophoneDenied: (get) =>
get(permissionsStore).microphonePermission == 'denied',
isCameraPrompted: (get) =>
get(permissionsStore).cameraPermission == 'prompt',
isMicrophonePrompted: (get) =>
get(permissionsStore).microphonePermission == 'prompt',
get isCameraGranted() {
return this.cameraPermission === 'granted'
},
{
proxy: permissionsStore,
}
)
get isMicrophoneGranted() {
return this.microphonePermission === 'granted'
},
get isCameraDenied() {
return this.cameraPermission === 'denied'
},
get isMicrophoneDenied() {
return this.microphonePermission === 'denied'
},
get isCameraPrompted() {
return this.cameraPermission === 'prompt'
},
get isMicrophonePrompted() {
return this.microphonePermission === 'prompt'
},
})
export const openPermissionsDialog = (
requestOrigin?: 'audioinput' | 'videoinput'
) => {
// When the block is at the OS level, browser-side instructions are
// useless — route to the system permissions dialog instead.
const systemBlocked =
requestOrigin === 'audioinput'
? permissionsStore.microphoneSystemDenied
: requestOrigin === 'videoinput'
? permissionsStore.cameraSystemDenied
: permissionsStore.microphoneSystemDenied ||
permissionsStore.cameraSystemDenied
if (systemBlocked) {
openSystemPermissionsDialog()
return
}
permissionsStore.isPermissionDialogOpen = true
permissionsStore.requestOrigin = requestOrigin
}
export const openSystemPermissionsDialog = () => {
// Never show both dialogs stacked: the system-level one wins.
permissionsStore.isPermissionDialogOpen = false
permissionsStore.isSystemPermissionDialogOpen = true
}
export const closeSystemPermissionsDialog = () => {
permissionsStore.isSystemPermissionDialogOpen = false
}
export const closePermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = false
}
export type PermissionKind = 'camera' | 'microphone'
const KIND_MAP = {
camera: 'videoinput',
microphone: 'audioinput',
} as const
export const PERMISSION_BY_DEVICE_KIND: Partial<
Record<MediaDeviceKind, PermissionKind>
> = {
videoinput: 'camera',
audioinput: 'microphone',
}
export const setPermissions = (
p: Partial<Record<PermissionKind, PermissionState>>
) => {
if (p.camera && p.camera !== permissionsStore.cameraPermission) {
permissionsStore.cameraPermission = p.camera
}
if (p.microphone && p.microphone !== permissionsStore.microphonePermission) {
permissionsStore.microphonePermission = p.microphone
}
permissionsStore.isLoading = false
}
const queryPermission = async (name: PermissionKind) => {
try {
const status = await navigator.permissions.query({
name: name as PermissionName,
})
return status.state
} catch {
return undefined
}
}
const labelsVisible = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
return (kind: PermissionKind) =>
devices.some((d) => d.kind === KIND_MAP[kind] && !!d.label)
} catch {
return (_kind: PermissionKind) => false
}
}
/**
* Outcomes witnessed first-hand from getUserMedia this session. The
* Permissions API is a weaker signal in both directions on Firefox:
*
* - denials: Firefox < 151 keeps temporary (non-"remembered") denials at
* 'prompt' — the default mode on mobile — and some browsers cannot
* query camera/microphone at all;
* - grants: one-time grants are coupled to the active capture, so the
* query drops back to 'prompt' the moment a track stops (e.g. the user
* toggling a device off), and the PermissionStatus change event
* re-syncs at exactly that moment.
*
* Without this memory, every syncPermissions (focus, devicechange,
* status change) clobbers the state right back to 'prompt', which the UI
* renders as "permission needed" — mid-session, on devices that just
* worked. A queried 'prompt' therefore never downgrades a witnessed
* outcome; only hard evidence (a queried 'granted'/'denied', or the next
* gUM outcome) replaces it.
*/
const gumOutcome: Record<PermissionKind, 'granted' | 'denied' | undefined> = {
camera: undefined,
microphone: undefined,
}
export const syncPermissions = async () => {
const [camera, microphone] = await Promise.all([
queryPermission('camera'),
queryPermission('microphone'),
])
const granted = camera && microphone ? () => false : await labelsVisible()
const resolve = (
kind: PermissionKind,
queried?: PermissionState
): PermissionState => {
// Hard evidence from the Permissions API wins in both directions.
if (queried === 'granted' || (!queried && granted(kind))) {
gumOutcome[kind] = 'granted'
return 'granted'
}
if (queried === 'denied') {
gumOutcome[kind] = 'denied'
return 'denied'
}
// 'prompt' (or no query support): a witnessed gUM outcome is the
// stronger signal — keep it.
if (gumOutcome[kind]) return gumOutcome[kind]
if (queried === 'prompt') return 'prompt'
const current =
kind === 'camera'
? permissionsStore.cameraPermission
: permissionsStore.microphonePermission
if (current === 'denied') return 'denied'
return 'prompt'
}
const resolved = {
camera: resolve('camera', camera),
microphone: resolve('microphone', microphone),
}
setPermissions(resolved)
}
export const notePermissionDeniedFromGum = (kind?: PermissionKind) => {
// A combined audio+video request fails atomically: no kind means the
// denial covers both devices.
const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera']
const denied: Partial<Record<PermissionKind, PermissionState>> = {}
for (const k of kinds) {
gumOutcome[k] = 'denied'
denied[k] = 'denied'
}
setPermissions(denied)
void syncPermissions()
}
export type PermissionDeniedScope = 'browser' | 'system'
/**
* Chromium reports OS-level blocks (macOS/Windows privacy settings) with an
* explicit, non-localized message: "Permission denied by system", as opposed
* to "Permission denied" for a browser-level block.
*/
export const isSystemPermissionError = (error: unknown): boolean =>
(error as Error)?.name === 'NotAllowedError' &&
/by system/i.test((error as Error)?.message ?? '')
/**
* Decides whether a getUserMedia permission failure comes from the browser
* or from the OS. Fallback for non-Chromium browsers: gUM rejected with
* NotAllowedError although the browser-level permission is granted — the
* block can only come from the OS.
*/
export const classifyPermissionError = async (
error: unknown,
kind?: PermissionKind
): Promise<PermissionDeniedScope> => {
if (isSystemPermissionError(error)) return 'system'
if ((error as Error)?.name !== 'NotAllowedError') return 'browser'
const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera']
const states = await Promise.all(kinds.map(queryPermission))
return states.every((state) => state === 'granted') ? 'system' : 'browser'
}
/**
* Firefox surfaces OS-level blocks as NotFoundError ("The object can not
* be found here.") instead of NotAllowedError.
*
* - macOS: tell it apart from a genuinely missing device — the device
* still shows up in enumerateDevices.
* - Android: Fenix rejects with NotFoundError when the app itself lacks
* the Android camera/mic permission (mozilla-mobile/fenix#15023), often
* before any site prompt, and may hide the blocked devices from
* enumerateDevices entirely. Phones and tablets always have both
* devices, so "not found" there means an OS-level block, not missing
* hardware.
*/
export const isLikelySystemNotFound = async (
error: unknown,
kind?: PermissionKind
): Promise<boolean> => {
if ((error as Error)?.name !== 'NotFoundError') return false
if (!isFireFox()) return false
if (isAndroid()) return true
if (!isMacintosh()) return false
try {
const devices = await navigator.mediaDevices.enumerateDevices()
const deviceKinds = kind
? [KIND_MAP[kind]]
: (['audioinput', 'videoinput'] as const)
return deviceKinds.every((deviceKind) =>
devices.some((device) => device.kind === deviceKind)
)
} catch {
return false
}
}
export const noteSystemPermissionDenied = (kind?: PermissionKind) => {
// Only flags the state (shown as the blocked-device hint on the toggle);
// the dialog itself opens when the user clicks the blocked toggle.
const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera']
for (const k of kinds) {
const key = k === 'camera' ? 'cameraSystemDenied' : 'microphoneSystemDenied'
permissionsStore[key] = true
}
void syncPermissions()
}
/**
* Successful acquisition proves the permission is granted and the OS no
* longer blocks the device — record it directly, since the Permissions
* API may still report 'prompt' (Firefox one-time grants).
*/
export const noteGumSuccess = (kind?: PermissionKind) => {
const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera']
const granted: Partial<Record<PermissionKind, PermissionState>> = {}
for (const k of kinds) {
gumOutcome[k] = 'granted'
granted[k] = 'granted'
if (k === 'camera') permissionsStore.cameraSystemDenied = false
else permissionsStore.microphoneSystemDenied = false
}
setPermissions(granted)
}
+98
View File
@@ -0,0 +1,98 @@
import { proxy } from 'valtio'
import { captureEvent } from '@/features/analytics/telemetry'
const DISCARD_STORAGE_KEY = 'silent-mic-detection-discarded'
/**
* One-shot UX check: a live microphone always produces at least a noise
* floor, so a mic that stays pinned to zero for a whole minute is not a
* quiet room — it is an OS-level permission block, a hardware mute
* switch, or a dead device. The check runs on the join screen and in
* the room until it settles:
*
* watching → passed first non-zero sample (check is over, no UI)
* watching → silent 45s of accumulated zero signal (warning badge)
* silent → passed sound finally arrives (warning auto-clears)
* any → discarded user opts out (persisted on this browser)
*/
export type SilentMicStatus = 'watching' | 'silent' | 'passed' | 'discarded'
const isDiscardPersisted = (): boolean => {
try {
return localStorage.getItem(DISCARD_STORAGE_KEY) === '1'
} catch {
return false
}
}
export const silentMicStore = proxy({
status: (isDiscardPersisted() ? 'discarded' : 'watching') as SilentMicStatus,
isDialogOpen: false,
})
const SILENT_DURATION_MS = 10_000
// Accumulation is module state, not store state: it changes every tick
// and must survive the join → room transition without re-rendering
// anything.
let silentMs = 0
let watchedTrackId: string | undefined
export type SilentMicSampleResult = 'silent-detected' | 'recovered' | null
/**
* Feed one detection sample. Returns the transition it caused, so the
* caller can attach telemetry with its own context.
*/
export const reportMicSample = ({
trackId,
silent,
deltaMs,
}: {
trackId?: string
silent: boolean
deltaMs: number
}): SilentMicSampleResult => {
const { status } = silentMicStore
if (status === 'discarded' || status === 'passed') return null
if (trackId !== watchedTrackId) {
// New capture (device switch, re-acquire): fresh window.
watchedTrackId = trackId
silentMs = 0
}
if (!silent) {
silentMs = 0
silentMicStore.status = 'passed'
silentMicStore.isDialogOpen = false
return status === 'silent' ? 'recovered' : null
}
if (status !== 'watching') return null
silentMs += deltaMs
if (silentMs >= SILENT_DURATION_MS) {
silentMicStore.status = 'silent'
return 'silent-detected'
}
return null
}
export const openSilentMicDialog = () => {
silentMicStore.isDialogOpen = true
}
export const closeSilentMicDialog = () => {
silentMicStore.isDialogOpen = false
}
export const discardSilentMicDetection = () => {
silentMicStore.status = 'discarded'
silentMicStore.isDialogOpen = false
try {
localStorage.setItem(DISCARD_STORAGE_KEY, '1')
} catch {
/* private mode: the opt-out just won't persist */
}
captureEvent('silent-mic-discarded')
}
+4 -4
View File
@@ -1,5 +1,6 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { reportError } from '@/features/analytics/telemetry'
type State = {
username: string
@@ -18,10 +19,9 @@ function getUserState(): State {
...parsed,
}
} catch (error: unknown) {
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
reportError('generic_failure', error, {
context: '[UserStore] Failed to parse stored settings:',
})
return DEFAULT_STATE
}
}
+4 -4
View File
@@ -1,5 +1,6 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { reportError } from '@/features/analytics/telemetry'
type State = {
is_idle_disconnect_modal_enabled: boolean
@@ -21,10 +22,9 @@ function getUserPreferencesState(): State {
...parsed,
}
} catch (error: unknown) {
console.error(
'[UserPreferencesStore] Failed to parse stored settings:',
error
)
reportError('generic_failure', error, {
context: '[UserPreferencesStore] Failed to parse stored settings:',
})
return DEFAULT_STATE
}
}
+2 -1
View File
@@ -13,6 +13,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import clsx from 'clsx'
import { reportError } from '@/features/analytics/telemetry'
/**
* Calls all functions in the order they were chained with the same arguments.
@@ -25,7 +26,7 @@ export function chain(...callbacks: any[]): (...args: any[]) => void {
try {
callback(...args)
} catch (e) {
console.error(e)
reportError('generic_failure', e)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
export type OS = 'macos' | 'windows' | 'linux' | 'android' | 'other'
export const isAndroid = (): boolean => /android/i.test(navigator.userAgent)
export const getOS = (): OS => {
const source = `${
(navigator as { userAgentData?: { platform?: string } }).userAgentData
?.platform ?? navigator.platform
} ${navigator.userAgent}`
// Android UAs also contain "Linux" (and desktop-mode ones may not say
// much else), so check it before the desktop platforms.
if (isAndroid()) return 'android'
if (/mac/i.test(source)) return 'macos'
if (/win/i.test(source)) return 'windows'
if (/linux|x11/i.test(source)) return 'linux'
return 'other'
}
+2 -14
View File
@@ -1,20 +1,8 @@
import { isMobileBrowser } from '@livekit/components-core'
import { useEffect, useState } from 'react'
import { useState } from 'react'
export const useIsMobile = () => {
const [isMobile, setIsMobile] = useState(isMobileBrowser())
useEffect(() => {
const handleResize = () => {
setIsMobile(isMobileBrowser())
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const [isMobile] = useState(() => isMobileBrowser())
return isMobile
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.25.2",
"version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.25.2",
"version": "1.26.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.25.2",
"version": "1.26.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.25.2",
"version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.25.2",
"version": "1.26.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.25.2",
"version": "1.26.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.25.2"
version = "1.26.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",