diff --git a/fluxer_app/src/features/user/components/modals/tabs/AdvancedSettingDirectControls.tsx b/fluxer_app/src/features/user/components/modals/tabs/AdvancedSettingDirectControls.tsx index 126dc858f..8836a224d 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/AdvancedSettingDirectControls.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/AdvancedSettingDirectControls.tsx @@ -42,8 +42,10 @@ import {UnreadBadgeCustomizationControl} from '@app/features/user/components/mod import { EmulatedDecodeCodecCapControl, OpenH264Control, + ScreenShareAv1OptInControl, ScreenShareCodecControl, ScreenShareEncoderControls, + ScreenShareHevcOptInControl, ScreenSharePreviewBehaviorControl, } from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls'; import { @@ -83,6 +85,8 @@ export const DIRECT_CONTROL_ITEM_IDS = new Set([ 'voice-video-new-device-alerts', 'voice-video-connection-volume-controls', 'voice-video-screen-share-codec', + 'voice-video-screen-share-av1-opt-in', + 'voice-video-screen-share-hevc-opt-in', 'voice-video-emulated-decode-codec-cap', 'voice-video-openh264-codec', 'voice-video-screen-share-preview-behavior', @@ -96,7 +100,6 @@ export const DIRECT_CONTROL_ITEM_IDS = new Set([ export const FULL_WIDTH_CONTROL_ITEM_IDS = new Set([ 'appearance-voice-channel-join-behavior', - 'voice-video-screen-share-codec', 'voice-video-screen-share-preview-behavior', ]); @@ -122,6 +125,8 @@ export const COMPACT_SWITCH_CONTROL_ITEM_IDS = new Set([ 'voice-video-new-device-alerts', 'voice-video-connection-volume-controls', 'voice-video-openh264-codec', + 'voice-video-screen-share-av1-opt-in', + 'voice-video-screen-share-hevc-opt-in', 'advanced-unread-badge-customization', 'client-developer-mode', 'accessibility-stay-interactive-unfocused', @@ -268,6 +273,14 @@ export const AdvancedSettingControl = observer(({item}: {item: SearchableSetting return ( ); + case 'voice-video-screen-share-av1-opt-in': + return ( + + ); + case 'voice-video-screen-share-hevc-opt-in': + return ( + + ); case 'voice-video-emulated-decode-codec-cap': return ( diff --git a/fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx b/fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx index 51ce13a5a..f6a732cf7 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControls.tsx @@ -11,7 +11,6 @@ import {SwitchGroup, SwitchGroupItem} from '@app/features/ui/components/SwitchGr import PiP from '@app/features/ui/state/PiP'; import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils'; import styles from '@app/features/user/components/modals/tabs/AdvancedSettingsTab.module.css'; -import {CodecSelectorSection} from '@app/features/user/components/modals/tabs/components/CodecSelectorSection'; import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow'; import PrivacyPreferences from '@app/features/user/state/PrivacyPreferences'; import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands'; @@ -24,12 +23,18 @@ import type { ScreenShareScalabilityModePreference, ScreenShareSoftwareQuality, } from '@app/features/voice/utils/CodecCapabilityDetector'; +import { + getCodecCapabilityReport, + selectAutomaticScreenShareCodec, +} from '@app/features/voice/utils/CodecCapabilityDetector'; +import {getGpuEncoderReportSync, loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities'; import {setOpenH264Enabled} from '@app/features/voice/utils/OpenH264Status'; +import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {GearIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; -import {useCallback, useEffect} from 'react'; +import {useCallback, useEffect, useState} from 'react'; const OPENH264_LABEL_DESCRIPTOR = msg({ message: 'OpenH264 Video Codec provided by Cisco Systems, Inc.', @@ -210,9 +215,104 @@ const resolveScreenShareBitrateInput = ( return getNearestNumericOptionValue(valueInMbps, options); }; -export const ScreenShareCodecControl = observer(() => ( - -)); +const PREFERRED_SCREEN_SHARE_CODEC_DESCRIPTOR = msg({ + message: 'Preferred screen share codec', + comment: 'Accessible label for the advanced screen-share codec preference select.', +}); +const AUTOMATIC_CODEC_OPTION_DESCRIPTOR = msg({ + message: 'Automatic ({codec})', + comment: 'Option label for the screen-share codec select. codec is the automatically selected codec label.', +}); +const AV1_REQUIRES_OPT_IN_OPTION_DESCRIPTOR = msg({ + message: 'AV1 (requires opt-in)', + comment: + 'Option label for the screen-share codec select while the AV1 opt-in is off and the option is disabled. AV1 is a codec name and should stay literal.', +}); +const H265_REQUIRES_OPT_IN_OPTION_DESCRIPTOR = msg({ + message: 'H.265 (requires opt-in)', + comment: + 'Option label for the screen-share codec select while the HEVC opt-in is off and the option is disabled. H.265 is a codec name and should stay literal.', +}); +const AV1_SCREEN_SHARE_OPT_IN_DESCRIPTOR = msg({ + message: 'Allow AV1 for screen sharing', + comment: 'Switch label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.', +}); +const HEVC_SCREEN_SHARE_OPT_IN_DESCRIPTOR = msg({ + message: 'Allow H.265 (HEVC) for screen sharing', + comment: + 'Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.', +}); +const SCREEN_SHARE_CODEC_OPTION_ORDER = ['av1', 'h265', 'h264', 'vp9', 'vp8'] as const; + +export const ScreenShareCodecControl = observer(() => { + const {i18n} = useLingui(); + const encoderMode = VoiceSettings.getScreenShareEncoderMode(); + const [gpuReport, setGpuReport] = useState(() => getGpuEncoderReportSync()); + useEffect(() => { + if (gpuReport) return; + let cancelled = false; + void loadGpuEncoderReport().then((report) => { + if (!cancelled) setGpuReport(report); + }); + return () => { + cancelled = true; + }; + }, [gpuReport]); + const automaticCodec = selectAutomaticScreenShareCodec(encoderMode).codec; + const av1OptIn = VoiceSettings.getScreenShareAv1OptIn(); + const hevcOptIn = VoiceSettings.getScreenShareHevcOptIn(); + const codecCapabilities = getCodecCapabilityReport(); + const options: ReadonlyArray> = [ + {value: 'auto', label: i18n._(AUTOMATIC_CODEC_OPTION_DESCRIPTOR, {codec: CODEC_DISPLAY_LABEL[automaticCodec]})}, + ...SCREEN_SHARE_CODEC_OPTION_ORDER.map((codec) => ({ + value: codec, + label: + codec === 'av1' && !av1OptIn + ? i18n._(AV1_REQUIRES_OPT_IN_OPTION_DESCRIPTOR) + : codec === 'h265' && !hevcOptIn + ? i18n._(H265_REQUIRES_OPT_IN_OPTION_DESCRIPTOR) + : CODEC_DISPLAY_LABEL[codec], + isDisabled: !codecCapabilities[codec].supported, + })), + ]; + return ( + + value={VoiceSettings.getPreferredScreenShareCodec()} + options={options} + onChange={(value) => VoiceSettingsCommands.update({preferredScreenShareCodec: value})} + density="compact" + isSearchable={false} + aria-label={i18n._(PREFERRED_SCREEN_SHARE_CODEC_DESCRIPTOR)} + data-flx="user.advanced-settings-tab.select.preferred-screen-share-codec" + /> + ); +}); + +export const ScreenShareAv1OptInControl = observer(() => { + const {i18n} = useLingui(); + return ( + VoiceSettingsCommands.update({screenShareAv1OptIn: value})} + compact + data-flx="user.advanced-settings-tab.switch.screen-share-av1-opt-in" + /> + ); +}); + +export const ScreenShareHevcOptInControl = observer(() => { + const {i18n} = useLingui(); + return ( + VoiceSettingsCommands.update({screenShareHevcOptIn: value})} + compact + data-flx="user.advanced-settings-tab.switch.screen-share-hevc-opt-in" + /> + ); +}); export const EmulatedDecodeCodecCapControl = observer(() => { const {i18n} = useLingui(); diff --git a/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorDescription.ts b/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorDescription.ts deleted file mode 100644 index c646502fa..000000000 --- a/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorDescription.ts +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import type {AutomaticScreenShareCodecSelection} from '@app/features/voice/utils/CodecCapabilityDetector'; -import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy'; -import type {I18n} from '@lingui/core'; -import {msg} from '@lingui/core/macro'; - -const AUTO_DESCRIPTION_FIREFOX_VP8_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because Firefox screen sharing works most reliably with it.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as VP8 and should not be translated. Keep Firefox as a product name.', -}); -const AUTO_DESCRIPTION_NON_CHROMIUM_H264_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because this browser supports it and it is the best fallback outside Chromium.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as H.264 and should not be translated. Keep Chromium as a product name.', -}); -const AUTO_DESCRIPTION_DESKTOP_NON_CHROMIUM_H264_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because your computer supports it and it is the best fallback outside Chromium.', - comment: - 'Desktop-client description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as H.264 and should not be translated. Keep Chromium as a product name.', -}); -const AUTO_DESCRIPTION_NON_CHROMIUM_VP8_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because this browser does not expose H.264, so VP8 is the safest fallback.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as VP8 and should not be translated. Keep codec names literal.', -}); -const AUTO_DESCRIPTION_DESKTOP_NON_CHROMIUM_VP8_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because your computer does not expose H.264, so VP8 is the safest fallback.', - comment: - 'Desktop-client description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as VP8 and should not be translated. Keep codec names literal.', -}); -const AUTO_DESCRIPTION_HARDWARE_AV1_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because your GPU can encode it in hardware, with the best compression for screen sharing.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as AV1 and should not be translated. Keep GPU as an acronym.', -}); -const AUTO_DESCRIPTION_HARDWARE_H264_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because your GPU can encode it in hardware and it is the most compatible screen-share default.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as H.264 and should not be translated. Keep GPU as an acronym.', -}); -const AUTO_DESCRIPTION_HARDWARE_H265_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because your GPU can encode it in hardware, with excellent compression and low CPU usage.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as H.265 and should not be translated. Keep GPU as an acronym.', -}); -const AUTO_DESCRIPTION_HARDWARE_VP9_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because your GPU can encode it in hardware and it gives strong screen-share quality.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as VP9 and should not be translated. Keep GPU as an acronym.', -}); -const AUTO_DESCRIPTION_SOFTWARE_AV1_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because software encoding is preferred and this browser exposes AV1.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as AV1 and should not be translated.', -}); -const AUTO_DESCRIPTION_DESKTOP_SOFTWARE_AV1_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because software encoding is preferred and your computer exposes AV1.', - comment: - 'Desktop-client description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as AV1 and should not be translated.', -}); -const AUTO_DESCRIPTION_SOFTWARE_H265_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because no hardware encoder is available and this browser exposes H.265.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as H.265 and should not be translated.', -}); -const AUTO_DESCRIPTION_DESKTOP_SOFTWARE_H265_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because no hardware encoder is available and your computer exposes H.265.', - comment: - 'Desktop-client description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as H.265 and should not be translated.', -}); -const AUTO_DESCRIPTION_SOFTWARE_VP9_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because software encoding is preferred and this browser exposes VP9.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as VP9 and should not be translated.', -}); -const AUTO_DESCRIPTION_DESKTOP_SOFTWARE_VP9_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because software encoding is preferred and your computer exposes VP9.', - comment: - 'Desktop-client description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as VP9 and should not be translated.', -}); -const AUTO_DESCRIPTION_SOFTWARE_H264_DESCRIPTOR = msg({ - message: 'Automatic would use {codecName} because no higher-quality encoder is available and H.264 is compatible.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as H.264 and should not be translated.', -}); -const AUTO_DESCRIPTION_SOFTWARE_VP8_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because software encoding is preferred and VP8 is the safest software fallback.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as VP8 and should not be translated.', -}); -const AUTO_DESCRIPTION_OPENH264_REQUIRED_DESCRIPTOR = msg({ - message: - 'Automatic would use {codecName} because no video encoder is exposed. On Linux, OpenH264 may need to be installed.', - comment: - 'Description shown under the Automatic option in the screen-share codec radio group. {codecName} is a codec label such as H.264 and should not be translated. Keep OpenH264 and Linux literal.', -}); - -export function getAutomaticDescription( - i18n: I18n, - selection: AutomaticScreenShareCodecSelection, - options: {isDesktopClient?: boolean} = {}, -): string { - const codecName = CODEC_DISPLAY_LABEL[selection.codec]; - switch (selection.reason) { - case 'firefox-vp8': - return i18n._(AUTO_DESCRIPTION_FIREFOX_VP8_DESCRIPTOR, {codecName}); - case 'non-chromium-h264': - return i18n._( - options.isDesktopClient - ? AUTO_DESCRIPTION_DESKTOP_NON_CHROMIUM_H264_DESCRIPTOR - : AUTO_DESCRIPTION_NON_CHROMIUM_H264_DESCRIPTOR, - {codecName}, - ); - case 'non-chromium-vp8': - return i18n._( - options.isDesktopClient - ? AUTO_DESCRIPTION_DESKTOP_NON_CHROMIUM_VP8_DESCRIPTOR - : AUTO_DESCRIPTION_NON_CHROMIUM_VP8_DESCRIPTOR, - {codecName}, - ); - case 'hardware-av1': - return i18n._(AUTO_DESCRIPTION_HARDWARE_AV1_DESCRIPTOR, {codecName}); - case 'hardware-h265': - return i18n._(AUTO_DESCRIPTION_HARDWARE_H265_DESCRIPTOR, {codecName}); - case 'hardware-h264': - return i18n._(AUTO_DESCRIPTION_HARDWARE_H264_DESCRIPTOR, {codecName}); - case 'hardware-vp9': - return i18n._(AUTO_DESCRIPTION_HARDWARE_VP9_DESCRIPTOR, {codecName}); - case 'software-av1': - return i18n._( - options.isDesktopClient - ? AUTO_DESCRIPTION_DESKTOP_SOFTWARE_AV1_DESCRIPTOR - : AUTO_DESCRIPTION_SOFTWARE_AV1_DESCRIPTOR, - {codecName}, - ); - case 'software-h265': - return i18n._( - options.isDesktopClient - ? AUTO_DESCRIPTION_DESKTOP_SOFTWARE_H265_DESCRIPTOR - : AUTO_DESCRIPTION_SOFTWARE_H265_DESCRIPTOR, - {codecName}, - ); - case 'software-vp9': - return i18n._( - options.isDesktopClient - ? AUTO_DESCRIPTION_DESKTOP_SOFTWARE_VP9_DESCRIPTOR - : AUTO_DESCRIPTION_SOFTWARE_VP9_DESCRIPTOR, - {codecName}, - ); - case 'software-h264': - return i18n._(AUTO_DESCRIPTION_SOFTWARE_H264_DESCRIPTOR, {codecName}); - case 'software-vp8': - return i18n._(AUTO_DESCRIPTION_SOFTWARE_VP8_DESCRIPTOR, {codecName}); - case 'openh264-required': - return i18n._(AUTO_DESCRIPTION_OPENH264_REQUIRED_DESCRIPTOR, {codecName}); - } -} diff --git a/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorSection.module.css b/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorSection.module.css deleted file mode 100644 index 1cafbe124..000000000 --- a/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorSection.module.css +++ /dev/null @@ -1,62 +0,0 @@ -/* SPDX-License-Identifier: AGPL-3.0-or-later */ - -.codecPicker { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.codecRadioGroup { - gap: 0.25rem; -} - -.codecLabelRow { - display: flex; - align-items: center; - gap: 0.5rem; - min-width: 0; -} - -.codecLabel { - color: var(--text-primary); - font-size: 0.9375rem; - font-weight: 500; - line-height: 1.4; -} - -.codecBadge { - flex: 0 0 auto; - border-radius: 62.4375rem; - padding: 0.0625rem 0.5rem; - background-color: var(--background-modifier-accent); - color: var(--text-primary-muted); - font-size: 0.6875rem; - font-weight: 600; - line-height: 1.1rem; - text-transform: uppercase; - letter-spacing: 0.04em; - border: 0.0625rem solid transparent; -} - -.codecBadgeRecommended { - background-color: var(--brand-primary); - color: white; - border-color: var(--brand-primary); -} - -.codecHint { - margin: 0; - color: var(--status-warning); - font-size: 0.8125rem; - line-height: 1.35; -} - -.codecAdvancedLink { - padding: 0; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - font: inherit; - text-decoration: underline; -} diff --git a/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorSection.tsx b/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorSection.tsx deleted file mode 100644 index 75d8f63c8..000000000 --- a/fluxer_app/src/features/user/components/modals/tabs/components/CodecSelectorSection.tsx +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import { - getCachedDesktopTroubleshootingSettings, - getDesktopTroubleshootingSettings, -} from '@app/features/devtools/utils/DesktopTroubleshootingUtils'; -import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; -import type {RadioOption} from '@app/features/ui/radio_group/RadioGroup'; -import {RadioGroup} from '@app/features/ui/radio_group/RadioGroup'; -import {isDesktop} from '@app/features/ui/utils/NativeUtils'; -import styles from '@app/features/user/components/modals/tabs/components/CodecSelectorSection.module.css'; -import {getUserSettingsTabLabel} from '@app/features/user/components/settings_utils/SettingsConstants'; -import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands'; -import VoiceSettings from '@app/features/voice/state/VoiceSettings'; -import { - type CodecPreference, - getCodecCapabilityReport, - getLiveKitSupportedCodecs, - selectAutomaticScreenShareCodec, -} from '@app/features/voice/utils/CodecCapabilityDetector'; -import { - getGpuEncoderReportSync, - type HardwareEncodeReport, - loadGpuEncoderReport, -} from '@app/features/voice/utils/GpuEncoderCapabilities'; -import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy'; -import {msg} from '@lingui/core/macro'; -import {Trans, useLingui} from '@lingui/react/macro'; -import {clsx} from 'clsx'; -import {observer} from 'mobx-react-lite'; -import {useCallback, useEffect, useMemo, useState} from 'react'; -import {getAutomaticDescription} from './CodecSelectorDescription'; - -const AUTO_DESCRIPTOR = msg({ - message: 'Automatic', - comment: 'Label for the auto codec option in the screen-share codec radio group. Keep it concise.', -}); -const RECOMMENDED_DESCRIPTOR = msg({ - message: 'Recommended', - comment: 'Badge shown next to the auto codec option in the screen-share codec radio group. Keep it concise.', -}); -const SCREEN_SHARE_VIDEO_CODEC_DESCRIPTOR = msg({ - message: 'Screen share video codec', - comment: 'Accessible label for the screen-share codec radio group.', -}); - -interface CodecRadioOption extends RadioOption { - value: CodecPreference; -} - -export const CodecSelectorSection = observer(() => { - const {i18n} = useLingui(); - const isDesktopClient = isDesktop(); - const advancedSettingsLabel = getUserSettingsTabLabel(i18n, 'advanced_settings'); - const preference = VoiceSettings.getPreferredScreenShareCodec(); - const encoderMode = VoiceSettings.getScreenShareEncoderMode(); - const [gpuReport, setGpuReport] = useState(() => getGpuEncoderReportSync()); - const [hardwareAccelDisabled, setHardwareAccelDisabled] = useState( - () => getCachedDesktopTroubleshootingSettings()?.disableHardwareAcceleration ?? null, - ); - useEffect(() => { - if (gpuReport) return; - let cancelled = false; - void loadGpuEncoderReport().then((report) => { - if (!cancelled) setGpuReport(report); - }); - return () => { - cancelled = true; - }; - }, [gpuReport]); - useEffect(() => { - if (!isDesktopClient || hardwareAccelDisabled !== null) return; - let cancelled = false; - void getDesktopTroubleshootingSettings().then((settings) => { - if (!cancelled && settings) setHardwareAccelDisabled(settings.disableHardwareAcceleration); - }); - return () => { - cancelled = true; - }; - }, [hardwareAccelDisabled, isDesktopClient]); - const report = useMemo(() => getCodecCapabilityReport(), [gpuReport, hardwareAccelDisabled]); - const liveKitCodecs = useMemo(() => getLiveKitSupportedCodecs(), []); - const automaticSelection = useMemo( - () => selectAutomaticScreenShareCodec(encoderMode), - [encoderMode, gpuReport, hardwareAccelDisabled], - ); - const options = useMemo>(() => { - const autoOption: CodecRadioOption = { - value: 'auto', - name: ( -
- - {i18n._(AUTO_DESCRIPTOR)} - - - {i18n._(RECOMMENDED_DESCRIPTOR)} - -
- ), - desc: getAutomaticDescription(i18n, automaticSelection, {isDesktopClient}), - }; - const codecOptions = liveKitCodecs.map((codec) => ({ - value: codec, - name: CODEC_DISPLAY_LABEL[codec], - disabled: !report[codec].supported, - })); - return [autoOption, ...codecOptions]; - }, [automaticSelection, liveKitCodecs, report, i18n.locale, isDesktopClient]); - const handleChange = useCallback((value: CodecPreference) => { - VoiceSettingsCommands.update({preferredScreenShareCodec: value}); - }, []); - const openAdvancedSettingsTab = useCallback((event: React.MouseEvent) => { - event.preventDefault(); - ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'advanced_settings'}); - }, []); - const hardwareAccelOff = isDesktopClient && hardwareAccelDisabled === true; - return ( -
- - aria-label={i18n._(SCREEN_SHARE_VIDEO_CODEC_DESCRIPTOR)} - options={options} - value={preference} - onChange={handleChange} - className={styles.codecRadioGroup} - data-flx="user.codec-selector-section.radio-group.change" - /> - {hardwareAccelOff ? ( -

- - Hardware acceleration is off, so codecs will encode on your CPU.{' '} - - . - -

- ) : null} -
- ); -}); diff --git a/fluxer_app/src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts b/fluxer_app/src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts index a173f3d98..22d1894c2 100644 --- a/fluxer_app/src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts +++ b/fluxer_app/src/features/user/components/settings_utils/search_index/VoiceVideoIndex.ts @@ -85,6 +85,27 @@ const CHOOSE_THE_VIDEO_CODEC_FOR_SCREEN_SHARING_DESCRIPTOR = msg({ message: 'Video codec for screen sharing', comment: 'Settings search entry description. One-line summary of what the settings search entry controls.', }); +const ALLOW_AV1_SCREEN_SHARE_DESCRIPTOR = msg({ + message: 'Allow AV1 for screen sharing', + comment: 'Settings search entry label for the AV1 screen-share opt-in. AV1 is a codec name and should stay literal.', +}); +const ALLOW_HEVC_SCREEN_SHARE_DESCRIPTOR = msg({ + message: 'Allow H.265 (HEVC) for screen sharing', + comment: + 'Settings search entry label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.', +}); +const ADVANCED_CODEC_COMPATIBILITY_NOTE_DESCRIPTOR = msg({ + message: 'May cause compatibility issues for viewers. We’re working on improving this.', + comment: 'Settings search entry description for the AV1 and H.265 screen-share opt-in toggles.', +}); +const AV1_KEYWORD_DESCRIPTOR = msg({ + message: 'AV1', + comment: 'Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.', +}); +const HEVC_KEYWORD_DESCRIPTOR = msg({ + message: 'HEVC', + comment: 'Settings search synonym. Codec name; keep literal. Used to match this term in the settings search bar.', +}); const OPENH264_VIDEO_CODEC_DESCRIPTOR = msg({ message: 'OpenH264 video codec', comment: 'Settings search entry label. Names the settings search entry in the settings UI.', @@ -207,6 +228,26 @@ export const voiceVideoIndex: Array = [ audience: 'advanced', tags: ['media', 'voice'], }, + { + id: 'voice-video-screen-share-av1-opt-in', + tabType: 'voice_video', + sectionId: 'video', + label: ALLOW_AV1_SCREEN_SHARE_DESCRIPTOR, + keywords: [AV1_KEYWORD_DESCRIPTOR, CODEC_DESCRIPTOR, VIDEO_CODEC_DESCRIPTOR], + description: ADVANCED_CODEC_COMPATIBILITY_NOTE_DESCRIPTOR, + audience: 'advanced', + tags: ['media', 'voice'], + }, + { + id: 'voice-video-screen-share-hevc-opt-in', + tabType: 'voice_video', + sectionId: 'video', + label: ALLOW_HEVC_SCREEN_SHARE_DESCRIPTOR, + keywords: [HEVC_KEYWORD_DESCRIPTOR, CODEC_DESCRIPTOR, VIDEO_CODEC_DESCRIPTOR], + description: ADVANCED_CODEC_COMPATIBILITY_NOTE_DESCRIPTOR, + audience: 'advanced', + tags: ['media', 'voice'], + }, { id: 'voice-video-emulated-decode-codec-cap', tabType: 'voice_video', diff --git a/fluxer_app/src/features/voice/commands/VoiceSettingsCommands.ts b/fluxer_app/src/features/voice/commands/VoiceSettingsCommands.ts index 49c2e4966..191f9ff76 100644 --- a/fluxer_app/src/features/voice/commands/VoiceSettingsCommands.ts +++ b/fluxer_app/src/features/voice/commands/VoiceSettingsCommands.ts @@ -61,6 +61,8 @@ type VoiceSettingsPatch = Partial<{ disablePictureInPicturePopoutScreenShare: boolean; preferredVideoCodec: CodecPreference; preferredScreenShareCodec: CodecPreference; + screenShareAv1OptIn: boolean; + screenShareHevcOptIn: boolean; emulatedDecodeVideoCodecCap: CodecPreference; screenShareContentHint: ScreenShareContentHint; screenShareEncoderMode: ScreenShareEncoderMode; @@ -108,6 +110,8 @@ const CAMERA_BACKGROUND_REFRESH_KEYS: Array = [ const CAMERA_CAPTURE_REFRESH_KEYS: Array = ['cameraResolution']; const SCREEN_SHARE_CODEC_REFRESH_KEYS: Array = [ 'preferredScreenShareCodec', + 'screenShareAv1OptIn', + 'screenShareHevcOptIn', 'screenShareEncoderMode', 'screenShareSoftwareQuality', 'screenShareScalabilityMode', diff --git a/fluxer_app/src/features/voice/engine/ScreenShareCodecNegotiation.ts b/fluxer_app/src/features/voice/engine/ScreenShareCodecNegotiation.ts index 23fb3a6dc..8ed9ad644 100644 --- a/fluxer_app/src/features/voice/engine/ScreenShareCodecNegotiation.ts +++ b/fluxer_app/src/features/voice/engine/ScreenShareCodecNegotiation.ts @@ -179,8 +179,14 @@ export function getScreenShareCodecPreferenceOrder( const encoderMode = resolveEffectiveScreenShareEncoderMode(VoiceSettings.getScreenShareEncoderMode()); const automaticOrder = encoderMode === 'software' ? SOFTWARE_CODEC_PREFERENCE : getHardwareFirstScreenShareCodecPreferenceOrder(); - if (preference !== 'auto') return [preference, ...automaticOrder.filter((codec) => codec !== preference)]; - return automaticOrder; + const order = + preference !== 'auto' ? [preference, ...automaticOrder.filter((codec) => codec !== preference)] : automaticOrder; + const filtered = order.filter( + (codec) => + (codec !== 'av1' || VoiceSettings.getScreenShareAv1OptIn()) && + (codec !== 'h265' || VoiceSettings.getScreenShareHevcOptIn()), + ); + return filtered; } function getHardwareFirstScreenShareCodecPreferenceOrder(): ReadonlyArray { diff --git a/fluxer_app/src/features/voice/engine/ScreenShareCodecNegotiationOptIn.test.ts b/fluxer_app/src/features/voice/engine/ScreenShareCodecNegotiationOptIn.test.ts new file mode 100644 index 000000000..2e7ef843f --- /dev/null +++ b/fluxer_app/src/features/voice/engine/ScreenShareCodecNegotiationOptIn.test.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +let av1OptIn = false; +let hevcOptIn = false; + +vi.mock('@app/features/voice/state/VoiceSettings', () => ({ + default: { + getScreenShareEncoderMode: () => 'software', + getPreferredScreenShareCodec: () => 'auto', + getScreenShareAv1OptIn: () => av1OptIn, + getScreenShareHevcOptIn: () => hevcOptIn, + }, +})); + +const {getScreenShareCodecPreferenceOrder} = await import('./ScreenShareCodecNegotiation'); + +describe('screen-share codec preference order opt-in filter', () => { + beforeEach(() => { + av1OptIn = false; + hevcOptIn = false; + }); + + it('excludes AV1 and HEVC from the automatic preference order by default', () => { + const order = getScreenShareCodecPreferenceOrder('auto'); + expect(order).not.toContain('av1'); + expect(order).not.toContain('h265'); + expect(order).toContain('vp9'); + }); + + it('includes AV1 only once its opt-in is on', () => { + expect(getScreenShareCodecPreferenceOrder('auto')).not.toContain('av1'); + av1OptIn = true; + expect(getScreenShareCodecPreferenceOrder('auto')).toContain('av1'); + }); + + it('includes HEVC only once its opt-in is on', () => { + expect(getScreenShareCodecPreferenceOrder('auto')).not.toContain('h265'); + hevcOptIn = true; + expect(getScreenShareCodecPreferenceOrder('auto')).toContain('h265'); + }); +}); diff --git a/fluxer_app/src/features/voice/state/VoiceSettings.ts b/fluxer_app/src/features/voice/state/VoiceSettings.ts index 64dcb7c4e..0e8bc3aeb 100644 --- a/fluxer_app/src/features/voice/state/VoiceSettings.ts +++ b/fluxer_app/src/features/voice/state/VoiceSettings.ts @@ -101,6 +101,8 @@ type VoiceSettingsUpdate = Partial<{ disablePictureInPicturePopoutScreenShare: boolean; preferredVideoCodec: CodecPreference; preferredScreenShareCodec: CodecPreference; + screenShareAv1OptIn: boolean; + screenShareHevcOptIn: boolean; emulatedDecodeVideoCodecCap: CodecPreference; screenShareContentHint: ScreenShareContentHint; screenShareEncoderMode: ScreenShareEncoderMode; @@ -259,6 +261,28 @@ function applyOutputVolumeRecalibrationMigrationV1(parsed: Record): boolean { + if (parsed.screenShareAv1OptOutMigratedV1 === true) { + return false; + } + if (parsed.preferredScreenShareCodec === 'av1') { + parsed.preferredScreenShareCodec = 'auto'; + } + parsed.screenShareAv1OptOutMigratedV1 = true; + return true; +} + +function applyScreenShareHevcOptOutMigrationV1(parsed: Record): boolean { + if (parsed.screenShareHevcOptOutMigratedV1 === true) { + return false; + } + if (parsed.preferredScreenShareCodec === 'h265') { + parsed.preferredScreenShareCodec = 'auto'; + } + parsed.screenShareHevcOptOutMigratedV1 = true; + return true; +} + function applyNoiseSuppressionStandardDefaultMigrationV1(parsed: Record): boolean { if (parsed.noiseSuppressionStandardDefaultMigratedV1 === true) { return false; @@ -332,6 +356,10 @@ class VoiceSettings { disablePictureInPicturePopoutScreenShare = false; preferredVideoCodec: CodecPreference = 'auto'; preferredScreenShareCodec: CodecPreference = 'auto'; + screenShareAv1OptIn = false; + screenShareHevcOptIn = false; + screenShareAv1OptOutMigratedV1 = false; + screenShareHevcOptOutMigratedV1 = false; emulatedDecodeVideoCodecCap: CodecPreference = 'auto'; screenShareContentHintPrefV2: ScreenShareContentHint = DEFAULT_SCREEN_SHARE_CONTENT_HINT; screenShareEncoderModePrefV2: ScreenShareEncoderMode = DEFAULT_SCREEN_SHARE_ENCODER_MODE; @@ -403,6 +431,8 @@ class VoiceSettings { getPreferredVideoCodec: false, getEmulatedDecodeVideoCodecCap: false, getPreferredScreenShareCodec: false, + getScreenShareAv1OptIn: false, + getScreenShareHevcOptIn: false, getScreenShareContentHint: false, getScreenShareContentHintOverride: false, getScreenShareEncoderMode: false, @@ -454,10 +484,14 @@ class VoiceSettings { changed = applyStreamingModeDefaultMigrationV1(parsed) || changed; changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed; changed = applyNoiseSuppressionStandardDefaultMigrationV1(parsed) || changed; + changed = applyScreenShareAv1OptOutMigrationV1(parsed) || changed; + changed = applyScreenShareHevcOptOutMigrationV1(parsed) || changed; if (changed) { AppStorage.setItem('VoiceSettings', JSON.stringify(parsed)); } this.outputVolumeRecalibratedV1 = parsed.outputVolumeRecalibratedV1 === true; + this.screenShareAv1OptOutMigratedV1 = parsed.screenShareAv1OptOutMigratedV1 === true; + this.screenShareHevcOptOutMigratedV1 = parsed.screenShareHevcOptOutMigratedV1 === true; } catch (error) { logger.warn('Failed to migrate persisted voice settings:', error); } @@ -508,6 +542,10 @@ class VoiceSettings { 'disablePictureInPicturePopoutScreenShare', 'preferredVideoCodec', 'preferredScreenShareCodec', + 'screenShareAv1OptIn', + 'screenShareHevcOptIn', + 'screenShareAv1OptOutMigratedV1', + 'screenShareHevcOptOutMigratedV1', 'emulatedDecodeVideoCodecCap', 'screenShareContentHintPrefV2', 'screenShareEncoderModePrefV2', @@ -871,9 +909,19 @@ class VoiceSettings { } getPreferredScreenShareCodec(): CodecPreference { + if (this.preferredScreenShareCodec === 'av1' && !this.screenShareAv1OptIn) return 'auto'; + if (this.preferredScreenShareCodec === 'h265' && !this.screenShareHevcOptIn) return 'auto'; return this.preferredScreenShareCodec; } + getScreenShareAv1OptIn(): boolean { + return this.screenShareAv1OptIn; + } + + getScreenShareHevcOptIn(): boolean { + return this.screenShareHevcOptIn; + } + getScreenShareContentHint(): ScreenShareContentHint { return this.screenShareContentHint; } @@ -1056,6 +1104,8 @@ class VoiceSettings { if (validated.preferredVideoCodec !== undefined) this.preferredVideoCodec = validated.preferredVideoCodec; if (validated.preferredScreenShareCodec !== undefined) this.preferredScreenShareCodec = validated.preferredScreenShareCodec; + if (validated.screenShareAv1OptIn !== undefined) this.screenShareAv1OptIn = validated.screenShareAv1OptIn; + if (validated.screenShareHevcOptIn !== undefined) this.screenShareHevcOptIn = validated.screenShareHevcOptIn; if (validated.emulatedDecodeVideoCodecCap !== undefined) this.emulatedDecodeVideoCodecCap = validated.emulatedDecodeVideoCodecCap; if (validated.screenShareContentHint !== undefined) this.screenShareContentHint = validated.screenShareContentHint; @@ -1137,6 +1187,8 @@ class VoiceSettings { const screenShareBackupCodecMode = validateScreenShareBackupCodecMode( data.screenShareBackupCodecMode ?? this.screenShareBackupCodecMode, ); + const screenShareAv1OptIn = data.screenShareAv1OptIn ?? this.screenShareAv1OptIn; + const screenShareHevcOptIn = data.screenShareHevcOptIn ?? this.screenShareHevcOptIn; const validCameraResolutions: Array = ['low', 'medium', 'high']; if (!validCameraResolutions.includes(cameraResolution)) { cameraResolution = 'medium'; @@ -1216,7 +1268,13 @@ class VoiceSettings { disablePictureInPicturePopoutScreenShare: data.disablePictureInPicturePopoutScreenShare ?? this.disablePictureInPicturePopoutScreenShare, preferredVideoCodec: data.preferredVideoCodec ?? this.preferredVideoCodec, - preferredScreenShareCodec: data.preferredScreenShareCodec ?? this.preferredScreenShareCodec, + preferredScreenShareCodec: validateCodecPreference( + data.preferredScreenShareCodec ?? this.preferredScreenShareCodec, + screenShareAv1OptIn, + screenShareHevcOptIn, + ), + screenShareAv1OptIn, + screenShareHevcOptIn, emulatedDecodeVideoCodecCap: data.emulatedDecodeVideoCodecCap ?? this.emulatedDecodeVideoCodecCap, screenShareContentHint: validateScreenShareContentHint( data.screenShareContentHint ?? this.screenShareContentHint, @@ -1278,6 +1336,12 @@ function validateLastScreenShareSource(value: unknown): LastScreenShareSource | }; } +function validateCodecPreference(value: unknown, allowAv1: boolean, allowHevc: boolean): CodecPreference { + if (value === 'av1') return allowAv1 ? 'av1' : 'auto'; + if (value === 'h265') return allowHevc ? 'h265' : 'auto'; + return value === 'vp8' || value === 'vp9' || value === 'h264' || value === 'auto' ? value : 'auto'; +} + function validateScreenShareEncoderMode(mode: unknown): ScreenShareEncoderMode { return mode === 'hardware' || mode === 'software' || mode === 'auto' ? mode : DEFAULT_SCREEN_SHARE_ENCODER_MODE; } diff --git a/fluxer_app/src/features/voice/state/VoiceSettingsCodecOptIn.test.ts b/fluxer_app/src/features/voice/state/VoiceSettingsCodecOptIn.test.ts new file mode 100644 index 000000000..895921dae --- /dev/null +++ b/fluxer_app/src/features/voice/state/VoiceSettingsCodecOptIn.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {runInAction} from 'mobx'; +import {describe, expect, it, vi} from 'vitest'; + +vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({ + areVoiceBackgroundsAvailable: () => true, +})); + +vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({ + LimitResolver: {resolve: () => 0}, +})); + +vi.mock('@app/features/app/utils/LimitUtils', () => ({ + isLimitToggleEnabled: () => false, +})); + +const AppStorage = (await import('@app/features/platform/state/PersistentStorage')).default; + +AppStorage.setItem('VoiceSettings', JSON.stringify({preferredScreenShareCodec: 'av1'})); + +const storageWrites: Array = []; +AppStorage.subscribe( + (event) => { + if (event.newValue != null) storageWrites.push(event.newValue); + }, + {key: 'VoiceSettings'}, +); + +const {default: VoiceSettings} = await import('./VoiceSettings'); + +describe('AV1/HEVC screen-share opt-in', () => { + it('rewrites a stored AV1 screen-share preference back to automatic on first launch', () => { + const migrated = JSON.parse(storageWrites[0]); + expect(migrated.preferredScreenShareCodec).toBe('auto'); + expect(migrated.screenShareAv1OptOutMigratedV1).toBe(true); + expect(migrated.screenShareHevcOptOutMigratedV1).toBe(true); + expect(VoiceSettings.screenShareAv1OptOutMigratedV1).toBe(true); + expect(VoiceSettings.screenShareHevcOptOutMigratedV1).toBe(true); + expect(VoiceSettings.screenShareAv1OptIn).toBe(false); + expect(VoiceSettings.screenShareHevcOptIn).toBe(false); + expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('auto'); + }); + + it('hands out automatic while a hydrated AV1 or HEVC preference is not opted in', () => { + VoiceSettings.updateSettings({screenShareAv1OptIn: false, screenShareHevcOptIn: false}); + runInAction(() => { + VoiceSettings.preferredScreenShareCodec = 'av1'; + }); + expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('auto'); + runInAction(() => { + VoiceSettings.preferredScreenShareCodec = 'h265'; + }); + expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('auto'); + }); + + it('refuses to store an AV1 or HEVC preference while the opt-in is off', () => { + VoiceSettings.updateSettings({ + screenShareAv1OptIn: false, + screenShareHevcOptIn: false, + preferredScreenShareCodec: 'auto', + }); + VoiceSettings.updateSettings({preferredScreenShareCodec: 'av1'}); + expect(VoiceSettings.preferredScreenShareCodec).toBe('auto'); + VoiceSettings.updateSettings({preferredScreenShareCodec: 'h265'}); + expect(VoiceSettings.preferredScreenShareCodec).toBe('auto'); + }); + + it('accepts an AV1 preference together with its opt-in in one patch', () => { + VoiceSettings.updateSettings({screenShareAv1OptIn: false, preferredScreenShareCodec: 'auto'}); + VoiceSettings.updateSettings({screenShareAv1OptIn: true, preferredScreenShareCodec: 'av1'}); + expect(VoiceSettings.preferredScreenShareCodec).toBe('av1'); + expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('av1'); + }); + + it('accepts an HEVC preference together with its opt-in in one patch', () => { + VoiceSettings.updateSettings({screenShareHevcOptIn: false, preferredScreenShareCodec: 'auto'}); + VoiceSettings.updateSettings({screenShareHevcOptIn: true, preferredScreenShareCodec: 'h265'}); + expect(VoiceSettings.preferredScreenShareCodec).toBe('h265'); + expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('h265'); + }); + + it('drops the AV1 preference again when the opt-in is turned back off', () => { + VoiceSettings.updateSettings({screenShareAv1OptIn: true, preferredScreenShareCodec: 'av1'}); + VoiceSettings.updateSettings({screenShareAv1OptIn: false}); + expect(VoiceSettings.preferredScreenShareCodec).toBe('auto'); + expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('auto'); + }); + + it('drops the HEVC preference again when the opt-in is turned back off', () => { + VoiceSettings.updateSettings({screenShareHevcOptIn: true, preferredScreenShareCodec: 'h265'}); + VoiceSettings.updateSettings({screenShareHevcOptIn: false}); + expect(VoiceSettings.preferredScreenShareCodec).toBe('auto'); + expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('auto'); + }); + + it('leaves an always-available codec preference alone', () => { + VoiceSettings.updateSettings({preferredScreenShareCodec: 'vp9'}); + expect(VoiceSettings.preferredScreenShareCodec).toBe('vp9'); + expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('vp9'); + }); +}); diff --git a/fluxer_app/src/features/voice/utils/CodecCapabilityDetector.test.ts b/fluxer_app/src/features/voice/utils/CodecCapabilityDetector.test.ts new file mode 100644 index 000000000..74caeffd7 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/CodecCapabilityDetector.test.ts @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {HardwareEncodeReport} from '@app/features/voice/utils/GpuEncoderCapabilities'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +let av1OptIn = false; +let hevcOptIn = false; +let desktop = true; +let gpuReport: HardwareEncodeReport | null = null; + +vi.mock('@app/features/voice/state/VoiceSettings', () => ({ + default: { + getScreenShareAv1OptIn: () => av1OptIn, + getScreenShareHevcOptIn: () => hevcOptIn, + }, +})); + +vi.mock('@app/features/devtools/utils/DesktopTroubleshootingUtils', () => ({ + getCachedDesktopTroubleshootingSettings: () => null, +})); + +vi.mock('@app/features/ui/utils/NativeUtils', () => ({ + guessPlatform: () => 'windows', + isChromiumBrowser: () => true, + isDesktop: () => desktop, + isFirefoxBrowser: () => false, +})); + +vi.mock('@app/features/voice/utils/GpuEncoderCapabilities', () => ({ + getGpuEncoderReportSync: () => gpuReport, +})); + +vi.mock('@app/features/voice/utils/NativeHardwareEncoderCapabilities', () => ({ + getNativeHardwareEncoderCapabilitiesSync: () => null, + hasNativeHardwareEncoder: () => false, + resetNativeHardwareEncoderCapabilities: () => undefined, +})); + +vi.mock('@app/features/voice/utils/OpenH264Status', () => ({ + getOpenH264StatusSync: () => null, + resetOpenH264Status: () => undefined, +})); + +Object.defineProperty(globalThis, 'RTCRtpSender', { + configurable: true, + writable: true, + value: { + getCapabilities: () => ({ + codecs: [ + {mimeType: 'video/VP8'}, + {mimeType: 'video/VP9'}, + {mimeType: 'video/H264'}, + {mimeType: 'video/H265'}, + {mimeType: 'video/AV1'}, + ], + }), + }, +}); + +const {getCodecCapabilityReport, resetCachedCodecCapabilities, selectAutomaticScreenShareCodec} = await import( + './CodecCapabilityDetector' +); + +const ALL_SOFTWARE: HardwareEncodeReport = { + av1: 'software', + h265: 'software', + h264: 'software', + vp9: 'software', + vp8: 'software', +}; + +describe('screen-share AV1/HEVC opt-in gate', () => { + beforeEach(() => { + av1OptIn = false; + hevcOptIn = false; + desktop = true; + gpuReport = null; + resetCachedCodecCapabilities(); + }); + + it('reports AV1 as unsupported until the user opts in, even when the encoder is there', () => { + expect(getCodecCapabilityReport().av1).toMatchObject({supported: false, reason: 'opt-in-required'}); + av1OptIn = true; + resetCachedCodecCapabilities(); + expect(getCodecCapabilityReport().av1).toMatchObject({supported: true, reason: 'supported'}); + }); + + it('reports HEVC as unsupported until the user opts in, even when the encoder is there', () => { + expect(getCodecCapabilityReport().h265).toMatchObject({supported: false, reason: 'opt-in-required'}); + hevcOptIn = true; + resetCachedCodecCapabilities(); + expect(getCodecCapabilityReport().h265).toMatchObject({supported: true, reason: 'supported'}); + }); + + it('leaves the always-available codecs alone while AV1 and HEVC are gated', () => { + const report = getCodecCapabilityReport(); + expect(report.vp9.supported).toBe(true); + expect(report.h264.supported).toBe(true); + expect(report.vp8.supported).toBe(true); + expect(report.av1.supported).toBe(false); + expect(report.h265.supported).toBe(false); + }); + + it('never selects AV1 for the forced-software path while the opt-in is off', () => { + expect(selectAutomaticScreenShareCodec('software')).toEqual({codec: 'vp9', reason: 'software-vp9'}); + av1OptIn = true; + resetCachedCodecCapabilities(); + expect(selectAutomaticScreenShareCodec('software')).toEqual({codec: 'av1', reason: 'software-av1'}); + }); + + it('never selects AV1 for the desktop hardware path while the opt-in is off', () => { + gpuReport = {...ALL_SOFTWARE, av1: 'hardware'}; + expect(selectAutomaticScreenShareCodec('auto').codec).not.toBe('av1'); + av1OptIn = true; + resetCachedCodecCapabilities(); + expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'av1', reason: 'hardware-av1'}); + }); + + it('never selects HEVC for the desktop hardware path while the opt-in is off', () => { + gpuReport = {...ALL_SOFTWARE, h265: 'hardware'}; + expect(selectAutomaticScreenShareCodec('auto').codec).not.toBe('h265'); + hevcOptIn = true; + resetCachedCodecCapabilities(); + expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'h265', reason: 'hardware-h265'}); + }); + + it('never selects AV1 or HEVC for the software fallback path while the opt-ins are off', () => { + desktop = false; + gpuReport = ALL_SOFTWARE; + const selection = selectAutomaticScreenShareCodec('auto').codec; + expect(selection).not.toBe('av1'); + expect(selection).not.toBe('h265'); + }); + + it('rebuilds the memoised report when either opt-in is flipped at runtime', () => { + expect(getCodecCapabilityReport().av1.supported).toBe(false); + expect(getCodecCapabilityReport().h265.supported).toBe(false); + av1OptIn = true; + expect(getCodecCapabilityReport().av1.supported).toBe(true); + hevcOptIn = true; + expect(getCodecCapabilityReport().h265.supported).toBe(true); + }); +}); diff --git a/fluxer_app/src/features/voice/utils/CodecCapabilityDetector.ts b/fluxer_app/src/features/voice/utils/CodecCapabilityDetector.ts index 1acf03352..364b1cf12 100644 --- a/fluxer_app/src/features/voice/utils/CodecCapabilityDetector.ts +++ b/fluxer_app/src/features/voice/utils/CodecCapabilityDetector.ts @@ -9,6 +9,7 @@ import { isFirefoxBrowser, type NativePlatform, } from '@app/features/ui/utils/NativeUtils'; +import VoiceSettings from '@app/features/voice/state/VoiceSettings'; import {getGpuEncoderReportSync, type HardwareEncodeAnswer} from '@app/features/voice/utils/GpuEncoderCapabilities'; import { getNativeHardwareEncoderCapabilitiesSync, @@ -35,6 +36,7 @@ export type CodecSupportReason = | 'unsupported-system' | 'unavailable-on-platform' | 'capabilities-unavailable' + | 'opt-in-required' | 'runtime-failed'; export interface CodecSupportInfo { @@ -84,6 +86,8 @@ let cachedReport: CodecCapabilityReport | null = null; let cachedReportGpuKey: object | null | undefined; let cachedReportNativeHardwareEncoderKey: object | null | undefined; let cachedReportHardwareAccelerationDisabled: boolean | undefined; +let cachedReportAv1OptIn: boolean | undefined; +let cachedReportHevcOptIn: boolean | undefined; const runtimeEncodeFailureCodecs = new Set(); interface RawProbeResult { @@ -160,6 +164,22 @@ function getScreenShareCodecPolicyUnsupported( codec: keyof CodecCapabilities, context: CodecPolicyContext, ): Omit | null { + if (codec === 'av1' && !VoiceSettings.getScreenShareAv1OptIn()) { + return { + supported: false, + reason: 'opt-in-required', + detail: + 'AV1 screen sharing is off by default because it may cause compatibility issues for viewers. We’re working on improving this. Turn on AV1 screen sharing in Advanced settings to use it.', + }; + } + if (codec === 'h265' && !VoiceSettings.getScreenShareHevcOptIn()) { + return { + supported: false, + reason: 'opt-in-required', + detail: + 'H.265 (HEVC) screen sharing is off by default because it may cause compatibility issues for viewers. We’re working on improving this. Turn on H.265 screen sharing in Advanced settings to use it.', + }; + } if (context.firefox) { switch (codec) { case 'av1': @@ -318,17 +338,23 @@ export function getCodecCapabilityReport(): CodecCapabilityReport { const currentGpu = getGpuEncoderReportSync(); const currentNativeHardwareEncoder = getNativeHardwareEncoderCapabilitiesSync(); const hardwareAccelerationDisabled = isDesktopHardwareAccelerationDisabled(); + const av1OptIn = VoiceSettings.getScreenShareAv1OptIn(); + const hevcOptIn = VoiceSettings.getScreenShareHevcOptIn(); if ( cachedReport && cachedReportGpuKey === currentGpu && cachedReportNativeHardwareEncoderKey === currentNativeHardwareEncoder && - cachedReportHardwareAccelerationDisabled === hardwareAccelerationDisabled + cachedReportHardwareAccelerationDisabled === hardwareAccelerationDisabled && + cachedReportAv1OptIn === av1OptIn && + cachedReportHevcOptIn === hevcOptIn ) return cachedReport; cachedReport = buildReport(); cachedReportGpuKey = currentGpu; cachedReportNativeHardwareEncoderKey = currentNativeHardwareEncoder; cachedReportHardwareAccelerationDisabled = hardwareAccelerationDisabled; + cachedReportAv1OptIn = av1OptIn; + cachedReportHevcOptIn = hevcOptIn; return cachedReport; } @@ -506,6 +532,8 @@ export function resetCachedCodecCapabilities(): void { cachedReportGpuKey = undefined; cachedReportNativeHardwareEncoderKey = undefined; cachedReportHardwareAccelerationDisabled = undefined; + cachedReportAv1OptIn = undefined; + cachedReportHevcOptIn = undefined; runtimeEncodeFailureCodecs.clear(); resetNativeHardwareEncoderCapabilities(); resetOpenH264Status();