Compare commits

..

5 Commits

Author SHA1 Message Date
lebaudantoine bcd95da21f 🐛(frontend) make the device-in-use logic race-free
The device-in-use logic could race when multiple detection events
fired close together (e.g. rapid mic/cam toggles or several devices
becoming busy at once), leading to inconsistent store state.

Refactor the flow so all updates go through a serialized path,
removing the race conditions and keeping the in-use state
consistent regardless of event ordering.
2026-08-23 17:14:17 +02:00
lebaudantoine 38f3f9f52d ♻️(frontend) track device-in-use state by id instead of by kind
Refactor the device-in-use handling to key state by device id
instead of only by kind (microphone or camera).

On computers with several devices of the same kind, keying by kind
meant that one device being in use marked the whole kind as busy,
even when the user could still use another device. Tracking per id
lets the UI and logic distinguish between devices correctly.

The store is updated accordingly so that per-device state is stored
and read consistently across the app.
2026-08-23 16:47:05 +02:00
lebaudantoine 3ec1627e25 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
Also consider `AbortError` with a message like "Timeout starting
video/audio source" as an "already in use" device error.

This can happen for other reasons in theory, but in practice most
occurrences are caused by another application still holding the
camera or microphone.

Route it through the existing device-in-use handling so users get
the same clear explanation as with the standard error, instead of a
generic failure.
2026-08-23 16:10:06 +02:00
lebaudantoine 2e31947bad 🐛(frontend) handle Firefox/Windows AbortError on device start
Some versions of Firefox on Windows do not raise the
`NotReadableError` that LiveKit expects when the camera or
microphone fails to start. Instead, they surface an `AbortError`
with a message explaining the browser could not start the video or
microphone input.

This case was observed in PostHog error tracking and was not
handled, so users hit an unhelpful failure state.

Detect that specific `AbortError` and route it through the same
device-in-use / not-readable handling as the standard error, so
users get a clear explanation.
2026-08-23 16:10:05 +02:00
lebaudantoine 54e9747350 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
On Chrome and Windows 10, when the camera or microphone is already
in use by another application, the browser rejects
`getUserMedia`. Without dedicated handling, users just saw their
camera or microphone not turning on, without any explanation.

Detect this specific failure and surface a clear message to the
user, so they understand another application is holding the device
and know what to do about it.
2026-08-23 16:10:05 +02:00
20 changed files with 100 additions and 249 deletions
-9
View File
@@ -8,14 +8,6 @@ and this project adheres to
## [Unreleased]
### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports
- 📱(frontend) stack idle modal buttons in a column on mobile
- 📱(frontend) improve feedback screen responsiveness on mobile
## [1.28.0] - 2026-08-24
### Added
- 📈(frontend) track errors when starting or stopping a recording
@@ -43,7 +35,6 @@ and this project adheres to
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
- 🐛(frontend) handle Firefox/Windows AbortError on device start
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
## [1.27.0] - 2026-08-14
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.28.0"
version = "1.27.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.28.0"
version = "1.27.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.28.0"
version = "1.27.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.28.0"
version = "1.27.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.28.0",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.28.0",
"version": "1.27.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.28.0",
"version": "1.27.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -2,12 +2,6 @@ import type { CaptureResult } from 'posthog-js'
const IGNORED_EXCEPTION_PATTERNS = [
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
// livekit-client leaks the raw WebSocket error Event as an unhandled
// rejection when the signal ws errors after connect (Firefox-heavy,
// coincides with signal reconnects). Carries zero diagnostic content —
// the close reason is already logged by the SDK.
// See: https://github.com/livekit/client-sdk-js/issues/2062
/^Event captured as exception with keys: isTrusted$/,
]
const shouldIgnoreException = (value: unknown): boolean =>
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
mobile: {
true: {
justifyContent: 'center',
width: '100%',
width: '330px',
},
},
},
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
@@ -6,8 +7,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { REACTIONS_TOOLBAR_ID } from '../constants'
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
import { layoutStore } from '@/stores/layout'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const focusReactionsToolbar = () => {
document
@@ -18,44 +17,35 @@ const focusReactionsToolbar = () => {
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
/* eslint-disable react-refresh/only-export-components */
export const reactionShortcutHandler = () => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const ReactionsToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { isOpen, toggle } = useReactionsToolbar()
const handleShortcut = useCallback(() => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}, [])
useRegisterKeyboardShortcut({
id: 'reaction',
handler: reactionShortcutHandler,
handler: handleShortcut,
})
return (
<ToggleButton
{...props}
id={REACTIONS_TOGGLE_ID}
data-attr="reactions-toggle"
square
variant={variant}
variant="primaryDark"
aria-label={t('button')}
aria-expanded={isOpen}
tooltip={t('button')}
isSelected={isOpen}
onChange={toggle}
onPress={onPress}
>
<RiEmotionLine />
</ToggleButton>
@@ -15,8 +15,7 @@ const Card = styled('div', {
marginTop: '1.5rem',
borderRadius: '0.25rem',
boxShadow: '',
width: '100%',
maxWidth: '380px',
minWidth: '380px',
minHeight: '196px',
},
})
@@ -38,11 +37,8 @@ const ratingButtonRecipe = cva({
color: 'initial',
border: 'none',
borderRadius: 0,
padding: { base: '0.5rem 0.25rem', xsm: '0.5rem 0.85rem' },
padding: '0.5rem 0.85rem',
flexGrow: '1',
flexBasis: 0,
minWidth: 0,
textAlign: 'center',
cursor: 'pointer',
},
variants: {
@@ -103,9 +99,7 @@ const OpenFeedback = ({
return (
<Card>
<H lvl={3} centered>
{t('question')}
</H>
<H lvl={3}>{t('question')}</H>
<TextArea
id="feedbackInput"
name="feedback"
@@ -174,9 +168,7 @@ const RateQuality = ({
return (
<Card>
<H lvl={3} centered>
{t('question')}
</H>
<H lvl={3}>{t('question')}</H>
<Bar>
{[...Array(maxRating)].map((_, index) => (
<RACButton
@@ -236,9 +228,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
}}
>
<VStack gap={0}>
<H lvl={3} centered>
{t('heading')}
</H>
<H lvl={3}>{t('heading')}</H>
<Text as="p" variant="paragraph" centered>
{t('body')}
</Text>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { Stack } from '@/styled-system/jsx'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
@@ -134,11 +134,7 @@ export const IsIdleDisconnectModal = () => {
</H>
<Description />
<Settings />
<Stack
direction={{ base: 'column', xsm: 'row' }}
align={{ base: 'stretch', xsm: 'center' }}
marginTop="2rem"
>
<HStack marginTop="2rem">
<Button
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
@@ -152,7 +148,7 @@ export const IsIdleDisconnectModal = () => {
<Button onPress={close} size="sm" variant="primary">
{t('stayButton')}
</Button>
</Stack>
</HStack>
</div>
)
}}
@@ -10,18 +10,10 @@ import {
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const HandToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
export const HandToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
@@ -82,16 +74,12 @@ export const HandToggle = ({
})}
>
<ToggleButton
{...props}
square
variant={variant}
variant="primaryDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={(e) => {
handleToggle()
onPress?.(e)
}}
onPress={handleToggle}
data-attr={`controls-hand-${tooltipLabel}`}
>
<RiHand />
@@ -1,7 +1,7 @@
import { supportsScreenSharing } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import type { ControlBarAuxProps } from './ControlBar'
import React, { useLayoutEffect, useRef, useState } from 'react'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { Track } from 'livekit-client'
@@ -26,26 +26,7 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { openSettingsDialog } from '@/stores/settings'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
import {
ReactionsToggle,
reactionShortcutHandler,
} from '@/features/reactions/components/ReactionsToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useRoomContext } from '@livekit/components-react'
// Hand collapses first, then reactions; hidden toggles move into the menu.
const COLLAPSIBLE_COUNT = 2
// Layout-neutral measuring wrapper: inherits the region's gap and refuses to
// flex-shrink so measured widths are natural content widths.
const measuredRow = css({
display: 'inline-flex',
alignItems: 'center',
gap: 'inherit',
flexShrink: 0,
})
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
export function MobileControlBar({
onDeviceError,
@@ -55,107 +36,50 @@ export function MobileControlBar({
const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel()
const containerRef = useRef<HTMLDivElement>(null)
const { width } = useSize(containerRef)
const barRef = useRef<HTMLDivElement>(null)
const { width: barWidth } = useSize(barRef)
const collapsibleRef = useRef<HTMLDivElement>(null)
const { width: collapsibleWidth } = useSize(collapsibleRef)
const [hiddenCount, setHiddenCount] = useState(0)
const calibration = useRef<{ essential: number; slot: number }>()
useLayoutEffect(() => {
if (hiddenCount === 0 && collapsibleWidth > 0 && barRef.current) {
const gap = parseFloat(getComputedStyle(barRef.current).columnGap) || 0
calibration.current = {
essential: barWidth - collapsibleWidth - gap,
slot: (collapsibleWidth + gap) / COLLAPSIBLE_COUNT,
}
}
if (!calibration.current || width <= 0) return
const { essential, slot } = calibration.current
const fits = Math.floor((width - essential) / slot)
const next = Math.min(
COLLAPSIBLE_COUNT,
Math.max(0, COLLAPSIBLE_COUNT - fits)
)
if (next !== hiddenCount) setHiddenCount(next)
}, [barWidth, collapsibleWidth, hiddenCount, width, setHiddenCount])
const hideHand = hiddenCount >= 1
const hideReactions = hiddenCount >= 2
const room = useRoomContext()
const { toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: toggleRaisedHand,
})
useRegisterKeyboardShortcut({
id: 'reaction',
handler: reactionShortcutHandler,
})
const { data } = useConfig()
const closeMenu = () => setIsMenuOpened(false)
return (
<>
<div
className={css({
width: '100vw',
display: 'flex',
padding: '1.125rem',
justifyContent: 'center',
})}
>
<div
ref={containerRef}
className={css({
width: '100%',
display: 'flex',
justifyContent: 'center',
})}
>
<ControlBarRegion mobile>
<div ref={barRef} className={measuredRow}>
<LeaveButton />
<AudioDevicesControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
hideMenu={true}
/>
<VideoDeviceControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
hideMenu={true}
/>
{/* Unmounted when empty so it doesn't leave a stray gap. */}
{!hideReactions && (
<div ref={collapsibleRef} className={measuredRow}>
<ReactionsToggle />
{!hideHand && <HandToggle />}
</div>
)}
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
onPress={() => setIsMenuOpened(true)}
>
<RiMore2Line />
</Button>
</div>
</ControlBarRegion>
</div>
<ControlBarRegion mobile>
<LeaveButton />
<AudioDevicesControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
hideMenu={true}
/>
<VideoDeviceControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
hideMenu={true}
/>
<ReactionsToggle />
<HandToggle />
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
onPress={() => setIsMenuOpened(true)}
>
<RiMore2Line />
</Button>
</ControlBarRegion>
</div>
<ResponsiveMenu isOpened={isMenuOpened} onClosed={closeMenu}>
<ResponsiveMenu
isOpened={isMenuOpened}
onClosed={() => setIsMenuOpened(false)}
>
<div
className={css({
display: 'flex',
@@ -174,20 +98,6 @@ export function MobileControlBar({
},
})}
>
{hideReactions && (
<ReactionsToggle
variant="primaryTextDark"
description={true}
onPress={closeMenu}
/>
)}
{hideHand && (
<HandToggle
variant="primaryTextDark"
description={true}
onPress={closeMenu}
/>
)}
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
@@ -195,16 +105,25 @@ export function MobileControlBar({
}
variant="primaryTextDark"
description={true}
onPress={closeMenu}
onPress={() => setIsMenuOpened(false)}
/>
)}
<ChatToggle description={true} onPress={closeMenu} />
<ParticipantsToggle description={true} onPress={closeMenu} />
<ToolsToggle description={true} onPress={closeMenu} />
<ChatToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ParticipantsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ToolsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<Button
onPress={() => {
toggleEffects()
closeMenu()
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.effects')}
@@ -221,7 +140,7 @@ export function MobileControlBar({
aria-label={t('options.items.feedback')}
description={true}
target="_blank"
onPress={closeMenu}
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
@@ -229,7 +148,7 @@ export function MobileControlBar({
<Button
onPress={() => {
openSettingsDialog()
closeMenu()
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.settings')}
@@ -238,7 +157,7 @@ export function MobileControlBar({
>
<RiSettings3Line size={20} />
</Button>
<CameraSwitchButton onPress={closeMenu} />
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
</div>
</div>
</ResponsiveMenu>
@@ -1,8 +1,7 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { Center, Stack, styled, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
@@ -15,18 +14,14 @@ const Heading = styled('h1', {
fontStyle: 'normal',
fontStretch: 'normal',
fontOpticalSizing: 'auto',
fontSize: { base: '1.75rem', xsm: '2.3rem' },
lineHeight: { base: '2.125rem', xsm: '2.5rem' },
fontSize: '2.3rem',
lineHeight: '2.5rem',
letterSpacing: '0',
paddingBottom: { base: '1.5rem', xsm: '2rem' },
paddingBottom: '2rem',
textAlign: 'center',
},
})
const buttonClass = css({
width: { base: '100%', xsm: 'auto' },
})
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
@@ -59,31 +54,19 @@ const FeedbackRoute = () => {
return (
<Screen layout="centered" footer={false}>
<Center width="100%">
<VStack width="100%" paddingX="1rem">
<Center>
<VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<Stack
direction={{ base: 'column', xsm: 'row' }}
width={{ base: '100%', xsm: 'auto' }}
maxWidth="380px"
>
<HStack>
{showBackButton && (
<Button
variant="secondary"
className={buttonClass}
onPress={() => window.history.back()}
>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
)}
<Button
variant="primary"
className={buttonClass}
onPress={() => setLocation('/')}
>
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
</Stack>
</HStack>
<Rating metadata={metadata} />
</VStack>
</Center>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.28.0",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.28.0",
"version": "1.27.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.28.0",
"version": "1.27.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.28.0",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.28.0",
"version": "1.27.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.28.0",
"version": "1.27.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.28.0"
version = "1.27.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",