Compare commits

..

16 Commits

Author SHA1 Message Date
lebaudantoine 98d0b48015 fixup! wip 2026-09-02 23:47:39 +02:00
lebaudantoine 7412c02341 fixup! wip enhance analytics 2026-09-02 23:47:09 +02:00
lebaudantoine f2b3af6633 fixup! wip 2026-09-02 23:12:35 +02:00
lebaudantoine 2aafd93b46 fixup! wip 2026-09-02 22:46:15 +02:00
lebaudantoine 87931f50cd fixup! wip try to enhance error handling 2026-09-02 22:30:16 +02:00
lebaudantoine 35cb114160 wip try to enhance error handling 2026-09-02 20:48:16 +02:00
lebaudantoine c783fefa57 fixup! wip 2026-09-02 20:36:33 +02:00
lebaudantoine 09a0238936 wip propose back a no effect options to help user deactivate their effect 2026-09-02 20:09:52 +02:00
lebaudantoine 03870ad1fa wip enhance analytics 2026-09-02 19:54:44 +02:00
lebaudantoine f37992a089 fixup! wip 2026-09-02 19:08:57 +02:00
lebaudantoine c7c6d20b91 wip silence
info log which are purely informational and log with std err
and failure that mediapipe also raises as real js exceptions
2026-09-02 19:05:12 +02:00
lebaudantoine 2a71e5b836 wip 2026-09-02 18:53:33 +02:00
lebaudantoine 089d016d12 🐛(frontend) cache supportsBackgroundProcessors WebGL2 probe result
`supportsBackgroundProcessors()` creates a live WebGL2 context on
every call and never releases it. The method is called from render
paths (e.g. the Effects button on the join screen), so without
caching, each re-render leaks a context until the browser hits its
live-context limit.

This is one of the ways MediaPipe later fails with:

  "emscripten_webgl_create_context() returned error 0"

Support cannot change within a session, so probe once and cache the
result.
2026-09-02 18:26:12 +02:00
Paul Csiki cf3960db95 (backend) add Traefik reverse proxy support for media-auth
Adds support for serving media behind Traefik, which currently cannot work
at all.

The media-auth subrequest views read the original request URL from a
hardcoded HTTP_X_ORIGINAL_URL header. That header is an nginx-ingress
convention. Traefik's ForwardAuth middleware sends X-Forwarded-Uri instead
and has no mechanism to emit X-Original-URL, so behind Traefik every
recording download and file attachment is rejected with a bare 403 --
indistinguishable from a legitimate permission denial, which makes it
painful to diagnose.

Add MEDIA_AUTH_ORIGINAL_URL_HEADER, defaulting to HTTP_X_ORIGINAL_URL so
existing nginx-ingress deployments are unaffected. Traefik deployments set
it to HTTP_X_FORWARDED_URI. It is used in both places that resolve the
header: RecordingViewSet._auth_get_original_url and the file attachment
_authorize_subrequest. The log message on a missing header now names the
header actually expected, which is what makes the failure diagnosable.

This mirrors the setting the sibling Docs project already exposes
(suitenumerique/docs, MEDIA_AUTH_ORIGINAL_URL_HEADER) for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:07:23 +02:00
lebaudantoine d80d31897c 🔒️(frontend) fix HIGH CVEs in libexpat 2.8.2-r0
Address the following HIGH severity CVEs in libexpat 2.8.2-r0,
reported by Trivy:

* CVE-2026-66046
* CVE-2026-76641
2026-09-02 15:05:01 +02:00
kaelvar 63a7751072 (frontend) add 1080p sending resolution option
The sending resolution selector stopped at 720p while `VideoPresets` already
exposes `h1080` (1920x1080), so publishers on a good uplink could not make use
of the capacity they had. Add "Very high definition (1080p)" above the existing
entries, translated in the five supported locales.

The default stays `h720`, so nothing changes unless a user goes and picks the
new entry. Being explicit about what that costs, since 1080p roughly doubles a
publisher's uplink: this is a per-user choice, and an instance operator has no
way today to decline it. Whether that warrants a server-side setting alongside
the existing `ApiConfig` flags is a call for maintainers — happy to add one if
you want it, rather than change the API contract unasked in a frontend PR.

While here, make the option list harder to get wrong. Resolutions now come from
a single `VIDEO_RESOLUTIONS` tuple that `VideoResolution` derives from, the
selector items are built by mapping over it against a
`Record<VideoResolution, string>` of labels — so a resolution cannot be added
to one and forgotten in the other — and a persisted value that is not in the
tuple falls back to `h720` instead of reaching `VideoPresets[...]` as
`undefined`, since `loadUserChoices` spreads localStorage without validating
it.

Known limitation, unchanged by this patch: `restartTrack` passes the resolution
as an `ideal` constraint, so a camera that cannot reach the selected height
degrades silently. That is already true of 720p on a 480p webcam; 1080p is the
first step where the gap is the common case rather than the edge one.
2026-09-02 15:05:01 +02:00
34 changed files with 717 additions and 1199 deletions
+5 -4
View File
@@ -8,6 +8,11 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
### Fixed ### Fixed
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667 - 🐛(frontend) keep the sending resolution picked while the camera is off #1667
@@ -222,10 +227,6 @@ and this project adheres to
- ♿️(frontend) focus side panel container on open #1452 - ♿️(frontend) focus side panel container on open #1452
- 🐛(summary) whisper call error handling - 🐛(summary) whisper call error handling
### Added
- ✨(frontend) add screen share zoom controls #1498
## [1.23.0] - 2026-07-08 ## [1.23.0] - 2026-07-08
### Added ### Added
+1
View File
@@ -65,6 +65,7 @@ RUN apk update && apk upgrade \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
+17 -7
View File
@@ -1076,9 +1076,10 @@ class RecordingViewSet(
def _auth_get_original_url(self, request): def _auth_get_original_url(self, request):
""" """
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header. Extracts and parses the original URL from the configured header.
Raises PermissionDenied if the header is missing. Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this. is configured to do this.
@@ -1088,9 +1089,13 @@ class RecordingViewSet(
reasons. reasons.
""" """
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL") original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url: if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url) logger.debug("Original url: '%s'", original_url)
@@ -1415,7 +1420,8 @@ class FileViewSet(
Authorize access based on the original URL of an Nginx subrequest Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized. and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header. The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this. is configured to do this.
@@ -1434,9 +1440,13 @@ class FileViewSet(
- PermissionDenied if authorization fails. - PermissionDenied if authorization fails.
""" """
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL") original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url: if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest") logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url) parsed_url = urlparse(original_url)
@@ -7,6 +7,7 @@ from urllib.parse import quote, urlparse
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -143,3 +144,59 @@ def test_api_files_media_auth_own_file_deleted():
) )
assert response.status_code == 403 assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_custom_original_url_header():
"""
Authorization should honour the configured original-url header.
Covers the attachment subrequest path, which resolves the header separately
from the recording one. Reverse proxies other than nginx-ingress use
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
emit X-Original-URL at all.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(file.file_key, BytesIO(b"my prose"))
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
assert response.status_code == 200
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
"""
Only the configured header should be honoured, never a hardcoded fallback.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -8,6 +8,7 @@ from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -282,3 +283,63 @@ def test_api_recordings_media_auth_success_administrator(mode):
timeout=1, timeout=1,
) )
assert response.content.decode("utf-8") == "my prose" assert response.content.decode("utf-8") == "my prose"
def test_api_recordings_media_auth_missing_header():
"""
Test that a subrequest without the configured original-url header is rejected.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/recordings/media-auth/")
assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_custom_original_url_header():
"""
Test that the header carrying the original URL can be configured.
Reverse proxies other than nginx-ingress use different headers: Traefik's
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
# The header was read and parsed: we get as far as looking the recording up,
# rather than being rejected for a missing header.
assert response.status_code == 404
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
"""
Test that only the configured header is honoured.
Guards against the header being read from a hardcoded name in parallel with
the setting.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
+9
View File
@@ -129,6 +129,15 @@ class Base(Configuration):
MEDIA_BASE_URL = values.Value( MEDIA_BASE_URL = values.Value(
"", environ_name="MEDIA_BASE_URL", environ_prefix=None "", environ_name="MEDIA_BASE_URL", environ_prefix=None
) )
# Header the reverse proxy uses to pass the original request URL to the
# media-auth subrequest views. nginx-ingress sends X-Original-URL, which is
# the default. Other proxies use different headers -- Traefik's ForwardAuth,
# for instance, sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
default="HTTP_X_ORIGINAL_URL",
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
environ_prefix=None,
)
SITE_ID = 1 SITE_ID = 1
+1
View File
@@ -53,6 +53,7 @@ RUN apk update && apk upgrade \
musl \ musl \
musl-utils \ musl-utils \
zlib>=1.3.2-r0 \ zlib>=1.3.2-r0 \
libexpat>=2.8.4-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
@@ -8,6 +8,28 @@ const IGNORED_EXCEPTION_PATTERNS = [
// the close reason is already logged by the SDK. // the close reason is already logged by the SDK.
// See: https://github.com/livekit/client-sdk-js/issues/2062 // See: https://github.com/livekit/client-sdk-js/issues/2062
/^Event captured as exception with keys: isTrusted$/, /^Event captured as exception with keys: isTrusted$/,
// MediaPipe's WASM writes its native logs to stderr, which Emscripten
// routes to console.error, which PostHog's console capture then promotes
// to an $exception — even though nothing was thrown. Two flavors:
//
// 1. "INFO: ..." lines are purely informational. In particular
// "INFO: Created TensorFlow Lite XNNPACK delegate for CPU." is a
// SUCCESS message: TFLite prints it when it lazily initializes CPU
// inference on the first segmented frame. It fires on every effects
// init, on every browser and delegate (the GPU delegate still
// instantiates the CPU/XNNPACK delegate for non-delegated ops), so it
// was our single noisiest "error" while carrying zero signal.
/^INFO: /,
//
// 2. absl-formatted log lines, e.g.
// "E0901 19:21:45.443000 1880752 gl_graph_runner_internal.cc:260]
// StartGraph failed: ..."
// (severity letter E/W/I/F + MMDD + timestamp). These are the stderr
// *copies* of failures that MediaPipe also raises as real JS
// exceptions, which we already capture via reportError / thrown
// errors. Dropping them de-duplicates each incident (previously
// counted 2-3x) without losing the actual error report.
/^[EWIF]\d{4} \d{2}:\d{2}:\d{2}\./,
] ]
const shouldIgnoreException = (value: unknown): boolean => const shouldIgnoreException = (value: unknown): boolean =>
@@ -20,7 +20,6 @@ import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from './ParticipantPlaceholder' import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus' import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning' import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog' import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels' import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
@@ -49,8 +48,6 @@ interface ParticipantTileExtendedProps extends ParticipantTileProps {
disableTileControls?: boolean disableTileControls?: boolean
} }
const MOUSE_IDLE_TIME = 3000
export const ParticipantTile: ( export const ParticipantTile: (
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement> props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef< ) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
@@ -92,8 +89,6 @@ export const ParticipantTile: (
) )
const isScreenShare = trackReference.source != Track.Source.Camera const isScreenShare = trackReference.source != Track.Source.Camera
const isRemoteScreenShare =
isScreenShare && !trackReference.participant.isLocal
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false) const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantColor = getParticipantColor(trackReference.participant) const participantColor = getParticipantColor(trackReference.participant)
@@ -103,38 +98,11 @@ export const ParticipantTile: (
}) })
const participantName = name || identity || 'Unknown' const participantName = name || identity || 'Unknown'
// Hover + idle tracking for the focus overlay (pin, effects, mute buttons).
const [isTileHovered, setIsTileHovered] = React.useState(false)
const [isIdle, setIsIdle] = React.useState(false)
const idleTimerRef = React.useRef<number | null>(null)
const handleTileMouseMove = React.useCallback(() => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = window.setTimeout(
() => setIsIdle(true),
MOUSE_IDLE_TIME
)
setIsIdle(false)
}, [])
const isOverlayVisible = hasKeyboardFocus || (isTileHovered && !isIdle)
// tileRef: fullscreen target. setRefs merges it with the forwarded ref on the same node.
const tileRef = React.useRef<HTMLDivElement>(null)
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
if (typeof ref === 'function') ref(node)
else if (ref)
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
},
[ref]
)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = { const interactiveProps = {
...elementProps, ...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0, tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }), 'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => { onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -152,57 +120,8 @@ export const ParticipantTile: (
}, },
} }
const isVideoTrack =
isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare)
let trackMedia: React.ReactNode = null
if (isVideoTrack) {
if (isRemoteScreenShare) {
trackMedia = (
<ScreenShareZoomableVideo
trackRef={trackReference}
tileRef={tileRef}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
} else {
trackMedia = (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
)
}
} else if (isTrackReference(trackReference)) {
trackMedia = (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
}
return ( return (
<div <div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
ref={setRefs}
style={{ position: 'relative' }}
{...interactiveProps}
onMouseEnter={() => setIsTileHovered(true)}
onMouseLeave={() => {
setIsTileHovered(false)
setIsIdle(false)
if (idleTimerRef.current) {
window.clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
}}
onMouseMove={handleTileMouseMove}
>
<TrackRefContextIfNeeded trackRef={trackReference}> <TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}> <ParticipantContextIfNeeded participant={trackReference.participant}>
{trackReference.participant.isLocal && ( {trackReference.participant.isLocal && (
@@ -210,7 +129,23 @@ export const ParticipantTile: (
)} )}
{children ?? ( {children ?? (
<> <>
{trackMedia} {isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
) : (
isTrackReference(trackReference) && (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
)}
<div className="lk-participant-placeholder"> <div className="lk-participant-placeholder">
<ParticipantPlaceholder <ParticipantPlaceholder
color={participantColor} color={participantColor}
@@ -229,7 +164,7 @@ export const ParticipantTile: (
{!disableMetadata && !disableTileControls && ( {!disableMetadata && !disableTileControls && (
<ParticipantTileFocus <ParticipantTileFocus
trackRef={trackReference} trackRef={trackReference}
isVisible={isOverlayVisible} hasKeyboardFocus={hasKeyboardFocus}
/> />
)} )}
</ParticipantContextIfNeeded> </ParticipantContextIfNeeded>
@@ -1,22 +1,44 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core' import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { ReactNode } from 'react' import { ReactNode, useEffect, useRef, useState } from 'react'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton' import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton' import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton' import { MuteButton } from './MuteButton'
import { ZoomButton } from './ZoomButton'
const MOUSE_IDLE_TIME = 3000
type FadeOverlayProps = { type FadeOverlayProps = {
children: ReactNode children: ReactNode
isVisible: boolean hasKeyboardFocus: boolean
} }
// Pointer-events none so this overlay doesn't block the zoom surface below. const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
// Hover and idle tracking therefore lives on the tile, which still receives const [active, setActive] = useState(false)
// the pointer events, and comes back in as isVisible. const idleTimerRef = useRef<number | null>(null)
const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
setActive(false)
}, MOUSE_IDLE_TIME)
}
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
useEffect(() => clearIdleTimer, [])
const isVisible = hasKeyboardFocus || active
return ( return (
<div <div
className={css({ className={css({
@@ -28,10 +50,15 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
alignItems: 'center', alignItems: 'center',
width: '100%', width: '100%',
height: '100%', height: '100%',
pointerEvents: 'none',
})} })}
data-visible={isVisible || undefined} data-visible={isVisible || undefined}
aria-hidden={!isVisible} aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
> >
{isVisible && children} {isVisible && children}
</div> </div>
@@ -40,10 +67,10 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
export const ParticipantTileFocus = ({ export const ParticipantTileFocus = ({
trackRef, trackRef,
isVisible, hasKeyboardFocus,
}: { }: {
trackRef: TrackReferenceOrPlaceholder trackRef: TrackReferenceOrPlaceholder
isVisible: boolean hasKeyboardFocus: boolean
}) => { }) => {
const participant = trackRef.participant const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare const isScreenShare = trackRef.source == Track.Source.ScreenShare
@@ -51,7 +78,7 @@ export const ParticipantTileFocus = ({
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
return ( return (
<FadeOverlay isVisible={isVisible}> <FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<div <div
className={css({ className={css({
backgroundColor: 'primaryDark.50', backgroundColor: 'primaryDark.50',
@@ -60,7 +87,6 @@ export const ParticipantTileFocus = ({
display: 'flex', display: 'flex',
opacity: 0.6, opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards', animation: 'overlayIn 200ms linear 300ms backwards',
pointerEvents: 'auto',
_hover: { _hover: {
opacity: 0.95, opacity: 0.95,
}, },
@@ -68,7 +94,7 @@ export const ParticipantTileFocus = ({
> >
<HStack gap={0.5} padding={0.5}> <HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} /> <FocusButton trackRef={trackRef} />
{!isScreenShare && ( {!isScreenShare ? (
<> <>
{isLocal ? ( {isLocal ? (
<EffectsButton /> <EffectsButton />
@@ -76,6 +102,8 @@ export const ParticipantTileFocus = ({
canMute && <MuteButton participant={participant} /> canMute && <MuteButton participant={participant} />
)} )}
</> </>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)} )}
</HStack> </HStack>
</div> </div>
@@ -0,0 +1,32 @@
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { Button } from '@/primitives'
import { RiFullscreenLine } from '@remixicon/react'
export const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
@@ -1,26 +0,0 @@
import { VideoTrack } from '@livekit/components-react'
import { type TrackReference } from '@livekit/components-core'
import { memo } from 'react'
interface ScreenShareVideoTrackProps {
trackRef: TrackReference
onSubscriptionStatusChanged: (subscribed: boolean) => void
manageSubscription?: boolean
}
// Zoom/pan updates the wrapper transform only; skip VideoTrack re-renders.
export const ScreenShareVideoTrack = memo(
({
trackRef,
onSubscriptionStatusChanged,
manageSubscription,
}: ScreenShareVideoTrackProps) => (
<VideoTrack
trackRef={trackRef}
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
manageSubscription={manageSubscription}
/>
)
)
ScreenShareVideoTrack.displayName = 'ScreenShareVideoTrack'
@@ -1,223 +0,0 @@
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import {
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
RiFullscreenExitLine,
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Toolbar } from 'react-aria-components'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { isMacintosh } from '@/utils/livekit'
import { srOnly } from '@/styles/a11y'
interface ScreenShareZoomControlsProps {
containerRef: React.RefObject<HTMLDivElement | null>
isZoomed: boolean
zoomPercentage: number
canZoomIn: boolean
canZoomOut: boolean
onZoomIn: () => void
onZoomOut: () => void
onResetZoom: () => void
}
export const ScreenShareZoomControls = ({
containerRef,
isZoomed,
zoomPercentage,
canZoomIn,
canZoomOut,
onZoomIn,
onZoomOut,
onResetZoom,
}: ScreenShareZoomControlsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
const hadFocusInCollapsibleRef = useRef(false)
const [isFullscreen, setIsFullscreen] = useState(false)
// Tracks whether this tile's container triggered fullscreen (vs another share's).
const wasThisTileFullscreen = useRef(false)
const isFullscreenAvailable = document.fullscreenEnabled
// Covers Esc and browser UI exits, not just the toolbar button.
// Only this tile's instance announces to avoid duplicates with multiple shares.
useEffect(() => {
const onChange = () => {
const isThisTileFullscreen =
document.fullscreenElement === containerRef.current
setIsFullscreen(isThisTileFullscreen)
if (isThisTileFullscreen) {
wasThisTileFullscreen.current = true
announce(t('fullScreenEntered'), 'assertive')
} else if (wasThisTileFullscreen.current) {
wasThisTileFullscreen.current = false
announce(t('fullScreenExited'), 'assertive')
}
}
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}, [announce, t, containerRef])
// Back at 100 % the collapsible controls are disabled and hidden, which drops
// keyboard focus on the body. Hand it to the zoom in button instead, the only
// control of that group still reachable.
useEffect(() => {
if (isZoomed || !hadFocusInCollapsibleRef.current) return
hadFocusInCollapsibleRef.current = false
zoomInButtonRef.current?.focus()
}, [isZoomed])
const toggleFullScreen = useCallback(async () => {
try {
if (document.fullscreenElement === containerRef.current) {
await document.exitFullscreen()
} else {
// Tile container so zoom controls stay visible in fullscreen.
await containerRef.current?.requestFullscreen()
}
} catch (error) {
console.error('Error toggling fullscreen:', error)
}
}, [containerRef])
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
return (
<div
className={css({
position: 'absolute',
bottom: '12px',
right: '12px',
zIndex: 2,
pointerEvents: 'auto',
})}
>
{/* react-aria Toolbar: left/right arrows move between the controls and
Tab leaves the group as a whole, as the toolbar role implies. */}
<Toolbar
aria-label={t('toolbarLabel')}
className={css({
display: 'flex',
alignItems: 'center',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
padding: '0.5rem',
opacity: 0.7,
transition: 'opacity 200ms linear',
_hover: {
opacity: 0.95,
},
})}
>
<span className={srOnly}>
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
</span>
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
trick keeps overflow:hidden from clipping focus rings. */}
<div
className={css({
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
padding: '3px',
margin: '-3px',
})}
style={{
maxWidth: isZoomed ? '12rem' : '0',
opacity: isZoomed ? 1 : 0,
}}
aria-hidden={!isZoomed}
onFocus={() => {
hadFocusInCollapsibleRef.current = true
}}
onBlur={(e) => {
// Disabling a focused button blurs it with no relatedTarget, so the
// flag must survive that case for the effect above to rescue focus.
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
}}
>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fitToWindow')}
aria-label={t('fitToWindow')}
isDisabled={!isZoomed}
onPress={onResetZoom}
>
<RiFullscreenExitLine size={20} />
</Button>
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomOutWithShortcut', {
shortcut: wheelShortcutVisual,
})}
aria-label={t('zoomOut')}
isDisabled={!isZoomed || !canZoomOut}
onPress={onZoomOut}
>
<RiZoomOutLine size={20} />
</Button>
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
<span
aria-hidden="true"
className={css({
color: 'white',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.25rem',
textAlign: 'center',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 0.25rem',
whiteSpace: 'nowrap',
})}
>
{zoomPercentage} %
</span>
</div>
<Button
ref={zoomInButtonRef}
size="sm"
variant="primaryTextDark"
square
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
aria-label={t('zoomIn')}
isDisabled={!canZoomIn}
onPress={onZoomIn}
>
<RiZoomInLine size={20} />
</Button>
{isFullscreenAvailable && (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
onPress={toggleFullScreen}
>
{isFullscreen ? (
<RiCollapseDiagonalLine size={20} />
) : (
<RiExpandDiagonalLine size={20} />
)}
</Button>
)}
</Toolbar>
</div>
)
}
@@ -1,106 +0,0 @@
import { css } from '@/styled-system/css'
import { type TrackReference } from '@livekit/components-core'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
import { ScreenShareVideoTrack } from './ScreenShareVideoTrack'
interface ScreenShareZoomableVideoProps {
trackRef: TrackReference
tileRef: React.RefObject<HTMLDivElement | null>
onSubscriptionStatusChanged: (subscribed: boolean) => void
manageSubscription?: boolean
}
export const ScreenShareZoomableVideo = ({
trackRef,
tileRef,
onSubscriptionStatusChanged,
manageSubscription,
}: ScreenShareZoomableVideoProps) => {
const zoom = useScreenShareZoom()
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
const announce = useScreenReaderAnnounce()
// SR announcement: announce zoom level on change, with a one-time pan hint
// on the first zoom above 100 % per session.
const prevZoomRef = useRef(zoom.zoomPercentage)
const hasAnnouncedPanHint = useRef(false)
useEffect(() => {
if (prevZoomRef.current === zoom.zoomPercentage) return
const wasAtDefault = prevZoomRef.current <= 100
prevZoomRef.current = zoom.zoomPercentage
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
hasAnnouncedPanHint.current = true
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
} else {
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
}
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
// Attach keyboard listener on the tile container (has tabIndex=0).
useEffect(() => {
const el = tileRef.current
if (!el) return
el.addEventListener('keydown', zoom.handleKeyDown)
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
}, [tileRef, zoom.handleKeyDown])
// Native wheel listener with { passive: false } so preventDefault works.
useEffect(() => {
const el = zoom.surfaceElRef.current
if (!el) return
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
return () => el.removeEventListener('wheel', zoom.handleWheel)
}, [zoom.handleWheel, zoom.surfaceElRef])
return (
<>
<div
ref={zoom.surfaceElRef}
className={css({
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
userSelect: 'none',
// Leaves the browser's native pinch-zoom available on touch devices
// while still routing single-pointer drags to useMove for panning.
touchAction: 'pinch-zoom',
})}
{...zoom.moveProps}
>
<div
ref={zoom.transformElRef}
style={{
width: '100%',
height: '100%',
pointerEvents: 'none',
transformOrigin: 'center center',
}}
>
<ScreenShareVideoTrack
trackRef={trackRef}
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
manageSubscription={manageSubscription}
/>
</div>
</div>
<ScreenShareZoomControls
containerRef={tileRef}
isZoomed={zoom.isZoomed}
zoomPercentage={zoom.zoomPercentage}
canZoomIn={zoom.canZoomIn}
canZoomOut={zoom.canZoomOut}
onZoomIn={zoom.zoomIn}
onZoomOut={zoom.zoomOut}
onResetZoom={zoom.resetZoom}
/>
</>
)
}
@@ -17,7 +17,7 @@ import {
type ProcessorType, type ProcessorType,
MEDIAPIPE_PATH_WASM, MEDIAPIPE_PATH_WASM,
} from '.' } from '.'
import { captureEvent } from '@/features/analytics/telemetry.ts' import { captureEvent, reportError } from '@/features/analytics/telemetry'
const PROCESSING_WIDTH = 256 const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144 const PROCESSING_HEIGHT = 144
@@ -26,6 +26,40 @@ const SEGMENTATION_MASK_CANVAS_ID = 'background-blur-local-segmentation'
const BLUR_CANVAS_ID = 'background-blur-local' const BLUR_CANVAS_ID = 'background-blur-local'
const DEFAULT_BLUR = '10' const DEFAULT_BLUR = '10'
const CONCEALING_BLUR = '25'
const FRAME_INTERVAL_MS = 1000 / 30
// After this many consecutive failed frames, stop segmenting and fall back to
// publishing a fully blurred frame: the user keeps a live camera instead of a
// frozen one, without ever exposing the surroundings they chose to conceal.
const MAX_CONSECUTIVE_ERRORS = 5
let webgl2Supported: boolean | undefined
/**
* MediaPipe's ImageSegmenter requires a WebGL2 context on the web even with
* `delegate: 'CPU'` (only inference runs on CPU; the mask post-processing in
* TensorsToSegmentationCalculator is GL-based). Without this check, machines
* with WebGL disabled or blocklisted fail at StartGraph with
* `emscripten_webgl_create_context() returned error 0`.
*
* The result is cached and the probe context is explicitly released so that
* repeated support checks do not count against the browser's limit on live
* WebGL contexts.
*/
const isWebGL2Supported = () => {
if (webgl2Supported === undefined) {
try {
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl2')
webgl2Supported = !!gl
gl?.getExtension('WEBGL_lose_context')?.loseContext()
} catch {
webgl2Supported = false
}
}
return webgl2Supported
}
/** /**
* This implementation of video blurring is made to be run on CPU for browser that are * This implementation of video blurring is made to be run on CPU for browser that are
@@ -42,14 +76,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
source?: MediaStreamTrack source?: MediaStreamTrack
sourceSettings?: MediaTrackSettings sourceSettings?: MediaTrackSettings
videoElement?: HTMLVideoElement videoElement?: HTMLVideoElement
videoElementLoaded?: boolean
// Canvas containing the video processing result, of which we extract as stream. // Canvas containing the video processing result, of which we extract as stream.
outputCanvas?: HTMLCanvasElement outputCanvas?: HTMLCanvasElement
outputCanvasCtx?: CanvasRenderingContext2D outputCanvasCtx?: CanvasRenderingContext2D
imageSegmenter?: ImageSegmenter imageSegmenter?: ImageSegmenter
imageSegmenterResult?: ImageSegmenterResult
// Canvas used for resizing video source and projecting mask. // Canvas used for resizing video source and projecting mask.
segmentationMaskCanvas?: HTMLCanvasElement segmentationMaskCanvas?: HTMLCanvasElement
@@ -66,6 +98,13 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
type: ProcessorType type: ProcessorType
virtualBackgroundImage?: HTMLImageElement virtualBackgroundImage?: HTMLImageElement
private virtualBackgroundImagePath?: string
private destroyed = false
private degraded = false
private consecutiveErrors = 0
private processing?: Promise<void>
private onVideoLoaded?: () => void
constructor(opts: ProcessorConfig) { constructor(opts: ProcessorConfig) {
this.name = 'blur' this.name = 'blur'
this.options = opts this.options = opts
@@ -73,7 +112,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
} }
static get isSupported() { static get isSupported() {
return navigator.userAgent.toLowerCase().includes('firefox') return (
navigator.userAgent.toLowerCase().includes('firefox') &&
isWebGL2Supported()
)
} }
async init(opts: ProcessorOptions<Track.Kind>) { async init(opts: ProcessorOptions<Track.Kind>) {
@@ -81,6 +123,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
throw new Error('Element is required for processing') throw new Error('Element is required for processing')
} }
this.destroyed = false
this.degraded = false
this.consecutiveErrors = 0
this.source = opts.track as MediaStreamTrack this.source = opts.track as MediaStreamTrack
this.sourceSettings = this.source!.getSettings() this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement this.videoElement = opts.element as HTMLVideoElement
@@ -97,26 +143,56 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.processedTrack = tracks[0] this.processedTrack = tracks[0]
this.segmentationMask = new ImageData(PROCESSING_WIDTH, PROCESSING_HEIGHT) this.segmentationMask = new ImageData(PROCESSING_WIDTH, PROCESSING_HEIGHT)
const t0 = performance.now()
await this.initSegmenter() await this.initSegmenter()
const segmenterInitMs = Math.round(performance.now() - t0)
this._initWorker() this._initWorker()
captureEvent('firefox-blurring-init', {}) captureEvent('legacy-background-processor', {
effect_type: this.options.type,
hw_concurrency: navigator.hardwareConcurrency,
video_width: this.videoElement?.videoWidth,
video_height: this.videoElement?.videoHeight,
segmenter_init_ms: segmenterInitMs,
})
} }
_initVirtualBackgroundImage() { _initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') { if (this.options.type !== 'virtual' || !this.options.imagePath) {
return return
} }
const needsUpdate = if (
this.options.imagePath &&
this.virtualBackgroundImage && this.virtualBackgroundImage &&
this.virtualBackgroundImage.src !== this.options.imagePath this.virtualBackgroundImagePath === this.options.imagePath
if (this.options.imagePath || needsUpdate) { ) {
this.virtualBackgroundImage = document.createElement('img') return
this.virtualBackgroundImage.crossOrigin = 'anonymous'
this.virtualBackgroundImage.src = this.options.imagePath!
} }
const image = document.createElement('img')
image.crossOrigin = 'anonymous'
image.src = this.options.imagePath
// Surface load failures once instead of letting drawImage throw on a
// broken image inside the processing loop.
image.decode().catch((error) => {
reportError('effects_processor_failure', error, {
context: 'Failed to load virtual background image',
image_path:
this.options.type === 'virtual' ? this.options.imagePath : undefined,
})
})
this.virtualBackgroundImage = image
this.virtualBackgroundImagePath = this.options.imagePath
}
_isVirtualBackgroundImageReady() {
return (
!!this.virtualBackgroundImage &&
this.virtualBackgroundImage.complete &&
this.virtualBackgroundImage.naturalWidth > 0
)
} }
async update(opts: ProcessorConfig): Promise<void> { async update(opts: ProcessorConfig): Promise<void> {
@@ -129,26 +205,26 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
name: 'Blurring', name: 'Blurring',
}) })
this.timerWorker.onmessage = (data) => this.onTimerMessage(data) this.timerWorker.onmessage = (data) => this.onTimerMessage(data)
// When hiding camera then showing it again, the onloadeddata callback is not fired again.
if (this.videoElementLoaded) { const startLoop = () => {
this.timerWorker!.postMessage({ this.onVideoLoaded = undefined
id: SET_TIMEOUT, this._syncOutputCanvasSize()
timeMs: 1000 / 30, this._scheduleNextFrame()
}) }
if (this.videoElement!.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
startLoop()
} else { } else {
this.videoElement!.onloadeddata = () => { this.onVideoLoaded = startLoop
this.videoElementLoaded = true this.videoElement!.addEventListener('loadeddata', this.onVideoLoaded, {
this.timerWorker!.postMessage({ once: true,
id: SET_TIMEOUT, })
timeMs: 1000 / 30,
})
}
} }
} }
onTimerMessage(response: { data: { id: number } }) { onTimerMessage(response: { data: { id: number } }) {
if (response.data.id === TIMEOUT_TICK) { if (response.data.id === TIMEOUT_TICK) {
this.process() this.processing = this.process()
} }
} }
@@ -194,30 +270,47 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
*/ */
async segment() { async segment() {
const startTimeMs = performance.now() const startTimeMs = performance.now()
return new Promise<void>((resolve) => { return new Promise<void>((resolve, reject) => {
this.imageSegmenter!.segmentForVideo( try {
this.sourceImageData!, this.imageSegmenter!.segmentForVideo(
startTimeMs, this.sourceImageData!,
(result: ImageSegmenterResult) => { startTimeMs,
this.imageSegmenterResult = result (result: ImageSegmenterResult) => {
resolve() try {
} // The mask is only valid for the duration of this callback:
) // MediaPipe frees the underlying WASM memory as soon as it
// returns, so the data must be copied out synchronously here.
this._applyMaskToAlphaChannel(result)
resolve()
} catch (error) {
reject(error)
}
}
)
} catch (error) {
reject(error)
}
}) })
} }
/** _applyMaskToAlphaChannel(result: ImageSegmenterResult) {
* TODO: future improvement with WebGL. const categoryMask = result.categoryMask
*/ if (!categoryMask) {
async blur() { return
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
} }
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array() const mask = categoryMask.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) { const alpha = this.segmentationMask!.data
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i] const length = Math.min(mask.length, alpha.length / 4)
for (let i = 0; i < length; ++i) {
alpha[i * 4 + 3] = 255 - mask[i]
} }
}
/**
* Composite the segmentation mask over the output canvas: mask first, then
* the clear body, leaving the background to be filled by the caller.
*/
_compositeMaskAndBody() {
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0) this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'copy' this.outputCanvasCtx!.globalCompositeOperation = 'copy'
@@ -240,6 +333,16 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.outputCanvasCtx!.globalCompositeOperation = 'source-in' this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none' this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0) this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
}
/**
* TODO: future improvement with WebGL.
*/
async blur() {
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
}
this._compositeMaskAndBody()
// Draw blurry background. // Draw blurry background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over' this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
@@ -251,87 +354,150 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
* TODO: future improvement with WebGL. * TODO: future improvement with WebGL.
*/ */
async drawVirtualBackground() { async drawVirtualBackground() {
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array() this._compositeMaskAndBody()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
}
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight
)
// Draw clear body.
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
// Draw virtual background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over' this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.drawImage( this.outputCanvasCtx!.filter = 'none'
this.virtualBackgroundImage!, if (this._isVirtualBackgroundImageReady()) {
0, // Draw virtual background.
0, this.outputCanvasCtx!.drawImage(
this.outputCanvas!.width, this.virtualBackgroundImage!,
this.outputCanvas!.height 0,
) 0,
this.outputCanvas!.width,
this.outputCanvas!.height
)
} else {
// Image not decoded (yet, or failed to load): fill the background with
// a heavy blur instead. Never fall back to the raw video here — the
// user selected this effect to conceal their surroundings, so the
// fallback must keep concealing them.
this.outputCanvasCtx!.filter = `blur(${CONCEALING_BLUR}px)`
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
}
}
/**
* Draw the whole frame heavily blurred (person included). Used when
* segmentation is broken: the outgoing video keeps flowing instead of
* freezing on a stale frame, while the surroundings the user chose to
* conceal stay concealed. Requires no segmenter, only one filtered draw.
*/
_drawDegradedFrame() {
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = `blur(${CONCEALING_BLUR}px)`
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
this.outputCanvasCtx!.filter = 'none'
} }
async process() { async process() {
await this.sizeSource() if (this.destroyed) {
await this.segment() return
if (this.options.type === 'blur') {
await this.blur()
} else {
await this.drawVirtualBackground()
} }
this.timerWorker!.postMessage({ try {
this._syncOutputCanvasSize()
// No decoded frame available (e.g. right after a device switch): skip
// this tick rather than processing a 0x0 source.
if (
!this.videoElement ||
this.videoElement.videoWidth === 0 ||
this.videoElement.videoHeight === 0
) {
this._scheduleNextFrame()
return
}
if (this.degraded) {
this._drawDegradedFrame()
this._scheduleNextFrame()
return
}
await this.sizeSource()
await this.segment()
if (this.destroyed) {
return
}
if (this.options.type === 'blur') {
await this.blur()
} else {
await this.drawVirtualBackground()
}
this.consecutiveErrors = 0
} catch (error) {
if (this.destroyed) {
return
}
this.consecutiveErrors += 1
if (this.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
// Degrade to a fully blurred frame: a live camera beats a frozen
// one, and concealment must survive the failure.
this.degraded = true
reportError('effects_processor_failure', error, {
context:
'Background processing failed repeatedly, falling back to fully blurred video',
consecutive_errors: this.consecutiveErrors,
})
this.imageSegmenter?.close()
this.imageSegmenter = undefined
}
}
this._scheduleNextFrame()
}
_scheduleNextFrame() {
if (this.destroyed) {
return
}
this.timerWorker?.postMessage({
id: SET_TIMEOUT, id: SET_TIMEOUT,
timeMs: 1000 / 30, timeMs: FRAME_INTERVAL_MS,
}) })
} }
_createMainCanvas() { /**
this.outputCanvas = document.querySelector( * Keep the output canvas in sync with the actual decoded video dimensions.
'canvas#background-blur-local' * `MediaStreamTrack.getSettings()` can be incomplete or stale on Firefox,
) as HTMLCanvasElement * so the video element is the source of truth.
if (!this.outputCanvas) { */
this.outputCanvas = this._createCanvas( _syncOutputCanvasSize() {
BLUR_CANVAS_ID, const width = this.videoElement?.videoWidth
this.sourceSettings!.width!, const height = this.videoElement?.videoHeight
this.sourceSettings!.height! if (!width || !height || !this.outputCanvas) {
) return
} }
if (
this.outputCanvas.width !== width ||
this.outputCanvas.height !== height
) {
this.outputCanvas.width = width
this.outputCanvas.height = height
}
}
_createMainCanvas() {
const width =
this.sourceSettings?.width || this.videoElement?.videoWidth || 1280
const height =
this.sourceSettings?.height || this.videoElement?.videoHeight || 720
this.outputCanvas = this._createCanvas(BLUR_CANVAS_ID, width, height)
this.outputCanvasCtx = this.outputCanvas.getContext('2d')! this.outputCanvasCtx = this.outputCanvas.getContext('2d')!
} }
_createMaskCanvas() { _createMaskCanvas() {
this.segmentationMaskCanvas = document.querySelector( this.segmentationMaskCanvas = this._createCanvas(
`#${SEGMENTATION_MASK_CANVAS_ID}` SEGMENTATION_MASK_CANVAS_ID,
) as HTMLCanvasElement PROCESSING_WIDTH,
if (!this.segmentationMaskCanvas) { PROCESSING_HEIGHT
this.segmentationMaskCanvas = this._createCanvas( )
SEGMENTATION_MASK_CANVAS_ID, // getImageData is called on this canvas 30 times per second: opt out of
PROCESSING_WIDTH, // GPU backing to avoid a costly readback on every frame.
PROCESSING_HEIGHT this.segmentationMaskCanvasCtx = this.segmentationMaskCanvas.getContext(
) '2d',
} { willReadFrequently: true }
this.segmentationMaskCanvasCtx = )!
this.segmentationMaskCanvas.getContext('2d')!
} }
_createCanvas(id: string, width: number, height: number) { _createCanvas(id: string, width: number, height: number) {
@@ -348,11 +514,39 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
} }
async destroy() { async destroy() {
this.destroyed = true
this.timerWorker?.postMessage({ this.timerWorker?.postMessage({
id: CLEAR_TIMEOUT, id: CLEAR_TIMEOUT,
}) })
// Let any in-flight frame finish before releasing the resources it uses,
// so segmentForVideo is never called on a closed segmenter.
try {
await this.processing
} catch {
// Failures are already handled inside process().
}
this.processing = undefined
if (this.onVideoLoaded && this.videoElement) {
this.videoElement.removeEventListener('loadeddata', this.onVideoLoaded)
}
this.onVideoLoaded = undefined
this.timerWorker?.terminate() this.timerWorker?.terminate()
this.timerWorker = undefined
this.imageSegmenter?.close() this.imageSegmenter?.close()
this.imageSegmenter = undefined
this.processedTrack?.stop()
this.processedTrack = undefined
this.outputCanvas = undefined
this.outputCanvasCtx = undefined
this.segmentationMaskCanvas = undefined
this.segmentationMaskCanvasCtx = undefined
this.sourceImageData = undefined
} }
} }
@@ -6,6 +6,7 @@ import type { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor' import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor' import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
import { FaceLandmarksOptions } from './FaceLandmarksProcessor' import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
import { captureEvent } from '@/features/analytics/telemetry'
export const SELFIE_SEGMENTER_MODEL_PATH = export const SELFIE_SEGMENTER_MODEL_PATH =
'/assets/mediapipe/models/selfie_segmenter_landscape.tflite' '/assets/mediapipe/models/selfie_segmenter_landscape.tflite'
@@ -31,15 +32,29 @@ export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind>
options: ProcessorConfig options: ProcessorConfig
} }
let unsupportedReported = false
export class BackgroundProcessorFactory { export class BackgroundProcessorFactory {
private static _isSupported?: boolean
static hasModernApiSupport() { static hasModernApiSupport() {
return ProcessorWrapper.hasModernApiSupport return ProcessorWrapper.hasModernApiSupport
} }
static isSupported() { static isSupported() {
return ( if (this._isSupported === undefined) {
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported this._isSupported =
) supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
}
if (!this._isSupported && !unsupportedReported) {
unsupportedReported = true
captureEvent('background-processor-unsupported', {
path: 'isSupported',
})
}
return this._isSupported
} }
static getProcessor( static getProcessor(
@@ -48,6 +63,8 @@ export class BackgroundProcessorFactory {
const isBlur = config.type === ProcessorType.BLUR const isBlur = config.type === ProcessorType.BLUR
const isVirtual = config.type === ProcessorType.VIRTUAL const isVirtual = config.type === ProcessorType.VIRTUAL
return new BackgroundCustomProcessor(config)
if (!isBlur && !isVirtual) return undefined if (!isBlur && !isVirtual) return undefined
if (supportsBackgroundProcessors()) { if (supportsBackgroundProcessors()) {
@@ -58,6 +75,12 @@ export class BackgroundProcessorFactory {
return new BackgroundCustomProcessor(config) return new BackgroundCustomProcessor(config)
} }
if (!unsupportedReported) {
captureEvent('background-processor-unsupported', {
path: 'getProcessor',
})
}
return undefined return undefined
} }
@@ -25,7 +25,11 @@ import {
} from '@/features/files/api/listFiles.ts' } from '@/features/files/api/listFiles.ts'
import { useCreateFile } from '@/features/files/api/createFile.ts' import { useCreateFile } from '@/features/files/api/createFile.ts'
import { FileTrigger } from 'react-aria-components' import { FileTrigger } from 'react-aria-components'
import { RiDeleteBinLine, RiImageAddFill } from '@remixicon/react' import {
RiDeleteBinLine,
RiImageAddFill,
RiProhibitedLine,
} from '@remixicon/react'
import { useDeleteFile } from '@/features/files/api/deleteFile.ts' import { useDeleteFile } from '@/features/files/api/deleteFile.ts'
import { useUser } from '@/features/auth/api/useUser' import { useUser } from '@/features/auth/api/useUser'
import { ApiFileItem } from '@/features/files/api/types.ts' import { ApiFileItem } from '@/features/files/api/types.ts'
@@ -197,10 +201,23 @@ export const EffectsConfiguration = ({
* *
* We arrive in this condition when we enter the room with the camera already off. * We arrive in this condition when we enter the room with the camera already off.
*/ */
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(config)! try {
await toggle(true, { const newProcessorTmp =
processor: newProcessorTmp, BackgroundProcessorFactory.getProcessor(config)!
}) await toggle(true, {
processor: newProcessorTmp,
})
} catch (error) {
reportError('effects_processor_failure', error, {
context: 'Error applying effect while enabling camera:',
})
saveProcessorConfig(undefined)
try {
await toggle(true)
} catch {
// Camera errors are handled by the toggle's own error path.
}
}
setTimeout(() => setProcessorPending(false)) setTimeout(() => setProcessorPending(false))
return return
} }
@@ -242,6 +259,14 @@ export const EffectsConfiguration = ({
reportError('effects_processor_failure', error, { reportError('effects_processor_failure', error, {
context: 'Error applying effect:', context: 'Error applying effect:',
}) })
try {
if (videoTrack.getProcessor()) {
await videoTrack.stopProcessor()
}
} catch {
// Best effort: the processor may already be broken.
}
saveProcessorConfig(undefined)
} finally { } finally {
// Without setTimeout the DOM is not refreshing when updating the options. // Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false)) setTimeout(() => setProcessorPending(false))
@@ -250,6 +275,24 @@ export const EffectsConfiguration = ({
[enabled, selectedId, toggle, updateEffectStatusMessage, videoTrack] [enabled, selectedId, toggle, updateEffectStatusMessage, videoTrack]
) )
const clearEffect = useCallback(async () => {
if (selectedId === 'none') return
setProcessorPending(true)
try {
if (videoTrack?.getProcessor()) {
await videoTrack.stopProcessor()
}
saveProcessorConfig(undefined)
announceEffectStatusMessage(t('blur.status.none'))
} catch (error) {
reportError('effects_processor_failure', error, {
context: 'Error clearing effect:',
})
} finally {
setTimeout(() => setProcessorPending(false))
}
}, [announceEffectStatusMessage, selectedId, t, videoTrack])
const { data: appConfig } = useConfig() const { data: appConfig } = useConfig()
const { isLoggedIn } = useUser() const { isLoggedIn } = useUser()
const canUploadBackground = const canUploadBackground =
@@ -647,6 +690,17 @@ export const EffectsConfiguration = ({
gap: '1.25rem', gap: '1.25rem',
})} })}
> >
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
tooltip={t('clear')}
isDisabled={processorOptions.isDisabled}
onChange={clearEffect}
isSelected={selectedId === 'none'}
data-attr="toggle-effect-none"
>
<RiProhibitedLine />
</ToggleButton>
{processorOptions.blurBased.map(({ Icon, ...option }) => ( {processorOptions.blurBased.map(({ Icon, ...option }) => (
<ToggleButton <ToggleButton
key={option.id} key={option.id}
@@ -22,10 +22,12 @@ import { VOICE_AUDIO_CONSTRAINTS } from '../utils/constants'
import { import {
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveAudioInputEnabled, saveAudioInputEnabled,
saveProcessorConfig,
saveVideoInputDeviceId, saveVideoInputDeviceId,
saveVideoInputEnabled, saveVideoInputEnabled,
userChoicesStore, userChoicesStore,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { reportError } from '@/features/analytics/telemetry'
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId' import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
// Module-level: effect dependencies, must be referentially stable. // Module-level: effect dependencies, must be referentially stable.
@@ -221,15 +223,32 @@ export function useJoinTracks(): {
[audioDeviceId] [audioDeviceId]
) )
const createVideo = useCallback( const createVideo = useCallback(async () => {
() => const processor =
createLocalVideoTrack({ BackgroundProcessorFactory.fromProcessorConfig(processorConfig)
if (!processor) {
return createLocalVideoTrack({ deviceId: videoDeviceId })
}
try {
return await createLocalVideoTrack({
deviceId: videoDeviceId, deviceId: videoDeviceId,
processor: processor,
BackgroundProcessorFactory.fromProcessorConfig(processorConfig), })
}), } catch (error) {
[videoDeviceId, processorConfig] // A camera problem (permission, device missing/busy) is not the
) // effect's fault: let the normal media error handling deal with it
// without touching the user's saved effect.
const e = getMediaDeviceFailure(error as Error)
if (e !== MediaDeviceFailure.Other && !!e) {
throw error
}
reportError('effects_processor_failure', error, {
context: 'Restoring saved effect failed, retrying without it',
})
saveProcessorConfig(undefined)
return createLocalVideoTrack({ deviceId: videoDeviceId })
}
}, [videoDeviceId, processorConfig])
const audioTrack = useLocalTrack({ const audioTrack = useLocalTrack({
ready: audioReady, ready: audioReady,
@@ -1,284 +0,0 @@
import { useCallback, useRef, useSyncExternalStore } from 'react'
import { useMove } from 'react-aria'
import type { MoveMoveEvent } from '@react-types/shared'
import {
FULL_PICTURE_RATIO,
MIN_ZOOM,
PAN_STEP,
WHEEL_ZOOM_SPEED,
ZOOM_STEP,
type PanOffset,
type ZoomSnapshot,
buildZoomSnapshot,
clampPan,
clampZoom,
getCursorFromZoomState,
getCursorPercentsFromWheelEvent,
getPanDeltaPercentsFromMove,
getPictureRatio,
getWheelPanOffset,
getZoomTransform,
} from '../utils/screenShareZoom'
/**
* Manages zoom and pan state for a remote screen share.
*
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
* (via transformElRef / surfaceElRef) so the hot path (drag, wheel) never
* triggers a React re-render. A useSyncExternalStore snapshot is flushed only
* when the toolbar UI needs to update (zoom level change, drag end).
*
* Drag/touch panning is handled by react-aria's useMove (moveProps).
* Ctrl/Cmd + wheel zoom is a native listener (must be non-passive to
* preventDefault and block browser page zoom).
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
* tile container (which has tabIndex=0 and focus).
*/
export const useScreenShareZoom = () => {
const zoomRef = useRef(MIN_ZOOM)
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
const draggingRef = useRef(false)
// The consumer binds these to the inner transform div and the outer drag surface.
const transformElRef = useRef<HTMLDivElement | null>(null)
const surfaceElRef = useRef<HTMLDivElement | null>(null)
// Snapshot store: subscribers are notified only on explicit flush() calls.
const snapshotRef = useRef<ZoomSnapshot>(
buildZoomSnapshot(MIN_ZOOM, { x: 0, y: 0 }, false)
)
const listenersRef = useRef(new Set<() => void>())
const subscribe = useCallback((cb: () => void) => {
listenersRef.current.add(cb)
return () => {
listenersRef.current.delete(cb)
}
}, [])
const getSnapshot = useCallback(() => snapshotRef.current, [])
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const flush = useCallback(() => {
snapshotRef.current = buildZoomSnapshot(
zoomRef.current,
panRef.current,
draggingRef.current
)
listenersRef.current.forEach((cb) => cb())
}, [])
const applyTransform = useCallback(() => {
const el = transformElRef.current
if (!el) return
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
}, [])
// The video is letterboxed inside the surface by object-fit: contain, so the
// pan bounds depend on how much of the surface the picture actually covers.
// Read live rather than cached: both the tile and the shared resolution can
// change at any time.
const readPictureRatio = useCallback(() => {
const surface = surfaceElRef.current
const video = transformElRef.current?.querySelector('video')
if (!surface || !video) return FULL_PICTURE_RATIO
return getPictureRatio(
surface.clientWidth,
surface.clientHeight,
video.videoWidth,
video.videoHeight
)
}, [])
const applyCursor = useCallback(() => {
const el = surfaceElRef.current
if (!el) return
el.style.cursor = getCursorFromZoomState(
zoomRef.current,
draggingRef.current
)
}, [])
const zoomIn = useCallback(() => {
const next = clampZoom(zoomRef.current + ZOOM_STEP)
zoomRef.current = next
panRef.current = clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush, readPictureRatio])
const zoomOut = useCallback(() => {
const next = clampZoom(zoomRef.current - ZOOM_STEP)
zoomRef.current = next
panRef.current =
next <= MIN_ZOOM
? { x: 0, y: 0 }
: clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush, readPictureRatio])
const resetZoom = useCallback(() => {
zoomRef.current = MIN_ZOOM
panRef.current = { x: 0, y: 0 }
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush])
// Must be attached with { passive: false } so preventDefault() blocks
// the browser's native Ctrl+scroll page zoom.
const handleWheel = useCallback(
(e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
e.stopPropagation()
const target = e.currentTarget as HTMLElement
const prev = zoomRef.current
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
const next = clampZoom(prev + delta)
if (next <= MIN_ZOOM) {
zoomRef.current = MIN_ZOOM
panRef.current = { x: 0, y: 0 }
} else {
const { cursorXPercent, cursorYPercent } =
getCursorPercentsFromWheelEvent(e, target)
zoomRef.current = next
panRef.current = getWheelPanOffset({
pan: panRef.current,
prevZoom: prev,
nextZoom: next,
cursorXPercent,
cursorYPercent,
ratio: readPictureRatio(),
})
}
applyTransform()
applyCursor()
flush()
},
[applyTransform, applyCursor, flush, readPictureRatio]
)
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
// here because moveProps is on the zoom surface, while focus is on the tile
// container, see handleKeyDown below.
const { moveProps } = useMove({
onMoveStart() {
if (zoomRef.current <= MIN_ZOOM) return
draggingRef.current = true
applyCursor()
flush()
},
onMove(e: MoveMoveEvent) {
if (zoomRef.current <= MIN_ZOOM) return
const el = surfaceElRef.current
if (!el) return
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
e.deltaX,
e.deltaY,
el
)
panRef.current = clampPan(
{
x: panRef.current.x + deltaXPercent,
y: panRef.current.y + deltaYPercent,
},
zoomRef.current,
readPictureRatio()
)
applyTransform()
// Mouse drag: skip flush (imperative-only) to avoid re-renders per frame.
// Keyboard: flush so the toolbar reflects the updated position.
if (e.pointerType === 'keyboard') {
flush()
}
},
onMoveEnd() {
draggingRef.current = false
applyTransform()
applyCursor()
flush()
},
})
const panBy = useCallback(
(dx: number, dy: number) => {
panRef.current = clampPan(
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
zoomRef.current,
readPictureRatio()
)
applyTransform()
flush()
},
[applyTransform, flush, readPictureRatio]
)
// Attached to the tile container (not the zoom surface) where keyboard
// focus lives. Arrows pan, +/-/0 zoom.
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const isZoomed = zoomRef.current > MIN_ZOOM
if (!isZoomed && e.key !== '+' && e.key !== '=') return
if (e.key.startsWith('Arrow') && e.target !== e.currentTarget) return
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
panBy(PAN_STEP, 0)
break
case 'ArrowRight':
e.preventDefault()
panBy(-PAN_STEP, 0)
break
case 'ArrowUp':
e.preventDefault()
panBy(0, PAN_STEP)
break
case 'ArrowDown':
e.preventDefault()
panBy(0, -PAN_STEP)
break
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
e.preventDefault()
zoomOut()
break
case '0':
e.preventDefault()
resetZoom()
break
}
},
[panBy, zoomIn, zoomOut, resetZoom]
)
return {
...snapshot,
transformElRef,
surfaceElRef,
moveProps,
zoomIn,
zoomOut,
resetZoom,
handleWheel,
handleKeyDown,
}
}
@@ -1,154 +0,0 @@
export const MIN_ZOOM = 1
export const MAX_ZOOM = 4
export const ZOOM_STEP = 0.1
export const WHEEL_ZOOM_SPEED = 0.002
export const PAN_STEP = 5
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
// cursor offset is measured from and the half extent the pan is clamped
// against, so the two stay consistent by construction.
export const HALF_EXTENT_PERCENT = 50
export interface PanOffset {
x: number
y: number
}
// Fraction of the surface each axis of the picture covers, in [0, 1].
export interface PictureRatio {
x: number
y: number
}
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
export interface ZoomSnapshot {
zoomLevel: number
zoomPercentage: number
panOffset: PanOffset
isZoomed: boolean
isDragging: boolean
canZoomIn: boolean
canZoomOut: boolean
}
export const clampZoom = (value: number) => {
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
}
// Restrict pan so the picture always covers the view. Pan is a % of the
// surface, in which object-fit: contain letterboxes the picture: its half
// extent is `ratio * 50` against a view half extent of 50, and scaling by
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
export const clampPan = (
pan: PanOffset,
zoom: number,
ratio: PictureRatio
): PanOffset => {
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * HALF_EXTENT_PERCENT)
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
return {
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
}
}
// Per-axis fraction of the surface covered by an object-fit: contain picture.
export const getPictureRatio = (
surfaceWidth: number,
surfaceHeight: number,
videoWidth: number,
videoHeight: number
): PictureRatio => {
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
return FULL_PICTURE_RATIO
}
const surfaceRatio = surfaceWidth / surfaceHeight
const videoRatio = videoWidth / videoHeight
return surfaceRatio > videoRatio
? { x: videoRatio / surfaceRatio, y: 1 }
: { x: 1, y: surfaceRatio / videoRatio }
}
export const buildZoomSnapshot = (
zoom: number,
pan: PanOffset,
dragging: boolean
): ZoomSnapshot => {
return {
zoomLevel: zoom,
zoomPercentage: Math.round(zoom * 100),
panOffset: pan,
isZoomed: zoom > MIN_ZOOM,
isDragging: dragging,
canZoomIn: zoom < MAX_ZOOM,
canZoomOut: zoom > MIN_ZOOM,
}
}
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
}
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
if (zoom <= MIN_ZOOM) return 'default'
return dragging ? 'grabbing' : 'grab'
}
// Keep the content point under the cursor anchored while zooming. With
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
// `z * (offset + pan)`, so holding it still gives:
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
export const getWheelPanOffset = ({
pan,
prevZoom,
nextZoom,
cursorXPercent,
cursorYPercent,
ratio,
}: {
pan: PanOffset
prevZoom: number
nextZoom: number
cursorXPercent: number
cursorYPercent: number
ratio: PictureRatio
}): PanOffset => {
const panShift = 1 / nextZoom - 1 / prevZoom
return clampPan(
{
x: pan.x + cursorXPercent * panShift,
y: pan.y + cursorYPercent * panShift,
},
nextZoom,
ratio
)
}
// Convert cursor pixel position to a % offset from the surface center.
export const getCursorPercentsFromWheelEvent = (
e: WheelEvent,
target: HTMLElement
) => {
const rect = target.getBoundingClientRect()
return {
cursorXPercent:
((e.clientX - rect.left) / rect.width) * 100 - HALF_EXTENT_PERCENT,
cursorYPercent:
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
}
}
// Convert useMove pixel deltas to % of the surface dimensions.
export const getPanDeltaPercentsFromMove = (
deltaX: number,
deltaY: number,
surface: HTMLElement
) => {
const rect = surface.getBoundingClientRect()
return {
deltaXPercent: (deltaX / rect.width) * 100,
deltaYPercent: (deltaY / rect.height) * 100,
}
}
@@ -18,6 +18,7 @@ import {
saveVideoPublishResolution, saveVideoPublishResolution,
saveVideoSubscribeQuality, saveVideoSubscribeQuality,
userChoicesStore, userChoicesStore,
VIDEO_RESOLUTIONS,
VideoResolution, VideoResolution,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper' import { RowWrapper } from './layout/RowWrapper'
@@ -70,7 +71,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
isDisabled: true, isDisabled: true,
} }
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => { const handleVideoResolutionChange = async (key: VideoResolution) => {
saveVideoPublishResolution(key) saveVideoPublishResolution(key)
const videoTrack = localParticipant.getTrackPublication( const videoTrack = localParticipant.getTrackPublication(
Track.Source.Camera Track.Source.Camera
@@ -124,20 +125,13 @@ export const VideoTab = ({ id }: VideoTabProps) => {
}, [videoDeviceId, videoElement]) }, [videoDeviceId, videoElement])
const resolutionItems = useMemo(() => { const resolutionItems = useMemo(() => {
return [ const labels: Record<VideoResolution, string> = {
{ h1080: `${t('resolution.publish.items.veryHigh')} (1080p)`,
value: 'h720', h720: `${t('resolution.publish.items.high')} (720p)`,
label: `${t('resolution.publish.items.high')} (720p)`, h360: `${t('resolution.publish.items.medium')} (360p)`,
}, h180: `${t('resolution.publish.items.low')} (180p)`,
{ }
value: 'h360', return VIDEO_RESOLUTIONS.map((value) => ({ value, label: labels[value] }))
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]
}, [t]) }, [t])
const videoQualityItems = useMemo(() => { const videoQualityItems = useMemo(() => {
+1 -28
View File
@@ -16,10 +16,6 @@ export type ShortcutId =
| 'recording' | 'recording'
| 'reaction' | 'reaction'
| 'fullscreen' | 'fullscreen'
| 'zoom-in'
| 'zoom-out'
| 'zoom-reset'
| 'zoom-pan'
export const getShortcutDescriptorById = (id: ShortcutId) => export const getShortcutDescriptorById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id) shortcutCatalog.find((item) => item.id === id)
@@ -28,7 +24,7 @@ export type ShortcutDescriptor = {
id: ShortcutId id: ShortcutId
category: ShortcutCategory category: ShortcutCategory
shortcut?: Shortcut shortcut?: Shortcut
kind?: 'press' | 'longPress' | 'arrows' kind?: 'press' | 'longPress'
code?: string // used when kind === 'longPress' (KeyboardEvent.code) code?: string // used when kind === 'longPress' (KeyboardEvent.code)
description?: string description?: string
} }
@@ -90,27 +86,4 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'interaction', category: 'interaction',
shortcut: { key: 'P', ctrlKey: true, shiftKey: true }, shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
}, },
// Screen share zoom keys are unmodified, so they are bound on the focused
// tile instead of being registered globally. They are listed here so the
// shortcuts panel stays exhaustive.
{
id: 'zoom-in',
category: 'interaction',
shortcut: { key: '+' },
},
{
id: 'zoom-out',
category: 'interaction',
shortcut: { key: '-' },
},
{
id: 'zoom-reset',
category: 'interaction',
shortcut: { key: '0' },
},
{
id: 'zoom-pan',
category: 'interaction',
kind: 'arrows',
},
] ]
@@ -25,7 +25,6 @@ export const formatShortcutLabelForSR = (
shiftLabel, shiftLabel,
plusLabel, plusLabel,
noShortcutLabel, noShortcutLabel,
keyLabels,
}: { }: {
controlLabel: string controlLabel: string
commandLabel: string commandLabel: string
@@ -34,12 +33,10 @@ export const formatShortcutLabelForSR = (
shiftLabel: string shiftLabel: string
plusLabel: string plusLabel: string
noShortcutLabel: string noShortcutLabel: string
// Spelled-out names for keys screen readers may skip or mispronounce.
keyLabels?: Record<string, string>
} }
) => { ) => {
if (!shortcut) return noShortcutLabel if (!shortcut) return noShortcutLabel
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase() const key = shortcut.key?.toUpperCase()
if (!key) return noShortcutLabel if (!key) return noShortcutLabel
const ctrlWord = isMacintosh() ? commandLabel : controlLabel const ctrlWord = isMacintosh() ? commandLabel : controlLabel
const altWord = isMacintosh() ? optionLabel : altLabel const altWord = isMacintosh() ? optionLabel : altLabel
@@ -12,9 +12,6 @@ export const useShortcutFormatting = () => {
const formatVisual = useCallback( const formatVisual = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => { (shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.visual.arrows')
}
if (code && kind === 'longPress') { if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code) const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.visual.hold', { key: label || '?' }) return t('shortcutsPanel.visual.hold', { key: label || '?' })
@@ -26,9 +23,6 @@ export const useShortcutFormatting = () => {
const formatForSR = useCallback( const formatForSR = useCallback(
(shortcut?: Shortcut, code?: string, kind?: string) => { (shortcut?: Shortcut, code?: string, kind?: string) => {
if (kind === 'arrows') {
return t('shortcutsPanel.sr.arrows')
}
if (code && kind === 'longPress') { if (code && kind === 'longPress') {
const label = getKeyLabelFromCode(code) const label = getKeyLabelFromCode(code)
return t('shortcutsPanel.sr.hold', { key: label || '?' }) return t('shortcutsPanel.sr.hold', { key: label || '?' })
@@ -41,10 +35,6 @@ export const useShortcutFormatting = () => {
shiftLabel: t('shortcutsPanel.sr.shift'), shiftLabel: t('shortcutsPanel.sr.shift'),
plusLabel: t('shortcutsPanel.sr.plus'), plusLabel: t('shortcutsPanel.sr.plus'),
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'), noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
keyLabels: {
'+': t('shortcutsPanel.sr.plusKey'),
'-': t('shortcutsPanel.sr.minusKey'),
},
}) })
}, },
[t] [t]
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "{{name}} stummschalten", "muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild" "fullScreen": "Vollbild"
}, },
"screenShareZoom": {
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
"zoomIn": "Vergrößern",
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
"zoomOut": "Verkleinern",
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
"wheelShortcut": "Steuerung plus Mausrad",
"wheelShortcutMac": "Befehl plus Mausrad",
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
"fitToWindow": "An Fenster anpassen",
"fullScreen": "Vollbild",
"exitFullScreen": "Vollbild beenden",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
"fullScreenEntered": "Vollbild aktiviert",
"fullScreenExited": "Vollbild deaktiviert"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Tastenkürzel", "title": "Tastenkürzel",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Hand heben oder senken", "raise-hand": "Hand heben oder senken",
"toggle-chat": "Chat anzeigen/ausblenden", "toggle-chat": "Chat anzeigen/ausblenden",
"toggle-participants": "Teilnehmende anzeigen/ausblenden", "toggle-participants": "Teilnehmende anzeigen/ausblenden",
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen", "open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
}, },
"sr": { "sr": {
"control": "Steuerung", "control": "Steuerung",
@@ -807,14 +785,10 @@
"shift": "Umschalt", "shift": "Umschalt",
"plus": "plus", "plus": "plus",
"hold": "Halte {{key}} gedrückt", "hold": "Halte {{key}} gedrückt",
"arrows": "Pfeiltasten",
"plusKey": "Plus-Taste",
"minusKey": "Minus-Taste",
"noShortcut": "Kein Tastenkürzel" "noShortcut": "Kein Tastenkürzel"
}, },
"visual": { "visual": {
"hold": "Halte {{key}} gedrückt", "hold": "Halte {{key}} gedrückt"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Wähle die maximale Auflösung beim Senden", "label": "Wähle die maximale Auflösung beim Senden",
"items": { "items": {
"veryHigh": "Sehr hohe Auflösung",
"high": "Hohe Auflösung", "high": "Hohe Auflösung",
"medium": "Mittlere Auflösung", "medium": "Mittlere Auflösung",
"low": "Niedrige Auflösung" "low": "Niedrige Auflösung"
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Mute {{name}}", "muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen" "fullScreen": "Full screen"
}, },
"screenShareZoom": {
"toolbarLabel": "Screen share zoom controls",
"zoomIn": "Zoom in",
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
"zoomOut": "Zoom out",
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
"wheelShortcut": "Control plus scroll wheel",
"wheelShortcutMac": "Command plus scroll wheel",
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
"fitToWindow": "Fit to window",
"fullScreen": "Full screen",
"exitFullScreen": "Exit full screen",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
"fullScreenEntered": "Full screen enabled",
"fullScreenExited": "Full screen disabled"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Keyboard shortcuts", "title": "Keyboard shortcuts",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Raise or lower hand", "raise-hand": "Raise or lower hand",
"toggle-chat": "Toggle chat", "toggle-chat": "Toggle chat",
"toggle-participants": "Toggle participants", "toggle-participants": "Toggle participants",
"open-shortcuts-settings": "Open shortcuts settings", "open-shortcuts-settings": "Open shortcuts settings"
"zoom-in": "Zoom in on a shared screen",
"zoom-out": "Zoom out on a shared screen",
"zoom-reset": "Reset the shared screen zoom",
"zoom-pan": "Move around a zoomed shared screen"
}, },
"sr": { "sr": {
"control": "Control", "control": "Control",
@@ -807,14 +785,10 @@
"shift": "Shift", "shift": "Shift",
"plus": "plus", "plus": "plus",
"hold": "Hold {{key}}", "hold": "Hold {{key}}",
"arrows": "Arrow keys",
"plusKey": "Plus key",
"minusKey": "Minus key",
"noShortcut": "No shortcut" "noShortcut": "No shortcut"
}, },
"visual": { "visual": {
"hold": "Hold {{key}}", "hold": "Hold {{key}}"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Select your sending resolution (max.)", "label": "Select your sending resolution (max.)",
"items": { "items": {
"veryHigh": "Very high definition",
"high": "High definition", "high": "High definition",
"medium": "Standard definition", "medium": "Standard definition",
"low": "Low definition" "low": "Low definition"
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Selecciona tu resolución de envío (máx.)", "label": "Selecciona tu resolución de envío (máx.)",
"items": { "items": {
"veryHigh": "Muy alta definición",
"high": "Alta definición", "high": "Alta definición",
"medium": "Definición estándar", "medium": "Definición estándar",
"low": "Baja definición" "low": "Baja definición"
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Couper le micro de {{name}}", "muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran" "fullScreen": "Plein écran"
}, },
"screenShareZoom": {
"toolbarLabel": "Contrôles de zoom du partage d'écran",
"zoomIn": "Zoomer",
"zoomInWithShortcut": "Zoomer ({{shortcut}})",
"zoomOut": "Dézoomer",
"zoomOutWithShortcut": "Dézoomer ({{shortcut}})",
"wheelShortcut": "Contrôle plus molette",
"wheelShortcutMac": "Commande plus molette",
"wheelShortcutHint": "Raccourci : Contrôle plus molette pour zoomer ou dézoomer.",
"wheelShortcutHintMac": "Raccourci : Commande plus molette pour zoomer ou dézoomer.",
"fitToWindow": "Ajuster à la fenêtre",
"fullScreen": "Plein écran",
"exitFullScreen": "Quitter le plein écran",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Glissez avec la souris, ou revenez sur le partage d'écran et utilisez les touches fléchées pour naviguer dans l'image.",
"fullScreenEntered": "Plein écran activé",
"fullScreenExited": "Plein écran désactivé"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Raccourcis clavier", "title": "Raccourcis clavier",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Lever ou baisser la main", "raise-hand": "Lever ou baisser la main",
"toggle-chat": "Afficher/Masquer le chat", "toggle-chat": "Afficher/Masquer le chat",
"toggle-participants": "Afficher/Masquer les participants", "toggle-participants": "Afficher/Masquer les participants",
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis", "open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
"zoom-in": "Zoomer sur un écran partagé",
"zoom-out": "Dézoomer sur un écran partagé",
"zoom-reset": "Réinitialiser le zoom de l’écran partagé",
"zoom-pan": "Se déplacer dans un écran partagé zoomé"
}, },
"sr": { "sr": {
"control": "Contrôle", "control": "Contrôle",
@@ -807,14 +785,10 @@
"shift": "Majuscule", "shift": "Majuscule",
"plus": "plus", "plus": "plus",
"hold": "Maintenir {{key}}", "hold": "Maintenir {{key}}",
"arrows": "Touches fléchées",
"plusKey": "Touche plus",
"minusKey": "Touche moins",
"noShortcut": "Aucun raccourci" "noShortcut": "Aucun raccourci"
}, },
"visual": { "visual": {
"hold": "Maintenir {{key}}", "hold": "Maintenir {{key}}"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Sélectionner votre résolution d'envoi (max.)", "label": "Sélectionner votre résolution d'envoi (max.)",
"items": { "items": {
"veryHigh": "Très haute définition",
"high": "Haute définition", "high": "Haute définition",
"medium": "Définition standard", "medium": "Définition standard",
"low": "Basse définition" "low": "Basse définition"
+2 -28
View File
@@ -756,24 +756,6 @@
"muteParticipant": "Demp {{name}}", "muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm" "fullScreen": "Volledig scherm"
}, },
"screenShareZoom": {
"toolbarLabel": "Zoombediening voor schermdeling",
"zoomIn": "Inzoomen",
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
"zoomOut": "Uitzoomen",
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
"wheelShortcut": "Control plus scrollwiel",
"wheelShortcutMac": "Command plus scrollwiel",
"wheelShortcutHint": "Sneltoets: Control plus scrollwiel om in of uit te zoomen.",
"wheelShortcutHintMac": "Sneltoets: Command plus scrollwiel om in of uit te zoomen.",
"fitToWindow": "Aanpassen aan venster",
"fullScreen": "Volledig scherm",
"exitFullScreen": "Volledig scherm verlaten",
"currentZoomLevel": "Zoom {{level}} %",
"panHint": "Zoom {{level}} %. Sleep met de muis, of zet de focus terug op de schermdeling en gebruik de pijltjestoetsen om door het beeld te navigeren.",
"fullScreenEntered": "Volledig scherm ingeschakeld",
"fullScreenExited": "Volledig scherm uitgeschakeld"
},
"shortcutsPanel": { "shortcutsPanel": {
"title": "Sneltoetsen", "title": "Sneltoetsen",
"categories": { "categories": {
@@ -793,11 +775,7 @@
"raise-hand": "Hand opsteken of laten zakken", "raise-hand": "Hand opsteken of laten zakken",
"toggle-chat": "Chat tonen/verbergen", "toggle-chat": "Chat tonen/verbergen",
"toggle-participants": "Deelnemers tonen/verbergen", "toggle-participants": "Deelnemers tonen/verbergen",
"open-shortcuts-settings": "Sneltoets-instellingen openen", "open-shortcuts-settings": "Sneltoets-instellingen openen"
"zoom-in": "Inzoomen op een gedeeld scherm",
"zoom-out": "Uitzoomen op een gedeeld scherm",
"zoom-reset": "Zoom van het gedeelde scherm herstellen",
"zoom-pan": "Navigeren in een ingezoomd gedeeld scherm"
}, },
"sr": { "sr": {
"control": "Control", "control": "Control",
@@ -807,14 +785,10 @@
"shift": "Shift", "shift": "Shift",
"plus": "plus", "plus": "plus",
"hold": "Houd {{key}} ingedrukt", "hold": "Houd {{key}} ingedrukt",
"arrows": "Pijltoetsen",
"plusKey": "Plus-toets",
"minusKey": "Min-toets",
"noShortcut": "Geen sneltoets" "noShortcut": "Geen sneltoets"
}, },
"visual": { "visual": {
"hold": "Houd {{key}} ingedrukt", "hold": "Houd {{key}} ingedrukt"
"arrows": "↑ ↓ ← →"
} }
}, },
"fullScreenWarning": { "fullScreenWarning": {
@@ -56,6 +56,7 @@
"publish": { "publish": {
"label": "Selecteer uw verzendresolutie (max.)", "label": "Selecteer uw verzendresolutie (max.)",
"items": { "items": {
"veryHigh": "Zeer hoge definitie",
"high": "Hoge definitie", "high": "Hoge definitie",
"medium": "Standaarddefinitie", "medium": "Standaarddefinitie",
"low": "Lage definitie" "low": "Lage definitie"
+11 -2
View File
@@ -10,7 +10,12 @@ import {
} from '@livekit/components-core' } from '@livekit/components-core'
import { VideoQuality } from 'livekit-client' import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180' export const VIDEO_RESOLUTIONS = ['h1080', 'h720', 'h360', 'h180'] as const
export type VideoResolution = (typeof VIDEO_RESOLUTIONS)[number]
const isVideoResolution = (value: unknown): value is VideoResolution =>
VIDEO_RESOLUTIONS.includes(value as VideoResolution)
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & { export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
processorConfig?: ProcessorConfig processorConfig?: ProcessorConfig
@@ -21,13 +26,17 @@ export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
} }
function getUserChoicesState(): LocalUserChoices { function getUserChoicesState(): LocalUserChoices {
return { const stored: LocalUserChoices = {
noiseReductionEnabled: false, noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
videoPublishResolution: 'h720', videoPublishResolution: 'h720',
videoSubscribeQuality: VideoQuality.HIGH, videoSubscribeQuality: VideoQuality.HIGH,
...loadUserChoices(), ...loadUserChoices(),
} }
if (!isVideoResolution(stored.videoPublishResolution)) {
stored.videoPublishResolution = 'h720'
}
return stored
} }
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState()) export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())