mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(voice): give window shares their own audio scope (#2446)
This commit is contained in:
@@ -40,7 +40,6 @@ import {
|
|||||||
} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls';
|
} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls';
|
||||||
import {UnreadBadgeCustomizationControl} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedExperimentalControls';
|
import {UnreadBadgeCustomizationControl} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedExperimentalControls';
|
||||||
import {
|
import {
|
||||||
ManualScreenShareAudioSourcesControl,
|
|
||||||
OpenH264Control,
|
OpenH264Control,
|
||||||
ScreenShareAv1OptInControl,
|
ScreenShareAv1OptInControl,
|
||||||
ScreenShareCodecControl,
|
ScreenShareCodecControl,
|
||||||
@@ -88,7 +87,6 @@ export const DIRECT_CONTROL_ITEM_IDS = new Set([
|
|||||||
'voice-video-screen-share-av1-opt-in',
|
'voice-video-screen-share-av1-opt-in',
|
||||||
'voice-video-screen-share-hevc-opt-in',
|
'voice-video-screen-share-hevc-opt-in',
|
||||||
'voice-video-openh264-codec',
|
'voice-video-openh264-codec',
|
||||||
'voice-video-manual-screen-share-audio-sources',
|
|
||||||
'voice-video-screen-share-preview-behavior',
|
'voice-video-screen-share-preview-behavior',
|
||||||
'voice-video-screen-share-encoder-controls',
|
'voice-video-screen-share-encoder-controls',
|
||||||
'advanced-unread-badge-customization',
|
'advanced-unread-badge-customization',
|
||||||
@@ -125,7 +123,6 @@ export const COMPACT_SWITCH_CONTROL_ITEM_IDS = new Set([
|
|||||||
'voice-video-new-device-alerts',
|
'voice-video-new-device-alerts',
|
||||||
'voice-video-connection-volume-controls',
|
'voice-video-connection-volume-controls',
|
||||||
'voice-video-openh264-codec',
|
'voice-video-openh264-codec',
|
||||||
'voice-video-manual-screen-share-audio-sources',
|
|
||||||
'voice-video-screen-share-av1-opt-in',
|
'voice-video-screen-share-av1-opt-in',
|
||||||
'voice-video-screen-share-hevc-opt-in',
|
'voice-video-screen-share-hevc-opt-in',
|
||||||
'advanced-unread-badge-customization',
|
'advanced-unread-badge-customization',
|
||||||
@@ -286,10 +283,6 @@ export const AdvancedSettingControl = observer(({item}: {item: SearchableSetting
|
|||||||
return (
|
return (
|
||||||
<OpenH264Control data-flx="user.advanced-setting-direct-controls.advanced-setting-control.open-h264-control" />
|
<OpenH264Control data-flx="user.advanced-setting-direct-controls.advanced-setting-control.open-h264-control" />
|
||||||
);
|
);
|
||||||
case 'voice-video-manual-screen-share-audio-sources':
|
|
||||||
return (
|
|
||||||
<ManualScreenShareAudioSourcesControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.manual-screen-share-audio-sources-control" />
|
|
||||||
);
|
|
||||||
case 'voice-video-screen-share-preview-behavior':
|
case 'voice-video-screen-share-preview-behavior':
|
||||||
return (
|
return (
|
||||||
<ScreenSharePreviewBehaviorControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.screen-share-preview-behavior-control" />
|
<ScreenSharePreviewBehaviorControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.screen-share-preview-behavior-control" />
|
||||||
|
|||||||
-33
@@ -28,11 +28,8 @@ import {
|
|||||||
selectAutomaticScreenShareCodec,
|
selectAutomaticScreenShareCodec,
|
||||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||||
import {getGpuEncoderReportSync, loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
import {getGpuEncoderReportSync, loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||||
import {getNativeAudioAvailabilitySnapshot} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
|
||||||
import {setOpenH264Enabled} from '@app/features/voice/utils/OpenH264Status';
|
import {setOpenH264Enabled} from '@app/features/voice/utils/OpenH264Status';
|
||||||
import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy';
|
import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy';
|
||||||
import {reapplyActiveScreenShareAudioSources} from '@app/features/voice/utils/ScreenShareStartFlow';
|
|
||||||
import {maySupportManualScreenShareAudioSourceSelection} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
|
||||||
import {msg} from '@lingui/core/macro';
|
import {msg} from '@lingui/core/macro';
|
||||||
import {useLingui} from '@lingui/react/macro';
|
import {useLingui} from '@lingui/react/macro';
|
||||||
import {GearIcon} from '@phosphor-icons/react';
|
import {GearIcon} from '@phosphor-icons/react';
|
||||||
@@ -192,11 +189,6 @@ const HEVC_SCREEN_SHARE_OPT_IN_DESCRIPTOR = msg({
|
|||||||
comment:
|
comment:
|
||||||
'Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.',
|
'Switch label for the H.265/HEVC screen-share opt-in. H.265 and HEVC are codec names and should stay literal.',
|
||||||
});
|
});
|
||||||
const MANUAL_SCREEN_SHARE_AUDIO_SOURCES_DESCRIPTOR = msg({
|
|
||||||
message: 'Pick the apps to capture audio from',
|
|
||||||
comment:
|
|
||||||
'Switch label for the advanced opt-in that reveals a manual per-application audio source picker in the stream settings menu.',
|
|
||||||
});
|
|
||||||
const SCREEN_SHARE_CODEC_OPTION_ORDER = ['av1', 'h265', 'h264', 'vp9', 'vp8'] as const;
|
const SCREEN_SHARE_CODEC_OPTION_ORDER = ['av1', 'h265', 'h264', 'vp9', 'vp8'] as const;
|
||||||
|
|
||||||
export const ScreenShareCodecControl = observer(() => {
|
export const ScreenShareCodecControl = observer(() => {
|
||||||
@@ -269,31 +261,6 @@ export const ScreenShareHevcOptInControl = observer(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ManualScreenShareAudioSourcesControl = observer(() => {
|
|
||||||
const {i18n} = useLingui();
|
|
||||||
if (!isDesktop()) return null;
|
|
||||||
if (
|
|
||||||
!maySupportManualScreenShareAudioSourceSelection({
|
|
||||||
platform: getElectronAPI()?.platform,
|
|
||||||
nativeAudioAvailability: getNativeAudioAvailabilitySnapshot(),
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
ariaLabel={i18n._(MANUAL_SCREEN_SHARE_AUDIO_SOURCES_DESCRIPTOR)}
|
|
||||||
value={VoiceSettings.getScreenShareManualAudioSourcesOptIn()}
|
|
||||||
onChange={(value) => {
|
|
||||||
VoiceSettingsCommands.update({screenShareManualAudioSourcesOptIn: value});
|
|
||||||
void reapplyActiveScreenShareAudioSources();
|
|
||||||
}}
|
|
||||||
compact
|
|
||||||
data-flx="user.advanced-settings-tab.switch.manual-screen-share-audio-sources"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const OpenH264Control = observer(() => {
|
export const OpenH264Control = observer(() => {
|
||||||
const {i18n} = useLingui();
|
const {i18n} = useLingui();
|
||||||
const handleChange = useCallback((value: boolean) => {
|
const handleChange = useCallback((value: boolean) => {
|
||||||
|
|||||||
-48
@@ -3,8 +3,6 @@
|
|||||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||||
import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||||
import type {SearchableSettingDescriptor} from '@app/features/user/components/settings_utils/search_index/SearchIndexTypes';
|
import type {SearchableSettingDescriptor} from '@app/features/user/components/settings_utils/search_index/SearchIndexTypes';
|
||||||
import {getNativeAudioAvailabilitySnapshot} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
|
||||||
import {maySupportManualScreenShareAudioSourceSelection} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
|
||||||
import {msg} from '@lingui/core/macro';
|
import {msg} from '@lingui/core/macro';
|
||||||
|
|
||||||
const SHOW_NEW_DEVICE_ALERTS_DESCRIPTOR = msg({
|
const SHOW_NEW_DEVICE_ALERTS_DESCRIPTOR = msg({
|
||||||
@@ -124,31 +122,6 @@ const ENABLE_OPENH264_SOFTWARE_ENCODING_AND_DECODING_ON_LINUX_DESCRIPTOR = msg({
|
|||||||
message: 'OpenH264 software codec on Linux',
|
message: 'OpenH264 software codec on Linux',
|
||||||
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
|
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
|
||||||
});
|
});
|
||||||
const PICK_THE_APPS_TO_CAPTURE_AUDIO_FROM_DESCRIPTOR = msg({
|
|
||||||
message: 'Pick the apps to capture audio from',
|
|
||||||
comment: 'Settings search entry label. Names an advanced screen-share audio setting in the settings UI.',
|
|
||||||
});
|
|
||||||
const APPLICATION_AUDIO_DESCRIPTOR = msg({
|
|
||||||
message: 'Application audio',
|
|
||||||
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
|
|
||||||
});
|
|
||||||
const AUDIO_SOURCES_DESCRIPTOR = msg({
|
|
||||||
message: 'Audio sources',
|
|
||||||
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
|
|
||||||
});
|
|
||||||
const CAPTURE_CARD_DESCRIPTOR = msg({
|
|
||||||
message: 'Capture card',
|
|
||||||
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
|
|
||||||
});
|
|
||||||
const VIRTUAL_MICROPHONE_DESCRIPTOR = msg({
|
|
||||||
message: 'Virtual microphone',
|
|
||||||
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
|
|
||||||
});
|
|
||||||
const MANUAL_AUDIO_SOURCE_PICKER_NOTE_DESCRIPTOR = msg({
|
|
||||||
message:
|
|
||||||
'Adds a manual audio source picker to the stream settings menu. Without it, a shared window already captures that app and a shared screen captures your desktop.',
|
|
||||||
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
|
|
||||||
});
|
|
||||||
const SCREEN_SHARE_PREVIEW_BEHAVIOR_DESCRIPTOR = msg({
|
const SCREEN_SHARE_PREVIEW_BEHAVIOR_DESCRIPTOR = msg({
|
||||||
message: 'Screen share preview behavior',
|
message: 'Screen share preview behavior',
|
||||||
comment: 'Settings search entry label. Names the settings search entry in the settings UI.',
|
comment: 'Settings search entry label. Names the settings search entry in the settings UI.',
|
||||||
@@ -274,27 +247,6 @@ export const voiceVideoIndex: Array<SearchableSettingDescriptor> = [
|
|||||||
tags: ['media', 'voice'],
|
tags: ['media', 'voice'],
|
||||||
isVisible: () => isDesktop() && getElectronAPI()?.platform === 'linux',
|
isVisible: () => isDesktop() && getElectronAPI()?.platform === 'linux',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'voice-video-manual-screen-share-audio-sources',
|
|
||||||
tabType: 'voice_video',
|
|
||||||
sectionId: 'video',
|
|
||||||
label: PICK_THE_APPS_TO_CAPTURE_AUDIO_FROM_DESCRIPTOR,
|
|
||||||
keywords: [
|
|
||||||
APPLICATION_AUDIO_DESCRIPTOR,
|
|
||||||
AUDIO_SOURCES_DESCRIPTOR,
|
|
||||||
CAPTURE_CARD_DESCRIPTOR,
|
|
||||||
VIRTUAL_MICROPHONE_DESCRIPTOR,
|
|
||||||
],
|
|
||||||
description: MANUAL_AUDIO_SOURCE_PICKER_NOTE_DESCRIPTOR,
|
|
||||||
audience: 'advanced',
|
|
||||||
tags: ['media', 'voice'],
|
|
||||||
isVisible: () =>
|
|
||||||
isDesktop() &&
|
|
||||||
maySupportManualScreenShareAudioSourceSelection({
|
|
||||||
platform: getElectronAPI()?.platform,
|
|
||||||
nativeAudioAvailability: getNativeAudioAvailabilitySnapshot(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'voice-video-screen-share-preview-behavior',
|
id: 'voice-video-screen-share-preview-behavior',
|
||||||
tabType: 'voice_video',
|
tabType: 'voice_video',
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ type VoiceSettingsPatch = Partial<{
|
|||||||
linuxAudioCaptureIgnoreDevices: boolean;
|
linuxAudioCaptureIgnoreDevices: boolean;
|
||||||
linuxAudioCaptureGranularSelect: boolean;
|
linuxAudioCaptureGranularSelect: boolean;
|
||||||
linuxAudioCaptureDeviceSelect: boolean;
|
linuxAudioCaptureDeviceSelect: boolean;
|
||||||
screenShareManualAudioSourcesOptIn: boolean;
|
|
||||||
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
||||||
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
||||||
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
||||||
|
|||||||
@@ -10,46 +10,35 @@ import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
|||||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||||
import {
|
import {
|
||||||
getLinuxAudioSourceDisplayName,
|
filterRoutableLinuxAudioSources,
|
||||||
type LinuxAudioSourceFilterOptions,
|
type LinuxAudioSourceFilterOptions,
|
||||||
type LinuxAudioSourceItem,
|
type LinuxAudioSourceItem,
|
||||||
linuxAudioSourceItemKey,
|
linuxAudioSourceItemKey,
|
||||||
mapLinuxAudioNodeToItems,
|
mapLinuxAudioNodeToItems,
|
||||||
uniqueLinuxAudioSourceItems,
|
uniqueLinuxAudioSourceItems,
|
||||||
} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||||
import type {StreamSettingsShareContext} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
import {
|
||||||
|
formatScreenShareAudioSummary,
|
||||||
|
MICROPHONE_DESCRIPTOR,
|
||||||
|
MICROPHONE_WITH_DEVICE_DESCRIPTOR,
|
||||||
|
} from '@app/features/voice/utils/ScreenShareAudioSummary';
|
||||||
|
import type {DisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||||
|
import {
|
||||||
|
resolveWindowShareAudioScope,
|
||||||
|
type StreamSettingsShareContext,
|
||||||
|
supportsWindowShareAudioScope,
|
||||||
|
type WindowShareAudioScope,
|
||||||
|
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||||
import type {VirtmicNode} from '@app/types/electron.d';
|
import type {VirtmicNode} from '@app/types/electron.d';
|
||||||
import {msg} from '@lingui/core/macro';
|
import {msg} from '@lingui/core/macro';
|
||||||
import {Trans, useLingui} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
import {useCallback, useEffect, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
|
|
||||||
const NO_AUDIO_DESCRIPTOR = msg({
|
const SHARED_WINDOW_AUDIO_DESCRIPTOR = msg({
|
||||||
message: 'No audio',
|
message: 'Shared window audio',
|
||||||
comment: 'Summary label in the Linux audio source picker when no audio sources are selected.',
|
|
||||||
});
|
|
||||||
const CUSTOM_DESCRIPTOR = msg({
|
|
||||||
message: 'Custom',
|
|
||||||
comment: 'Summary label in the Linux audio source picker when a custom subset of apps is selected.',
|
|
||||||
});
|
|
||||||
const APPS_DESCRIPTOR = msg({
|
|
||||||
message: '{length} apps',
|
|
||||||
comment:
|
comment:
|
||||||
'Summary label in the Linux audio source picker when N apps are included. {length} is the integer app count.',
|
'Capture option in the Linux audio source picker on a window share. Captures only the audio of the window being shared.',
|
||||||
});
|
|
||||||
const ENTIRE_SYSTEM_DESCRIPTOR = msg({
|
|
||||||
message: 'Entire system',
|
|
||||||
comment: 'Summary label in the Linux audio source picker when capturing the whole system audio mix.',
|
|
||||||
});
|
|
||||||
const MICROPHONE_DESCRIPTOR = msg({
|
|
||||||
message: 'Microphone',
|
|
||||||
comment:
|
|
||||||
'Summary label in the Linux audio source picker on a video device share, where the default is the microphone rather than the system mix.',
|
|
||||||
});
|
|
||||||
const MICROPHONE_WITH_DEVICE_DESCRIPTOR = msg({
|
|
||||||
message: 'Microphone ({deviceLabel})',
|
|
||||||
comment:
|
|
||||||
'Capture option in the Linux audio source picker on a video device share. {deviceLabel} is the selected audio input device name.',
|
|
||||||
});
|
});
|
||||||
const AUDIO_SOURCES_DESCRIPTOR = msg({
|
const AUDIO_SOURCES_DESCRIPTOR = msg({
|
||||||
message: 'Audio sources: {summaryLabel}',
|
message: 'Audio sources: {summaryLabel}',
|
||||||
@@ -129,15 +118,26 @@ async function fetchAudioSources(options: LinuxAudioSourceFilterOptions): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AudioSourcePickerLinuxSubmenuProps {
|
interface AudioSourcePickerLinuxSubmenuProps {
|
||||||
onSelectionChange?: () => void;
|
onSelectionChange?: (nextWindowAudioScope?: WindowShareAudioScope) => void;
|
||||||
shareContext?: StreamSettingsShareContext;
|
shareContext?: StreamSettingsShareContext;
|
||||||
|
displayShareEnvironment?: DisplayShareEnvironment;
|
||||||
|
windowAudioScope?: WindowShareAudioScope;
|
||||||
microphoneLabel?: string;
|
microphoneLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerLinuxSubmenuProps) => {
|
export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerLinuxSubmenuProps) => {
|
||||||
const {onSelectionChange, shareContext = 'display', microphoneLabel} = props;
|
const {
|
||||||
|
onSelectionChange,
|
||||||
|
shareContext = 'display',
|
||||||
|
displayShareEnvironment,
|
||||||
|
windowAudioScope,
|
||||||
|
microphoneLabel,
|
||||||
|
} = props;
|
||||||
const {i18n} = useLingui();
|
const {i18n} = useLingui();
|
||||||
const isDeviceShare = shareContext === 'device';
|
const isDeviceShare = shareContext === 'device';
|
||||||
|
const scopeInput = {shareContext, displayShareEnvironment, windowAudioScope};
|
||||||
|
const offersWindowScope = supportsWindowShareAudioScope(scopeInput);
|
||||||
|
const resolvedScope = resolveWindowShareAudioScope(scopeInput);
|
||||||
const sourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
const sourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
||||||
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
||||||
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
|
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
|
||||||
@@ -145,6 +145,8 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
|
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
|
||||||
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
|
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
|
||||||
const [snapshot, setSnapshot] = useState<AudioSourceSnapshot>(EMPTY_SNAPSHOT);
|
const [snapshot, setSnapshot] = useState<AudioSourceSnapshot>(EMPTY_SNAPSHOT);
|
||||||
|
const routesSelectedSources = sourceMode === 'specific' && filterRoutableLinuxAudioSources(includeSources).length > 0;
|
||||||
|
const widenedScope = offersWindowScope ? ('system' as const) : undefined;
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setSnapshot((prev) => ({...prev, loading: true}));
|
setSnapshot((prev) => ({...prev, loading: true}));
|
||||||
void fetchAudioSources({granular, deviceSelect, ignoreVirtual}).then((next) => {
|
void fetchAudioSources({granular, deviceSelect, ignoreVirtual}).then((next) => {
|
||||||
@@ -161,13 +163,16 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
const handlePickWindow = useCallback(() => {
|
||||||
|
onSelectionChange?.('window');
|
||||||
|
}, [onSelectionChange]);
|
||||||
const handlePickSystem = useCallback(() => {
|
const handlePickSystem = useCallback(() => {
|
||||||
VoiceSettingsCommands.update({
|
VoiceSettingsCommands.update({
|
||||||
screenShareAudioSourceMode: 'system',
|
screenShareAudioSourceMode: 'system',
|
||||||
screenShareAudioIncludeSources: [],
|
screenShareAudioIncludeSources: [],
|
||||||
});
|
});
|
||||||
onSelectionChange?.();
|
onSelectionChange?.(widenedScope);
|
||||||
}, [onSelectionChange]);
|
}, [onSelectionChange, widenedScope]);
|
||||||
const handlePickNone = useCallback(() => {
|
const handlePickNone = useCallback(() => {
|
||||||
VoiceSettingsCommands.update({
|
VoiceSettingsCommands.update({
|
||||||
screenShareAudioSourceMode: 'none',
|
screenShareAudioSourceMode: 'none',
|
||||||
@@ -185,9 +190,9 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system',
|
screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system',
|
||||||
screenShareAudioIncludeSources: nextSources,
|
screenShareAudioIncludeSources: nextSources,
|
||||||
});
|
});
|
||||||
onSelectionChange?.();
|
onSelectionChange?.(widenedScope);
|
||||||
},
|
},
|
||||||
[includeSources, onSelectionChange],
|
[includeSources, onSelectionChange, widenedScope],
|
||||||
);
|
);
|
||||||
const handleToggleExcludeApp = useCallback(
|
const handleToggleExcludeApp = useCallback(
|
||||||
(item: LinuxAudioSourceItem) => {
|
(item: LinuxAudioSourceItem) => {
|
||||||
@@ -202,19 +207,19 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
},
|
},
|
||||||
[excludeSources, onSelectionChange],
|
[excludeSources, onSelectionChange],
|
||||||
);
|
);
|
||||||
const defaultSourceLabel = isDeviceShare
|
const deviceSourceLabel = i18n._(MICROPHONE_WITH_DEVICE_DESCRIPTOR, {
|
||||||
? i18n._(MICROPHONE_WITH_DEVICE_DESCRIPTOR, {deviceLabel: microphoneLabel ?? i18n._(MICROPHONE_DESCRIPTOR)})
|
deviceLabel: microphoneLabel ?? i18n._(MICROPHONE_DESCRIPTOR),
|
||||||
: i18n._(ENTIRE_SYSTEM_DESCRIPTOR);
|
});
|
||||||
const summaryLabel =
|
const summaryLabel = formatScreenShareAudioSummary(i18n, {
|
||||||
sourceMode === 'none'
|
sourceMode,
|
||||||
? i18n._(NO_AUDIO_DESCRIPTOR)
|
includeSources,
|
||||||
: sourceMode === 'specific'
|
shareContext,
|
||||||
? includeSources.length === 1
|
microphoneLabel,
|
||||||
? (getLinuxAudioSourceDisplayName(includeSources[0]) ?? i18n._(CUSTOM_DESCRIPTOR))
|
displayShareEnvironment,
|
||||||
: i18n._(APPS_DESCRIPTOR, {length: includeSources.length})
|
windowAudioScope,
|
||||||
: isDeviceShare
|
});
|
||||||
? i18n._(MICROPHONE_DESCRIPTOR)
|
const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system';
|
||||||
: i18n._(ENTIRE_SYSTEM_DESCRIPTOR);
|
const wideSourceIsSelected = isDeviceShare ? !routesSelectedSources : sourceMode === 'system';
|
||||||
if (!snapshot.available && !snapshot.loading) {
|
if (!snapshot.available && !snapshot.loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -227,22 +232,33 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.capture">
|
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.capture">
|
||||||
{i18n._(CAPTURE_DESCRIPTOR)}
|
{i18n._(CAPTURE_DESCRIPTOR)}
|
||||||
</MenuGroupLabel>
|
</MenuGroupLabel>
|
||||||
|
{offersWindowScope && (
|
||||||
|
<MenuItemRadio
|
||||||
|
selected={resolvedScope === 'window'}
|
||||||
|
onSelect={handlePickWindow}
|
||||||
|
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-window"
|
||||||
|
>
|
||||||
|
{i18n._(SHARED_WINDOW_AUDIO_DESCRIPTOR)}
|
||||||
|
</MenuItemRadio>
|
||||||
|
)}
|
||||||
<MenuItemRadio
|
<MenuItemRadio
|
||||||
selected={sourceMode === 'system'}
|
selected={showsWideSourceLists && wideSourceIsSelected}
|
||||||
onSelect={handlePickSystem}
|
onSelect={handlePickSystem}
|
||||||
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-system"
|
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-system"
|
||||||
>
|
>
|
||||||
{isDeviceShare ? defaultSourceLabel : <Trans>Entire system audio</Trans>}
|
{isDeviceShare ? deviceSourceLabel : <Trans>Entire system audio</Trans>}
|
||||||
</MenuItemRadio>
|
|
||||||
<MenuItemRadio
|
|
||||||
selected={sourceMode === 'none'}
|
|
||||||
onSelect={handlePickNone}
|
|
||||||
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-none"
|
|
||||||
>
|
|
||||||
<Trans>None</Trans>
|
|
||||||
</MenuItemRadio>
|
</MenuItemRadio>
|
||||||
|
{!isDeviceShare && !offersWindowScope && (
|
||||||
|
<MenuItemRadio
|
||||||
|
selected={sourceMode === 'none'}
|
||||||
|
onSelect={handlePickNone}
|
||||||
|
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-none"
|
||||||
|
>
|
||||||
|
<Trans>None</Trans>
|
||||||
|
</MenuItemRadio>
|
||||||
|
)}
|
||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
{snapshot.items.length > 0 && (
|
{showsWideSourceLists && snapshot.items.length > 0 && (
|
||||||
<MenuGroup data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-group--2">
|
<MenuGroup data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-group--2">
|
||||||
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.include-apps">
|
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.include-apps">
|
||||||
{i18n._(INCLUDE_APPS_DESCRIPTOR)}
|
{i18n._(INCLUDE_APPS_DESCRIPTOR)}
|
||||||
@@ -259,7 +275,7 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
))}
|
))}
|
||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
)}
|
)}
|
||||||
{!isDeviceShare && sourceMode === 'system' && snapshot.items.length > 0 && (
|
{!isDeviceShare && showsWideSourceLists && sourceMode === 'system' && snapshot.items.length > 0 && (
|
||||||
<MenuGroup data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-group--3">
|
<MenuGroup data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-group--3">
|
||||||
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.exclude-from-system">
|
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.exclude-from-system">
|
||||||
{i18n._(EXCLUDE_FROM_SYSTEM_DESCRIPTOR)}
|
{i18n._(EXCLUDE_FROM_SYSTEM_DESCRIPTOR)}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/Med
|
|||||||
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||||
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||||
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
||||||
|
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||||
import VoiceSettings, {type ScreenshareResolution, type StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
import VoiceSettings, {type ScreenshareResolution, type StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||||
import {resolveScreenShareContentHintForContext} from '@app/features/voice/utils/CodecCapabilityDetector';
|
import {resolveScreenShareContentHintForContext} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||||
|
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||||
import {
|
import {
|
||||||
getNativeAudioAvailabilityCached,
|
getNativeAudioAvailabilityCached,
|
||||||
getNativeAudioAvailabilitySnapshot,
|
getNativeAudioAvailabilitySnapshot,
|
||||||
@@ -42,7 +44,9 @@ import {
|
|||||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||||
import {isScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
import {isScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||||
import {
|
import {
|
||||||
|
applyLiveScreenShareAudioSourceChange,
|
||||||
reconfigureActiveDeviceShareAudio,
|
reconfigureActiveDeviceShareAudio,
|
||||||
|
reconfigureActiveLinuxAppShareAudio,
|
||||||
reconfigureActiveLinuxScreenShareAudioLink,
|
reconfigureActiveLinuxScreenShareAudioLink,
|
||||||
stopActiveLinuxScreenShareAudioLink,
|
stopActiveLinuxScreenShareAudioLink,
|
||||||
} from '@app/features/voice/utils/ScreenShareStartFlow';
|
} from '@app/features/voice/utils/ScreenShareStartFlow';
|
||||||
@@ -51,6 +55,7 @@ import {
|
|||||||
isLinuxDesktopAudioShare,
|
isLinuxDesktopAudioShare,
|
||||||
type StreamSettingsShareContext,
|
type StreamSettingsShareContext,
|
||||||
shouldReconfigureAudioForActiveStreamSettings,
|
shouldReconfigureAudioForActiveStreamSettings,
|
||||||
|
type WindowShareAudioScope,
|
||||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||||
import {hasHigherVideoQuality as resolveHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
import {hasHigherVideoQuality as resolveHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||||
import {formatVoiceAudioDeviceLabel} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
import {formatVoiceAudioDeviceLabel} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
||||||
@@ -149,6 +154,11 @@ const SHARE_STREAM_AUDIO_DESCRIPTOR = msg({
|
|||||||
message: 'Share stream audio',
|
message: 'Share stream audio',
|
||||||
comment: 'Toggle label for sharing audio with an active stream.',
|
comment: 'Toggle label for sharing audio with an active stream.',
|
||||||
});
|
});
|
||||||
|
const CAPTURE_ENTIRE_SYSTEM_AUDIO_DESCRIPTOR = msg({
|
||||||
|
message: 'Capture entire system audio',
|
||||||
|
comment:
|
||||||
|
'Toggle label in the stream settings menu for a window share whose audio scope the user widened from the shared window to the whole system mix.',
|
||||||
|
});
|
||||||
const logger = new Logger('StreamSettingsMenuContent');
|
const logger = new Logger('StreamSettingsMenuContent');
|
||||||
const SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source;
|
const SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source;
|
||||||
|
|
||||||
@@ -291,6 +301,8 @@ export async function pushActiveStreamSettings(
|
|||||||
audioLinkUpdated = await stopActiveLinuxScreenShareAudioLink();
|
audioLinkUpdated = await stopActiveLinuxScreenShareAudioLink();
|
||||||
} else if (shareContext === 'device') {
|
} else if (shareContext === 'device') {
|
||||||
audioLinkUpdated = await reconfigureActiveDeviceShareAudio();
|
audioLinkUpdated = await reconfigureActiveDeviceShareAudio();
|
||||||
|
} else if (shareContext === 'app') {
|
||||||
|
audioLinkUpdated = await reconfigureActiveLinuxAppShareAudio();
|
||||||
} else {
|
} else {
|
||||||
audioLinkUpdated = await reconfigureActiveLinuxScreenShareAudioLink();
|
audioLinkUpdated = await reconfigureActiveLinuxScreenShareAudioLink();
|
||||||
}
|
}
|
||||||
@@ -374,9 +386,13 @@ export const StreamSettingsMenuContent = observer(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
const platform = getElectronAPI()?.platform;
|
const platform = getElectronAPI()?.platform;
|
||||||
const manualAudioSourcesOptIn = VoiceSettings.getScreenShareManualAudioSourcesOptIn();
|
|
||||||
const audioSourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
const audioSourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
||||||
const selectedAudioSourceCount = VoiceSettings.getScreenShareAudioIncludeSources().length;
|
const selectedAudioSourceCount = filterRoutableLinuxAudioSources(
|
||||||
|
VoiceSettings.getScreenShareAudioIncludeSources(),
|
||||||
|
).length;
|
||||||
|
const windowAudioScope = applyToLiveStream
|
||||||
|
? ActiveScreenShareSource.getWindowAudioScope()
|
||||||
|
: ActiveScreenShareSource.getPendingWindowAudioScope();
|
||||||
const audioMenuState = useMemo(
|
const audioMenuState = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectStreamSettingsAudioMenuState({
|
selectStreamSettingsAudioMenuState({
|
||||||
@@ -388,9 +404,9 @@ export const StreamSettingsMenuContent = observer(
|
|||||||
hasLiveScreenShareAudioPublication,
|
hasLiveScreenShareAudioPublication,
|
||||||
nativeAudioAvailability,
|
nativeAudioAvailability,
|
||||||
platform,
|
platform,
|
||||||
manualAudioSourcesOptIn,
|
|
||||||
audioSourceMode,
|
audioSourceMode,
|
||||||
selectedAudioSourceCount,
|
selectedAudioSourceCount,
|
||||||
|
windowAudioScope,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
applyToLiveStream,
|
applyToLiveStream,
|
||||||
@@ -398,12 +414,12 @@ export const StreamSettingsMenuContent = observer(
|
|||||||
captureAudioEnabled,
|
captureAudioEnabled,
|
||||||
displayShareEnvironment,
|
displayShareEnvironment,
|
||||||
hasLiveScreenShareAudioPublication,
|
hasLiveScreenShareAudioPublication,
|
||||||
manualAudioSourcesOptIn,
|
|
||||||
nativeAudioAvailability,
|
nativeAudioAvailability,
|
||||||
platform,
|
platform,
|
||||||
selectedAudioSourceCount,
|
selectedAudioSourceCount,
|
||||||
shareContext,
|
shareContext,
|
||||||
supportsStreamAudio,
|
supportsStreamAudio,
|
||||||
|
windowAudioScope,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const modeOptions: Array<Option<StreamingMode>> = useMemo(() => {
|
const modeOptions: Array<Option<StreamingMode>> = useMemo(() => {
|
||||||
@@ -474,14 +490,28 @@ export const StreamSettingsMenuContent = observer(
|
|||||||
},
|
},
|
||||||
[applyToLiveStream, displayShareEnvironment, hasHigherVideoQuality, shareContext],
|
[applyToLiveStream, displayShareEnvironment, hasHigherVideoQuality, shareContext],
|
||||||
);
|
);
|
||||||
const applyAudioSourceChange = useCallback(() => {
|
const applyAudioSourceChange = useCallback(
|
||||||
if (!applyToLiveStream) return;
|
(nextWindowAudioScope?: WindowShareAudioScope) => {
|
||||||
const applied =
|
if (!applyToLiveStream) {
|
||||||
shareContext === 'device' ? reconfigureActiveDeviceShareAudio() : reconfigureActiveLinuxScreenShareAudioLink();
|
if (nextWindowAudioScope != null) {
|
||||||
void applied.catch((error) => {
|
ActiveScreenShareSource.setPendingWindowAudioScope(nextWindowAudioScope);
|
||||||
logger.warn('Failed to apply the screen share audio source change', error);
|
}
|
||||||
});
|
return;
|
||||||
}, [applyToLiveStream, shareContext]);
|
}
|
||||||
|
void applyLiveScreenShareAudioSourceChange(shareContext, nextWindowAudioScope)
|
||||||
|
.then((applied) => {
|
||||||
|
if (applied) return;
|
||||||
|
logger.warn('The screen share audio source change did not take; the share is running without audio', {
|
||||||
|
shareContext,
|
||||||
|
nextWindowAudioScope,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to apply the screen share audio source change', error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyToLiveStream, shareContext],
|
||||||
|
);
|
||||||
const handleModeSelect = useCallback(
|
const handleModeSelect = useCallback(
|
||||||
(option: Option<StreamingMode>) => {
|
(option: Option<StreamingMode>) => {
|
||||||
if (option.isPremium && !hasHigherVideoQuality) {
|
if (option.isPremium && !hasHigherVideoQuality) {
|
||||||
@@ -673,6 +703,8 @@ export const StreamSettingsMenuContent = observer(
|
|||||||
<StreamSettingsAudioGroup
|
<StreamSettingsAudioGroup
|
||||||
audioMenuState={audioMenuState}
|
audioMenuState={audioMenuState}
|
||||||
shareContext={shareContext}
|
shareContext={shareContext}
|
||||||
|
displayShareEnvironment={displayShareEnvironment}
|
||||||
|
windowAudioScope={windowAudioScope}
|
||||||
compact={true}
|
compact={true}
|
||||||
audioDeviceOptions={audioDeviceOptions}
|
audioDeviceOptions={audioDeviceOptions}
|
||||||
currentAudioDeviceId={currentAudioDeviceId}
|
currentAudioDeviceId={currentAudioDeviceId}
|
||||||
@@ -787,6 +819,8 @@ export const StreamSettingsMenuContent = observer(
|
|||||||
<StreamSettingsAudioGroup
|
<StreamSettingsAudioGroup
|
||||||
audioMenuState={audioMenuState}
|
audioMenuState={audioMenuState}
|
||||||
shareContext={shareContext}
|
shareContext={shareContext}
|
||||||
|
displayShareEnvironment={displayShareEnvironment}
|
||||||
|
windowAudioScope={windowAudioScope}
|
||||||
compact={false}
|
compact={false}
|
||||||
audioDeviceOptions={audioDeviceOptions}
|
audioDeviceOptions={audioDeviceOptions}
|
||||||
currentAudioDeviceId={currentAudioDeviceId}
|
currentAudioDeviceId={currentAudioDeviceId}
|
||||||
@@ -814,12 +848,14 @@ StreamSettingsMenuContent.displayName = 'StreamSettingsMenuContent';
|
|||||||
interface StreamSettingsAudioGroupProps {
|
interface StreamSettingsAudioGroupProps {
|
||||||
audioMenuState: StreamSettingsAudioMenuViewState;
|
audioMenuState: StreamSettingsAudioMenuViewState;
|
||||||
shareContext: StreamSettingsShareContext;
|
shareContext: StreamSettingsShareContext;
|
||||||
|
displayShareEnvironment: DisplayShareEnvironment;
|
||||||
|
windowAudioScope: WindowShareAudioScope;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
audioDeviceOptions: Array<MediaDeviceInfo>;
|
audioDeviceOptions: Array<MediaDeviceInfo>;
|
||||||
currentAudioDeviceId: string;
|
currentAudioDeviceId: string;
|
||||||
selectedAudioDeviceLabel: string;
|
selectedAudioDeviceLabel: string;
|
||||||
onCaptureAudioToggle: (checked: boolean) => void;
|
onCaptureAudioToggle: (checked: boolean) => void;
|
||||||
onAudioSourceChange: () => void;
|
onAudioSourceChange: (nextWindowAudioScope?: WindowShareAudioScope) => void;
|
||||||
onAudioDeviceSelect: (deviceId: string) => void;
|
onAudioDeviceSelect: (deviceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,6 +863,8 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
|||||||
const {
|
const {
|
||||||
audioMenuState,
|
audioMenuState,
|
||||||
shareContext,
|
shareContext,
|
||||||
|
displayShareEnvironment,
|
||||||
|
windowAudioScope,
|
||||||
compact,
|
compact,
|
||||||
audioDeviceOptions,
|
audioDeviceOptions,
|
||||||
currentAudioDeviceId,
|
currentAudioDeviceId,
|
||||||
@@ -840,6 +878,7 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
|||||||
if (compact) return i18n._(SHARE_STREAM_AUDIO_DESCRIPTOR);
|
if (compact) return i18n._(SHARE_STREAM_AUDIO_DESCRIPTOR);
|
||||||
if (audioMenuState.control.labelKey === 'captureDeviceAudio') return <Trans>Capture device audio</Trans>;
|
if (audioMenuState.control.labelKey === 'captureDeviceAudio') return <Trans>Capture device audio</Trans>;
|
||||||
if (audioMenuState.control.labelKey === 'captureAppAudio') return <Trans>Capture app audio</Trans>;
|
if (audioMenuState.control.labelKey === 'captureAppAudio') return <Trans>Capture app audio</Trans>;
|
||||||
|
if (audioMenuState.control.labelKey === 'captureSystemAudio') return i18n._(CAPTURE_ENTIRE_SYSTEM_AUDIO_DESCRIPTOR);
|
||||||
return <Trans>Capture desktop audio</Trans>;
|
return <Trans>Capture desktop audio</Trans>;
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -858,6 +897,8 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
|||||||
<AudioSourcePickerLinuxSubmenu
|
<AudioSourcePickerLinuxSubmenu
|
||||||
onSelectionChange={onAudioSourceChange}
|
onSelectionChange={onAudioSourceChange}
|
||||||
shareContext={shareContext}
|
shareContext={shareContext}
|
||||||
|
displayShareEnvironment={displayShareEnvironment}
|
||||||
|
windowAudioScope={windowAudioScope}
|
||||||
microphoneLabel={selectedAudioDeviceLabel}
|
microphoneLabel={selectedAudioDeviceLabel}
|
||||||
data-flx="voice.stream-settings-menu-content.audio-group.audio-source-picker-submenu"
|
data-flx="voice.stream-settings-menu-content.audio-group.audio-source-picker-submenu"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+103
-62
@@ -133,32 +133,37 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
expect(selectStreamSettingsAudioControlState(signals({displayShareEnvironment: 'web'}))).toBe('toggle');
|
expect(selectStreamSettingsAudioControlState(signals({displayShareEnvironment: 'web'}))).toBe('toggle');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides manual audio sources for every share type until the advanced opt-in is on', () => {
|
it('offers manual audio sources on every desktop share type, window shares included', () => {
|
||||||
|
for (const shareContext of ['app', 'device', 'display'] as const) {
|
||||||
|
for (const displayShareEnvironment of ['desktop-custom', 'desktop-wayland'] as const) {
|
||||||
|
expect(
|
||||||
|
selectStreamSettingsAudioMenuState(
|
||||||
|
signals({
|
||||||
|
shareContext,
|
||||||
|
displayShareEnvironment,
|
||||||
|
platform: 'linux',
|
||||||
|
captureAudioEnabled: true,
|
||||||
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
|
}),
|
||||||
|
).showManualAudioSources,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves audio source selection to the browser picker on the web', () => {
|
||||||
for (const shareContext of ['app', 'device', 'display'] as const) {
|
for (const shareContext of ['app', 'device', 'display'] as const) {
|
||||||
const displayShareEnvironment = shareContext === 'app' ? 'desktop-wayland' : 'desktop-custom';
|
|
||||||
expect(
|
expect(
|
||||||
selectStreamSettingsAudioMenuState(
|
selectStreamSettingsAudioMenuState(
|
||||||
signals({
|
signals({
|
||||||
shareContext,
|
shareContext,
|
||||||
displayShareEnvironment,
|
displayShareEnvironment: 'web',
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
captureAudioEnabled: true,
|
captureAudioEnabled: true,
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
}),
|
}),
|
||||||
).showManualAudioSources,
|
).showManualAudioSources,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
|
||||||
selectStreamSettingsAudioMenuState(
|
|
||||||
signals({
|
|
||||||
shareContext,
|
|
||||||
displayShareEnvironment,
|
|
||||||
platform: 'linux',
|
|
||||||
captureAudioEnabled: true,
|
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
}),
|
|
||||||
).showManualAudioSources,
|
|
||||||
).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +175,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
platform,
|
platform,
|
||||||
captureAudioEnabled: true,
|
captureAudioEnabled: true,
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
}),
|
}),
|
||||||
).showManualAudioSources,
|
).showManualAudioSources,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -181,7 +185,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
platform: null,
|
platform: null,
|
||||||
captureAudioEnabled: true,
|
captureAudioEnabled: true,
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
}),
|
}),
|
||||||
).showManualAudioSources,
|
).showManualAudioSources,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -191,7 +194,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
captureAudioEnabled: true,
|
captureAudioEnabled: true,
|
||||||
nativeAudioAvailability: availableNativeAudio({available: false, reason: 'no-pipewire'}),
|
nativeAudioAvailability: availableNativeAudio({available: false, reason: 'no-pipewire'}),
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
}),
|
}),
|
||||||
).showManualAudioSources,
|
).showManualAudioSources,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -201,7 +203,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
captureAudioEnabled: true,
|
captureAudioEnabled: true,
|
||||||
nativeAudioAvailability: null,
|
nativeAudioAvailability: null,
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
}),
|
}),
|
||||||
).showManualAudioSources,
|
).showManualAudioSources,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -214,7 +215,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
captureAudioEnabled: false,
|
captureAudioEnabled: false,
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
}),
|
}),
|
||||||
).showManualAudioSources,
|
).showManualAudioSources,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -225,12 +225,76 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
supportsStreamAudio: false,
|
supportsStreamAudio: false,
|
||||||
captureAudioEnabled: true,
|
captureAudioEnabled: true,
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
}),
|
}),
|
||||||
).showManualAudioSources,
|
).showManualAudioSources,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names the real scope on a window share instead of always saying app audio', () => {
|
||||||
|
const common = {
|
||||||
|
shareContext: 'app',
|
||||||
|
displayShareEnvironment: 'desktop-custom',
|
||||||
|
platform: 'linux',
|
||||||
|
captureAudioEnabled: true,
|
||||||
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
expect(selectStreamSettingsAudioMenuState(signals(common)).control.labelKey).toBe('captureAppAudio');
|
||||||
|
expect(selectStreamSettingsAudioMenuState(signals({...common, windowAudioScope: 'window'})).control.labelKey).toBe(
|
||||||
|
'captureAppAudio',
|
||||||
|
);
|
||||||
|
expect(selectStreamSettingsAudioMenuState(signals({...common, windowAudioScope: 'system'})).control.labelKey).toBe(
|
||||||
|
'captureSystemAudio',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
selectStreamSettingsAudioMenuState(
|
||||||
|
signals({
|
||||||
|
...common,
|
||||||
|
windowAudioScope: 'system',
|
||||||
|
audioSourceMode: 'specific',
|
||||||
|
selectedAudioSourceCount: 2,
|
||||||
|
}),
|
||||||
|
).control.labelKey,
|
||||||
|
).toBe('captureAppAudio');
|
||||||
|
expect(
|
||||||
|
selectStreamSettingsAudioMenuState(signals({...common, windowAudioScope: 'system', audioSourceMode: 'none'}))
|
||||||
|
.control.labelKey,
|
||||||
|
).toBe('captureAppAudio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps saying app audio on a window share whose stored display selection it does not follow', () => {
|
||||||
|
const common = {
|
||||||
|
shareContext: 'app',
|
||||||
|
displayShareEnvironment: 'desktop-custom',
|
||||||
|
platform: 'linux',
|
||||||
|
captureAudioEnabled: true,
|
||||||
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
|
windowAudioScope: 'window',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
for (const audioSourceMode of ['none', 'system', 'specific'] as const) {
|
||||||
|
expect(
|
||||||
|
selectStreamSettingsAudioMenuState(signals({...common, audioSourceMode, selectedAudioSourceCount: 2})).control
|
||||||
|
.labelKey,
|
||||||
|
).toBe('captureAppAudio');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never widens a Wayland window share label, because its window cannot be isolated', () => {
|
||||||
|
expect(
|
||||||
|
selectStreamSettingsAudioMenuState(
|
||||||
|
signals({
|
||||||
|
shareContext: 'app',
|
||||||
|
displayShareEnvironment: 'desktop-wayland',
|
||||||
|
platform: 'linux',
|
||||||
|
captureAudioEnabled: true,
|
||||||
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
|
windowAudioScope: 'window',
|
||||||
|
}),
|
||||||
|
).control.labelKey,
|
||||||
|
).toBe('captureSystemAudio');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the audio device menu on a device share whether or not sources are routed', () => {
|
it('keeps the audio device menu on a device share whether or not sources are routed', () => {
|
||||||
const routed = selectStreamSettingsAudioMenuState(
|
const routed = selectStreamSettingsAudioMenuState(
|
||||||
signals({
|
signals({
|
||||||
@@ -238,7 +302,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
captureAudioEnabled: true,
|
captureAudioEnabled: true,
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
audioSourceMode: 'specific',
|
audioSourceMode: 'specific',
|
||||||
selectedAudioSourceCount: 2,
|
selectedAudioSourceCount: 2,
|
||||||
}),
|
}),
|
||||||
@@ -247,7 +310,7 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
expect(routed.showDeviceAudioMenu).toBe(true);
|
expect(routed.showDeviceAudioMenu).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pins the audio menu shape for every share type on every platform, opt-in off and on', () => {
|
it('pins the audio menu shape for every share type on every platform', () => {
|
||||||
const platforms = [
|
const platforms = [
|
||||||
{platform: 'linux', nativeAudioAvailability: availableNativeAudio(), manualCapable: true},
|
{platform: 'linux', nativeAudioAvailability: availableNativeAudio(), manualCapable: true},
|
||||||
{platform: 'win32', nativeAudioAvailability: availableNativeAudio(), manualCapable: false},
|
{platform: 'win32', nativeAudioAvailability: availableNativeAudio(), manualCapable: false},
|
||||||
@@ -262,57 +325,35 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
|||||||
|
|
||||||
for (const {platform, nativeAudioAvailability, manualCapable} of platforms) {
|
for (const {platform, nativeAudioAvailability, manualCapable} of platforms) {
|
||||||
for (const shareContext of ['app', 'device', 'display'] as const) {
|
for (const shareContext of ['app', 'device', 'display'] as const) {
|
||||||
for (const manualAudioSourcesOptIn of [false, true]) {
|
const state = selectStreamSettingsAudioMenuState(
|
||||||
const state = selectStreamSettingsAudioMenuState(
|
signals({
|
||||||
signals({
|
shareContext,
|
||||||
shareContext,
|
platform,
|
||||||
platform,
|
nativeAudioAvailability,
|
||||||
nativeAudioAvailability,
|
captureAudioEnabled: true,
|
||||||
captureAudioEnabled: true,
|
}),
|
||||||
manualAudioSourcesOptIn,
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.control).toMatchObject({
|
expect(state.control).toMatchObject({
|
||||||
value: 'toggle',
|
value: 'toggle',
|
||||||
checked: true,
|
checked: true,
|
||||||
labelKey: expectedLabelKey[shareContext],
|
labelKey: expectedLabelKey[shareContext],
|
||||||
});
|
});
|
||||||
expect(state.showManualAudioSources).toBe(manualCapable && manualAudioSourcesOptIn && shareContext !== 'app');
|
expect(state.showManualAudioSources).toBe(manualCapable);
|
||||||
expect(state.showDeviceAudioMenu).toBe(shareContext === 'device');
|
expect(state.showDeviceAudioMenu).toBe(shareContext === 'device');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers manual sources on a window share only where the capture rule decides its audio', () => {
|
|
||||||
const common = {
|
|
||||||
shareContext: 'app',
|
|
||||||
platform: 'linux',
|
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
|
||||||
captureAudioEnabled: true,
|
|
||||||
manualAudioSourcesOptIn: true,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
expect(
|
|
||||||
selectStreamSettingsAudioMenuState(signals({...common, displayShareEnvironment: 'desktop-custom'}))
|
|
||||||
.showManualAudioSources,
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
selectStreamSettingsAudioMenuState(signals({...common, displayShareEnvironment: 'desktop-wayland'}))
|
|
||||||
.showManualAudioSources,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves the same audio group whether the menu is pre-start or attached to a live stream', () => {
|
it('resolves the same audio group whether the menu is pre-start or attached to a live stream', () => {
|
||||||
for (const shareContext of ['app', 'device', 'display'] as const) {
|
for (const shareContext of ['app', 'device', 'display'] as const) {
|
||||||
for (const manualAudioSourcesOptIn of [false, true]) {
|
for (const windowAudioScope of ['window', 'system'] as const) {
|
||||||
const common = {
|
const common = {
|
||||||
shareContext,
|
shareContext,
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
nativeAudioAvailability: availableNativeAudio(),
|
nativeAudioAvailability: availableNativeAudio(),
|
||||||
captureAudioEnabled: true,
|
captureAudioEnabled: true,
|
||||||
manualAudioSourcesOptIn,
|
windowAudioScope,
|
||||||
};
|
};
|
||||||
const prestart = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: false}));
|
const prestart = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: false}));
|
||||||
const live = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: true}));
|
const live = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: true}));
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
canSelectManualAudioSources,
|
canSelectManualAudioSources,
|
||||||
manualAudioSourcesGovernShare,
|
manualAudioSourcesGovernShare,
|
||||||
|
resolveWindowShareAudioScope,
|
||||||
type ScreenShareAudioSourceMode,
|
type ScreenShareAudioSourceMode,
|
||||||
type StreamSettingsShareContext,
|
type StreamSettingsShareContext,
|
||||||
|
selectAppShareAudioRoute,
|
||||||
|
type WindowShareAudioScope,
|
||||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||||
import type {NativeAudioAvailability} from '@app/types/electron.d';
|
import type {NativeAudioAvailability} from '@app/types/electron.d';
|
||||||
import {getInitialSnapshot, setup, transition} from 'xstate';
|
import {getInitialSnapshot, setup, transition} from 'xstate';
|
||||||
@@ -20,7 +23,11 @@ export type StreamSettingsAudioControlStateValue =
|
|||||||
| 'prestartNativePickerOwned'
|
| 'prestartNativePickerOwned'
|
||||||
| 'restartRequired'
|
| 'restartRequired'
|
||||||
| 'toggle';
|
| 'toggle';
|
||||||
export type StreamSettingsAudioControlLabelKey = 'captureAppAudio' | 'captureDesktopAudio' | 'captureDeviceAudio';
|
export type StreamSettingsAudioControlLabelKey =
|
||||||
|
| 'captureAppAudio'
|
||||||
|
| 'captureDesktopAudio'
|
||||||
|
| 'captureDeviceAudio'
|
||||||
|
| 'captureSystemAudio';
|
||||||
type StreamSettingsNativeAudioUnsupportedScope = 'process' | 'system';
|
type StreamSettingsNativeAudioUnsupportedScope = 'process' | 'system';
|
||||||
|
|
||||||
export interface StreamSettingsNativeAudioSignals {
|
export interface StreamSettingsNativeAudioSignals {
|
||||||
@@ -35,9 +42,9 @@ export interface StreamSettingsAudioControlSignals extends StreamSettingsNativeA
|
|||||||
supportsStreamAudio: boolean;
|
supportsStreamAudio: boolean;
|
||||||
captureAudioEnabled: boolean;
|
captureAudioEnabled: boolean;
|
||||||
hasLiveScreenShareAudioPublication: boolean;
|
hasLiveScreenShareAudioPublication: boolean;
|
||||||
manualAudioSourcesOptIn?: boolean;
|
|
||||||
audioSourceMode?: ScreenShareAudioSourceMode;
|
audioSourceMode?: ScreenShareAudioSourceMode;
|
||||||
selectedAudioSourceCount?: number;
|
selectedAudioSourceCount?: number;
|
||||||
|
windowAudioScope?: WindowShareAudioScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamSettingsAudioControlViewState {
|
export interface StreamSettingsAudioControlViewState {
|
||||||
@@ -148,10 +155,15 @@ export function selectStreamSettingsAudioControlState(
|
|||||||
return typeof snapshot.value === 'string' ? (snapshot.value as StreamSettingsAudioControlStateValue) : 'hidden';
|
return typeof snapshot.value === 'string' ? (snapshot.value as StreamSettingsAudioControlStateValue) : 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAudioControlLabelKey(shareContext: StreamSettingsShareContext): StreamSettingsAudioControlLabelKey {
|
function selectAudioControlLabelKey(signals: StreamSettingsAudioControlSignals): StreamSettingsAudioControlLabelKey {
|
||||||
if (shareContext === 'device') return 'captureDeviceAudio';
|
if (signals.shareContext === 'device') return 'captureDeviceAudio';
|
||||||
if (shareContext === 'app') return 'captureAppAudio';
|
if (signals.shareContext !== 'app') return 'captureDesktopAudio';
|
||||||
return 'captureDesktopAudio';
|
const route = selectAppShareAudioRoute({
|
||||||
|
audioSourceMode: signals.audioSourceMode,
|
||||||
|
selectedSourceCount: signals.selectedAudioSourceCount,
|
||||||
|
windowAudioScope: resolveWindowShareAudioScope(signals),
|
||||||
|
});
|
||||||
|
return route === 'system' ? 'captureSystemAudio' : 'captureAppAudio';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectStreamSettingsAudioMenuState(
|
export function selectStreamSettingsAudioMenuState(
|
||||||
@@ -162,7 +174,7 @@ export function selectStreamSettingsAudioMenuState(
|
|||||||
control: {
|
control: {
|
||||||
value,
|
value,
|
||||||
checked: signals.captureAudioEnabled,
|
checked: signals.captureAudioEnabled,
|
||||||
labelKey: selectAudioControlLabelKey(signals.shareContext),
|
labelKey: selectAudioControlLabelKey(signals),
|
||||||
},
|
},
|
||||||
showManualAudioSources:
|
showManualAudioSources:
|
||||||
signals.supportsStreamAudio &&
|
signals.supportsStreamAudio &&
|
||||||
@@ -170,11 +182,7 @@ export function selectStreamSettingsAudioMenuState(
|
|||||||
manualAudioSourcesGovernShare(signals) &&
|
manualAudioSourcesGovernShare(signals) &&
|
||||||
canSelectManualAudioSources({
|
canSelectManualAudioSources({
|
||||||
platform: signals.platform,
|
platform: signals.platform,
|
||||||
shareContext: signals.shareContext,
|
|
||||||
nativeAudioAvailability: signals.nativeAudioAvailability,
|
nativeAudioAvailability: signals.nativeAudioAvailability,
|
||||||
manualOptIn: signals.manualAudioSourcesOptIn,
|
|
||||||
audioSourceMode: signals.audioSourceMode,
|
|
||||||
selectedSourceCount: signals.selectedAudioSourceCount,
|
|
||||||
}),
|
}),
|
||||||
showDeviceAudioMenu: signals.shareContext === 'device' && signals.captureAudioEnabled,
|
showDeviceAudioMenu: signals.shareContext === 'device' && signals.captureAudioEnabled,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,14 +67,17 @@ import MediaEngine, {useVoiceEngineV2Model} from '@app/features/voice/engine/Med
|
|||||||
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
||||||
import {selectVoiceEngineV2AppConnection} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
import {selectVoiceEngineV2AppConnection} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||||
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
||||||
|
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||||
import VoiceSettings, {
|
import VoiceSettings, {
|
||||||
type LastScreenShareSource,
|
type LastScreenShareSource,
|
||||||
type LastScreenShareSourceKind,
|
type LastScreenShareSourceKind,
|
||||||
type ScreenshareResolution,
|
type ScreenshareResolution,
|
||||||
type StreamingMode,
|
type StreamingMode,
|
||||||
} from '@app/features/voice/state/VoiceSettings';
|
} from '@app/features/voice/state/VoiceSettings';
|
||||||
|
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||||
import {getNativeAudioAvailabilityCached} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
import {getNativeAudioAvailabilityCached} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||||
import {isScreenShareAudioCaptureError} from '@app/features/voice/utils/ScreenShareAudioCaptureError';
|
import {isScreenShareAudioCaptureError} from '@app/features/voice/utils/ScreenShareAudioCaptureError';
|
||||||
|
import {formatScreenShareAudioSummary} from '@app/features/voice/utils/ScreenShareAudioSummary';
|
||||||
import {
|
import {
|
||||||
getDisplayShareEnvironment,
|
getDisplayShareEnvironment,
|
||||||
shouldShowDesktopDownloadCta,
|
shouldShowDesktopDownloadCta,
|
||||||
@@ -92,6 +95,7 @@ import {
|
|||||||
switchConfiguredDeviceScreenShare,
|
switchConfiguredDeviceScreenShare,
|
||||||
switchConfiguredDisplayScreenShare,
|
switchConfiguredDisplayScreenShare,
|
||||||
} from '@app/features/voice/utils/ScreenShareStartFlow';
|
} from '@app/features/voice/utils/ScreenShareStartFlow';
|
||||||
|
import {manualAudioSourcesGovernShare} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||||
import {
|
import {
|
||||||
formatFallbackCameraLabel,
|
formatFallbackCameraLabel,
|
||||||
formatVoiceAudioDeviceLabel,
|
formatVoiceAudioDeviceLabel,
|
||||||
@@ -1039,6 +1043,7 @@ const ScreenSharePickerModalLoadedContent = observer(
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => () => ActiveScreenShareSource.clearPendingWindowAudioScope(), []);
|
||||||
const platform = getElectronAPI()?.platform;
|
const platform = getElectronAPI()?.platform;
|
||||||
const displayPermission = useScreenSharePickerDisplayPermission({
|
const displayPermission = useScreenSharePickerDisplayPermission({
|
||||||
activeTab,
|
activeTab,
|
||||||
@@ -1453,6 +1458,10 @@ const ScreenSharePickerModalLoadedContent = observer(
|
|||||||
: activeTab === 'apps'
|
: activeTab === 'apps'
|
||||||
? VoiceSettings.getShareAppAudio()
|
? VoiceSettings.getShareAppAudio()
|
||||||
: VoiceSettings.getShareDesktopAudio();
|
: VoiceSettings.getShareDesktopAudio();
|
||||||
|
const audioSourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
||||||
|
const audioIncludeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
||||||
|
const routableAudioSourceCount = filterRoutableLinuxAudioSources(audioIncludeSources).length;
|
||||||
|
const windowAudioScope = ActiveScreenShareSource.getPendingWindowAudioScope();
|
||||||
const audioMenuState = selectStreamSettingsAudioMenuState({
|
const audioMenuState = selectStreamSettingsAudioMenuState({
|
||||||
applyToLiveStream: false,
|
applyToLiveStream: false,
|
||||||
shareContext,
|
shareContext,
|
||||||
@@ -1465,18 +1474,28 @@ const ScreenSharePickerModalLoadedContent = observer(
|
|||||||
hasLiveScreenShareAudioPublication: false,
|
hasLiveScreenShareAudioPublication: false,
|
||||||
nativeAudioAvailability,
|
nativeAudioAvailability,
|
||||||
platform,
|
platform,
|
||||||
|
audioSourceMode,
|
||||||
|
selectedAudioSourceCount: routableAudioSourceCount,
|
||||||
|
windowAudioScope,
|
||||||
});
|
});
|
||||||
const captureAudioEnabled = audioMenuState.control.value === 'toggle' && audioMenuState.control.checked;
|
const captureAudioEnabled = audioMenuState.control.value === 'toggle' && audioMenuState.control.checked;
|
||||||
const configuredAudioDeviceId = VoiceSettings.getEffectiveScreenShareAudioDeviceId();
|
const configuredAudioDeviceId = VoiceSettings.getEffectiveScreenShareAudioDeviceId();
|
||||||
const selectedAudioDevice = inputDevices.find((device) => device.deviceId === configuredAudioDeviceId);
|
const selectedAudioDevice = inputDevices.find((device) => device.deviceId === configuredAudioDeviceId);
|
||||||
const audioSummary =
|
const microphoneLabel = selectedAudioDevice
|
||||||
activeTab !== 'devices'
|
? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR))
|
||||||
? null
|
: i18n._(SYSTEM_DEFAULT_DESCRIPTOR);
|
||||||
: captureAudioEnabled
|
const audioSummary = captureAudioEnabled
|
||||||
? selectedAudioDevice
|
? formatScreenShareAudioSummary(i18n, {
|
||||||
? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR))
|
sourceMode: manualAudioSourcesGovernShare({platform, displayShareEnvironment}) ? audioSourceMode : 'system',
|
||||||
: i18n._(SYSTEM_DEFAULT_DESCRIPTOR)
|
includeSources: audioIncludeSources,
|
||||||
: i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR);
|
shareContext,
|
||||||
|
microphoneLabel,
|
||||||
|
displayShareEnvironment,
|
||||||
|
windowAudioScope,
|
||||||
|
})
|
||||||
|
: activeTab === 'devices'
|
||||||
|
? i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR)
|
||||||
|
: null;
|
||||||
const streamSummaryDetails = [
|
const streamSummaryDetails = [
|
||||||
activeTab === 'devices' ? deviceSelectionError : null,
|
activeTab === 'devices' ? deviceSelectionError : null,
|
||||||
streamSummaryDescription,
|
streamSummaryDescription,
|
||||||
|
|||||||
@@ -2229,6 +2229,10 @@ class MediaEngineFacade extends Store {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ensureWindowScreenShareAudioPublication(sourceId: string): Promise<boolean> {
|
||||||
|
return voiceEngineV2AppScreenShareExecutionAdapter.ensureWindowScreenShareAudioPublication(this.room, sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
getActiveScreenShareVideoDeviceId(): string {
|
getActiveScreenShareVideoDeviceId(): string {
|
||||||
return voiceEngineV2AppScreenShareExecutionAdapter.getActiveScreenShareVideoDeviceId(this.room);
|
return voiceEngineV2AppScreenShareExecutionAdapter.getActiveScreenShareVideoDeviceId(this.room);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -73,6 +73,7 @@ import {
|
|||||||
import {disarmVirtmic} from '@app/features/voice/utils/LinuxScreenShareAudio';
|
import {disarmVirtmic} from '@app/features/voice/utils/LinuxScreenShareAudio';
|
||||||
import {
|
import {
|
||||||
captureNativeAudioTrackForLinuxRouting,
|
captureNativeAudioTrackForLinuxRouting,
|
||||||
|
captureNativeAudioTrackForWindowPid,
|
||||||
commitNativeAudioBridgeReplacement,
|
commitNativeAudioBridgeReplacement,
|
||||||
disarmNativeAudio,
|
disarmNativeAudio,
|
||||||
reconfigureLinuxNativeAudioRouting,
|
reconfigureLinuxNativeAudioRouting,
|
||||||
@@ -785,6 +786,36 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ensureWindowScreenShareAudioPublication(room: Room | null, sourceId: string): Promise<boolean> {
|
||||||
|
const participant = room?.localParticipant;
|
||||||
|
if (!participant || !participant.isScreenShareEnabled) return false;
|
||||||
|
const targetPid = await getElectronAPI()
|
||||||
|
?.nativeAudio?.resolveAudioRootPidForSource(sourceId)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to resolve the shared window audio process', {error, sourceId});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (targetPid == null) return false;
|
||||||
|
const capturedTrack = await captureNativeAudioTrackForWindowPid(targetPid);
|
||||||
|
if (!capturedTrack) return false;
|
||||||
|
let adopted = false;
|
||||||
|
try {
|
||||||
|
adopted = await this.replaceActiveScreenShareAudioTrackInternal(participant, capturedTrack);
|
||||||
|
if (adopted) {
|
||||||
|
commitNativeAudioBridgeReplacement();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to publish mid-stream window screen-share audio track', {error, sourceId});
|
||||||
|
}
|
||||||
|
if (!adopted) {
|
||||||
|
stopMediaTrack(capturedTrack);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.syncLocalScreenShareAudioStateInternal(participant, true);
|
||||||
|
this.syncPersistedScreenShareAudioPreferenceInternal(participant);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
getActiveScreenShareVideoDeviceId(room: Room | null): string {
|
getActiveScreenShareVideoDeviceId(room: Room | null): string {
|
||||||
const publication = room?.localParticipant?.getTrackPublication(Track.Source.ScreenShare);
|
const publication = room?.localParticipant?.getTrackPublication(Track.Source.ScreenShare);
|
||||||
return publication?.videoTrack?.mediaStreamTrack.getSettings().deviceId ?? '';
|
return publication?.videoTrack?.mediaStreamTrack.getSettings().deviceId ?? '';
|
||||||
|
|||||||
@@ -24,4 +24,43 @@ describe('ActiveScreenShareSource', () => {
|
|||||||
expect(ActiveScreenShareSource.isOwnWindow()).toBe(false);
|
expect(ActiveScreenShareSource.isOwnWindow()).toBe(false);
|
||||||
expect(ActiveScreenShareSource.getShareContext()).toBeNull();
|
expect(ActiveScreenShareSource.getShareContext()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a scope picked before the share starts out of the running share until it is committed', () => {
|
||||||
|
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('window');
|
||||||
|
|
||||||
|
ActiveScreenShareSource.setPendingWindowAudioScope('system');
|
||||||
|
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('system');
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('window');
|
||||||
|
|
||||||
|
ActiveScreenShareSource.clearPendingWindowAudioScope();
|
||||||
|
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('window');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the running share own scope to the next picker until the user picks another one', () => {
|
||||||
|
ActiveScreenShareSource.setPublishedSource('app', 'window:42:0');
|
||||||
|
ActiveScreenShareSource.setWindowAudioScope('system');
|
||||||
|
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('system');
|
||||||
|
|
||||||
|
ActiveScreenShareSource.setPendingWindowAudioScope('window');
|
||||||
|
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('window');
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
|
||||||
|
|
||||||
|
ActiveScreenShareSource.clearPendingWindowAudioScope();
|
||||||
|
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the window audio scope for the running share and drops it when the share ends', () => {
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('window');
|
||||||
|
|
||||||
|
ActiveScreenShareSource.setWindowAudioScope('system');
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
|
||||||
|
|
||||||
|
ActiveScreenShareSource.setPublishedSource('app', 'window:42:0');
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
|
||||||
|
|
||||||
|
ActiveScreenShareSource.setPendingWindowAudioScope('system');
|
||||||
|
ActiveScreenShareSource.clear();
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('window');
|
||||||
|
expect(ActiveScreenShareSource.getPendingWindowAudioScope()).toBe('window');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import type {StreamSettingsShareContext} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
import type {
|
||||||
|
StreamSettingsShareContext,
|
||||||
|
WindowShareAudioScope,
|
||||||
|
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||||
import {makeAutoObservable} from 'mobx';
|
import {makeAutoObservable} from 'mobx';
|
||||||
|
|
||||||
export interface ActiveScreenShareSourceOptions {
|
export interface ActiveScreenShareSourceOptions {
|
||||||
@@ -13,6 +16,8 @@ class ActiveScreenShareSource {
|
|||||||
sourceId: string | null = null;
|
sourceId: string | null = null;
|
||||||
ownWindow = false;
|
ownWindow = false;
|
||||||
publishedSource: PublishedScreenShareSource | null = null;
|
publishedSource: PublishedScreenShareSource | null = null;
|
||||||
|
windowAudioScope: WindowShareAudioScope = 'window';
|
||||||
|
pendingWindowAudioScope: WindowShareAudioScope | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this, {}, {autoBind: true});
|
makeAutoObservable(this, {}, {autoBind: true});
|
||||||
@@ -40,6 +45,26 @@ class ActiveScreenShareSource {
|
|||||||
return this.publishedSource;
|
return this.publishedSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWindowAudioScope(): WindowShareAudioScope {
|
||||||
|
return this.windowAudioScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWindowAudioScope(scope: WindowShareAudioScope): void {
|
||||||
|
this.windowAudioScope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingWindowAudioScope(): WindowShareAudioScope {
|
||||||
|
return this.pendingWindowAudioScope ?? this.windowAudioScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingWindowAudioScope(scope: WindowShareAudioScope): void {
|
||||||
|
this.pendingWindowAudioScope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPendingWindowAudioScope(): void {
|
||||||
|
this.pendingWindowAudioScope = null;
|
||||||
|
}
|
||||||
|
|
||||||
getShareContext(): StreamSettingsShareContext | null {
|
getShareContext(): StreamSettingsShareContext | null {
|
||||||
if (this.publishedSource === 'app') return 'app';
|
if (this.publishedSource === 'app') return 'app';
|
||||||
if (this.publishedSource === 'device') return 'device';
|
if (this.publishedSource === 'device') return 'device';
|
||||||
@@ -51,6 +76,8 @@ class ActiveScreenShareSource {
|
|||||||
this.sourceId = null;
|
this.sourceId = null;
|
||||||
this.ownWindow = false;
|
this.ownWindow = false;
|
||||||
this.publishedSource = null;
|
this.publishedSource = null;
|
||||||
|
this.windowAudioScope = 'window';
|
||||||
|
this.pendingWindowAudioScope = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ type VoiceSettingsUpdate = Partial<{
|
|||||||
linuxAudioCaptureIgnoreDevices: boolean;
|
linuxAudioCaptureIgnoreDevices: boolean;
|
||||||
linuxAudioCaptureGranularSelect: boolean;
|
linuxAudioCaptureGranularSelect: boolean;
|
||||||
linuxAudioCaptureDeviceSelect: boolean;
|
linuxAudioCaptureDeviceSelect: boolean;
|
||||||
screenShareManualAudioSourcesOptIn: boolean;
|
|
||||||
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
||||||
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
||||||
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
||||||
@@ -236,35 +235,16 @@ function applyScreenShareAudioDefaultOnMigrationV1(parsed: Record<string, unknow
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MANUAL_AUDIO_SOURCE_TUNING_DEFAULTS: Record<string, boolean> = {
|
export function applyManualAudioSourcesOptOutResetMigrationV1(parsed: Record<string, unknown>): boolean {
|
||||||
linuxAudioCaptureOnlySpeakers: true,
|
if (parsed.manualAudioSourcesOptOutResetMigratedV1 === true) {
|
||||||
linuxAudioCaptureOnlyDefaultSpeakers: true,
|
|
||||||
linuxAudioCaptureIgnoreInputMedia: true,
|
|
||||||
linuxAudioCaptureIgnoreVirtual: false,
|
|
||||||
linuxAudioCaptureIgnoreDevices: true,
|
|
||||||
linuxAudioCaptureGranularSelect: false,
|
|
||||||
linuxAudioCaptureDeviceSelect: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function applyManualAudioSourcesOptInMigrationV1(parsed: Record<string, unknown>): boolean {
|
|
||||||
if (parsed.manualScreenShareAudioSourcesOptInMigratedV1 === true) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const storedMode = parsed.screenShareAudioSourceMode;
|
if (parsed.screenShareManualAudioSourcesOptIn === false) {
|
||||||
const includeSources = parsed.screenShareAudioIncludeSources;
|
parsed.screenShareAudioSourceMode = 'system';
|
||||||
const excludeSources = parsed.screenShareAudioExcludeSources;
|
parsed.screenShareAudioIncludeSources = [];
|
||||||
const hadManualSelection =
|
parsed.screenShareAudioExcludeSources = [];
|
||||||
parsed.linuxDeviceShareAppAudioOptIn === true ||
|
|
||||||
(typeof storedMode === 'string' && storedMode !== 'system') ||
|
|
||||||
(Array.isArray(includeSources) && includeSources.length > 0) ||
|
|
||||||
(Array.isArray(excludeSources) && excludeSources.length > 0) ||
|
|
||||||
Object.entries(MANUAL_AUDIO_SOURCE_TUNING_DEFAULTS).some(
|
|
||||||
([key, fallback]) => typeof parsed[key] === 'boolean' && parsed[key] !== fallback,
|
|
||||||
);
|
|
||||||
if (hadManualSelection) {
|
|
||||||
parsed.screenShareManualAudioSourcesOptIn = true;
|
|
||||||
}
|
}
|
||||||
parsed.manualScreenShareAudioSourcesOptInMigratedV1 = true;
|
parsed.manualAudioSourcesOptOutResetMigratedV1 = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,8 +430,7 @@ class VoiceSettings {
|
|||||||
linuxAudioCaptureIgnoreDevices = true;
|
linuxAudioCaptureIgnoreDevices = true;
|
||||||
linuxAudioCaptureGranularSelect = false;
|
linuxAudioCaptureGranularSelect = false;
|
||||||
linuxAudioCaptureDeviceSelect = false;
|
linuxAudioCaptureDeviceSelect = false;
|
||||||
screenShareManualAudioSourcesOptIn = false;
|
manualAudioSourcesOptOutResetMigratedV1 = false;
|
||||||
manualScreenShareAudioSourcesOptInMigratedV1 = false;
|
|
||||||
screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system';
|
screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system';
|
||||||
screenShareAudioIncludeSources: Array<Record<string, string>> = [];
|
screenShareAudioIncludeSources: Array<Record<string, string>> = [];
|
||||||
screenShareAudioExcludeSources: Array<Record<string, string>> = [];
|
screenShareAudioExcludeSources: Array<Record<string, string>> = [];
|
||||||
@@ -525,7 +504,6 @@ class VoiceSettings {
|
|||||||
getLinuxAudioCaptureIgnoreDevices: false,
|
getLinuxAudioCaptureIgnoreDevices: false,
|
||||||
getLinuxAudioCaptureGranularSelect: false,
|
getLinuxAudioCaptureGranularSelect: false,
|
||||||
getLinuxAudioCaptureDeviceSelect: false,
|
getLinuxAudioCaptureDeviceSelect: false,
|
||||||
getScreenShareManualAudioSourcesOptIn: false,
|
|
||||||
getEffectiveScreenShareAudioSourceMode: false,
|
getEffectiveScreenShareAudioSourceMode: false,
|
||||||
getEffectiveScreenShareAudioIncludeSources: false,
|
getEffectiveScreenShareAudioIncludeSources: false,
|
||||||
getEffectiveScreenShareAudioExcludeSources: false,
|
getEffectiveScreenShareAudioExcludeSources: false,
|
||||||
@@ -556,7 +534,7 @@ class VoiceSettings {
|
|||||||
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
|
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
|
||||||
changed = applyScreenShareAudioDefaultOnMigrationV1(parsed) || changed;
|
changed = applyScreenShareAudioDefaultOnMigrationV1(parsed) || changed;
|
||||||
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
|
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
|
||||||
changed = applyManualAudioSourcesOptInMigrationV1(parsed) || changed;
|
changed = applyManualAudioSourcesOptOutResetMigrationV1(parsed) || changed;
|
||||||
changed = applyScreenShareContentHintDefaultMigrationV1(parsed) || changed;
|
changed = applyScreenShareContentHintDefaultMigrationV1(parsed) || changed;
|
||||||
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
|
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
|
||||||
changed = applyNoiseSuppressionStandardDefaultMigrationV1(parsed) || changed;
|
changed = applyNoiseSuppressionStandardDefaultMigrationV1(parsed) || changed;
|
||||||
@@ -569,6 +547,7 @@ class VoiceSettings {
|
|||||||
this.outputVolumeRecalibratedV1 = parsed.outputVolumeRecalibratedV1 === true;
|
this.outputVolumeRecalibratedV1 = parsed.outputVolumeRecalibratedV1 === true;
|
||||||
this.screenShareAv1OptOutMigratedV1 = parsed.screenShareAv1OptOutMigratedV1 === true;
|
this.screenShareAv1OptOutMigratedV1 = parsed.screenShareAv1OptOutMigratedV1 === true;
|
||||||
this.screenShareHevcOptOutMigratedV1 = parsed.screenShareHevcOptOutMigratedV1 === true;
|
this.screenShareHevcOptOutMigratedV1 = parsed.screenShareHevcOptOutMigratedV1 === true;
|
||||||
|
this.manualAudioSourcesOptOutResetMigratedV1 = parsed.manualAudioSourcesOptOutResetMigratedV1 === true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to migrate persisted voice settings:', error);
|
logger.warn('Failed to migrate persisted voice settings:', error);
|
||||||
}
|
}
|
||||||
@@ -640,8 +619,7 @@ class VoiceSettings {
|
|||||||
'linuxAudioCaptureIgnoreDevices',
|
'linuxAudioCaptureIgnoreDevices',
|
||||||
'linuxAudioCaptureGranularSelect',
|
'linuxAudioCaptureGranularSelect',
|
||||||
'linuxAudioCaptureDeviceSelect',
|
'linuxAudioCaptureDeviceSelect',
|
||||||
'screenShareManualAudioSourcesOptIn',
|
'manualAudioSourcesOptOutResetMigratedV1',
|
||||||
'manualScreenShareAudioSourcesOptInMigratedV1',
|
|
||||||
'screenShareAudioSourceMode',
|
'screenShareAudioSourceMode',
|
||||||
'screenShareAudioIncludeSources',
|
'screenShareAudioIncludeSources',
|
||||||
'screenShareAudioExcludeSources',
|
'screenShareAudioExcludeSources',
|
||||||
@@ -1018,10 +996,6 @@ class VoiceSettings {
|
|||||||
return this.linuxAudioCaptureDeviceSelect;
|
return this.linuxAudioCaptureDeviceSelect;
|
||||||
}
|
}
|
||||||
|
|
||||||
getScreenShareManualAudioSourcesOptIn(): boolean {
|
|
||||||
return this.screenShareManualAudioSourcesOptIn;
|
|
||||||
}
|
|
||||||
|
|
||||||
getScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
|
getScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
|
||||||
return this.screenShareAudioSourceMode;
|
return this.screenShareAudioSourceMode;
|
||||||
}
|
}
|
||||||
@@ -1035,15 +1009,15 @@ class VoiceSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
|
getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
|
||||||
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioSourceMode : 'system';
|
return this.getScreenShareAudioSourceMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
getEffectiveScreenShareAudioIncludeSources(): Array<Record<string, string>> {
|
getEffectiveScreenShareAudioIncludeSources(): Array<Record<string, string>> {
|
||||||
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioIncludeSources : [];
|
return this.getScreenShareAudioIncludeSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
getEffectiveScreenShareAudioExcludeSources(): Array<Record<string, string>> {
|
getEffectiveScreenShareAudioExcludeSources(): Array<Record<string, string>> {
|
||||||
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioExcludeSources : [];
|
return this.getScreenShareAudioExcludeSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
getOpenH264Enabled(): boolean {
|
getOpenH264Enabled(): boolean {
|
||||||
@@ -1146,8 +1120,6 @@ class VoiceSettings {
|
|||||||
this.linuxAudioCaptureGranularSelect = validated.linuxAudioCaptureGranularSelect;
|
this.linuxAudioCaptureGranularSelect = validated.linuxAudioCaptureGranularSelect;
|
||||||
if (validated.linuxAudioCaptureDeviceSelect !== undefined)
|
if (validated.linuxAudioCaptureDeviceSelect !== undefined)
|
||||||
this.linuxAudioCaptureDeviceSelect = validated.linuxAudioCaptureDeviceSelect;
|
this.linuxAudioCaptureDeviceSelect = validated.linuxAudioCaptureDeviceSelect;
|
||||||
if (validated.screenShareManualAudioSourcesOptIn !== undefined)
|
|
||||||
this.screenShareManualAudioSourcesOptIn = validated.screenShareManualAudioSourcesOptIn;
|
|
||||||
if (validated.screenShareAudioSourceMode !== undefined)
|
if (validated.screenShareAudioSourceMode !== undefined)
|
||||||
this.screenShareAudioSourceMode = validated.screenShareAudioSourceMode;
|
this.screenShareAudioSourceMode = validated.screenShareAudioSourceMode;
|
||||||
if (validated.screenShareAudioIncludeSources !== undefined)
|
if (validated.screenShareAudioIncludeSources !== undefined)
|
||||||
@@ -1278,8 +1250,6 @@ class VoiceSettings {
|
|||||||
linuxAudioCaptureIgnoreDevices: data.linuxAudioCaptureIgnoreDevices ?? this.linuxAudioCaptureIgnoreDevices,
|
linuxAudioCaptureIgnoreDevices: data.linuxAudioCaptureIgnoreDevices ?? this.linuxAudioCaptureIgnoreDevices,
|
||||||
linuxAudioCaptureGranularSelect: data.linuxAudioCaptureGranularSelect ?? this.linuxAudioCaptureGranularSelect,
|
linuxAudioCaptureGranularSelect: data.linuxAudioCaptureGranularSelect ?? this.linuxAudioCaptureGranularSelect,
|
||||||
linuxAudioCaptureDeviceSelect: data.linuxAudioCaptureDeviceSelect ?? this.linuxAudioCaptureDeviceSelect,
|
linuxAudioCaptureDeviceSelect: data.linuxAudioCaptureDeviceSelect ?? this.linuxAudioCaptureDeviceSelect,
|
||||||
screenShareManualAudioSourcesOptIn:
|
|
||||||
data.screenShareManualAudioSourcesOptIn ?? this.screenShareManualAudioSourcesOptIn,
|
|
||||||
screenShareAudioSourceMode: validateAudioSourceMode(
|
screenShareAudioSourceMode: validateAudioSourceMode(
|
||||||
data.screenShareAudioSourceMode ?? this.screenShareAudioSourceMode,
|
data.screenShareAudioSourceMode ?? this.screenShareAudioSourceMode,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ AppStorage.setItem(
|
|||||||
preferredScreenShareCodec: 'av1',
|
preferredScreenShareCodec: 'av1',
|
||||||
screenShareContentHintPrefV2: 'auto',
|
screenShareContentHintPrefV2: 'auto',
|
||||||
screenshareResolution: 'low_240p',
|
screenshareResolution: 'low_240p',
|
||||||
|
inputDeviceId: 'headset-1',
|
||||||
|
screenShareAudioSourceMode: 'specific',
|
||||||
|
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
||||||
|
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
|
||||||
|
screenShareManualAudioSourcesOptIn: true,
|
||||||
|
manualScreenShareAudioSourcesOptInMigratedV1: true,
|
||||||
__mps__: {version: 1},
|
__mps__: {version: 1},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -68,7 +74,7 @@ async function loadVoiceSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VoiceSettings = await loadVoiceSettings();
|
const VoiceSettings = await loadVoiceSettings();
|
||||||
const {applyManualAudioSourcesOptInMigrationV1} = await import('./VoiceSettings');
|
const {applyManualAudioSourcesOptOutResetMigrationV1} = await import('./VoiceSettings');
|
||||||
|
|
||||||
describe('AV1/HEVC screen-share opt-in', () => {
|
describe('AV1/HEVC screen-share opt-in', () => {
|
||||||
it('rewrites a stored AV1 screen-share preference back to automatic on first launch', () => {
|
it('rewrites a stored AV1 screen-share preference back to automatic on first launch', () => {
|
||||||
@@ -142,92 +148,100 @@ describe('AV1/HEVC screen-share opt-in', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('manual screen-share audio source opt-in', () => {
|
describe('manual screen-share audio sources', () => {
|
||||||
it('defaults to off and is never stored by an unrelated patch', () => {
|
it('hydrates a profile that still stores the removed opt-in keys without losing anything else', () => {
|
||||||
expect(VoiceSettings.screenShareManualAudioSourcesOptIn).toBe(false);
|
const stored = JSON.parse(storageWrites[0]);
|
||||||
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(false);
|
expect(stored.screenShareManualAudioSourcesOptIn).toBe(true);
|
||||||
VoiceSettings.updateSettings({outputDeviceId: 'default'});
|
expect(stored.manualScreenShareAudioSourcesOptInMigratedV1).toBe(true);
|
||||||
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(false);
|
expect(stored.manualAudioSourcesOptOutResetMigratedV1).toBe(true);
|
||||||
|
expect(VoiceSettings.inputDeviceId).toBe('headset-1');
|
||||||
|
expect(VoiceSettings.getScreenShareAudioIncludeSources()).toEqual([{'application.name': 'mpv'}]);
|
||||||
|
expect(VoiceSettings.getScreenShareAudioExcludeSources()).toEqual([{'application.name': 'Fluxer'}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the manual source settings inert until the opt-in is on', () => {
|
it('keeps the reset migration out of the way of a profile that had the opt-in on', () => {
|
||||||
|
expect(VoiceSettings.manualAudioSourcesOptOutResetMigratedV1).toBe(true);
|
||||||
|
expect(VoiceSettings.getScreenShareAudioSourceMode()).toBe('specific');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a profile that switched the opt-in off to the defaults it was already getting', () => {
|
||||||
|
const stored: Record<string, unknown> = {
|
||||||
|
screenShareManualAudioSourcesOptIn: false,
|
||||||
|
screenShareAudioSourceMode: 'none',
|
||||||
|
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
||||||
|
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
|
||||||
|
inputDeviceId: 'headset-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(applyManualAudioSourcesOptOutResetMigrationV1(stored)).toBe(true);
|
||||||
|
|
||||||
|
expect(stored.screenShareAudioSourceMode).toBe('system');
|
||||||
|
expect(stored.screenShareAudioIncludeSources).toEqual([]);
|
||||||
|
expect(stored.screenShareAudioExcludeSources).toEqual([]);
|
||||||
|
expect(stored.inputDeviceId).toBe('headset-1');
|
||||||
|
expect(stored.manualAudioSourcesOptOutResetMigratedV1).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves every other profile untouched and never runs twice', () => {
|
||||||
|
const optedIn: Record<string, unknown> = {
|
||||||
|
screenShareManualAudioSourcesOptIn: true,
|
||||||
|
screenShareAudioSourceMode: 'specific',
|
||||||
|
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
||||||
|
};
|
||||||
|
expect(applyManualAudioSourcesOptOutResetMigrationV1(optedIn)).toBe(true);
|
||||||
|
expect(optedIn.screenShareAudioSourceMode).toBe('specific');
|
||||||
|
expect(optedIn.screenShareAudioIncludeSources).toEqual([{'application.name': 'mpv'}]);
|
||||||
|
|
||||||
|
const fresh: Record<string, unknown> = {};
|
||||||
|
expect(applyManualAudioSourcesOptOutResetMigrationV1(fresh)).toBe(true);
|
||||||
|
expect(fresh).toEqual({manualAudioSourcesOptOutResetMigratedV1: true});
|
||||||
|
|
||||||
|
const alreadyMigrated: Record<string, unknown> = {
|
||||||
|
manualAudioSourcesOptOutResetMigratedV1: true,
|
||||||
|
screenShareManualAudioSourcesOptIn: false,
|
||||||
|
screenShareAudioSourceMode: 'none',
|
||||||
|
};
|
||||||
|
expect(applyManualAudioSourcesOptOutResetMigrationV1(alreadyMigrated)).toBe(false);
|
||||||
|
expect(alreadyMigrated.screenShareAudioSourceMode).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands every Linux user the stored selection with no opt-in left to satisfy', () => {
|
||||||
VoiceSettings.updateSettings({
|
VoiceSettings.updateSettings({
|
||||||
screenShareAudioSourceMode: 'specific',
|
screenShareAudioSourceMode: 'specific',
|
||||||
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
||||||
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
|
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
|
||||||
});
|
});
|
||||||
expect(VoiceSettings.getScreenShareAudioSourceMode()).toBe('specific');
|
expect(VoiceSettings.getScreenShareAudioSourceMode()).toBe('specific');
|
||||||
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('system');
|
|
||||||
expect(VoiceSettings.getEffectiveScreenShareAudioIncludeSources()).toEqual([]);
|
|
||||||
expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([]);
|
|
||||||
|
|
||||||
VoiceSettings.updateSettings({screenShareManualAudioSourcesOptIn: true});
|
|
||||||
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('specific');
|
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('specific');
|
||||||
expect(VoiceSettings.getEffectiveScreenShareAudioIncludeSources()).toEqual([{'application.name': 'mpv'}]);
|
expect(VoiceSettings.getEffectiveScreenShareAudioIncludeSources()).toEqual([{'application.name': 'mpv'}]);
|
||||||
expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([{'application.name': 'Fluxer'}]);
|
expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([{'application.name': 'Fluxer'}]);
|
||||||
|
|
||||||
|
VoiceSettings.updateSettings({screenShareAudioSourceMode: 'none'});
|
||||||
|
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('none');
|
||||||
|
|
||||||
VoiceSettings.updateSettings({
|
VoiceSettings.updateSettings({
|
||||||
screenShareManualAudioSourcesOptIn: false,
|
screenShareAudioSourceMode: 'system',
|
||||||
screenShareAudioSourceMode: 'none',
|
screenShareAudioIncludeSources: [],
|
||||||
|
screenShareAudioExcludeSources: [],
|
||||||
});
|
});
|
||||||
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('system');
|
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('system');
|
||||||
expect(VoiceSettings.getScreenShareAudioSourceMode()).toBe('none');
|
expect(VoiceSettings.getEffectiveScreenShareAudioIncludeSources()).toEqual([]);
|
||||||
|
expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores and persists the opt-in in both directions', async () => {
|
it('drops the removed opt-in keys from storage on the next write and keeps the selection', async () => {
|
||||||
VoiceSettings.updateSettings({screenShareManualAudioSourcesOptIn: true});
|
VoiceSettings.updateSettings({
|
||||||
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(true);
|
|
||||||
await vi.waitFor(() =>
|
|
||||||
expect(JSON.parse(storageWrites[storageWrites.length - 1]).screenShareManualAudioSourcesOptIn).toBe(true),
|
|
||||||
);
|
|
||||||
VoiceSettings.updateSettings({screenShareManualAudioSourcesOptIn: false});
|
|
||||||
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(false);
|
|
||||||
await vi.waitFor(() =>
|
|
||||||
expect(JSON.parse(storageWrites[storageWrites.length - 1]).screenShareManualAudioSourcesOptIn).toBe(false),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opts a fresh profile out and records the migration', () => {
|
|
||||||
const migrated = JSON.parse(storageWrites[0]);
|
|
||||||
expect(migrated.manualScreenShareAudioSourcesOptInMigratedV1).toBe(true);
|
|
||||||
expect(migrated.screenShareManualAudioSourcesOptIn).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opts in only profiles that already configured audio sources', () => {
|
|
||||||
const cases: Array<[Record<string, unknown>, boolean]> = [
|
|
||||||
[{}, false],
|
|
||||||
[{screenShareAudioSourceMode: 'system'}, false],
|
|
||||||
[{screenShareAudioIncludeSources: [], screenShareAudioExcludeSources: []}, false],
|
|
||||||
[{linuxDeviceShareAppAudioOptIn: true}, true],
|
|
||||||
[{screenShareAudioSourceMode: 'specific'}, true],
|
|
||||||
[{screenShareAudioSourceMode: 'none'}, true],
|
|
||||||
[{screenShareAudioIncludeSources: [{'application.name': 'mpv'}]}, true],
|
|
||||||
[{screenShareAudioExcludeSources: [{'application.name': 'mpv'}]}, true],
|
|
||||||
[{linuxAudioCaptureIgnoreInputMedia: true, linuxAudioCaptureOnlySpeakers: true}, false],
|
|
||||||
[{linuxAudioCaptureIgnoreInputMedia: false}, true],
|
|
||||||
[{linuxAudioCaptureOnlySpeakers: false}, true],
|
|
||||||
[{linuxAudioCaptureIgnoreVirtual: true}, true],
|
|
||||||
[{linuxAudioCaptureDeviceSelect: true}, true],
|
|
||||||
[{linuxAudioCaptureWorkaround: true}, false],
|
|
||||||
];
|
|
||||||
for (const [stored, expected] of cases) {
|
|
||||||
const before = JSON.stringify(stored);
|
|
||||||
expect(applyManualAudioSourcesOptInMigrationV1(stored)).toBe(true);
|
|
||||||
expect(stored.manualScreenShareAudioSourcesOptInMigratedV1).toBe(true);
|
|
||||||
expect(stored.screenShareManualAudioSourcesOptIn === true).toBe(expected);
|
|
||||||
delete stored.manualScreenShareAudioSourcesOptInMigratedV1;
|
|
||||||
delete stored.screenShareManualAudioSourcesOptIn;
|
|
||||||
expect(JSON.stringify(stored)).toBe(before);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never runs twice over the same profile', () => {
|
|
||||||
const stored: Record<string, unknown> = {
|
|
||||||
manualScreenShareAudioSourcesOptInMigratedV1: true,
|
|
||||||
screenShareAudioSourceMode: 'specific',
|
screenShareAudioSourceMode: 'specific',
|
||||||
};
|
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
||||||
expect(applyManualAudioSourcesOptInMigrationV1(stored)).toBe(false);
|
});
|
||||||
expect(stored.screenShareManualAudioSourcesOptIn).toBeUndefined();
|
await vi.waitFor(() => {
|
||||||
|
const latest = JSON.parse(storageWrites[storageWrites.length - 1]);
|
||||||
|
expect(latest.screenShareManualAudioSourcesOptIn).toBeUndefined();
|
||||||
|
expect(latest.manualScreenShareAudioSourcesOptInMigratedV1).toBeUndefined();
|
||||||
|
expect(latest.manualAudioSourcesOptOutResetMigratedV1).toBe(true);
|
||||||
|
expect(latest.screenShareAudioSourceMode).toBe('specific');
|
||||||
|
expect(latest.inputDeviceId).toBe('headset-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type {VirtmicNode} from '@app/types/electron.d';
|
import type {VirtmicNode} from '@app/types/electron.d';
|
||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
filterRoutableLinuxAudioSources,
|
||||||
getLinuxAudioSourceDisplayName,
|
getLinuxAudioSourceDisplayName,
|
||||||
LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY,
|
LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY,
|
||||||
LINUX_AUDIO_PRESERVE_VOLATILE_IDENTITY_PATTERN_KEY,
|
LINUX_AUDIO_PRESERVE_VOLATILE_IDENTITY_PATTERN_KEY,
|
||||||
@@ -136,6 +137,16 @@ describe('LinuxAudioSourceRules', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe('Built-in speakers');
|
).toBe('Built-in speakers');
|
||||||
});
|
});
|
||||||
|
it('keeps only the selections the capture layer can still match on', () => {
|
||||||
|
const routable = {'application.name': 'Firefox'};
|
||||||
|
const displayOnly = {[LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY]: 'Firefox'};
|
||||||
|
const volatileOnly = {'application.process.id': '1234'};
|
||||||
|
expect(filterRoutableLinuxAudioSources([routable, displayOnly, volatileOnly])).toEqual([routable, volatileOnly]);
|
||||||
|
expect(filterRoutableLinuxAudioSources([{...routable, [LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY]: 'Firefox'}])).toEqual(
|
||||||
|
[{...routable, [LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY]: 'Firefox'}],
|
||||||
|
);
|
||||||
|
expect(filterRoutableLinuxAudioSources([])).toEqual([]);
|
||||||
|
});
|
||||||
it('deduplicates identical item values without hiding same-named different selectors', () => {
|
it('deduplicates identical item values without hiding same-named different selectors', () => {
|
||||||
const duplicate = {name: 'Firefox', value: {'application.name': 'Firefox'}};
|
const duplicate = {name: 'Firefox', value: {'application.name': 'Firefox'}};
|
||||||
const pidSpecific = {name: 'Firefox', value: {'application.name': 'Firefox', 'application.process.id': '1234'}};
|
const pidSpecific = {name: 'Firefox', value: {'application.name': 'Firefox', 'application.process.id': '1234'}};
|
||||||
|
|||||||
@@ -180,3 +180,7 @@ export function toNativeLinuxAudioPatterns(patterns: Array<VirtmicNode>): Array<
|
|||||||
return next ? [next] : [];
|
return next ? [next] : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterRoutableLinuxAudioSources(patterns: ReadonlyArray<VirtmicNode>): Array<VirtmicNode> {
|
||||||
|
return patterns.filter((pattern) => toNativeLinuxAudioPattern(pattern) != null);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@lingui/core/macro', () => {
|
||||||
|
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||||
|
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
APP_COUNT_DESCRIPTOR,
|
||||||
|
CUSTOM_SOURCES_DESCRIPTOR,
|
||||||
|
ENTIRE_SYSTEM_DESCRIPTOR,
|
||||||
|
MICROPHONE_DESCRIPTOR,
|
||||||
|
MICROPHONE_WITH_DEVICE_DESCRIPTOR,
|
||||||
|
NO_AUDIO_DESCRIPTOR,
|
||||||
|
resolveScreenShareAudioSummary,
|
||||||
|
SHARED_WINDOW_DESCRIPTOR,
|
||||||
|
} = await import('./ScreenShareAudioSummary');
|
||||||
|
type ScreenShareAudioSummaryInput = Parameters<typeof resolveScreenShareAudioSummary>[0];
|
||||||
|
|
||||||
|
function summary(overrides: Partial<ScreenShareAudioSummaryInput> = {}) {
|
||||||
|
return resolveScreenShareAudioSummary({
|
||||||
|
sourceMode: 'system',
|
||||||
|
includeSources: [],
|
||||||
|
shareContext: 'display',
|
||||||
|
displayShareEnvironment: 'desktop-custom',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveScreenShareAudioSummary', () => {
|
||||||
|
it('says no audio on a display share once the sources are switched off', () => {
|
||||||
|
expect(summary({shareContext: 'display', sourceMode: 'none'})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: NO_AUDIO_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
expect(summary({shareContext: 'app', windowAudioScope: 'system', sourceMode: 'none'})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: NO_AUDIO_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never says no audio on a device share, whose microphone the source mode cannot switch off', () => {
|
||||||
|
expect(summary({shareContext: 'device', sourceMode: 'none', microphoneLabel: 'Yeti'})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR,
|
||||||
|
values: {deviceLabel: 'Yeti'},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never says no audio on a window share it still captures the window of', () => {
|
||||||
|
expect(summary({shareContext: 'app', sourceMode: 'none', windowAudioScope: 'window'})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: SHARED_WINDOW_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the single picked application in every share context, capture cards included', () => {
|
||||||
|
for (const shareContext of ['device', 'display'] as const) {
|
||||||
|
expect(
|
||||||
|
summary({
|
||||||
|
shareContext,
|
||||||
|
sourceMode: 'specific',
|
||||||
|
includeSources: [{'application.name': 'mpv'}],
|
||||||
|
microphoneLabel: 'Yeti',
|
||||||
|
}),
|
||||||
|
).toEqual({kind: 'sourceName', name: 'mpv'});
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
summary({
|
||||||
|
shareContext: 'app',
|
||||||
|
windowAudioScope: 'system',
|
||||||
|
sourceMode: 'specific',
|
||||||
|
includeSources: [{'application.name': 'mpv'}],
|
||||||
|
}),
|
||||||
|
).toEqual({kind: 'sourceName', name: 'mpv'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a selection the capture layer cannot express as the scope actually armed', () => {
|
||||||
|
expect(summary({sourceMode: 'specific', includeSources: [{'fluxer.display.name': 'mpv'}]})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: ENTIRE_SYSTEM_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
summary({
|
||||||
|
shareContext: 'app',
|
||||||
|
windowAudioScope: 'system',
|
||||||
|
sourceMode: 'specific',
|
||||||
|
includeSources: [{'fluxer.display.name': 'mpv'}],
|
||||||
|
}),
|
||||||
|
).toEqual({kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR});
|
||||||
|
expect(
|
||||||
|
summary({
|
||||||
|
shareContext: 'device',
|
||||||
|
microphoneLabel: 'Yeti',
|
||||||
|
sourceMode: 'specific',
|
||||||
|
includeSources: [{'fluxer.display.name': 'mpv'}],
|
||||||
|
}),
|
||||||
|
).toEqual({kind: 'message', descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR, values: {deviceLabel: 'Yeti'}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the picked applications past the first one', () => {
|
||||||
|
expect(
|
||||||
|
summary({
|
||||||
|
sourceMode: 'specific',
|
||||||
|
includeSources: [{'application.name': 'mpv'}, {'application.name': 'Firefox'}],
|
||||||
|
}),
|
||||||
|
).toEqual({kind: 'message', descriptor: APP_COUNT_DESCRIPTOR, values: {length: 2}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a custom label for a picked source with no readable name', () => {
|
||||||
|
expect(summary({sourceMode: 'specific', includeSources: [{'object.serial': '41'}]})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: CUSTOM_SOURCES_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads an empty specific selection as the wide scope it actually routes', () => {
|
||||||
|
expect(summary({sourceMode: 'specific', includeSources: []})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: ENTIRE_SYSTEM_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
expect(summary({shareContext: 'app', sourceMode: 'specific', includeSources: []})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: SHARED_WINDOW_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
summary({shareContext: 'app', windowAudioScope: 'system', sourceMode: 'specific', includeSources: []}),
|
||||||
|
).toEqual({kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the microphone on a device share and its device when one is resolved', () => {
|
||||||
|
expect(summary({shareContext: 'device', microphoneLabel: 'Yeti'})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR,
|
||||||
|
values: {deviceLabel: 'Yeti'},
|
||||||
|
});
|
||||||
|
expect(summary({shareContext: 'device'})).toEqual({kind: 'message', descriptor: MICROPHONE_DESCRIPTOR});
|
||||||
|
expect(summary({shareContext: 'device', microphoneLabel: ''})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: MICROPHONE_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a window share on the shared window whatever the stored display selection says', () => {
|
||||||
|
for (const sourceMode of ['none', 'system', 'specific'] as const) {
|
||||||
|
expect(summary({shareContext: 'app', sourceMode, includeSources: [{'application.name': 'mpv'}]})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: SHARED_WINDOW_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the shared window on a window share and the entire system once it is widened', () => {
|
||||||
|
expect(summary({shareContext: 'app'})).toEqual({kind: 'message', descriptor: SHARED_WINDOW_DESCRIPTOR});
|
||||||
|
expect(summary({shareContext: 'app', windowAudioScope: 'window'})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: SHARED_WINDOW_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
expect(summary({shareContext: 'app', windowAudioScope: 'system'})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: ENTIRE_SYSTEM_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never promises a Wayland window share its own window audio', () => {
|
||||||
|
expect(
|
||||||
|
summary({shareContext: 'app', displayShareEnvironment: 'desktop-wayland', windowAudioScope: 'window'}),
|
||||||
|
).toEqual({kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the entire system on a display share whatever the window scope holds', () => {
|
||||||
|
for (const windowAudioScope of ['window', 'system'] as const) {
|
||||||
|
expect(summary({shareContext: 'display', windowAudioScope})).toEqual({
|
||||||
|
kind: 'message',
|
||||||
|
descriptor: ENTIRE_SYSTEM_DESCRIPTOR,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {
|
||||||
|
filterRoutableLinuxAudioSources,
|
||||||
|
getLinuxAudioSourceDisplayName,
|
||||||
|
} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||||
|
import type {DisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||||
|
import {
|
||||||
|
resolveWindowShareAudioScope,
|
||||||
|
type ScreenShareAudioSourceMode,
|
||||||
|
type StreamSettingsShareContext,
|
||||||
|
selectAppShareAudioRoute,
|
||||||
|
supportsWindowShareAudioScope,
|
||||||
|
type WindowShareAudioScope,
|
||||||
|
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||||
|
import type {VirtmicNode} from '@app/types/electron.d';
|
||||||
|
import type {I18n, MessageDescriptor} from '@lingui/core';
|
||||||
|
import {msg} from '@lingui/core/macro';
|
||||||
|
|
||||||
|
export const NO_AUDIO_DESCRIPTOR = msg({
|
||||||
|
message: 'No audio',
|
||||||
|
comment: 'Screen-share audio summary shown when the share publishes no audio at all.',
|
||||||
|
});
|
||||||
|
export const CUSTOM_SOURCES_DESCRIPTOR = msg({
|
||||||
|
message: 'Custom',
|
||||||
|
comment: 'Screen-share audio summary shown when one audio source is selected but it has no readable name.',
|
||||||
|
});
|
||||||
|
export const APP_COUNT_DESCRIPTOR = msg({
|
||||||
|
message: '{length} apps',
|
||||||
|
comment: 'Screen-share audio summary listing how many apps are captured. {length} is the integer app count.',
|
||||||
|
});
|
||||||
|
export const ENTIRE_SYSTEM_DESCRIPTOR = msg({
|
||||||
|
message: 'Entire system',
|
||||||
|
comment: 'Screen-share audio summary shown when the whole system audio mix is captured.',
|
||||||
|
});
|
||||||
|
export const SHARED_WINDOW_DESCRIPTOR = msg({
|
||||||
|
message: 'Shared window',
|
||||||
|
comment: 'Screen-share audio summary shown when a window share captures only the audio of the window it shares.',
|
||||||
|
});
|
||||||
|
export const MICROPHONE_DESCRIPTOR = msg({
|
||||||
|
message: 'Microphone',
|
||||||
|
comment: 'Screen-share audio summary on a video device share whose audio comes from an unnamed microphone.',
|
||||||
|
});
|
||||||
|
export const MICROPHONE_WITH_DEVICE_DESCRIPTOR = msg({
|
||||||
|
message: 'Microphone ({deviceLabel})',
|
||||||
|
comment:
|
||||||
|
'Screen-share audio summary on a video device share. {deviceLabel} is the name of the selected audio input device.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ScreenShareAudioSummaryInput {
|
||||||
|
sourceMode: ScreenShareAudioSourceMode;
|
||||||
|
includeSources: ReadonlyArray<VirtmicNode>;
|
||||||
|
shareContext: StreamSettingsShareContext;
|
||||||
|
microphoneLabel?: string | null;
|
||||||
|
displayShareEnvironment?: DisplayShareEnvironment;
|
||||||
|
windowAudioScope?: WindowShareAudioScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScreenShareAudioSummary =
|
||||||
|
| {readonly kind: 'sourceName'; readonly name: string}
|
||||||
|
| {
|
||||||
|
readonly kind: 'message';
|
||||||
|
readonly descriptor: MessageDescriptor;
|
||||||
|
readonly values?: Record<string, string | number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function summariseSelectedSources(selected: ReadonlyArray<VirtmicNode>): ScreenShareAudioSummary {
|
||||||
|
if (selected.length === 1) {
|
||||||
|
const name = getLinuxAudioSourceDisplayName(selected[0]);
|
||||||
|
return name == null ? {kind: 'message', descriptor: CUSTOM_SOURCES_DESCRIPTOR} : {kind: 'sourceName', name};
|
||||||
|
}
|
||||||
|
return {kind: 'message', descriptor: APP_COUNT_DESCRIPTOR, values: {length: selected.length}};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summariseMicrophone(microphoneLabel?: string | null): ScreenShareAudioSummary {
|
||||||
|
if (microphoneLabel == null || microphoneLabel === '') {
|
||||||
|
return {kind: 'message', descriptor: MICROPHONE_DESCRIPTOR};
|
||||||
|
}
|
||||||
|
return {kind: 'message', descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR, values: {deviceLabel: microphoneLabel}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveScreenShareAudioSummary(input: ScreenShareAudioSummaryInput): ScreenShareAudioSummary {
|
||||||
|
const selected = filterRoutableLinuxAudioSources(input.includeSources);
|
||||||
|
const routesSelectedSources = input.sourceMode === 'specific' && selected.length > 0;
|
||||||
|
if (input.shareContext === 'device') {
|
||||||
|
return routesSelectedSources ? summariseSelectedSources(selected) : summariseMicrophone(input.microphoneLabel);
|
||||||
|
}
|
||||||
|
if (supportsWindowShareAudioScope(input)) {
|
||||||
|
const route = selectAppShareAudioRoute({
|
||||||
|
audioSourceMode: input.sourceMode,
|
||||||
|
selectedSourceCount: selected.length,
|
||||||
|
windowAudioScope: resolveWindowShareAudioScope(input),
|
||||||
|
});
|
||||||
|
if (route === 'window') return {kind: 'message', descriptor: SHARED_WINDOW_DESCRIPTOR};
|
||||||
|
if (route === 'none') return {kind: 'message', descriptor: NO_AUDIO_DESCRIPTOR};
|
||||||
|
if (route === 'apps') return summariseSelectedSources(selected);
|
||||||
|
return {kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR};
|
||||||
|
}
|
||||||
|
if (input.sourceMode === 'none') return {kind: 'message', descriptor: NO_AUDIO_DESCRIPTOR};
|
||||||
|
if (routesSelectedSources) return summariseSelectedSources(selected);
|
||||||
|
return {kind: 'message', descriptor: ENTIRE_SYSTEM_DESCRIPTOR};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatScreenShareAudioSummary(i18n: I18n, input: ScreenShareAudioSummaryInput): string {
|
||||||
|
const summary = resolveScreenShareAudioSummary(input);
|
||||||
|
if (summary.kind === 'sourceName') return summary.name;
|
||||||
|
return i18n._(summary.descriptor, summary.values);
|
||||||
|
}
|
||||||
@@ -8,11 +8,14 @@ const voiceSettings = {
|
|||||||
audioSourceMode: 'system' as 'none' | 'system' | 'specific',
|
audioSourceMode: 'system' as 'none' | 'system' | 'specific',
|
||||||
includeSources: [] as Array<Record<string, string>>,
|
includeSources: [] as Array<Record<string, string>>,
|
||||||
excludeSources: [] as Array<Record<string, string>>,
|
excludeSources: [] as Array<Record<string, string>>,
|
||||||
manualAudioSourcesOptIn: false,
|
|
||||||
audioDeviceId: 'default',
|
audioDeviceId: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeShareContext = {current: null as 'app' | 'device' | 'display' | null};
|
const activeShareContext = {current: null as 'app' | 'device' | 'display' | null};
|
||||||
|
const activeShareSourceId = {current: null as string | null};
|
||||||
|
const activeShareOwnWindow = {current: false};
|
||||||
|
const activeWindowAudioScope = {current: 'window' as 'window' | 'system'};
|
||||||
|
const pendingWindowAudioScope = {current: null as 'window' | 'system' | null};
|
||||||
const activeShareVideoDeviceId = {current: ''};
|
const activeShareVideoDeviceId = {current: ''};
|
||||||
|
|
||||||
const nativeAudioAvailability = {
|
const nativeAudioAvailability = {
|
||||||
@@ -27,8 +30,10 @@ const setScreenShareEnabled = vi.fn(async () => {});
|
|||||||
const replaceActiveDisplayScreenShare = vi.fn(async () => true);
|
const replaceActiveDisplayScreenShare = vi.fn(async () => true);
|
||||||
const startDeviceScreenShare = vi.fn(async () => {});
|
const startDeviceScreenShare = vi.fn(async () => {});
|
||||||
const replaceActiveDeviceScreenShare = vi.fn(async () => true);
|
const replaceActiveDeviceScreenShare = vi.fn(async () => true);
|
||||||
|
const disarmNativeAudio = vi.fn();
|
||||||
const ensureLinuxScreenShareAudioPublication = vi.fn(async (_rule: Record<string, unknown>) => true);
|
const ensureLinuxScreenShareAudioPublication = vi.fn(async (_rule: Record<string, unknown>) => true);
|
||||||
const ensureDeviceScreenShareMicPublication = vi.fn(async () => true);
|
const ensureDeviceScreenShareMicPublication = vi.fn(async () => true);
|
||||||
|
const ensureWindowScreenShareAudioPublication = vi.fn(async (_sourceId: string) => true);
|
||||||
const armNativeAudioForNextCapture = vi.fn(async () => true);
|
const armNativeAudioForNextCapture = vi.fn(async () => true);
|
||||||
const armNativeSystemAudioForNextCapture = vi.fn(async () => true);
|
const armNativeSystemAudioForNextCapture = vi.fn(async () => true);
|
||||||
const armNativeAudioForLinuxRouting = vi.fn(async (_rule: Record<string, unknown>) => true);
|
const armNativeAudioForLinuxRouting = vi.fn(async (_rule: Record<string, unknown>) => true);
|
||||||
@@ -53,6 +58,7 @@ vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({
|
|||||||
replaceActiveDeviceScreenShare,
|
replaceActiveDeviceScreenShare,
|
||||||
ensureLinuxScreenShareAudioPublication,
|
ensureLinuxScreenShareAudioPublication,
|
||||||
ensureDeviceScreenShareMicPublication,
|
ensureDeviceScreenShareMicPublication,
|
||||||
|
ensureWindowScreenShareAudioPublication,
|
||||||
getActiveScreenShareVideoDeviceId: () => activeShareVideoDeviceId.current,
|
getActiveScreenShareVideoDeviceId: () => activeShareVideoDeviceId.current,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -66,7 +72,7 @@ vi.mock('@app/features/voice/utils/NativeAudioCaptureBridge', () => ({
|
|||||||
armNativeAudioForLinuxRouting,
|
armNativeAudioForLinuxRouting,
|
||||||
armNativeAudioForNextCapture,
|
armNativeAudioForNextCapture,
|
||||||
armNativeSystemAudioForNextCapture,
|
armNativeSystemAudioForNextCapture,
|
||||||
disarmNativeAudio: vi.fn(),
|
disarmNativeAudio,
|
||||||
disarmPendingNativeAudio: vi.fn(),
|
disarmPendingNativeAudio: vi.fn(),
|
||||||
getLastNativeAudioArmFailure: () => null,
|
getLastNativeAudioArmFailure: () => null,
|
||||||
getNativeAudioAvailabilityCached: async () => nativeAudioAvailability.current,
|
getNativeAudioAvailabilityCached: async () => nativeAudioAvailability.current,
|
||||||
@@ -80,8 +86,14 @@ vi.mock('@app/features/voice/state/ActiveScreenShareSource', () => ({
|
|||||||
default: {
|
default: {
|
||||||
setPublishedSource: vi.fn(),
|
setPublishedSource: vi.fn(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
getSourceId: () => null,
|
getSourceId: () => activeShareSourceId.current,
|
||||||
getShareContext: () => activeShareContext.current,
|
getShareContext: () => activeShareContext.current,
|
||||||
|
isOwnWindow: () => activeShareOwnWindow.current,
|
||||||
|
getWindowAudioScope: () => activeWindowAudioScope.current,
|
||||||
|
setWindowAudioScope: vi.fn((scope: 'window' | 'system') => {
|
||||||
|
activeWindowAudioScope.current = scope;
|
||||||
|
}),
|
||||||
|
getPendingWindowAudioScope: () => pendingWindowAudioScope.current ?? activeWindowAudioScope.current,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -137,13 +149,9 @@ vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
|||||||
getScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
|
getScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
|
||||||
getScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
getScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
||||||
getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
||||||
getScreenShareManualAudioSourcesOptIn: () => voiceSettings.manualAudioSourcesOptIn,
|
getEffectiveScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
|
||||||
getEffectiveScreenShareAudioSourceMode: () =>
|
getEffectiveScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
||||||
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.audioSourceMode : 'system',
|
getEffectiveScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
||||||
getEffectiveScreenShareAudioIncludeSources: () =>
|
|
||||||
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.includeSources : [],
|
|
||||||
getEffectiveScreenShareAudioExcludeSources: () =>
|
|
||||||
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.excludeSources : [],
|
|
||||||
getLinuxAudioCaptureIgnoreInputMedia: () => true,
|
getLinuxAudioCaptureIgnoreInputMedia: () => true,
|
||||||
getLinuxAudioCaptureIgnoreVirtual: () => false,
|
getLinuxAudioCaptureIgnoreVirtual: () => false,
|
||||||
getLinuxAudioCaptureIgnoreDevices: () => true,
|
getLinuxAudioCaptureIgnoreDevices: () => true,
|
||||||
@@ -153,8 +161,9 @@ vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
reapplyActiveScreenShareAudioSources,
|
applyLiveScreenShareAudioSourceChange,
|
||||||
reconfigureActiveDeviceShareAudio,
|
reconfigureActiveDeviceShareAudio,
|
||||||
|
reconfigureActiveLinuxAppShareAudio,
|
||||||
startConfiguredDeviceScreenShare,
|
startConfiguredDeviceScreenShare,
|
||||||
startConfiguredDisplayScreenShare,
|
startConfiguredDisplayScreenShare,
|
||||||
switchConfiguredDeviceScreenShare,
|
switchConfiguredDeviceScreenShare,
|
||||||
@@ -168,9 +177,12 @@ beforeEach(() => {
|
|||||||
voiceSettings.audioSourceMode = 'system';
|
voiceSettings.audioSourceMode = 'system';
|
||||||
voiceSettings.includeSources = [];
|
voiceSettings.includeSources = [];
|
||||||
voiceSettings.excludeSources = [];
|
voiceSettings.excludeSources = [];
|
||||||
voiceSettings.manualAudioSourcesOptIn = false;
|
|
||||||
voiceSettings.audioDeviceId = 'default';
|
voiceSettings.audioDeviceId = 'default';
|
||||||
activeShareContext.current = null;
|
activeShareContext.current = null;
|
||||||
|
activeShareSourceId.current = null;
|
||||||
|
activeShareOwnWindow.current = false;
|
||||||
|
activeWindowAudioScope.current = 'window';
|
||||||
|
pendingWindowAudioScope.current = null;
|
||||||
activeShareVideoDeviceId.current = '';
|
activeShareVideoDeviceId.current = '';
|
||||||
nativeAudioAvailability.current = {
|
nativeAudioAvailability.current = {
|
||||||
available: true,
|
available: true,
|
||||||
@@ -183,6 +195,7 @@ beforeEach(() => {
|
|||||||
replaceActiveDeviceScreenShare.mockResolvedValue(true);
|
replaceActiveDeviceScreenShare.mockResolvedValue(true);
|
||||||
ensureLinuxScreenShareAudioPublication.mockResolvedValue(true);
|
ensureLinuxScreenShareAudioPublication.mockResolvedValue(true);
|
||||||
ensureDeviceScreenShareMicPublication.mockResolvedValue(true);
|
ensureDeviceScreenShareMicPublication.mockResolvedValue(true);
|
||||||
|
ensureWindowScreenShareAudioPublication.mockResolvedValue(true);
|
||||||
enumerateDevices.mockResolvedValue([]);
|
enumerateDevices.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,32 +216,42 @@ describe('sharing a video device', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the microphone and ignores a stored source selection while the opt-in is off', async () => {
|
test('opens no microphone and links the selected applications', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
|
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
|
||||||
|
|
||||||
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBe('mic-1');
|
|
||||||
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('opens no microphone and links the selected applications once the opt-in is on', async () => {
|
|
||||||
platform.current = 'linux';
|
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
|
||||||
|
|
||||||
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
|
|
||||||
|
|
||||||
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBeUndefined();
|
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBeUndefined();
|
||||||
expect(ensureLinuxScreenShareAudioPublication).toHaveBeenCalledTimes(1);
|
expect(ensureLinuxScreenShareAudioPublication).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the microphone while the opt-in is on but no application is selected', async () => {
|
test('keeps the microphone while no application is selected', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
|
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
|
||||||
|
|
||||||
|
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBe('mic-1');
|
||||||
|
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the microphone on a device share whose stored source mode says none', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
voiceSettings.audioSourceMode = 'none';
|
||||||
|
|
||||||
|
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
|
||||||
|
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBe('mic-1');
|
||||||
|
|
||||||
|
expect(await reconfigureActiveDeviceShareAudio()).toBe(true);
|
||||||
|
expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledWith('mic-1');
|
||||||
|
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the microphone when the picked applications cannot be routed', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
|
voiceSettings.includeSources = [{'fluxer.display.name': 'mpv'}];
|
||||||
|
|
||||||
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
|
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
|
||||||
|
|
||||||
@@ -237,7 +260,6 @@ describe('sharing a video device', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('never routes application audio into a device share off Linux', async () => {
|
test('never routes application audio into a device share off Linux', async () => {
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
@@ -250,7 +272,6 @@ describe('sharing a video device', () => {
|
|||||||
test('never routes application audio when the capture layer cannot express a selection', async () => {
|
test('never routes application audio when the capture layer cannot express a selection', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
nativeAudioAvailability.current = {available: false, backend: 'linux-pipewire'};
|
nativeAudioAvailability.current = {available: false, backend: 'linux-pipewire'};
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
@@ -301,7 +322,6 @@ describe('sharing a video device', () => {
|
|||||||
|
|
||||||
test('relinks the selected applications after switching the capture device', async () => {
|
test('relinks the selected applications after switching the capture device', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
@@ -313,7 +333,6 @@ describe('sharing a video device', () => {
|
|||||||
|
|
||||||
test('does not relink application audio when the device switch fails', async () => {
|
test('does not relink application audio when the device switch fails', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
replaceActiveDeviceScreenShare.mockResolvedValue(false);
|
replaceActiveDeviceScreenShare.mockResolvedValue(false);
|
||||||
@@ -325,7 +344,6 @@ describe('sharing a video device', () => {
|
|||||||
|
|
||||||
test('swaps a live device share between the microphone and the selected applications', async () => {
|
test('swaps a live device share between the microphone and the selected applications', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
@@ -360,10 +378,8 @@ describe('sharing a video device', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('sharing a whole display', () => {
|
describe('sharing a whole display', () => {
|
||||||
test('captures the desktop mix without Fluxer on Linux, whatever is stored while opted out', async () => {
|
test('captures the desktop mix without Fluxer on Linux when nothing is selected', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
|
||||||
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
|
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
|
||||||
|
|
||||||
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
|
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
|
||||||
@@ -371,26 +387,26 @@ describe('sharing a whole display', () => {
|
|||||||
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1);
|
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1);
|
||||||
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
|
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
|
||||||
include: [],
|
include: [],
|
||||||
exclude: [],
|
exclude: [{'application.name': 'Discord'}],
|
||||||
ignoreInputMedia: true,
|
ignoreInputMedia: true,
|
||||||
onlySpeakers: true,
|
onlySpeakers: true,
|
||||||
onlyDefaultSpeakers: true,
|
onlyDefaultSpeakers: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('still publishes the desktop mix when a stored no-audio selection is opted out of', async () => {
|
test('publishes no audio at all when the stored selection asks for none', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
voiceSettings.audioSourceMode = 'none';
|
voiceSettings.audioSourceMode = 'none';
|
||||||
|
|
||||||
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
|
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
|
||||||
|
|
||||||
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1);
|
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
|
||||||
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({include: []});
|
const call = setScreenShareEnabled.mock.calls[0] as unknown as [boolean, {audio: boolean}, unknown];
|
||||||
|
expect(call[1].audio).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('honours the stored include and exclude lists once the opt-in is on', async () => {
|
test('honours the stored include list', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
@@ -447,6 +463,206 @@ describe('sharing another application window while app audio is enabled', () =>
|
|||||||
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
|
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
|
||||||
expect(setScreenShareEnabled).not.toHaveBeenCalled();
|
expect(setScreenShareEnabled).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('takes the shared window own audio by default on Linux, never the whole system mix', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
|
||||||
|
|
||||||
|
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
|
||||||
|
|
||||||
|
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
|
||||||
|
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('widens a Linux window share to the system mix minus the stored exclusions on request', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
pendingWindowAudioScope.current = 'system';
|
||||||
|
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
|
||||||
|
|
||||||
|
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
|
||||||
|
|
||||||
|
expect(armNativeAudioForNextCapture).not.toHaveBeenCalled();
|
||||||
|
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1);
|
||||||
|
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
|
||||||
|
include: [],
|
||||||
|
exclude: [{'application.name': 'Discord'}],
|
||||||
|
onlySpeakers: true,
|
||||||
|
onlyDefaultSpeakers: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores a stored display-share selection until the window scope is widened', async () => {
|
||||||
|
for (const audioSourceMode of ['none', 'specific'] as const) {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
platform.current = 'linux';
|
||||||
|
voiceSettings.audioSourceMode = audioSourceMode;
|
||||||
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
|
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
|
||||||
|
|
||||||
|
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
|
||||||
|
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('routes the picked applications once the window share was widened to the system', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
pendingWindowAudioScope.current = 'system';
|
||||||
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
|
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
|
||||||
|
|
||||||
|
expect(armNativeAudioForNextCapture).not.toHaveBeenCalled();
|
||||||
|
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
|
||||||
|
include: [{'application.name': 'mpv'}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('captures the shared window rather than the whole desktop when the picked apps cannot be routed', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
|
voiceSettings.includeSources = [{'fluxer.display.name': 'mpv'}];
|
||||||
|
|
||||||
|
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
|
||||||
|
|
||||||
|
expect(armNativeAudioForNextCapture).toHaveBeenCalledWith('window:42:0');
|
||||||
|
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishes no audio at all on a widened Linux window share asked for none', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
pendingWindowAudioScope.current = 'system';
|
||||||
|
voiceSettings.audioSourceMode = 'none';
|
||||||
|
|
||||||
|
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
|
||||||
|
|
||||||
|
expect(armNativeAudioForNextCapture).not.toHaveBeenCalled();
|
||||||
|
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
|
||||||
|
const call = setScreenShareEnabled.mock.calls[0] as unknown as [boolean, {audio: boolean}, unknown];
|
||||||
|
expect(call[1].audio).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('commits the scope it armed to the running share', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
pendingWindowAudioScope.current = 'system';
|
||||||
|
|
||||||
|
expect(await startConfiguredDisplayScreenShare('window:42:0', {preferredDisplaySurface: 'window'})).toBe(true);
|
||||||
|
|
||||||
|
expect(ActiveScreenShareSource.setWindowAudioScope).toHaveBeenCalledWith('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never carries a window scope over into a display share', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
pendingWindowAudioScope.current = 'system';
|
||||||
|
|
||||||
|
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
|
||||||
|
|
||||||
|
expect(ActiveScreenShareSource.setWindowAudioScope).toHaveBeenCalledWith('window');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changing the audio sources of a live window share', () => {
|
||||||
|
test('re-publishes the shared window own audio while the scope is the window', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
activeShareSourceId.current = 'window:42:0';
|
||||||
|
|
||||||
|
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(true);
|
||||||
|
|
||||||
|
expect(ensureWindowScreenShareAudioPublication).toHaveBeenCalledWith('window:42:0');
|
||||||
|
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relinks a widened window share through the manual rule instead', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
activeShareSourceId.current = 'window:42:0';
|
||||||
|
activeWindowAudioScope.current = 'system';
|
||||||
|
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
|
||||||
|
|
||||||
|
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(true);
|
||||||
|
|
||||||
|
expect(ensureWindowScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
expect(ensureLinuxScreenShareAudioPublication.mock.calls[0][0]).toMatchObject({
|
||||||
|
include: [],
|
||||||
|
exclude: [{'application.name': 'Discord'}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relinks the picked applications on a live window share that was widened', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
activeShareSourceId.current = 'window:42:0';
|
||||||
|
activeWindowAudioScope.current = 'system';
|
||||||
|
voiceSettings.audioSourceMode = 'specific';
|
||||||
|
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||||
|
|
||||||
|
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(true);
|
||||||
|
|
||||||
|
expect(ensureWindowScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
expect(ensureLinuxScreenShareAudioPublication.mock.calls[0][0]).toMatchObject({
|
||||||
|
include: [{'application.name': 'mpv'}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves a window share alone off Linux, and drops its audio when no window source is published', async () => {
|
||||||
|
platform.current = 'darwin';
|
||||||
|
activeShareSourceId.current = 'window:42:0';
|
||||||
|
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(false);
|
||||||
|
expect(disarmNativeAudio).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
platform.current = 'linux';
|
||||||
|
activeShareSourceId.current = null;
|
||||||
|
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(false);
|
||||||
|
expect(disarmNativeAudio).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(ensureWindowScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops the audio rather than keeping the system mix live when narrowing back to the window fails', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
activeShareSourceId.current = 'window:42:0';
|
||||||
|
activeWindowAudioScope.current = 'system';
|
||||||
|
ensureWindowScreenShareAudioPublication.mockResolvedValue(false);
|
||||||
|
|
||||||
|
expect(await applyLiveScreenShareAudioSourceChange('app', 'window')).toBe(false);
|
||||||
|
|
||||||
|
expect(ensureWindowScreenShareAudioPublication).toHaveBeenCalledWith('window:42:0');
|
||||||
|
expect(disarmNativeAudio).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops the audio rather than keeping the system mix live when the window publication throws', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
activeShareSourceId.current = 'window:42:0';
|
||||||
|
activeWindowAudioScope.current = 'system';
|
||||||
|
ensureWindowScreenShareAudioPublication.mockRejectedValue(new Error('window vanished'));
|
||||||
|
|
||||||
|
expect(await applyLiveScreenShareAudioSourceChange('app', 'window')).toBe(false);
|
||||||
|
|
||||||
|
expect(disarmNativeAudio).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('commits the narrowed scope only once the window audio is actually publishing', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
activeShareSourceId.current = 'window:42:0';
|
||||||
|
activeWindowAudioScope.current = 'system';
|
||||||
|
|
||||||
|
expect(await applyLiveScreenShareAudioSourceChange('app', 'window')).toBe(true);
|
||||||
|
|
||||||
|
expect(ActiveScreenShareSource.getWindowAudioScope()).toBe('window');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never routes audio into a Fluxer-owned window share', async () => {
|
||||||
|
platform.current = 'linux';
|
||||||
|
activeShareSourceId.current = 'window:99:0';
|
||||||
|
activeShareOwnWindow.current = true;
|
||||||
|
|
||||||
|
expect(await reconfigureActiveLinuxAppShareAudio()).toBe(false);
|
||||||
|
|
||||||
|
expect(ensureWindowScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('switching the display source', () => {
|
describe('switching the display source', () => {
|
||||||
@@ -470,95 +686,3 @@ describe('switching the display source', () => {
|
|||||||
expect(ActiveScreenShareSource.clear).not.toHaveBeenCalled();
|
expect(ActiveScreenShareSource.clear).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('turning the advanced audio source opt-in off mid-share', () => {
|
|
||||||
test('relinks a live display share back to the desktop mix instead of leaving the selection running', async () => {
|
|
||||||
platform.current = 'linux';
|
|
||||||
activeShareContext.current = 'display';
|
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
|
||||||
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(true);
|
|
||||||
expect(ensureLinuxScreenShareAudioPublication.mock.calls[0][0]).toMatchObject({
|
|
||||||
include: [{'application.name': 'mpv'}],
|
|
||||||
});
|
|
||||||
|
|
||||||
voiceSettings.manualAudioSourcesOptIn = false;
|
|
||||||
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(true);
|
|
||||||
expect(ensureLinuxScreenShareAudioPublication.mock.calls[1][0]).toMatchObject({
|
|
||||||
include: [],
|
|
||||||
exclude: [],
|
|
||||||
onlySpeakers: true,
|
|
||||||
onlyDefaultSpeakers: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gives a live device share its microphone back instead of leaving the applications routed', async () => {
|
|
||||||
platform.current = 'linux';
|
|
||||||
activeShareContext.current = 'device';
|
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
|
||||||
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(true);
|
|
||||||
expect(ensureDeviceScreenShareMicPublication).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
voiceSettings.manualAudioSourcesOptIn = false;
|
|
||||||
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(true);
|
|
||||||
expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledWith('mic-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leaves a share alone when nothing was ever selected, on every share type', async () => {
|
|
||||||
platform.current = 'linux';
|
|
||||||
for (const shareContext of ['app', 'device', 'display'] as const) {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
activeShareContext.current = shareContext;
|
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'system';
|
|
||||||
voiceSettings.includeSources = [];
|
|
||||||
voiceSettings.excludeSources = [];
|
|
||||||
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
|
|
||||||
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
|
||||||
expect(ensureDeviceScreenShareMicPublication).not.toHaveBeenCalled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leaves a window share captured by process alone instead of swapping it for the desktop mix', async () => {
|
|
||||||
platform.current = 'linux';
|
|
||||||
activeShareContext.current = 'app';
|
|
||||||
voiceSettings.manualAudioSourcesOptIn = true;
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
|
||||||
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
|
|
||||||
|
|
||||||
voiceSettings.manualAudioSourcesOptIn = false;
|
|
||||||
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
|
|
||||||
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leaves a video-only share, an idle client and an incapable host alone', async () => {
|
|
||||||
platform.current = 'linux';
|
|
||||||
voiceSettings.audioSourceMode = 'specific';
|
|
||||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
|
||||||
|
|
||||||
activeShareContext.current = null;
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
|
|
||||||
|
|
||||||
activeShareContext.current = 'display';
|
|
||||||
nativeAudioAvailability.current = {available: false, backend: 'linux-pipewire'};
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
|
|
||||||
|
|
||||||
nativeAudioAvailability.current = {available: true, capabilities: {process: true, system: true}};
|
|
||||||
platform.current = 'win32';
|
|
||||||
expect(await reapplyActiveScreenShareAudioSources()).toBe(false);
|
|
||||||
|
|
||||||
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
|
|
||||||
expect(ensureDeviceScreenShareMicPublication).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type ScreenShareContentSource,
|
type ScreenShareContentSource,
|
||||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||||
import {
|
import {
|
||||||
|
filterRoutableLinuxAudioSources,
|
||||||
LINUX_AUDIO_TARGET_OBJECTS_PATTERN_KEY,
|
LINUX_AUDIO_TARGET_OBJECTS_PATTERN_KEY,
|
||||||
toNativeLinuxAudioPatterns,
|
toNativeLinuxAudioPatterns,
|
||||||
} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||||
@@ -48,10 +49,12 @@ import {
|
|||||||
} from '@app/features/voice/utils/ScreenSharePortalUnavailableError';
|
} from '@app/features/voice/utils/ScreenSharePortalUnavailableError';
|
||||||
import {executeScreenShareOperation} from '@app/features/voice/utils/ScreenShareUtils';
|
import {executeScreenShareOperation} from '@app/features/voice/utils/ScreenShareUtils';
|
||||||
import {
|
import {
|
||||||
canSelectManualAudioSources,
|
type AppShareAudioRoute,
|
||||||
|
resolveWindowShareAudioScope,
|
||||||
routesManualAudioSources,
|
routesManualAudioSources,
|
||||||
type StreamSettingsShareContext,
|
type StreamSettingsShareContext,
|
||||||
supportsManualScreenShareAudioSourceSelection,
|
selectAppShareAudioRoute,
|
||||||
|
type WindowShareAudioScope,
|
||||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||||
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||||
import type {NativeAudioStartOptions, VirtmicNode} from '@app/types/electron.d';
|
import type {NativeAudioStartOptions, VirtmicNode} from '@app/types/electron.d';
|
||||||
@@ -151,56 +154,88 @@ async function getManualAudioSourceSelectionInput(shareContext: StreamSettingsSh
|
|||||||
platform,
|
platform,
|
||||||
shareContext,
|
shareContext,
|
||||||
nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null,
|
nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null,
|
||||||
manualOptIn: VoiceSettings.getScreenShareManualAudioSourcesOptIn(),
|
|
||||||
audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(),
|
audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(),
|
||||||
selectedSourceCount: VoiceSettings.getScreenShareAudioIncludeSources().length,
|
selectedSourceCount: countRoutableAudioSources(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function canSelectManualAudioSourcesForShare(shareContext: StreamSettingsShareContext): Promise<boolean> {
|
|
||||||
return canSelectManualAudioSources(await getManualAudioSourceSelectionInput(shareContext));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function shouldRouteManualAudioSourcesForShare(
|
export async function shouldRouteManualAudioSourcesForShare(
|
||||||
shareContext: StreamSettingsShareContext,
|
shareContext: StreamSettingsShareContext,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return routesManualAudioSources(await getManualAudioSourceSelectionInput(shareContext));
|
return routesManualAudioSources(await getManualAudioSourceSelectionInput(shareContext));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasStoredManualAudioSourceSelection(): boolean {
|
function appShareAudioRouteToSourceMode(route: AppShareAudioRoute | null): 'none' | 'system' | 'specific' | null {
|
||||||
return (
|
if (route === 'none') return 'none';
|
||||||
VoiceSettings.getScreenShareAudioSourceMode() !== 'system' ||
|
if (route === 'apps') return 'specific';
|
||||||
VoiceSettings.getScreenShareAudioIncludeSources().length > 0 ||
|
if (route === 'system') return 'system';
|
||||||
VoiceSettings.getScreenShareAudioExcludeSources().length > 0
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRoutableAudioSources(): number {
|
||||||
|
return filterRoutableLinuxAudioSources(VoiceSettings.getScreenShareAudioIncludeSources()).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAppShareAudioScope(requestedScope: WindowShareAudioScope): Promise<WindowShareAudioScope> {
|
||||||
|
return resolveWindowShareAudioScope({
|
||||||
|
shareContext: 'app',
|
||||||
|
displayShareEnvironment: await getDisplayShareEnvironment(),
|
||||||
|
windowAudioScope: requestedScope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAppShareAudioRouteForScope(scope: WindowShareAudioScope): AppShareAudioRoute {
|
||||||
|
return selectAppShareAudioRoute({
|
||||||
|
audioSourceMode: VoiceSettings.getEffectiveScreenShareAudioSourceMode(),
|
||||||
|
selectedSourceCount: countRoutableAudioSources(),
|
||||||
|
windowAudioScope: scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconfigureActiveLinuxAppShareAudio(
|
||||||
|
requestedWindowAudioScope?: WindowShareAudioScope,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const electronApi = getElectronAPI();
|
||||||
|
if (!electronApi || electronApi.platform !== 'linux') return false;
|
||||||
|
if (ActiveScreenShareSource.isOwnWindow()) {
|
||||||
|
logger.warn('Refusing to route audio into a Fluxer-owned window share');
|
||||||
|
await stopActiveLinuxScreenShareAudioLink();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const scope = await resolveAppShareAudioScope(
|
||||||
|
requestedWindowAudioScope ?? ActiveScreenShareSource.getWindowAudioScope(),
|
||||||
);
|
);
|
||||||
|
if (selectAppShareAudioRouteForScope(scope) !== 'window') {
|
||||||
|
return reconfigureActiveLinuxScreenShareAudioLink();
|
||||||
|
}
|
||||||
|
const sourceId = ActiveScreenShareSource.getSourceId();
|
||||||
|
if (sourceId == null) {
|
||||||
|
logger.warn('Cannot route the shared window own audio without a published window source');
|
||||||
|
disarmNativeAudio();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (await MediaEngine.ensureWindowScreenShareAudioPublication(sourceId).catch(() => false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logger.warn('Failed to narrow the live share back to the shared window own audio; dropping its audio instead', {
|
||||||
|
sourceId,
|
||||||
|
});
|
||||||
|
disarmNativeAudio();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureAudioEnabledForShare(shareContext: StreamSettingsShareContext): boolean {
|
export async function applyLiveScreenShareAudioSourceChange(
|
||||||
if (shareContext === 'app') return VoiceSettings.getShareAppAudio();
|
shareContext: StreamSettingsShareContext,
|
||||||
if (shareContext === 'device') return VoiceSettings.getShareDeviceAudio();
|
requestedWindowAudioScope?: WindowShareAudioScope,
|
||||||
return VoiceSettings.getShareDesktopAudio();
|
): Promise<boolean> {
|
||||||
}
|
if (shareContext === 'device') return reconfigureActiveDeviceShareAudio();
|
||||||
|
if (shareContext !== 'app') return reconfigureActiveLinuxScreenShareAudioLink();
|
||||||
export async function reapplyActiveScreenShareAudioSources(): Promise<boolean> {
|
const applied = await reconfigureActiveLinuxAppShareAudio(requestedWindowAudioScope).catch((error) => {
|
||||||
const shareContext = ActiveScreenShareSource.getShareContext();
|
logger.warn('Failed to apply the window share audio scope', {error, requestedWindowAudioScope});
|
||||||
if (shareContext === null) return false;
|
|
||||||
if (shareContext === 'app') return false;
|
|
||||||
if (!hasStoredManualAudioSourceSelection()) return false;
|
|
||||||
if (!captureAudioEnabledForShare(shareContext)) return false;
|
|
||||||
const selectionInput = await getManualAudioSourceSelectionInput(shareContext);
|
|
||||||
if (!supportsManualScreenShareAudioSourceSelection(selectionInput)) return false;
|
|
||||||
const applied = await (shareContext === 'device'
|
|
||||||
? reconfigureActiveDeviceShareAudio()
|
|
||||||
: reconfigureActiveLinuxScreenShareAudioLink()
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn('Failed to reapply the active screen share audio sources', {shareContext, error});
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
if (!applied) {
|
if (applied && requestedWindowAudioScope != null) {
|
||||||
logger.warn('Active screen share audio sources could not be reapplied', {
|
ActiveScreenShareSource.setWindowAudioScope(requestedWindowAudioScope);
|
||||||
shareContext,
|
|
||||||
sourceMode: VoiceSettings.getEffectiveScreenShareAudioSourceMode(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return applied;
|
return applied;
|
||||||
}
|
}
|
||||||
@@ -426,11 +461,16 @@ async function runConfiguredDisplayScreenShare(
|
|||||||
(electronApi.platform === 'darwin' || electronApi.platform === 'win32') &&
|
(electronApi.platform === 'darwin' || electronApi.platform === 'win32') &&
|
||||||
sourceId?.startsWith('screen:');
|
sourceId?.startsWith('screen:');
|
||||||
const requestedNativePickerAudioOnLinux = requestedAudio && electronApi.platform === 'linux' && useWaylandPortal;
|
const requestedNativePickerAudioOnLinux = requestedAudio && electronApi.platform === 'linux' && useWaylandPortal;
|
||||||
const linuxDesktopAudioSourceMode =
|
const appShareAudioScope = requestedAppAudioOnLinux
|
||||||
electronApi.platform === 'linux' && (requestedDesktopAudio || requestedNativePickerAudioOnLinux)
|
? await resolveAppShareAudioScope(ActiveScreenShareSource.getPendingWindowAudioScope())
|
||||||
|
: null;
|
||||||
|
const appShareAudioRoute = appShareAudioScope == null ? null : selectAppShareAudioRouteForScope(appShareAudioScope);
|
||||||
|
const linuxAudioSourceMode = requestedAppAudioOnLinux
|
||||||
|
? appShareAudioRouteToSourceMode(appShareAudioRoute)
|
||||||
|
: electronApi.platform === 'linux' && (requestedDesktopAudio || requestedNativePickerAudioOnLinux)
|
||||||
? VoiceSettings.getEffectiveScreenShareAudioSourceMode()
|
? VoiceSettings.getEffectiveScreenShareAudioSourceMode()
|
||||||
: null;
|
: null;
|
||||||
if (requestedAppAudioOnLinux) {
|
if (requestedAppAudioOnLinux && appShareAudioRoute === 'window') {
|
||||||
try {
|
try {
|
||||||
nativeAudioArmed = await armNativeAudioForNextCapture(sourceId ?? '');
|
nativeAudioArmed = await armNativeAudioForNextCapture(sourceId ?? '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -467,10 +507,10 @@ async function runConfiguredDisplayScreenShare(
|
|||||||
logger.warn('Desktop audio unavailable; aborting screen share because audio was requested', debugInfo);
|
logger.warn('Desktop audio unavailable; aborting screen share because audio was requested', debugInfo);
|
||||||
failRequestedAudioCapture(debugInfo);
|
failRequestedAudioCapture(debugInfo);
|
||||||
}
|
}
|
||||||
} else if (linuxDesktopAudioSourceMode === 'none') {
|
} else if (linuxAudioSourceMode === 'none') {
|
||||||
removeAudioFromCaptureOptions(captureOptions);
|
removeAudioFromCaptureOptions(captureOptions);
|
||||||
} else if ((requestedDesktopAudio || requestedNativePickerAudioOnLinux) && electronApi.platform === 'linux') {
|
} else if (linuxAudioSourceMode !== null && electronApi.platform === 'linux') {
|
||||||
const sourceMode = linuxDesktopAudioSourceMode ?? 'system';
|
const sourceMode = linuxAudioSourceMode;
|
||||||
const userIncludeSources = VoiceSettings.getEffectiveScreenShareAudioIncludeSources().map((entry) => ({
|
const userIncludeSources = VoiceSettings.getEffectiveScreenShareAudioIncludeSources().map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
}));
|
}));
|
||||||
@@ -496,6 +536,7 @@ async function runConfiguredDisplayScreenShare(
|
|||||||
if (!nativeAudioArmed) {
|
if (!nativeAudioArmed) {
|
||||||
failRequestedAudioCapture(
|
failRequestedAudioCapture(
|
||||||
buildAudioCaptureFailureDebug({
|
buildAudioCaptureFailureDebug({
|
||||||
|
sourceId,
|
||||||
sourceMode,
|
sourceMode,
|
||||||
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
|
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
|
||||||
}),
|
}),
|
||||||
@@ -579,6 +620,7 @@ async function runConfiguredDisplayScreenShare(
|
|||||||
ActiveScreenShareSource.setPublishedSource(sourceId.startsWith('window:') ? 'app' : 'display', sourceId, {
|
ActiveScreenShareSource.setPublishedSource(sourceId.startsWith('window:') ? 'app' : 'display', sourceId, {
|
||||||
isOwnWindow: isOwnWindowShare,
|
isOwnWindow: isOwnWindowShare,
|
||||||
});
|
});
|
||||||
|
ActiveScreenShareSource.setWindowAudioScope(appShareAudioScope ?? 'window');
|
||||||
}
|
}
|
||||||
if (captured && useWaylandPortal) {
|
if (captured && useWaylandPortal) {
|
||||||
ActiveScreenShareSource.setPublishedSource('wayland', null);
|
ActiveScreenShareSource.setPublishedSource('wayland', null);
|
||||||
@@ -594,8 +636,8 @@ async function runConfiguredDisplayScreenShare(
|
|||||||
captured &&
|
captured &&
|
||||||
requestedAudio &&
|
requestedAudio &&
|
||||||
electronApi.platform === 'linux' &&
|
electronApi.platform === 'linux' &&
|
||||||
linuxDesktopAudioSourceMode !== null &&
|
linuxAudioSourceMode !== null &&
|
||||||
linuxDesktopAudioSourceMode !== 'none'
|
linuxAudioSourceMode !== 'none'
|
||||||
) {
|
) {
|
||||||
const audioRelinked = await reconfigureActiveLinuxScreenShareAudioLink().catch((error) => {
|
const audioRelinked = await reconfigureActiveLinuxScreenShareAudioLink().catch((error) => {
|
||||||
logger.warn('Failed to link Linux screen-share audio after capture start', {mode, error});
|
logger.warn('Failed to link Linux screen-share audio after capture start', {mode, error});
|
||||||
@@ -603,7 +645,7 @@ async function runConfiguredDisplayScreenShare(
|
|||||||
});
|
});
|
||||||
if (!audioRelinked) {
|
if (!audioRelinked) {
|
||||||
const debugInfo = buildAudioCaptureFailureDebug({
|
const debugInfo = buildAudioCaptureFailureDebug({
|
||||||
sourceMode: linuxDesktopAudioSourceMode,
|
sourceMode: linuxAudioSourceMode,
|
||||||
platform: electronApi.platform,
|
platform: electronApi.platform,
|
||||||
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
|
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {describe, expect, it} from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canSelectManualAudioSources,
|
canSelectManualAudioSources,
|
||||||
isLinuxDesktopAudioShare,
|
isLinuxDesktopAudioShare,
|
||||||
maySupportManualScreenShareAudioSourceSelection,
|
manualAudioSourcesGovernShare,
|
||||||
|
resolveWindowShareAudioScope,
|
||||||
routesManualAudioSources,
|
routesManualAudioSources,
|
||||||
|
selectAppShareAudioRoute,
|
||||||
shouldReconfigureAudioForActiveStreamSettings,
|
shouldReconfigureAudioForActiveStreamSettings,
|
||||||
supportsManualScreenShareAudioSourceSelection,
|
supportsWindowShareAudioScope,
|
||||||
} from './StreamSettingsUpdatePolicy';
|
} from './StreamSettingsUpdatePolicy';
|
||||||
|
|
||||||
const LINUX_PIPEWIRE: NativeAudioAvailability = {
|
const LINUX_PIPEWIRE: NativeAudioAvailability = {
|
||||||
@@ -88,90 +90,119 @@ describe('StreamSettingsUpdatePolicy', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('offers manual audio source selection only where the capture layer can express it', () => {
|
it('offers manual audio source selection only where the capture layer can express it', () => {
|
||||||
|
expect(canSelectManualAudioSources({platform: 'linux', nativeAudioAvailability: LINUX_PIPEWIRE})).toBe(true);
|
||||||
|
expect(canSelectManualAudioSources({platform: 'win32', nativeAudioAvailability: WINDOWS_WASAPI})).toBe(false);
|
||||||
|
expect(canSelectManualAudioSources({platform: 'darwin', nativeAudioAvailability: MACOS_SCK})).toBe(false);
|
||||||
|
expect(canSelectManualAudioSources({platform: 'linux', nativeAudioAvailability: null})).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
supportsManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: LINUX_PIPEWIRE}),
|
canSelectManualAudioSources({
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
supportsManualScreenShareAudioSourceSelection({platform: 'win32', nativeAudioAvailability: WINDOWS_WASAPI}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
supportsManualScreenShareAudioSourceSelection({platform: 'darwin', nativeAudioAvailability: MACOS_SCK}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(supportsManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: null})).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
supportsManualScreenShareAudioSourceSelection({
|
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
nativeAudioAvailability: {available: false, backend: 'linux-pipewire', reason: 'no-pipewire'},
|
nativeAudioAvailability: {available: false, backend: 'linux-pipewire', reason: 'no-pipewire'},
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
supportsManualScreenShareAudioSourceSelection({
|
canSelectManualAudioSources({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
nativeAudioAvailability: {available: true, capabilities: {process: false, system: true}},
|
nativeAudioAvailability: {available: true, capabilities: {process: false, system: true}},
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the advanced toggle on Linux while the capture layer is still being probed', () => {
|
it('offers manual audio source selection on every desktop share type, with no opt-in left to satisfy', () => {
|
||||||
expect(maySupportManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: null})).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
maySupportManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: LINUX_PIPEWIRE}),
|
canSelectManualAudioSources({
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
maySupportManualScreenShareAudioSourceSelection({
|
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
nativeAudioAvailability: {available: false, backend: 'linux-pipewire', reason: 'no-pipewire'},
|
nativeAudioAvailability: LINUX_PIPEWIRE,
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
expect(maySupportManualScreenShareAudioSourceSelection({platform: 'win32', nativeAudioAvailability: null})).toBe(
|
for (const displayShareEnvironment of ['desktop-custom', 'desktop-wayland'] as const) {
|
||||||
false,
|
expect(manualAudioSourcesGovernShare({platform: 'linux', displayShareEnvironment})).toBe(true);
|
||||||
);
|
}
|
||||||
expect(
|
|
||||||
maySupportManualScreenShareAudioSourceSelection({platform: 'darwin', nativeAudioAvailability: MACOS_SCK}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(maySupportManualScreenShareAudioSourceSelection({platform: null, nativeAudioAvailability: null})).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps manual audio source selection behind the advanced opt-in in every share context', () => {
|
it('leaves the audio source decision to the platform wherever the stored mode cannot reach the capture', () => {
|
||||||
for (const shareContext of ['app', 'device', 'display'] as const) {
|
expect(manualAudioSourcesGovernShare({platform: 'linux', displayShareEnvironment: 'web'})).toBe(false);
|
||||||
expect(
|
for (const platform of ['win32', 'darwin', null, undefined]) {
|
||||||
canSelectManualAudioSources({
|
expect(manualAudioSourcesGovernShare({platform, displayShareEnvironment: 'desktop-custom'})).toBe(false);
|
||||||
platform: 'linux',
|
|
||||||
shareContext,
|
|
||||||
nativeAudioAvailability: LINUX_PIPEWIRE,
|
|
||||||
manualOptIn: true,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
canSelectManualAudioSources({
|
|
||||||
platform: 'linux',
|
|
||||||
shareContext,
|
|
||||||
nativeAudioAvailability: LINUX_PIPEWIRE,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
canSelectManualAudioSources({
|
|
||||||
platform: 'win32',
|
|
||||||
shareContext,
|
|
||||||
nativeAudioAvailability: WINDOWS_WASAPI,
|
|
||||||
manualOptIn: true,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers the window audio scope only to a window share whose window the capture layer can name', () => {
|
||||||
|
expect(supportsWindowShareAudioScope({shareContext: 'app', displayShareEnvironment: 'desktop-custom'})).toBe(true);
|
||||||
|
expect(supportsWindowShareAudioScope({shareContext: 'app', displayShareEnvironment: 'desktop-wayland'})).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(supportsWindowShareAudioScope({shareContext: 'app', displayShareEnvironment: 'web'})).toBe(false);
|
||||||
|
expect(supportsWindowShareAudioScope({shareContext: 'app'})).toBe(false);
|
||||||
|
for (const shareContext of ['device', 'display'] as const) {
|
||||||
|
expect(supportsWindowShareAudioScope({shareContext, displayShareEnvironment: 'desktop-custom'})).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults a window share to its own window and widens only where the user asked for it', () => {
|
||||||
|
expect(resolveWindowShareAudioScope({shareContext: 'app', displayShareEnvironment: 'desktop-custom'})).toBe(
|
||||||
|
'window',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveWindowShareAudioScope({
|
||||||
|
shareContext: 'app',
|
||||||
|
displayShareEnvironment: 'desktop-custom',
|
||||||
|
windowAudioScope: 'window',
|
||||||
|
}),
|
||||||
|
).toBe('window');
|
||||||
|
expect(
|
||||||
|
resolveWindowShareAudioScope({
|
||||||
|
shareContext: 'app',
|
||||||
|
displayShareEnvironment: 'desktop-custom',
|
||||||
|
windowAudioScope: 'system',
|
||||||
|
}),
|
||||||
|
).toBe('system');
|
||||||
|
expect(
|
||||||
|
resolveWindowShareAudioScope({
|
||||||
|
shareContext: 'app',
|
||||||
|
displayShareEnvironment: 'desktop-wayland',
|
||||||
|
windowAudioScope: 'window',
|
||||||
|
}),
|
||||||
|
).toBe('system');
|
||||||
|
for (const shareContext of ['device', 'display'] as const) {
|
||||||
|
expect(
|
||||||
|
resolveWindowShareAudioScope({
|
||||||
|
shareContext,
|
||||||
|
displayShareEnvironment: 'desktop-custom',
|
||||||
|
windowAudioScope: 'window',
|
||||||
|
}),
|
||||||
|
).toBe('system');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a window share on its own window until the user widens the scope', () => {
|
||||||
|
for (const audioSourceMode of ['none', 'system', 'specific'] as const) {
|
||||||
|
for (const selectedSourceCount of [0, 2]) {
|
||||||
|
expect(selectAppShareAudioRoute({audioSourceMode, selectedSourceCount, windowAudioScope: 'window'})).toBe(
|
||||||
|
'window',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(selectAppShareAudioRoute({windowAudioScope: 'window'})).toBe('window');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the stored selection govern a window share only once it is widened to the system', () => {
|
||||||
|
expect(selectAppShareAudioRoute({audioSourceMode: 'system', windowAudioScope: 'system'})).toBe('system');
|
||||||
|
expect(selectAppShareAudioRoute({windowAudioScope: 'system'})).toBe('system');
|
||||||
|
expect(selectAppShareAudioRoute({audioSourceMode: 'none', windowAudioScope: 'system'})).toBe('none');
|
||||||
|
expect(
|
||||||
|
selectAppShareAudioRoute({audioSourceMode: 'specific', selectedSourceCount: 2, windowAudioScope: 'system'}),
|
||||||
|
).toBe('apps');
|
||||||
|
expect(
|
||||||
|
selectAppShareAudioRoute({audioSourceMode: 'specific', selectedSourceCount: 0, windowAudioScope: 'system'}),
|
||||||
|
).toBe('system');
|
||||||
|
});
|
||||||
|
|
||||||
it('routes manual audio sources only once specific sources are selected', () => {
|
it('routes manual audio sources only once specific sources are selected', () => {
|
||||||
const base = {
|
const base = {
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
shareContext: 'device',
|
shareContext: 'device',
|
||||||
nativeAudioAvailability: LINUX_PIPEWIRE,
|
nativeAudioAvailability: LINUX_PIPEWIRE,
|
||||||
manualOptIn: true,
|
|
||||||
} as const;
|
} as const;
|
||||||
expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 1})).toBe(true);
|
expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 1})).toBe(true);
|
||||||
expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 0})).toBe(false);
|
expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 0})).toBe(false);
|
||||||
@@ -180,7 +211,8 @@ describe('StreamSettingsUpdatePolicy', () => {
|
|||||||
expect(
|
expect(
|
||||||
routesManualAudioSources({
|
routesManualAudioSources({
|
||||||
...base,
|
...base,
|
||||||
manualOptIn: false,
|
platform: 'win32',
|
||||||
|
nativeAudioAvailability: WINDOWS_WASAPI,
|
||||||
audioSourceMode: 'specific',
|
audioSourceMode: 'specific',
|
||||||
selectedSourceCount: 2,
|
selectedSourceCount: 2,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {NativeAudioAvailability} from '@app/types/electron.d';
|
|||||||
|
|
||||||
export type StreamSettingsShareContext = 'app' | 'device' | 'display';
|
export type StreamSettingsShareContext = 'app' | 'device' | 'display';
|
||||||
export type ScreenShareAudioSourceMode = 'none' | 'system' | 'specific';
|
export type ScreenShareAudioSourceMode = 'none' | 'system' | 'specific';
|
||||||
|
export type WindowShareAudioScope = 'window' | 'system';
|
||||||
|
export type AppShareAudioRoute = 'none' | 'window' | 'apps' | 'system';
|
||||||
|
|
||||||
export interface StreamSettingsUpdatePolicyInput {
|
export interface StreamSettingsUpdatePolicyInput {
|
||||||
platform?: string | null;
|
platform?: string | null;
|
||||||
@@ -16,16 +18,44 @@ export interface ManualAudioSourceSelectionInput {
|
|||||||
platform?: string | null;
|
platform?: string | null;
|
||||||
shareContext: StreamSettingsShareContext;
|
shareContext: StreamSettingsShareContext;
|
||||||
nativeAudioAvailability?: NativeAudioAvailability | null;
|
nativeAudioAvailability?: NativeAudioAvailability | null;
|
||||||
manualOptIn?: boolean;
|
|
||||||
audioSourceMode?: ScreenShareAudioSourceMode;
|
audioSourceMode?: ScreenShareAudioSourceMode;
|
||||||
selectedSourceCount?: number;
|
selectedSourceCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function manualAudioSourcesGovernShare(input: {
|
export interface WindowShareAudioScopeInput {
|
||||||
shareContext: StreamSettingsShareContext;
|
shareContext: StreamSettingsShareContext;
|
||||||
displayShareEnvironment?: DisplayShareEnvironment;
|
displayShareEnvironment?: DisplayShareEnvironment;
|
||||||
|
windowAudioScope?: WindowShareAudioScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppShareAudioRouteInput {
|
||||||
|
audioSourceMode?: ScreenShareAudioSourceMode;
|
||||||
|
selectedSourceCount?: number;
|
||||||
|
windowAudioScope: WindowShareAudioScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manualAudioSourcesGovernShare(input: {
|
||||||
|
platform?: string | null;
|
||||||
|
displayShareEnvironment?: DisplayShareEnvironment;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return !(input.shareContext === 'app' && input.displayShareEnvironment === 'desktop-custom');
|
return input.platform === 'linux' && input.displayShareEnvironment !== 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsWindowShareAudioScope(input: WindowShareAudioScopeInput): boolean {
|
||||||
|
if (input.shareContext !== 'app') return false;
|
||||||
|
return input.displayShareEnvironment === 'desktop-custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWindowShareAudioScope(input: WindowShareAudioScopeInput): WindowShareAudioScope {
|
||||||
|
if (!supportsWindowShareAudioScope(input)) return 'system';
|
||||||
|
return input.windowAudioScope ?? 'window';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectAppShareAudioRoute(input: AppShareAudioRouteInput): AppShareAudioRoute {
|
||||||
|
if (input.windowAudioScope !== 'system') return 'window';
|
||||||
|
if (input.audioSourceMode === 'none') return 'none';
|
||||||
|
if (input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0) return 'apps';
|
||||||
|
return 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLinuxDesktopAudioShare(
|
export function isLinuxDesktopAudioShare(
|
||||||
@@ -34,7 +64,7 @@ export function isLinuxDesktopAudioShare(
|
|||||||
return input.platform === 'linux' && input.shareContext !== 'device';
|
return input.platform === 'linux' && input.shareContext !== 'device';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsManualScreenShareAudioSourceSelection(
|
export function canSelectManualAudioSources(
|
||||||
input: Pick<ManualAudioSourceSelectionInput, 'platform' | 'nativeAudioAvailability'>,
|
input: Pick<ManualAudioSourceSelectionInput, 'platform' | 'nativeAudioAvailability'>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (input.platform !== 'linux') return false;
|
if (input.platform !== 'linux') return false;
|
||||||
@@ -43,18 +73,6 @@ export function supportsManualScreenShareAudioSourceSelection(
|
|||||||
return availability.available === true && availability.capabilities?.process !== false;
|
return availability.available === true && availability.capabilities?.process !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maySupportManualScreenShareAudioSourceSelection(
|
|
||||||
input: Pick<ManualAudioSourceSelectionInput, 'platform' | 'nativeAudioAvailability'>,
|
|
||||||
): boolean {
|
|
||||||
if (input.platform !== 'linux') return false;
|
|
||||||
if (input.nativeAudioAvailability == null) return true;
|
|
||||||
return supportsManualScreenShareAudioSourceSelection(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canSelectManualAudioSources(input: ManualAudioSourceSelectionInput): boolean {
|
|
||||||
return input.manualOptIn === true && supportsManualScreenShareAudioSourceSelection(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean {
|
export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean {
|
||||||
return (
|
return (
|
||||||
canSelectManualAudioSources(input) && input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0
|
canSelectManualAudioSources(input) && input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user