diff --git a/CHANGELOG.md b/CHANGELOG.md index 1078d410..858ab8c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(frontend) introduce performance mode with auto-detection and telemetry + ### Changed - 🔥(frontend) drop unused vendored ConnectionObserver diff --git a/src/frontend/src/features/analytics/hardware.ts b/src/frontend/src/features/analytics/hardware.ts new file mode 100644 index 00000000..51096fa9 --- /dev/null +++ b/src/frontend/src/features/analytics/hardware.ts @@ -0,0 +1,68 @@ +export interface HardwareSnapshot { + /** navigator.hardwareConcurrency — logical CPU cores. */ + cpu_cores: number | null + /** navigator.deviceMemory — RAM in GiB, bucketed by the browser (Chromium only). */ + device_memory_gb: number | null + /** performance.memory.jsHeapSizeLimit in MB (Chromium only, non-standard). */ + js_heap_limit_mb: number | null + /** performance.memory.usedJSHeapSize in MB (Chromium only, non-standard). */ + js_heap_used_mb: number | null + /** Battery level 0..1 via navigator.getBattery() (Chromium only). */ + battery_level: number | null + /** Whether the device is plugged in, via navigator.getBattery(). */ + battery_charging: boolean | null +} + +const BATTERY_TIMEOUT_MS = 1_000 + +const toMb = (bytes: unknown): number | null => + typeof bytes === 'number' ? Math.round(bytes / (1024 * 1024)) : null + +export const collectHardwareSnapshot = async (): Promise => { + const snapshot: HardwareSnapshot = { + cpu_cores: null, + device_memory_gb: null, + js_heap_limit_mb: null, + js_heap_used_mb: null, + battery_level: null, + battery_charging: null, + } + + try { + snapshot.cpu_cores = navigator.hardwareConcurrency ?? null + + const nav = navigator as Navigator & { + deviceMemory?: number + userAgentData?: { mobile?: boolean; platform?: string } + getBattery?: () => Promise<{ level: number; charging: boolean }> + } + + snapshot.device_memory_gb = nav.deviceMemory ?? null + + const memory = ( + performance as Performance & { + memory?: { jsHeapSizeLimit?: number; usedJSHeapSize?: number } + } + ).memory + snapshot.js_heap_limit_mb = toMb(memory?.jsHeapSizeLimit) + snapshot.js_heap_used_mb = toMb(memory?.usedJSHeapSize) + + if (typeof nav.getBattery === 'function') { + // getBattery can hang on some platforms — don't let it delay the event. + const battery = await Promise.race([ + nav.getBattery(), + new Promise((resolve) => + setTimeout(() => resolve(null), BATTERY_TIMEOUT_MS) + ), + ]).catch(() => null) + if (battery) { + snapshot.battery_level = battery.level + snapshot.battery_charging = battery.charging + } + } + } catch { + // telemetry must never break the app + } + + return snapshot +} diff --git a/src/frontend/src/features/analytics/telemetry.ts b/src/frontend/src/features/analytics/telemetry.ts index d8d64669..a0788bd8 100644 --- a/src/frontend/src/features/analytics/telemetry.ts +++ b/src/frontend/src/features/analytics/telemetry.ts @@ -30,6 +30,7 @@ export type LogCode = | 'clipboard_failure' | 'fullscreen_failure' | 'publish_sources_failure' + | 'performance_mode_failure' | 'disconnect_failure' | 'generic_failure' diff --git a/src/frontend/src/features/notifications/NotificationDuration.ts b/src/frontend/src/features/notifications/NotificationDuration.ts index 193187f9..cf6c4159 100644 --- a/src/frontend/src/features/notifications/NotificationDuration.ts +++ b/src/frontend/src/features/notifications/NotificationDuration.ts @@ -3,6 +3,7 @@ export enum ToastDuration { MEDIUM = 4000, LONG = 5000, EXTRA_LONG = 7000, + UNDO_WINDOW = 30000, } export const NotificationDuration = { @@ -15,4 +16,5 @@ export const NotificationDuration = { REACTION_RECEIVED: ToastDuration.SHORT, RECORDING_REQUESTED: ToastDuration.LONG, ROLE_CHANGED: ToastDuration.LONG, + CPU_CONSTRAINED: ToastDuration.UNDO_WINDOW, } as const diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index e42fe7c3..3c18fd37 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -18,4 +18,5 @@ export enum NotificationType { RecordingSaving = 'recordingSaving', PermissionsRemoved = 'permissionsRemoved', RoleChanged = 'roleChanged', + CpuConstrained = 'cpuConstrained', } diff --git a/src/frontend/src/features/notifications/components/ToastCpuConstrained.tsx b/src/frontend/src/features/notifications/components/ToastCpuConstrained.tsx new file mode 100644 index 00000000..7fb26660 --- /dev/null +++ b/src/frontend/src/features/notifications/components/ToastCpuConstrained.tsx @@ -0,0 +1,61 @@ +import { useToast } from 'react-aria' +import { useRef } from 'react' + +import { type ToastProps } from './Toast' +import { VStack } from '@/styled-system/jsx' +import { useTranslation } from 'react-i18next' +import { Button, Text } from '@/primitives' +import { css } from '@/styled-system/css' +import { StyledToastContainer } from './StyledToastContainer' +import { disablePerformanceMode } from '@/stores/performanceMode' +import { captureEvent } from '@/features/analytics/telemetry' + +// todo - make it closable +export function ToastCpuConstrained({ state, ...props }: Readonly) { + const { t } = useTranslation('notifications', { + keyPrefix: 'cpuConstrained', + }) + const ref = useRef(null) + const { toastProps, contentProps } = useToast(props, state, ref) + const toast = props.toast + + const handleKeepQuality = () => { + captureEvent('cpu-constrained-degradation-cancelled') + disablePerformanceMode({ declinedAuto: true }) + state.close(toast.key) + } + + return ( + + + + {t('message')} + + + + + ) +} diff --git a/src/frontend/src/features/notifications/components/ToastRegion.tsx b/src/frontend/src/features/notifications/components/ToastRegion.tsx index f39c667b..54881033 100644 --- a/src/frontend/src/features/notifications/components/ToastRegion.tsx +++ b/src/frontend/src/features/notifications/components/ToastRegion.tsx @@ -15,6 +15,7 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved' import { ToastRecordingRequest } from './ToastRecordingRequest' import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom' import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged' +import { ToastCpuConstrained } from './ToastCpuConstrained' interface ToastRegionProps extends AriaToastRegionProps { state: ToastState @@ -74,6 +75,9 @@ const renderToast = ( case NotificationType.RoleChanged: return + case NotificationType.CpuConstrained: + return + default: return } diff --git a/src/frontend/src/features/notifications/utils.ts b/src/frontend/src/features/notifications/utils.ts index d1dbda00..829d7ad8 100644 --- a/src/frontend/src/features/notifications/utils.ts +++ b/src/frontend/src/features/notifications/utils.ts @@ -15,6 +15,15 @@ export const notifyAutoMutedOnJoin = () => { ) } +export const notifyCpuConstrained = () => { + toastQueue.add( + { + type: NotificationType.CpuConstrained, + }, + { timeout: NotificationDuration.CPU_CONSTRAINED } + ) +} + export const showLowerHandToast = ( participant: Participant, onClose: () => void diff --git a/src/frontend/src/features/performance/components/CpuConstrainedObserver.tsx b/src/frontend/src/features/performance/components/CpuConstrainedObserver.tsx new file mode 100644 index 00000000..e3f0872a --- /dev/null +++ b/src/frontend/src/features/performance/components/CpuConstrainedObserver.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react' +import { useRoomContext } from '@livekit/components-react' +import { + LocalTrackPublication, + LocalVideoTrack, + ParticipantEvent, + Track, +} from 'livekit-client' + +import { captureEvent } from '@/features/analytics/telemetry' +import { collectHardwareSnapshot } from '@/features/analytics/hardware' +import { notifyCpuConstrained } from '@/features/notifications/utils' +import { isFireFox } from '@/utils/livekit' +import { + enablePerformanceMode, + performanceModeStore, +} from '@/stores/performanceMode' + +export const CpuConstrainedObserver = () => { + const room = useRoomContext() + const degradedTracksRef = useRef(new WeakSet()) + + useEffect(() => { + const localParticipant = room.localParticipant + + const handleCpuConstrained = ( + track: LocalVideoTrack, + publication: LocalTrackPublication + ) => { + const { enabled, userDeclinedAuto } = performanceModeStore + + const shouldDegrade = + publication.source === Track.Source.Camera && + !enabled && + !userDeclinedAuto && + !degradedTracksRef.current.has(track) + + void collectHardwareSnapshot().then((hardware) => { + captureEvent('cpu-constrained', { + firefox: isFireFox(), + source: publication.source, + degraded: shouldDegrade, + trackOptions: publication.options, + performance_mode_enabled: enabled, + user_declined_auto: userDeclinedAuto, + ...hardware, + }) + }) + + if (!shouldDegrade) return + degradedTracksRef.current.add(track) + + enablePerformanceMode('cpu') + notifyCpuConstrained() + } + + localParticipant.on( + ParticipantEvent.LocalTrackCpuConstrained, + handleCpuConstrained + ) + + return () => { + localParticipant.off( + ParticipantEvent.LocalTrackCpuConstrained, + handleCpuConstrained + ) + } + }, [room]) + + return null +} diff --git a/src/frontend/src/features/performance/components/PerformanceModeController.tsx b/src/frontend/src/features/performance/components/PerformanceModeController.tsx new file mode 100644 index 00000000..840eed67 --- /dev/null +++ b/src/frontend/src/features/performance/components/PerformanceModeController.tsx @@ -0,0 +1,108 @@ +import { useEffect } from 'react' +import { useRoomContext } from '@livekit/components-react' +import { + LocalTrackPublication, + LocalVideoTrack, + ParticipantEvent, + Track, + TrackEvent, +} from 'livekit-client' +import { useSnapshot } from 'valtio' +import { reportError } from '@/features/analytics/telemetry' +import { + disablePerformanceMode, + performanceModeStore, +} from '@/stores/performanceMode' +import { degradeVideoTrack, restoreVideoTrack } from '../degradation' + +/** Delay to re-apply degradation after restart to avoid racing LiveKit's encoding recompute. */ +const REAPPLY_AFTER_RESTART_MS = 1_000 + +/** Syncs performance mode store state to outbound camera track encoding settings. */ +export const PerformanceModeController = () => { + const room = useRoomContext() + const { enabled } = useSnapshot(performanceModeStore) + + // Manage degradation application and track lifecycle events + useEffect(() => { + const localParticipant = room.localParticipant + + const getCameraTrack = () => { + const pub = localParticipant.getTrackPublication(Track.Source.Camera) + return pub?.track instanceof LocalVideoTrack ? pub.track : null + } + + const track = getCameraTrack() + + // Restore track quality if performance mode is disabled + if (!enabled) { + if (track) { + restoreVideoTrack(track).catch((err) => + reportError('performance_mode_failure', err, { action: 'restore' }) + ) + } + return + } + + const applyDegradation = (t: LocalVideoTrack, action = 'degrade') => { + degradeVideoTrack(t).catch((err) => + reportError('performance_mode_failure', err, { action }) + ) + } + + // Re-apply degradation after track restarts (e.g. device/resolution changes) + const watchTrackRestart = (t: LocalVideoTrack) => { + let timeoutId: ReturnType + const onRestarted = () => { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => { + if (performanceModeStore.enabled) applyDegradation(t, 'reapply') + }, REAPPLY_AFTER_RESTART_MS) + } + t.on(TrackEvent.Restarted, onRestarted) + return () => { + clearTimeout(timeoutId) + t.off(TrackEvent.Restarted, onRestarted) + } + } + + let unwatchRestart: (() => void) | undefined + + if (track) { + applyDegradation(track) + unwatchRestart = watchTrackRestart(track) + } + + // Apply degradation to newly published camera tracks + const handlePublished = (pub: LocalTrackPublication) => { + if ( + pub.source === Track.Source.Camera && + pub.track instanceof LocalVideoTrack + ) { + unwatchRestart?.() + applyDegradation(pub.track) + unwatchRestart = watchTrackRestart(pub.track) + } + } + localParticipant.on(ParticipantEvent.LocalTrackPublished, handlePublished) + + return () => { + localParticipant.off( + ParticipantEvent.LocalTrackPublished, + handlePublished + ) + unwatchRestart?.() + } + }, [room, enabled]) + + // Reset auto (CPU-triggered) performance mode on unmount/leave room + useEffect(() => { + return () => { + if (performanceModeStore.trigger === 'cpu') { + disablePerformanceMode() + } + } + }, []) + + return null +} diff --git a/src/frontend/src/features/performance/degradation.ts b/src/frontend/src/features/performance/degradation.ts new file mode 100644 index 00000000..af18baa7 --- /dev/null +++ b/src/frontend/src/features/performance/degradation.ts @@ -0,0 +1,126 @@ +import { LocalVideoTrack } from 'livekit-client' +import { isFireFox } from '@/utils/livekit' + +/** Target degraded encoding for layer 0 (~360p @ 15fps, ≤300kbps). */ +export const DEGRADED_MAX_HEIGHT = 360 +export const DEGRADED_MAX_FRAMERATE = 15 +export const DEGRADED_MAX_BITRATE = 300_000 + +/** + * Firefox ignores `active = false` on RTCRtpSender. + * We starve higher layers using LiveKit's sentinel workaround values instead. + */ +const FF_DISABLED_SCALE_DOWN = 4 +const FF_DISABLED_MAX_BITRATE = 10 +const FF_DISABLED_MAX_FRAMERATE = 2 + +type SavedEncoding = Pick< + RTCRtpEncodingParameters, + 'active' | 'scaleResolutionDownBy' | 'maxBitrate' | 'maxFramerate' +> + +/** Pre-degradation encoding snapshots keyed by track to prevent leaks. */ +const savedEncodingsByTrack = new WeakMap() + +export const isTrackDegraded = (track: LocalVideoTrack) => + savedEncodingsByTrack.has(track) + +/** + * Revertibly degrades local video quality without unpublishing. + * Avoids `prioritizePerformance()` because its internal flag disables dynacast permanently. + * + * - Layer 0: Capped to 360p / 15fps / 300kbps. + * - Other layers: Set to `active: false` (or starved on Firefox). + */ +export const degradeVideoTrack = async (track: LocalVideoTrack) => { + const sender = track.sender + if (!sender) { + throw new Error('sender not found') + } + + const params = sender.getParameters() + if (!params.encodings || params.encodings.length === 0) { + return + } + + // Snapshot once so re-applications don't overwrite pristine encodings + if (!savedEncodingsByTrack.has(track)) { + savedEncodingsByTrack.set( + track, + params.encodings.map((e) => ({ + active: e.active, + scaleResolutionDownBy: e.scaleResolutionDownBy, + maxBitrate: e.maxBitrate, + maxFramerate: e.maxFramerate, + })) + ) + } + + const captureHeight = + track.mediaStreamTrack.getSettings().height ?? DEGRADED_MAX_HEIGHT + + params.encodings = params.encodings.map((encoding, idx) => { + if (idx === 0) { + return { + ...encoding, + active: true, + scaleResolutionDownBy: Math.max( + 1, + Math.ceil(captureHeight / DEGRADED_MAX_HEIGHT) + ), + maxFramerate: DEGRADED_MAX_FRAMERATE, + maxBitrate: Math.min( + encoding.maxBitrate ?? DEGRADED_MAX_BITRATE, + DEGRADED_MAX_BITRATE + ), + } + } + + if (isFireFox()) { + const starved: RTCRtpEncodingParameters = { + ...encoding, + // Firefox workaround: active=false prevents LiveKit re-encodes, while starved values limit bitrate + active: false, + scaleResolutionDownBy: FF_DISABLED_SCALE_DOWN, + maxBitrate: FF_DISABLED_MAX_BITRATE, + maxFramerate: FF_DISABLED_MAX_FRAMERATE, + } + // LiveKit legacy property fallback for Firefox + ;(starved as Record).maxFrameRate = + FF_DISABLED_MAX_FRAMERATE + return starved + } + + return { ...encoding, active: false } + }) + + await sender.setParameters(params) +} + +/** Restores encodings captured prior to degradation. */ +export const restoreVideoTrack = async (track: LocalVideoTrack) => { + const saved = savedEncodingsByTrack.get(track) + savedEncodingsByTrack.delete(track) + + const sender = track.sender + if (!saved || !sender) { + return + } + + const params = sender.getParameters() + if (!params.encodings || params.encodings.length !== saved.length) { + return + } + + params.encodings = params.encodings.map((encoding, idx) => { + const restored: RTCRtpEncodingParameters = { + ...encoding, + ...saved[idx], + } + // Clean up Firefox legacy property if set during degradation + ;(restored as Record).maxFrameRate = undefined + return restored + }) + + await sender.setParameters(params) +} diff --git a/src/frontend/src/features/rooms/livekit/components/VideoResolutionSubscription.tsx b/src/frontend/src/features/rooms/livekit/components/VideoResolutionSubscription.tsx index b045140a..f13591d9 100644 --- a/src/frontend/src/features/rooms/livekit/components/VideoResolutionSubscription.tsx +++ b/src/frontend/src/features/rooms/livekit/components/VideoResolutionSubscription.tsx @@ -9,15 +9,18 @@ import { } from 'livekit-client' import { useSnapshot } from 'valtio' import { userChoicesStore } from '@/stores/userChoices' +import { performanceModeStore } from '@/stores/performanceMode' -/** - * Sets initial video quality for new participants as they join. - * LiveKit doesn't allow handling video quality preferences at the room level. - */ export const VideoResolutionSubscription = () => { const { videoSubscribeQuality } = useSnapshot(userChoicesStore) + const { enabled: isPerformanceModeEnabled } = + useSnapshot(performanceModeStore) const room = useRoomContext() + const effectiveQuality = isPerformanceModeEnabled + ? VideoQuality.LOW + : (videoSubscribeQuality ?? VideoQuality.HIGH) + useEffect(() => { if (!room) return @@ -25,18 +28,12 @@ export const VideoResolutionSubscription = () => { publication: RemoteTrackPublication, _participant: RemoteParticipant ) => { - // By default, the maximum quality is set to high - if ( - videoSubscribeQuality === undefined || - videoSubscribeQuality === VideoQuality.HIGH - ) - return - + if (effectiveQuality === VideoQuality.HIGH) return if ( publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare ) { - publication.setVideoQuality(videoSubscribeQuality) + publication.setVideoQuality(effectiveQuality) } } @@ -44,7 +41,20 @@ export const VideoResolutionSubscription = () => { return () => { room.off(RoomEvent.TrackPublished, handleTrackPublished) } - }, [room, videoSubscribeQuality]) + }, [room, effectiveQuality]) + + useEffect(() => { + if (!room) return + + room.remoteParticipants.forEach((participant) => { + participant.videoTrackPublications.forEach((publication) => { + if (publication.source === Track.Source.ScreenShare) return + if (publication.videoQuality !== effectiveQuality) { + publication.setVideoQuality(effectiveQuality) + } + }) + }) + }, [room, effectiveQuality]) return null } diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 53f42f67..1a49635f 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -27,6 +27,8 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer' import { ChatProvider } from '@/features/chat/components/ChatProvider' import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences' import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector' +import { CpuConstrainedObserver } from '@/features/performance/components/CpuConstrainedObserver' +import { PerformanceModeController } from '@/features/performance/components/PerformanceModeController' /** * @public @@ -70,6 +72,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) { + +
& Pick @@ -32,7 +37,7 @@ const EMPTY_PROPS = {} export const VideoTab = ({ id }: VideoTabProps) => { const { t } = useTranslation('settings', { keyPrefix: 'video' }) - const { localParticipant, remoteParticipants } = useRoomContext() + const { localParticipant } = useRoomContext() const { videoDeviceId, @@ -41,6 +46,9 @@ export const VideoTab = ({ id }: VideoTabProps) => { videoSubscribeQuality, } = useSnapshot(userChoicesStore) + const { enabled: isPerformanceModeEnabled } = + useSnapshot(performanceModeStore) + const [videoElement, setVideoElement] = useState( null ) @@ -85,22 +93,6 @@ export const VideoTab = ({ id }: VideoTabProps) => { } } - /** - * Updates video quality for all existing remote video tracks when user preference changes. - * LiveKit doesn't support setting video quality preferences at the room level for remote participants, - * so this function applies the selected quality to all existing remote video tracks. - * Hook useVideoResolutionSubscription updates quality preferences of new participants joining. - */ - const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => { - remoteParticipants.forEach((participant) => { - participant.videoTrackPublications.forEach((publication) => { - if (publication.videoQuality !== selectedQuality) { - publication.setVideoQuality(selectedQuality) - } - }) - }) - } - useEffect(() => { let videoTrack: LocalVideoTrack | null = null @@ -217,10 +209,13 @@ export const VideoTab = ({ id }: VideoTabProps) => { type="select" label={t('resolution.publish.label')} items={resolutionItems} - selectedKey={videoPublishResolution} + selectedKey={ + isPerformanceModeEnabled ? 'h360' : videoPublishResolution + } onSelectionChange={async (key) => { await handleVideoResolutionChange(key as VideoResolution) }} + isDisabled={isPerformanceModeEnabled} style={{ width: '100%', }} @@ -232,19 +227,43 @@ export const VideoTab = ({ id }: VideoTabProps) => { type="select" label={t('resolution.subscribe.label')} items={videoQualityItems} - selectedKey={videoSubscribeQuality?.toString()} + selectedKey={ + isPerformanceModeEnabled + ? VideoQuality.LOW.toString() + : videoSubscribeQuality?.toString() + } onSelectionChange={(key) => { if (key == undefined) return const selectedQuality = Number(String(key)) saveVideoSubscribeQuality(selectedQuality) - updateExistingRemoteVideoQuality(selectedQuality) }} + isDisabled={isPerformanceModeEnabled} style={{ width: '100%', }} /> <> + + { + if (value) { + enablePerformanceMode('manual') + } else { + disablePerformanceMode() + } + }} + wrapperProps={{ + noMargin: true, + fullWidth: true, + }} + /> + <> + ) } diff --git a/src/frontend/src/locales/de/notifications.json b/src/frontend/src/locales/de/notifications.json index 41122ae9..5b7460e1 100644 --- a/src/frontend/src/locales/de/notifications.json +++ b/src/frontend/src/locales/de/notifications.json @@ -18,6 +18,10 @@ "auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.", "dismiss": "Stummschaltung aufheben" }, + "cpuConstrained": { + "message": "Ihr Gerät ist stark ausgelastet. Die Videoqualität wurde reduziert, um das Gespräch flüssig zu halten.", + "keepQuality": "Ursprüngliche Qualität beibehalten" + }, "reaction": { "description": "{{name}} hat mit {{emoji}} reagiert" }, diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json index a58f264b..15b3641d 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -70,6 +70,11 @@ } } }, + "performance": { + "heading": "Leistung", + "label": "Leistung priorisieren", + "description": "Reduziert die Qualität Ihrer Videos, um Ihr Gerät zu entlasten. Wird bei Überlastung automatisch aktiviert. Deaktivieren Sie diese Funktion, um wieder die maximale Qualität zu erhalten." + }, "permissionsRequired": "Berechtigungen erforderlich" }, "transcription": { diff --git a/src/frontend/src/locales/en/notifications.json b/src/frontend/src/locales/en/notifications.json index f64b19f5..5fefb85f 100644 --- a/src/frontend/src/locales/en/notifications.json +++ b/src/frontend/src/locales/en/notifications.json @@ -18,6 +18,10 @@ "auto": "You have joined a large meeting. Your microphone has been automatically muted.", "dismiss": "Unmute" }, + "cpuConstrained": { + "message": "Your device is running low on processing power. Video quality has been reduced to keep the call smooth.", + "keepQuality": "Keep original quality" + }, "reaction": { "description": "{{name}} reacted with {{emoji}}" }, diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index 62aa37b8..4819948b 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -70,6 +70,11 @@ } } }, + "performance": { + "heading": "Performance", + "label": "Prioritize performance", + "description": "Reduces the quality of your videos to take the strain off your device. Automatically activates in case of overload. Disable it to restore maximum quality." + }, "permissionsRequired": "Permissions required" }, "transcription": { diff --git a/src/frontend/src/locales/fr/notifications.json b/src/frontend/src/locales/fr/notifications.json index 3d4a1ff7..cf17f1b6 100644 --- a/src/frontend/src/locales/fr/notifications.json +++ b/src/frontend/src/locales/fr/notifications.json @@ -18,6 +18,10 @@ "auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.", "dismiss": "Rétablir le micro" }, + "cpuConstrained": { + "message": "Votre appareil manque de puissance. La qualité vidéo a été réduite pour préserver la fluidité de l'appel.", + "keepQuality": "Conserver la qualité d'origine" + }, "reaction": { "description": "{{name}} a reagi avec {{emoji}}" }, diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index 9878ecc7..8be547dd 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -70,6 +70,11 @@ } } }, + "performance": { + "heading": "Performances", + "label": "Privilégier les performances", + "description": "Réduit la qualité de vos vidéos pour soulager votre appareil. S’active automatiquement en cas de surcharge. Désactivez-la pour retrouver la qualité maximale." + }, "permissionsRequired": "Autorisations nécessaires" }, "transcription": { diff --git a/src/frontend/src/locales/nl/notifications.json b/src/frontend/src/locales/nl/notifications.json index 6afc63fe..19768f34 100644 --- a/src/frontend/src/locales/nl/notifications.json +++ b/src/frontend/src/locales/nl/notifications.json @@ -18,6 +18,10 @@ "auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.", "dismiss": "Microfoon inschakelen" }, + "cpuConstrained": { + "message": "Uw apparaat is zwaar belast. De videokwaliteit is verlaagd om het gesprek soepel te laten verlopen.", + "keepQuality": "Oorspronkelijke kwaliteit behouden" + }, "reaction": { "description": "{{name}} reageerde met {{emoji}}" }, diff --git a/src/frontend/src/locales/nl/settings.json b/src/frontend/src/locales/nl/settings.json index 3c9d3f10..82996f22 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -70,6 +70,11 @@ } } }, + "performance": { + "heading": "Prestaties", + "label": "Prestaties prioriteren", + "description": "Vermindert de kwaliteit van je video's om je apparaat te ontlasten. Wordt automatisch geactiveerd bij overbelasting. Schakel het uit om de maximale kwaliteit te herstellen." + }, "permissionsRequired": "Machtigingen vereist" }, "transcription": { diff --git a/src/frontend/src/stores/performanceMode.ts b/src/frontend/src/stores/performanceMode.ts new file mode 100644 index 00000000..a01b3465 --- /dev/null +++ b/src/frontend/src/stores/performanceMode.ts @@ -0,0 +1,30 @@ +import { proxy } from 'valtio' + +export type PerformanceModeTrigger = 'cpu' | 'manual' + +export const performanceModeStore = proxy<{ + enabled: boolean + trigger: PerformanceModeTrigger | null + userDeclinedAuto: boolean +}>({ + enabled: false, + trigger: null, + userDeclinedAuto: false, +}) + +export const enablePerformanceMode = (trigger: PerformanceModeTrigger) => { + if (performanceModeStore.enabled) return + performanceModeStore.enabled = true + performanceModeStore.trigger = trigger +} + +export const disablePerformanceMode = ({ + declinedAuto = false, +}: { declinedAuto?: boolean } = {}) => { + if (declinedAuto) { + performanceModeStore.userDeclinedAuto = true + } + if (!performanceModeStore.enabled) return + performanceModeStore.enabled = false + performanceModeStore.trigger = null +}