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';
|
||||
import {UnreadBadgeCustomizationControl} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedExperimentalControls';
|
||||
import {
|
||||
ManualScreenShareAudioSourcesControl,
|
||||
OpenH264Control,
|
||||
ScreenShareAv1OptInControl,
|
||||
ScreenShareCodecControl,
|
||||
@@ -88,7 +87,6 @@ export const DIRECT_CONTROL_ITEM_IDS = new Set([
|
||||
'voice-video-screen-share-av1-opt-in',
|
||||
'voice-video-screen-share-hevc-opt-in',
|
||||
'voice-video-openh264-codec',
|
||||
'voice-video-manual-screen-share-audio-sources',
|
||||
'voice-video-screen-share-preview-behavior',
|
||||
'voice-video-screen-share-encoder-controls',
|
||||
'advanced-unread-badge-customization',
|
||||
@@ -125,7 +123,6 @@ export const COMPACT_SWITCH_CONTROL_ITEM_IDS = new Set([
|
||||
'voice-video-new-device-alerts',
|
||||
'voice-video-connection-volume-controls',
|
||||
'voice-video-openh264-codec',
|
||||
'voice-video-manual-screen-share-audio-sources',
|
||||
'voice-video-screen-share-av1-opt-in',
|
||||
'voice-video-screen-share-hevc-opt-in',
|
||||
'advanced-unread-badge-customization',
|
||||
@@ -286,10 +283,6 @@ export const AdvancedSettingControl = observer(({item}: {item: SearchableSetting
|
||||
return (
|
||||
<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':
|
||||
return (
|
||||
<ScreenSharePreviewBehaviorControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.screen-share-preview-behavior-control" />
|
||||
|
||||
-33
@@ -28,11 +28,8 @@ import {
|
||||
selectAutomaticScreenShareCodec,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
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 {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 {useLingui} from '@lingui/react/macro';
|
||||
import {GearIcon} from '@phosphor-icons/react';
|
||||
@@ -192,11 +189,6 @@ const HEVC_SCREEN_SHARE_OPT_IN_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'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;
|
||||
|
||||
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(() => {
|
||||
const {i18n} = useLingui();
|
||||
const handleChange = useCallback((value: boolean) => {
|
||||
|
||||
-48
@@ -3,8 +3,6 @@
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
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';
|
||||
|
||||
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',
|
||||
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({
|
||||
message: 'Screen share preview behavior',
|
||||
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'],
|
||||
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',
|
||||
tabType: 'voice_video',
|
||||
|
||||
@@ -76,7 +76,6 @@ type VoiceSettingsPatch = Partial<{
|
||||
linuxAudioCaptureIgnoreDevices: boolean;
|
||||
linuxAudioCaptureGranularSelect: boolean;
|
||||
linuxAudioCaptureDeviceSelect: boolean;
|
||||
screenShareManualAudioSourcesOptIn: boolean;
|
||||
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
||||
screenShareAudioIncludeSources: 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 VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
getLinuxAudioSourceDisplayName,
|
||||
filterRoutableLinuxAudioSources,
|
||||
type LinuxAudioSourceFilterOptions,
|
||||
type LinuxAudioSourceItem,
|
||||
linuxAudioSourceItemKey,
|
||||
mapLinuxAudioNodeToItems,
|
||||
uniqueLinuxAudioSourceItems,
|
||||
} 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 {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
const NO_AUDIO_DESCRIPTOR = msg({
|
||||
message: 'No 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',
|
||||
const SHARED_WINDOW_AUDIO_DESCRIPTOR = msg({
|
||||
message: 'Shared window audio',
|
||||
comment:
|
||||
'Summary label in the Linux audio source picker when N apps are included. {length} is the integer app count.',
|
||||
});
|
||||
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.',
|
||||
'Capture option in the Linux audio source picker on a window share. Captures only the audio of the window being shared.',
|
||||
});
|
||||
const AUDIO_SOURCES_DESCRIPTOR = msg({
|
||||
message: 'Audio sources: {summaryLabel}',
|
||||
@@ -129,15 +118,26 @@ async function fetchAudioSources(options: LinuxAudioSourceFilterOptions): Promis
|
||||
}
|
||||
|
||||
interface AudioSourcePickerLinuxSubmenuProps {
|
||||
onSelectionChange?: () => void;
|
||||
onSelectionChange?: (nextWindowAudioScope?: WindowShareAudioScope) => void;
|
||||
shareContext?: StreamSettingsShareContext;
|
||||
displayShareEnvironment?: DisplayShareEnvironment;
|
||||
windowAudioScope?: WindowShareAudioScope;
|
||||
microphoneLabel?: string;
|
||||
}
|
||||
|
||||
export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerLinuxSubmenuProps) => {
|
||||
const {onSelectionChange, shareContext = 'display', microphoneLabel} = props;
|
||||
const {
|
||||
onSelectionChange,
|
||||
shareContext = 'display',
|
||||
displayShareEnvironment,
|
||||
windowAudioScope,
|
||||
microphoneLabel,
|
||||
} = props;
|
||||
const {i18n} = useLingui();
|
||||
const isDeviceShare = shareContext === 'device';
|
||||
const scopeInput = {shareContext, displayShareEnvironment, windowAudioScope};
|
||||
const offersWindowScope = supportsWindowShareAudioScope(scopeInput);
|
||||
const resolvedScope = resolveWindowShareAudioScope(scopeInput);
|
||||
const sourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
||||
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
||||
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
|
||||
@@ -145,6 +145,8 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
|
||||
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
|
||||
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(() => {
|
||||
setSnapshot((prev) => ({...prev, loading: true}));
|
||||
void fetchAudioSources({granular, deviceSelect, ignoreVirtual}).then((next) => {
|
||||
@@ -161,13 +163,16 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
const handlePickWindow = useCallback(() => {
|
||||
onSelectionChange?.('window');
|
||||
}, [onSelectionChange]);
|
||||
const handlePickSystem = useCallback(() => {
|
||||
VoiceSettingsCommands.update({
|
||||
screenShareAudioSourceMode: 'system',
|
||||
screenShareAudioIncludeSources: [],
|
||||
});
|
||||
onSelectionChange?.();
|
||||
}, [onSelectionChange]);
|
||||
onSelectionChange?.(widenedScope);
|
||||
}, [onSelectionChange, widenedScope]);
|
||||
const handlePickNone = useCallback(() => {
|
||||
VoiceSettingsCommands.update({
|
||||
screenShareAudioSourceMode: 'none',
|
||||
@@ -185,9 +190,9 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system',
|
||||
screenShareAudioIncludeSources: nextSources,
|
||||
});
|
||||
onSelectionChange?.();
|
||||
onSelectionChange?.(widenedScope);
|
||||
},
|
||||
[includeSources, onSelectionChange],
|
||||
[includeSources, onSelectionChange, widenedScope],
|
||||
);
|
||||
const handleToggleExcludeApp = useCallback(
|
||||
(item: LinuxAudioSourceItem) => {
|
||||
@@ -202,19 +207,19 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
},
|
||||
[excludeSources, onSelectionChange],
|
||||
);
|
||||
const defaultSourceLabel = isDeviceShare
|
||||
? i18n._(MICROPHONE_WITH_DEVICE_DESCRIPTOR, {deviceLabel: microphoneLabel ?? i18n._(MICROPHONE_DESCRIPTOR)})
|
||||
: i18n._(ENTIRE_SYSTEM_DESCRIPTOR);
|
||||
const summaryLabel =
|
||||
sourceMode === 'none'
|
||||
? i18n._(NO_AUDIO_DESCRIPTOR)
|
||||
: sourceMode === 'specific'
|
||||
? includeSources.length === 1
|
||||
? (getLinuxAudioSourceDisplayName(includeSources[0]) ?? i18n._(CUSTOM_DESCRIPTOR))
|
||||
: i18n._(APPS_DESCRIPTOR, {length: includeSources.length})
|
||||
: isDeviceShare
|
||||
? i18n._(MICROPHONE_DESCRIPTOR)
|
||||
: i18n._(ENTIRE_SYSTEM_DESCRIPTOR);
|
||||
const deviceSourceLabel = i18n._(MICROPHONE_WITH_DEVICE_DESCRIPTOR, {
|
||||
deviceLabel: microphoneLabel ?? i18n._(MICROPHONE_DESCRIPTOR),
|
||||
});
|
||||
const summaryLabel = formatScreenShareAudioSummary(i18n, {
|
||||
sourceMode,
|
||||
includeSources,
|
||||
shareContext,
|
||||
microphoneLabel,
|
||||
displayShareEnvironment,
|
||||
windowAudioScope,
|
||||
});
|
||||
const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system';
|
||||
const wideSourceIsSelected = isDeviceShare ? !routesSelectedSources : sourceMode === 'system';
|
||||
if (!snapshot.available && !snapshot.loading) {
|
||||
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">
|
||||
{i18n._(CAPTURE_DESCRIPTOR)}
|
||||
</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
|
||||
selected={sourceMode === 'system'}
|
||||
selected={showsWideSourceLists && wideSourceIsSelected}
|
||||
onSelect={handlePickSystem}
|
||||
data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.menu-item-radio.pick-system"
|
||||
>
|
||||
{isDeviceShare ? defaultSourceLabel : <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>
|
||||
{isDeviceShare ? deviceSourceLabel : <Trans>Entire system audio</Trans>}
|
||||
</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>
|
||||
{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">
|
||||
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.include-apps">
|
||||
{i18n._(INCLUDE_APPS_DESCRIPTOR)}
|
||||
@@ -259,7 +275,7 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
))}
|
||||
</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">
|
||||
<MenuGroupLabel data-flx="voice.audio-source-picker-linux.audio-source-picker-linux-submenu.group-label.exclude-from-system">
|
||||
{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 {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
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 {resolveScreenShareContentHintForContext} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||
import {
|
||||
getNativeAudioAvailabilityCached,
|
||||
getNativeAudioAvailabilitySnapshot,
|
||||
@@ -42,7 +44,9 @@ import {
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {isScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||
import {
|
||||
applyLiveScreenShareAudioSourceChange,
|
||||
reconfigureActiveDeviceShareAudio,
|
||||
reconfigureActiveLinuxAppShareAudio,
|
||||
reconfigureActiveLinuxScreenShareAudioLink,
|
||||
stopActiveLinuxScreenShareAudioLink,
|
||||
} from '@app/features/voice/utils/ScreenShareStartFlow';
|
||||
@@ -51,6 +55,7 @@ import {
|
||||
isLinuxDesktopAudioShare,
|
||||
type StreamSettingsShareContext,
|
||||
shouldReconfigureAudioForActiveStreamSettings,
|
||||
type WindowShareAudioScope,
|
||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||
import {hasHigherVideoQuality as resolveHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import {formatVoiceAudioDeviceLabel} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
||||
@@ -149,6 +154,11 @@ const SHARE_STREAM_AUDIO_DESCRIPTOR = msg({
|
||||
message: 'Share stream audio',
|
||||
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 SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source;
|
||||
|
||||
@@ -291,6 +301,8 @@ export async function pushActiveStreamSettings(
|
||||
audioLinkUpdated = await stopActiveLinuxScreenShareAudioLink();
|
||||
} else if (shareContext === 'device') {
|
||||
audioLinkUpdated = await reconfigureActiveDeviceShareAudio();
|
||||
} else if (shareContext === 'app') {
|
||||
audioLinkUpdated = await reconfigureActiveLinuxAppShareAudio();
|
||||
} else {
|
||||
audioLinkUpdated = await reconfigureActiveLinuxScreenShareAudioLink();
|
||||
}
|
||||
@@ -374,9 +386,13 @@ export const StreamSettingsMenuContent = observer(
|
||||
};
|
||||
}, []);
|
||||
const platform = getElectronAPI()?.platform;
|
||||
const manualAudioSourcesOptIn = VoiceSettings.getScreenShareManualAudioSourcesOptIn();
|
||||
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(
|
||||
() =>
|
||||
selectStreamSettingsAudioMenuState({
|
||||
@@ -388,9 +404,9 @@ export const StreamSettingsMenuContent = observer(
|
||||
hasLiveScreenShareAudioPublication,
|
||||
nativeAudioAvailability,
|
||||
platform,
|
||||
manualAudioSourcesOptIn,
|
||||
audioSourceMode,
|
||||
selectedAudioSourceCount,
|
||||
windowAudioScope,
|
||||
}),
|
||||
[
|
||||
applyToLiveStream,
|
||||
@@ -398,12 +414,12 @@ export const StreamSettingsMenuContent = observer(
|
||||
captureAudioEnabled,
|
||||
displayShareEnvironment,
|
||||
hasLiveScreenShareAudioPublication,
|
||||
manualAudioSourcesOptIn,
|
||||
nativeAudioAvailability,
|
||||
platform,
|
||||
selectedAudioSourceCount,
|
||||
shareContext,
|
||||
supportsStreamAudio,
|
||||
windowAudioScope,
|
||||
],
|
||||
);
|
||||
const modeOptions: Array<Option<StreamingMode>> = useMemo(() => {
|
||||
@@ -474,14 +490,28 @@ export const StreamSettingsMenuContent = observer(
|
||||
},
|
||||
[applyToLiveStream, displayShareEnvironment, hasHigherVideoQuality, shareContext],
|
||||
);
|
||||
const applyAudioSourceChange = useCallback(() => {
|
||||
if (!applyToLiveStream) return;
|
||||
const applied =
|
||||
shareContext === 'device' ? reconfigureActiveDeviceShareAudio() : reconfigureActiveLinuxScreenShareAudioLink();
|
||||
void applied.catch((error) => {
|
||||
logger.warn('Failed to apply the screen share audio source change', error);
|
||||
});
|
||||
}, [applyToLiveStream, shareContext]);
|
||||
const applyAudioSourceChange = useCallback(
|
||||
(nextWindowAudioScope?: WindowShareAudioScope) => {
|
||||
if (!applyToLiveStream) {
|
||||
if (nextWindowAudioScope != null) {
|
||||
ActiveScreenShareSource.setPendingWindowAudioScope(nextWindowAudioScope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
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(
|
||||
(option: Option<StreamingMode>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
@@ -673,6 +703,8 @@ export const StreamSettingsMenuContent = observer(
|
||||
<StreamSettingsAudioGroup
|
||||
audioMenuState={audioMenuState}
|
||||
shareContext={shareContext}
|
||||
displayShareEnvironment={displayShareEnvironment}
|
||||
windowAudioScope={windowAudioScope}
|
||||
compact={true}
|
||||
audioDeviceOptions={audioDeviceOptions}
|
||||
currentAudioDeviceId={currentAudioDeviceId}
|
||||
@@ -787,6 +819,8 @@ export const StreamSettingsMenuContent = observer(
|
||||
<StreamSettingsAudioGroup
|
||||
audioMenuState={audioMenuState}
|
||||
shareContext={shareContext}
|
||||
displayShareEnvironment={displayShareEnvironment}
|
||||
windowAudioScope={windowAudioScope}
|
||||
compact={false}
|
||||
audioDeviceOptions={audioDeviceOptions}
|
||||
currentAudioDeviceId={currentAudioDeviceId}
|
||||
@@ -814,12 +848,14 @@ StreamSettingsMenuContent.displayName = 'StreamSettingsMenuContent';
|
||||
interface StreamSettingsAudioGroupProps {
|
||||
audioMenuState: StreamSettingsAudioMenuViewState;
|
||||
shareContext: StreamSettingsShareContext;
|
||||
displayShareEnvironment: DisplayShareEnvironment;
|
||||
windowAudioScope: WindowShareAudioScope;
|
||||
compact: boolean;
|
||||
audioDeviceOptions: Array<MediaDeviceInfo>;
|
||||
currentAudioDeviceId: string;
|
||||
selectedAudioDeviceLabel: string;
|
||||
onCaptureAudioToggle: (checked: boolean) => void;
|
||||
onAudioSourceChange: () => void;
|
||||
onAudioSourceChange: (nextWindowAudioScope?: WindowShareAudioScope) => void;
|
||||
onAudioDeviceSelect: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
@@ -827,6 +863,8 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
||||
const {
|
||||
audioMenuState,
|
||||
shareContext,
|
||||
displayShareEnvironment,
|
||||
windowAudioScope,
|
||||
compact,
|
||||
audioDeviceOptions,
|
||||
currentAudioDeviceId,
|
||||
@@ -840,6 +878,7 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
||||
if (compact) return i18n._(SHARE_STREAM_AUDIO_DESCRIPTOR);
|
||||
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 === 'captureSystemAudio') return i18n._(CAPTURE_ENTIRE_SYSTEM_AUDIO_DESCRIPTOR);
|
||||
return <Trans>Capture desktop audio</Trans>;
|
||||
};
|
||||
return (
|
||||
@@ -858,6 +897,8 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
||||
<AudioSourcePickerLinuxSubmenu
|
||||
onSelectionChange={onAudioSourceChange}
|
||||
shareContext={shareContext}
|
||||
displayShareEnvironment={displayShareEnvironment}
|
||||
windowAudioScope={windowAudioScope}
|
||||
microphoneLabel={selectedAudioDeviceLabel}
|
||||
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');
|
||||
});
|
||||
|
||||
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) {
|
||||
const displayShareEnvironment = shareContext === 'app' ? 'desktop-wayland' : 'desktop-custom';
|
||||
expect(
|
||||
selectStreamSettingsAudioMenuState(
|
||||
signals({
|
||||
shareContext,
|
||||
displayShareEnvironment,
|
||||
displayShareEnvironment: 'web',
|
||||
platform: 'linux',
|
||||
captureAudioEnabled: true,
|
||||
nativeAudioAvailability: availableNativeAudio(),
|
||||
}),
|
||||
).showManualAudioSources,
|
||||
).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,
|
||||
captureAudioEnabled: true,
|
||||
nativeAudioAvailability: availableNativeAudio(),
|
||||
manualAudioSourcesOptIn: true,
|
||||
}),
|
||||
).showManualAudioSources,
|
||||
).toBe(false);
|
||||
@@ -181,7 +185,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
||||
platform: null,
|
||||
captureAudioEnabled: true,
|
||||
nativeAudioAvailability: availableNativeAudio(),
|
||||
manualAudioSourcesOptIn: true,
|
||||
}),
|
||||
).showManualAudioSources,
|
||||
).toBe(false);
|
||||
@@ -191,7 +194,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
||||
platform: 'linux',
|
||||
captureAudioEnabled: true,
|
||||
nativeAudioAvailability: availableNativeAudio({available: false, reason: 'no-pipewire'}),
|
||||
manualAudioSourcesOptIn: true,
|
||||
}),
|
||||
).showManualAudioSources,
|
||||
).toBe(false);
|
||||
@@ -201,7 +203,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
||||
platform: 'linux',
|
||||
captureAudioEnabled: true,
|
||||
nativeAudioAvailability: null,
|
||||
manualAudioSourcesOptIn: true,
|
||||
}),
|
||||
).showManualAudioSources,
|
||||
).toBe(false);
|
||||
@@ -214,7 +215,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
||||
platform: 'linux',
|
||||
captureAudioEnabled: false,
|
||||
nativeAudioAvailability: availableNativeAudio(),
|
||||
manualAudioSourcesOptIn: true,
|
||||
}),
|
||||
).showManualAudioSources,
|
||||
).toBe(false);
|
||||
@@ -225,12 +225,76 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
||||
supportsStreamAudio: false,
|
||||
captureAudioEnabled: true,
|
||||
nativeAudioAvailability: availableNativeAudio(),
|
||||
manualAudioSourcesOptIn: true,
|
||||
}),
|
||||
).showManualAudioSources,
|
||||
).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', () => {
|
||||
const routed = selectStreamSettingsAudioMenuState(
|
||||
signals({
|
||||
@@ -238,7 +302,6 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
||||
platform: 'linux',
|
||||
captureAudioEnabled: true,
|
||||
nativeAudioAvailability: availableNativeAudio(),
|
||||
manualAudioSourcesOptIn: true,
|
||||
audioSourceMode: 'specific',
|
||||
selectedAudioSourceCount: 2,
|
||||
}),
|
||||
@@ -247,7 +310,7 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
||||
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 = [
|
||||
{platform: 'linux', nativeAudioAvailability: availableNativeAudio(), manualCapable: true},
|
||||
{platform: 'win32', nativeAudioAvailability: availableNativeAudio(), manualCapable: false},
|
||||
@@ -262,57 +325,35 @@ describe('StreamSettingsMenuContentStateMachine', () => {
|
||||
|
||||
for (const {platform, nativeAudioAvailability, manualCapable} of platforms) {
|
||||
for (const shareContext of ['app', 'device', 'display'] as const) {
|
||||
for (const manualAudioSourcesOptIn of [false, true]) {
|
||||
const state = selectStreamSettingsAudioMenuState(
|
||||
signals({
|
||||
shareContext,
|
||||
platform,
|
||||
nativeAudioAvailability,
|
||||
captureAudioEnabled: true,
|
||||
manualAudioSourcesOptIn,
|
||||
}),
|
||||
);
|
||||
const state = selectStreamSettingsAudioMenuState(
|
||||
signals({
|
||||
shareContext,
|
||||
platform,
|
||||
nativeAudioAvailability,
|
||||
captureAudioEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.control).toMatchObject({
|
||||
value: 'toggle',
|
||||
checked: true,
|
||||
labelKey: expectedLabelKey[shareContext],
|
||||
});
|
||||
expect(state.showManualAudioSources).toBe(manualCapable && manualAudioSourcesOptIn && shareContext !== 'app');
|
||||
expect(state.showDeviceAudioMenu).toBe(shareContext === 'device');
|
||||
}
|
||||
expect(state.control).toMatchObject({
|
||||
value: 'toggle',
|
||||
checked: true,
|
||||
labelKey: expectedLabelKey[shareContext],
|
||||
});
|
||||
expect(state.showManualAudioSources).toBe(manualCapable);
|
||||
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', () => {
|
||||
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 = {
|
||||
shareContext,
|
||||
platform: 'linux',
|
||||
nativeAudioAvailability: availableNativeAudio(),
|
||||
captureAudioEnabled: true,
|
||||
manualAudioSourcesOptIn,
|
||||
windowAudioScope,
|
||||
};
|
||||
const prestart = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: false}));
|
||||
const live = selectStreamSettingsAudioMenuState(signals({...common, applyToLiveStream: true}));
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
import {
|
||||
canSelectManualAudioSources,
|
||||
manualAudioSourcesGovernShare,
|
||||
resolveWindowShareAudioScope,
|
||||
type ScreenShareAudioSourceMode,
|
||||
type StreamSettingsShareContext,
|
||||
selectAppShareAudioRoute,
|
||||
type WindowShareAudioScope,
|
||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||
import type {NativeAudioAvailability} from '@app/types/electron.d';
|
||||
import {getInitialSnapshot, setup, transition} from 'xstate';
|
||||
@@ -20,7 +23,11 @@ export type StreamSettingsAudioControlStateValue =
|
||||
| 'prestartNativePickerOwned'
|
||||
| 'restartRequired'
|
||||
| 'toggle';
|
||||
export type StreamSettingsAudioControlLabelKey = 'captureAppAudio' | 'captureDesktopAudio' | 'captureDeviceAudio';
|
||||
export type StreamSettingsAudioControlLabelKey =
|
||||
| 'captureAppAudio'
|
||||
| 'captureDesktopAudio'
|
||||
| 'captureDeviceAudio'
|
||||
| 'captureSystemAudio';
|
||||
type StreamSettingsNativeAudioUnsupportedScope = 'process' | 'system';
|
||||
|
||||
export interface StreamSettingsNativeAudioSignals {
|
||||
@@ -35,9 +42,9 @@ export interface StreamSettingsAudioControlSignals extends StreamSettingsNativeA
|
||||
supportsStreamAudio: boolean;
|
||||
captureAudioEnabled: boolean;
|
||||
hasLiveScreenShareAudioPublication: boolean;
|
||||
manualAudioSourcesOptIn?: boolean;
|
||||
audioSourceMode?: ScreenShareAudioSourceMode;
|
||||
selectedAudioSourceCount?: number;
|
||||
windowAudioScope?: WindowShareAudioScope;
|
||||
}
|
||||
|
||||
export interface StreamSettingsAudioControlViewState {
|
||||
@@ -148,10 +155,15 @@ export function selectStreamSettingsAudioControlState(
|
||||
return typeof snapshot.value === 'string' ? (snapshot.value as StreamSettingsAudioControlStateValue) : 'hidden';
|
||||
}
|
||||
|
||||
function selectAudioControlLabelKey(shareContext: StreamSettingsShareContext): StreamSettingsAudioControlLabelKey {
|
||||
if (shareContext === 'device') return 'captureDeviceAudio';
|
||||
if (shareContext === 'app') return 'captureAppAudio';
|
||||
return 'captureDesktopAudio';
|
||||
function selectAudioControlLabelKey(signals: StreamSettingsAudioControlSignals): StreamSettingsAudioControlLabelKey {
|
||||
if (signals.shareContext === 'device') return 'captureDeviceAudio';
|
||||
if (signals.shareContext !== 'app') return 'captureDesktopAudio';
|
||||
const route = selectAppShareAudioRoute({
|
||||
audioSourceMode: signals.audioSourceMode,
|
||||
selectedSourceCount: signals.selectedAudioSourceCount,
|
||||
windowAudioScope: resolveWindowShareAudioScope(signals),
|
||||
});
|
||||
return route === 'system' ? 'captureSystemAudio' : 'captureAppAudio';
|
||||
}
|
||||
|
||||
export function selectStreamSettingsAudioMenuState(
|
||||
@@ -162,7 +174,7 @@ export function selectStreamSettingsAudioMenuState(
|
||||
control: {
|
||||
value,
|
||||
checked: signals.captureAudioEnabled,
|
||||
labelKey: selectAudioControlLabelKey(signals.shareContext),
|
||||
labelKey: selectAudioControlLabelKey(signals),
|
||||
},
|
||||
showManualAudioSources:
|
||||
signals.supportsStreamAudio &&
|
||||
@@ -170,11 +182,7 @@ export function selectStreamSettingsAudioMenuState(
|
||||
manualAudioSourcesGovernShare(signals) &&
|
||||
canSelectManualAudioSources({
|
||||
platform: signals.platform,
|
||||
shareContext: signals.shareContext,
|
||||
nativeAudioAvailability: signals.nativeAudioAvailability,
|
||||
manualOptIn: signals.manualAudioSourcesOptIn,
|
||||
audioSourceMode: signals.audioSourceMode,
|
||||
selectedSourceCount: signals.selectedAudioSourceCount,
|
||||
}),
|
||||
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 {selectVoiceEngineV2AppConnection} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import VoiceSettings, {
|
||||
type LastScreenShareSource,
|
||||
type LastScreenShareSourceKind,
|
||||
type ScreenshareResolution,
|
||||
type StreamingMode,
|
||||
} from '@app/features/voice/state/VoiceSettings';
|
||||
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||
import {getNativeAudioAvailabilityCached} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {isScreenShareAudioCaptureError} from '@app/features/voice/utils/ScreenShareAudioCaptureError';
|
||||
import {formatScreenShareAudioSummary} from '@app/features/voice/utils/ScreenShareAudioSummary';
|
||||
import {
|
||||
getDisplayShareEnvironment,
|
||||
shouldShowDesktopDownloadCta,
|
||||
@@ -92,6 +95,7 @@ import {
|
||||
switchConfiguredDeviceScreenShare,
|
||||
switchConfiguredDisplayScreenShare,
|
||||
} from '@app/features/voice/utils/ScreenShareStartFlow';
|
||||
import {manualAudioSourcesGovernShare} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||
import {
|
||||
formatFallbackCameraLabel,
|
||||
formatVoiceAudioDeviceLabel,
|
||||
@@ -1039,6 +1043,7 @@ const ScreenSharePickerModalLoadedContent = observer(
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => () => ActiveScreenShareSource.clearPendingWindowAudioScope(), []);
|
||||
const platform = getElectronAPI()?.platform;
|
||||
const displayPermission = useScreenSharePickerDisplayPermission({
|
||||
activeTab,
|
||||
@@ -1453,6 +1458,10 @@ const ScreenSharePickerModalLoadedContent = observer(
|
||||
: activeTab === 'apps'
|
||||
? VoiceSettings.getShareAppAudio()
|
||||
: VoiceSettings.getShareDesktopAudio();
|
||||
const audioSourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
||||
const audioIncludeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
||||
const routableAudioSourceCount = filterRoutableLinuxAudioSources(audioIncludeSources).length;
|
||||
const windowAudioScope = ActiveScreenShareSource.getPendingWindowAudioScope();
|
||||
const audioMenuState = selectStreamSettingsAudioMenuState({
|
||||
applyToLiveStream: false,
|
||||
shareContext,
|
||||
@@ -1465,18 +1474,28 @@ const ScreenSharePickerModalLoadedContent = observer(
|
||||
hasLiveScreenShareAudioPublication: false,
|
||||
nativeAudioAvailability,
|
||||
platform,
|
||||
audioSourceMode,
|
||||
selectedAudioSourceCount: routableAudioSourceCount,
|
||||
windowAudioScope,
|
||||
});
|
||||
const captureAudioEnabled = audioMenuState.control.value === 'toggle' && audioMenuState.control.checked;
|
||||
const configuredAudioDeviceId = VoiceSettings.getEffectiveScreenShareAudioDeviceId();
|
||||
const selectedAudioDevice = inputDevices.find((device) => device.deviceId === configuredAudioDeviceId);
|
||||
const audioSummary =
|
||||
activeTab !== 'devices'
|
||||
? null
|
||||
: captureAudioEnabled
|
||||
? selectedAudioDevice
|
||||
? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR))
|
||||
: i18n._(SYSTEM_DEFAULT_DESCRIPTOR)
|
||||
: i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR);
|
||||
const microphoneLabel = selectedAudioDevice
|
||||
? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR))
|
||||
: i18n._(SYSTEM_DEFAULT_DESCRIPTOR);
|
||||
const audioSummary = captureAudioEnabled
|
||||
? formatScreenShareAudioSummary(i18n, {
|
||||
sourceMode: manualAudioSourcesGovernShare({platform, displayShareEnvironment}) ? audioSourceMode : 'system',
|
||||
includeSources: audioIncludeSources,
|
||||
shareContext,
|
||||
microphoneLabel,
|
||||
displayShareEnvironment,
|
||||
windowAudioScope,
|
||||
})
|
||||
: activeTab === 'devices'
|
||||
? i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR)
|
||||
: null;
|
||||
const streamSummaryDetails = [
|
||||
activeTab === 'devices' ? deviceSelectionError : null,
|
||||
streamSummaryDescription,
|
||||
|
||||
@@ -2229,6 +2229,10 @@ class MediaEngineFacade extends Store {
|
||||
);
|
||||
}
|
||||
|
||||
async ensureWindowScreenShareAudioPublication(sourceId: string): Promise<boolean> {
|
||||
return voiceEngineV2AppScreenShareExecutionAdapter.ensureWindowScreenShareAudioPublication(this.room, sourceId);
|
||||
}
|
||||
|
||||
getActiveScreenShareVideoDeviceId(): string {
|
||||
return voiceEngineV2AppScreenShareExecutionAdapter.getActiveScreenShareVideoDeviceId(this.room);
|
||||
}
|
||||
|
||||
+31
@@ -73,6 +73,7 @@ import {
|
||||
import {disarmVirtmic} from '@app/features/voice/utils/LinuxScreenShareAudio';
|
||||
import {
|
||||
captureNativeAudioTrackForLinuxRouting,
|
||||
captureNativeAudioTrackForWindowPid,
|
||||
commitNativeAudioBridgeReplacement,
|
||||
disarmNativeAudio,
|
||||
reconfigureLinuxNativeAudioRouting,
|
||||
@@ -785,6 +786,36 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
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 {
|
||||
const publication = room?.localParticipant?.getTrackPublication(Track.Source.ScreenShare);
|
||||
return publication?.videoTrack?.mediaStreamTrack.getSettings().deviceId ?? '';
|
||||
|
||||
@@ -24,4 +24,43 @@ describe('ActiveScreenShareSource', () => {
|
||||
expect(ActiveScreenShareSource.isOwnWindow()).toBe(false);
|
||||
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
|
||||
|
||||
import type {StreamSettingsShareContext} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||
import type {
|
||||
StreamSettingsShareContext,
|
||||
WindowShareAudioScope,
|
||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
|
||||
export interface ActiveScreenShareSourceOptions {
|
||||
@@ -13,6 +16,8 @@ class ActiveScreenShareSource {
|
||||
sourceId: string | null = null;
|
||||
ownWindow = false;
|
||||
publishedSource: PublishedScreenShareSource | null = null;
|
||||
windowAudioScope: WindowShareAudioScope = 'window';
|
||||
pendingWindowAudioScope: WindowShareAudioScope | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -40,6 +45,26 @@ class ActiveScreenShareSource {
|
||||
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 {
|
||||
if (this.publishedSource === 'app') return 'app';
|
||||
if (this.publishedSource === 'device') return 'device';
|
||||
@@ -51,6 +76,8 @@ class ActiveScreenShareSource {
|
||||
this.sourceId = null;
|
||||
this.ownWindow = false;
|
||||
this.publishedSource = null;
|
||||
this.windowAudioScope = 'window';
|
||||
this.pendingWindowAudioScope = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,6 @@ type VoiceSettingsUpdate = Partial<{
|
||||
linuxAudioCaptureIgnoreDevices: boolean;
|
||||
linuxAudioCaptureGranularSelect: boolean;
|
||||
linuxAudioCaptureDeviceSelect: boolean;
|
||||
screenShareManualAudioSourcesOptIn: boolean;
|
||||
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
||||
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
||||
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
||||
@@ -236,35 +235,16 @@ function applyScreenShareAudioDefaultOnMigrationV1(parsed: Record<string, unknow
|
||||
return true;
|
||||
}
|
||||
|
||||
const MANUAL_AUDIO_SOURCE_TUNING_DEFAULTS: Record<string, boolean> = {
|
||||
linuxAudioCaptureOnlySpeakers: 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) {
|
||||
export function applyManualAudioSourcesOptOutResetMigrationV1(parsed: Record<string, unknown>): boolean {
|
||||
if (parsed.manualAudioSourcesOptOutResetMigratedV1 === true) {
|
||||
return false;
|
||||
}
|
||||
const storedMode = parsed.screenShareAudioSourceMode;
|
||||
const includeSources = parsed.screenShareAudioIncludeSources;
|
||||
const excludeSources = parsed.screenShareAudioExcludeSources;
|
||||
const hadManualSelection =
|
||||
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;
|
||||
if (parsed.screenShareManualAudioSourcesOptIn === false) {
|
||||
parsed.screenShareAudioSourceMode = 'system';
|
||||
parsed.screenShareAudioIncludeSources = [];
|
||||
parsed.screenShareAudioExcludeSources = [];
|
||||
}
|
||||
parsed.manualScreenShareAudioSourcesOptInMigratedV1 = true;
|
||||
parsed.manualAudioSourcesOptOutResetMigratedV1 = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -450,8 +430,7 @@ class VoiceSettings {
|
||||
linuxAudioCaptureIgnoreDevices = true;
|
||||
linuxAudioCaptureGranularSelect = false;
|
||||
linuxAudioCaptureDeviceSelect = false;
|
||||
screenShareManualAudioSourcesOptIn = false;
|
||||
manualScreenShareAudioSourcesOptInMigratedV1 = false;
|
||||
manualAudioSourcesOptOutResetMigratedV1 = false;
|
||||
screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system';
|
||||
screenShareAudioIncludeSources: Array<Record<string, string>> = [];
|
||||
screenShareAudioExcludeSources: Array<Record<string, string>> = [];
|
||||
@@ -525,7 +504,6 @@ class VoiceSettings {
|
||||
getLinuxAudioCaptureIgnoreDevices: false,
|
||||
getLinuxAudioCaptureGranularSelect: false,
|
||||
getLinuxAudioCaptureDeviceSelect: false,
|
||||
getScreenShareManualAudioSourcesOptIn: false,
|
||||
getEffectiveScreenShareAudioSourceMode: false,
|
||||
getEffectiveScreenShareAudioIncludeSources: false,
|
||||
getEffectiveScreenShareAudioExcludeSources: false,
|
||||
@@ -556,7 +534,7 @@ class VoiceSettings {
|
||||
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
|
||||
changed = applyScreenShareAudioDefaultOnMigrationV1(parsed) || changed;
|
||||
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
|
||||
changed = applyManualAudioSourcesOptInMigrationV1(parsed) || changed;
|
||||
changed = applyManualAudioSourcesOptOutResetMigrationV1(parsed) || changed;
|
||||
changed = applyScreenShareContentHintDefaultMigrationV1(parsed) || changed;
|
||||
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
|
||||
changed = applyNoiseSuppressionStandardDefaultMigrationV1(parsed) || changed;
|
||||
@@ -569,6 +547,7 @@ class VoiceSettings {
|
||||
this.outputVolumeRecalibratedV1 = parsed.outputVolumeRecalibratedV1 === true;
|
||||
this.screenShareAv1OptOutMigratedV1 = parsed.screenShareAv1OptOutMigratedV1 === true;
|
||||
this.screenShareHevcOptOutMigratedV1 = parsed.screenShareHevcOptOutMigratedV1 === true;
|
||||
this.manualAudioSourcesOptOutResetMigratedV1 = parsed.manualAudioSourcesOptOutResetMigratedV1 === true;
|
||||
} catch (error) {
|
||||
logger.warn('Failed to migrate persisted voice settings:', error);
|
||||
}
|
||||
@@ -640,8 +619,7 @@ class VoiceSettings {
|
||||
'linuxAudioCaptureIgnoreDevices',
|
||||
'linuxAudioCaptureGranularSelect',
|
||||
'linuxAudioCaptureDeviceSelect',
|
||||
'screenShareManualAudioSourcesOptIn',
|
||||
'manualScreenShareAudioSourcesOptInMigratedV1',
|
||||
'manualAudioSourcesOptOutResetMigratedV1',
|
||||
'screenShareAudioSourceMode',
|
||||
'screenShareAudioIncludeSources',
|
||||
'screenShareAudioExcludeSources',
|
||||
@@ -1018,10 +996,6 @@ class VoiceSettings {
|
||||
return this.linuxAudioCaptureDeviceSelect;
|
||||
}
|
||||
|
||||
getScreenShareManualAudioSourcesOptIn(): boolean {
|
||||
return this.screenShareManualAudioSourcesOptIn;
|
||||
}
|
||||
|
||||
getScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
|
||||
return this.screenShareAudioSourceMode;
|
||||
}
|
||||
@@ -1035,15 +1009,15 @@ class VoiceSettings {
|
||||
}
|
||||
|
||||
getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
|
||||
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioSourceMode : 'system';
|
||||
return this.getScreenShareAudioSourceMode();
|
||||
}
|
||||
|
||||
getEffectiveScreenShareAudioIncludeSources(): Array<Record<string, string>> {
|
||||
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioIncludeSources : [];
|
||||
return this.getScreenShareAudioIncludeSources();
|
||||
}
|
||||
|
||||
getEffectiveScreenShareAudioExcludeSources(): Array<Record<string, string>> {
|
||||
return this.screenShareManualAudioSourcesOptIn ? this.screenShareAudioExcludeSources : [];
|
||||
return this.getScreenShareAudioExcludeSources();
|
||||
}
|
||||
|
||||
getOpenH264Enabled(): boolean {
|
||||
@@ -1146,8 +1120,6 @@ class VoiceSettings {
|
||||
this.linuxAudioCaptureGranularSelect = validated.linuxAudioCaptureGranularSelect;
|
||||
if (validated.linuxAudioCaptureDeviceSelect !== undefined)
|
||||
this.linuxAudioCaptureDeviceSelect = validated.linuxAudioCaptureDeviceSelect;
|
||||
if (validated.screenShareManualAudioSourcesOptIn !== undefined)
|
||||
this.screenShareManualAudioSourcesOptIn = validated.screenShareManualAudioSourcesOptIn;
|
||||
if (validated.screenShareAudioSourceMode !== undefined)
|
||||
this.screenShareAudioSourceMode = validated.screenShareAudioSourceMode;
|
||||
if (validated.screenShareAudioIncludeSources !== undefined)
|
||||
@@ -1278,8 +1250,6 @@ class VoiceSettings {
|
||||
linuxAudioCaptureIgnoreDevices: data.linuxAudioCaptureIgnoreDevices ?? this.linuxAudioCaptureIgnoreDevices,
|
||||
linuxAudioCaptureGranularSelect: data.linuxAudioCaptureGranularSelect ?? this.linuxAudioCaptureGranularSelect,
|
||||
linuxAudioCaptureDeviceSelect: data.linuxAudioCaptureDeviceSelect ?? this.linuxAudioCaptureDeviceSelect,
|
||||
screenShareManualAudioSourcesOptIn:
|
||||
data.screenShareManualAudioSourcesOptIn ?? this.screenShareManualAudioSourcesOptIn,
|
||||
screenShareAudioSourceMode: validateAudioSourceMode(
|
||||
data.screenShareAudioSourceMode ?? this.screenShareAudioSourceMode,
|
||||
),
|
||||
|
||||
@@ -24,6 +24,12 @@ AppStorage.setItem(
|
||||
preferredScreenShareCodec: 'av1',
|
||||
screenShareContentHintPrefV2: 'auto',
|
||||
screenshareResolution: 'low_240p',
|
||||
inputDeviceId: 'headset-1',
|
||||
screenShareAudioSourceMode: 'specific',
|
||||
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
||||
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
|
||||
screenShareManualAudioSourcesOptIn: true,
|
||||
manualScreenShareAudioSourcesOptInMigratedV1: true,
|
||||
__mps__: {version: 1},
|
||||
}),
|
||||
);
|
||||
@@ -68,7 +74,7 @@ async function loadVoiceSettings() {
|
||||
}
|
||||
|
||||
const VoiceSettings = await loadVoiceSettings();
|
||||
const {applyManualAudioSourcesOptInMigrationV1} = await import('./VoiceSettings');
|
||||
const {applyManualAudioSourcesOptOutResetMigrationV1} = await import('./VoiceSettings');
|
||||
|
||||
describe('AV1/HEVC screen-share opt-in', () => {
|
||||
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', () => {
|
||||
it('defaults to off and is never stored by an unrelated patch', () => {
|
||||
expect(VoiceSettings.screenShareManualAudioSourcesOptIn).toBe(false);
|
||||
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(false);
|
||||
VoiceSettings.updateSettings({outputDeviceId: 'default'});
|
||||
expect(VoiceSettings.getScreenShareManualAudioSourcesOptIn()).toBe(false);
|
||||
describe('manual screen-share audio sources', () => {
|
||||
it('hydrates a profile that still stores the removed opt-in keys without losing anything else', () => {
|
||||
const stored = JSON.parse(storageWrites[0]);
|
||||
expect(stored.screenShareManualAudioSourcesOptIn).toBe(true);
|
||||
expect(stored.manualScreenShareAudioSourcesOptInMigratedV1).toBe(true);
|
||||
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({
|
||||
screenShareAudioSourceMode: 'specific',
|
||||
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
||||
screenShareAudioExcludeSources: [{'application.name': 'Fluxer'}],
|
||||
});
|
||||
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.getEffectiveScreenShareAudioIncludeSources()).toEqual([{'application.name': 'mpv'}]);
|
||||
expect(VoiceSettings.getEffectiveScreenShareAudioExcludeSources()).toEqual([{'application.name': 'Fluxer'}]);
|
||||
|
||||
VoiceSettings.updateSettings({screenShareAudioSourceMode: 'none'});
|
||||
expect(VoiceSettings.getEffectiveScreenShareAudioSourceMode()).toBe('none');
|
||||
|
||||
VoiceSettings.updateSettings({
|
||||
screenShareManualAudioSourcesOptIn: false,
|
||||
screenShareAudioSourceMode: 'none',
|
||||
screenShareAudioSourceMode: 'system',
|
||||
screenShareAudioIncludeSources: [],
|
||||
screenShareAudioExcludeSources: [],
|
||||
});
|
||||
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 () => {
|
||||
VoiceSettings.updateSettings({screenShareManualAudioSourcesOptIn: true});
|
||||
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,
|
||||
it('drops the removed opt-in keys from storage on the next write and keeps the selection', async () => {
|
||||
VoiceSettings.updateSettings({
|
||||
screenShareAudioSourceMode: 'specific',
|
||||
};
|
||||
expect(applyManualAudioSourcesOptInMigrationV1(stored)).toBe(false);
|
||||
expect(stored.screenShareManualAudioSourcesOptIn).toBeUndefined();
|
||||
screenShareAudioIncludeSources: [{'application.name': 'mpv'}],
|
||||
});
|
||||
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 {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
filterRoutableLinuxAudioSources,
|
||||
getLinuxAudioSourceDisplayName,
|
||||
LINUX_AUDIO_DISPLAY_NAME_PATTERN_KEY,
|
||||
LINUX_AUDIO_PRESERVE_VOLATILE_IDENTITY_PATTERN_KEY,
|
||||
@@ -136,6 +137,16 @@ describe('LinuxAudioSourceRules', () => {
|
||||
}),
|
||||
).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', () => {
|
||||
const duplicate = {name: 'Firefox', value: {'application.name': 'Firefox'}};
|
||||
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] : [];
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
includeSources: [] as Array<Record<string, string>>,
|
||||
excludeSources: [] as Array<Record<string, string>>,
|
||||
manualAudioSourcesOptIn: false,
|
||||
audioDeviceId: 'default',
|
||||
};
|
||||
|
||||
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 nativeAudioAvailability = {
|
||||
@@ -27,8 +30,10 @@ const setScreenShareEnabled = vi.fn(async () => {});
|
||||
const replaceActiveDisplayScreenShare = vi.fn(async () => true);
|
||||
const startDeviceScreenShare = vi.fn(async () => {});
|
||||
const replaceActiveDeviceScreenShare = vi.fn(async () => true);
|
||||
const disarmNativeAudio = vi.fn();
|
||||
const ensureLinuxScreenShareAudioPublication = vi.fn(async (_rule: Record<string, unknown>) => true);
|
||||
const ensureDeviceScreenShareMicPublication = vi.fn(async () => true);
|
||||
const ensureWindowScreenShareAudioPublication = vi.fn(async (_sourceId: string) => true);
|
||||
const armNativeAudioForNextCapture = vi.fn(async () => true);
|
||||
const armNativeSystemAudioForNextCapture = vi.fn(async () => true);
|
||||
const armNativeAudioForLinuxRouting = vi.fn(async (_rule: Record<string, unknown>) => true);
|
||||
@@ -53,6 +58,7 @@ vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({
|
||||
replaceActiveDeviceScreenShare,
|
||||
ensureLinuxScreenShareAudioPublication,
|
||||
ensureDeviceScreenShareMicPublication,
|
||||
ensureWindowScreenShareAudioPublication,
|
||||
getActiveScreenShareVideoDeviceId: () => activeShareVideoDeviceId.current,
|
||||
},
|
||||
}));
|
||||
@@ -66,7 +72,7 @@ vi.mock('@app/features/voice/utils/NativeAudioCaptureBridge', () => ({
|
||||
armNativeAudioForLinuxRouting,
|
||||
armNativeAudioForNextCapture,
|
||||
armNativeSystemAudioForNextCapture,
|
||||
disarmNativeAudio: vi.fn(),
|
||||
disarmNativeAudio,
|
||||
disarmPendingNativeAudio: vi.fn(),
|
||||
getLastNativeAudioArmFailure: () => null,
|
||||
getNativeAudioAvailabilityCached: async () => nativeAudioAvailability.current,
|
||||
@@ -80,8 +86,14 @@ vi.mock('@app/features/voice/state/ActiveScreenShareSource', () => ({
|
||||
default: {
|
||||
setPublishedSource: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
getSourceId: () => null,
|
||||
getSourceId: () => activeShareSourceId.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,
|
||||
getScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
||||
getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
||||
getScreenShareManualAudioSourcesOptIn: () => voiceSettings.manualAudioSourcesOptIn,
|
||||
getEffectiveScreenShareAudioSourceMode: () =>
|
||||
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.audioSourceMode : 'system',
|
||||
getEffectiveScreenShareAudioIncludeSources: () =>
|
||||
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.includeSources : [],
|
||||
getEffectiveScreenShareAudioExcludeSources: () =>
|
||||
voiceSettings.manualAudioSourcesOptIn ? voiceSettings.excludeSources : [],
|
||||
getEffectiveScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
|
||||
getEffectiveScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
||||
getEffectiveScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
||||
getLinuxAudioCaptureIgnoreInputMedia: () => true,
|
||||
getLinuxAudioCaptureIgnoreVirtual: () => false,
|
||||
getLinuxAudioCaptureIgnoreDevices: () => true,
|
||||
@@ -153,8 +161,9 @@ vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
||||
}));
|
||||
|
||||
const {
|
||||
reapplyActiveScreenShareAudioSources,
|
||||
applyLiveScreenShareAudioSourceChange,
|
||||
reconfigureActiveDeviceShareAudio,
|
||||
reconfigureActiveLinuxAppShareAudio,
|
||||
startConfiguredDeviceScreenShare,
|
||||
startConfiguredDisplayScreenShare,
|
||||
switchConfiguredDeviceScreenShare,
|
||||
@@ -168,9 +177,12 @@ beforeEach(() => {
|
||||
voiceSettings.audioSourceMode = 'system';
|
||||
voiceSettings.includeSources = [];
|
||||
voiceSettings.excludeSources = [];
|
||||
voiceSettings.manualAudioSourcesOptIn = false;
|
||||
voiceSettings.audioDeviceId = 'default';
|
||||
activeShareContext.current = null;
|
||||
activeShareSourceId.current = null;
|
||||
activeShareOwnWindow.current = false;
|
||||
activeWindowAudioScope.current = 'window';
|
||||
pendingWindowAudioScope.current = null;
|
||||
activeShareVideoDeviceId.current = '';
|
||||
nativeAudioAvailability.current = {
|
||||
available: true,
|
||||
@@ -183,6 +195,7 @@ beforeEach(() => {
|
||||
replaceActiveDeviceScreenShare.mockResolvedValue(true);
|
||||
ensureLinuxScreenShareAudioPublication.mockResolvedValue(true);
|
||||
ensureDeviceScreenShareMicPublication.mockResolvedValue(true);
|
||||
ensureWindowScreenShareAudioPublication.mockResolvedValue(true);
|
||||
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';
|
||||
voiceSettings.audioSourceMode = 'specific';
|
||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||
|
||||
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(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';
|
||||
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);
|
||||
|
||||
@@ -237,7 +260,6 @@ describe('sharing a video device', () => {
|
||||
});
|
||||
|
||||
test('never routes application audio into a device share off Linux', async () => {
|
||||
voiceSettings.manualAudioSourcesOptIn = true;
|
||||
voiceSettings.audioSourceMode = 'specific';
|
||||
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 () => {
|
||||
platform.current = 'linux';
|
||||
nativeAudioAvailability.current = {available: false, backend: 'linux-pipewire'};
|
||||
voiceSettings.manualAudioSourcesOptIn = true;
|
||||
voiceSettings.audioSourceMode = 'specific';
|
||||
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 () => {
|
||||
platform.current = 'linux';
|
||||
voiceSettings.manualAudioSourcesOptIn = true;
|
||||
voiceSettings.audioSourceMode = 'specific';
|
||||
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 () => {
|
||||
platform.current = 'linux';
|
||||
voiceSettings.manualAudioSourcesOptIn = true;
|
||||
voiceSettings.audioSourceMode = 'specific';
|
||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||
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 () => {
|
||||
platform.current = 'linux';
|
||||
voiceSettings.manualAudioSourcesOptIn = true;
|
||||
voiceSettings.audioSourceMode = 'specific';
|
||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||
|
||||
@@ -360,10 +378,8 @@ describe('sharing a video device', () => {
|
||||
});
|
||||
|
||||
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';
|
||||
voiceSettings.audioSourceMode = 'specific';
|
||||
voiceSettings.includeSources = [{'application.name': 'mpv'}];
|
||||
voiceSettings.excludeSources = [{'application.name': 'Discord'}];
|
||||
|
||||
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
|
||||
@@ -371,26 +387,26 @@ describe('sharing a whole display', () => {
|
||||
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1);
|
||||
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
|
||||
include: [],
|
||||
exclude: [],
|
||||
exclude: [{'application.name': 'Discord'}],
|
||||
ignoreInputMedia: true,
|
||||
onlySpeakers: 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';
|
||||
voiceSettings.audioSourceMode = 'none';
|
||||
|
||||
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
|
||||
|
||||
expect(armNativeAudioForLinuxRouting).toHaveBeenCalledTimes(1);
|
||||
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({include: []});
|
||||
expect(armNativeAudioForLinuxRouting).not.toHaveBeenCalled();
|
||||
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';
|
||||
voiceSettings.manualAudioSourcesOptIn = true;
|
||||
voiceSettings.audioSourceMode = 'specific';
|
||||
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(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', () => {
|
||||
@@ -470,95 +686,3 @@ describe('switching the display source', () => {
|
||||
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,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {
|
||||
filterRoutableLinuxAudioSources,
|
||||
LINUX_AUDIO_TARGET_OBJECTS_PATTERN_KEY,
|
||||
toNativeLinuxAudioPatterns,
|
||||
} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||
@@ -48,10 +49,12 @@ import {
|
||||
} from '@app/features/voice/utils/ScreenSharePortalUnavailableError';
|
||||
import {executeScreenShareOperation} from '@app/features/voice/utils/ScreenShareUtils';
|
||||
import {
|
||||
canSelectManualAudioSources,
|
||||
type AppShareAudioRoute,
|
||||
resolveWindowShareAudioScope,
|
||||
routesManualAudioSources,
|
||||
type StreamSettingsShareContext,
|
||||
supportsManualScreenShareAudioSourceSelection,
|
||||
selectAppShareAudioRoute,
|
||||
type WindowShareAudioScope,
|
||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import type {NativeAudioStartOptions, VirtmicNode} from '@app/types/electron.d';
|
||||
@@ -151,56 +154,88 @@ async function getManualAudioSourceSelectionInput(shareContext: StreamSettingsSh
|
||||
platform,
|
||||
shareContext,
|
||||
nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null,
|
||||
manualOptIn: VoiceSettings.getScreenShareManualAudioSourcesOptIn(),
|
||||
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(
|
||||
shareContext: StreamSettingsShareContext,
|
||||
): Promise<boolean> {
|
||||
return routesManualAudioSources(await getManualAudioSourceSelectionInput(shareContext));
|
||||
}
|
||||
|
||||
function hasStoredManualAudioSourceSelection(): boolean {
|
||||
return (
|
||||
VoiceSettings.getScreenShareAudioSourceMode() !== 'system' ||
|
||||
VoiceSettings.getScreenShareAudioIncludeSources().length > 0 ||
|
||||
VoiceSettings.getScreenShareAudioExcludeSources().length > 0
|
||||
function appShareAudioRouteToSourceMode(route: AppShareAudioRoute | null): 'none' | 'system' | 'specific' | null {
|
||||
if (route === 'none') return 'none';
|
||||
if (route === 'apps') return 'specific';
|
||||
if (route === 'system') return 'system';
|
||||
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 {
|
||||
if (shareContext === 'app') return VoiceSettings.getShareAppAudio();
|
||||
if (shareContext === 'device') return VoiceSettings.getShareDeviceAudio();
|
||||
return VoiceSettings.getShareDesktopAudio();
|
||||
}
|
||||
|
||||
export async function reapplyActiveScreenShareAudioSources(): Promise<boolean> {
|
||||
const shareContext = ActiveScreenShareSource.getShareContext();
|
||||
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});
|
||||
export async function applyLiveScreenShareAudioSourceChange(
|
||||
shareContext: StreamSettingsShareContext,
|
||||
requestedWindowAudioScope?: WindowShareAudioScope,
|
||||
): Promise<boolean> {
|
||||
if (shareContext === 'device') return reconfigureActiveDeviceShareAudio();
|
||||
if (shareContext !== 'app') return reconfigureActiveLinuxScreenShareAudioLink();
|
||||
const applied = await reconfigureActiveLinuxAppShareAudio(requestedWindowAudioScope).catch((error) => {
|
||||
logger.warn('Failed to apply the window share audio scope', {error, requestedWindowAudioScope});
|
||||
return false;
|
||||
});
|
||||
if (!applied) {
|
||||
logger.warn('Active screen share audio sources could not be reapplied', {
|
||||
shareContext,
|
||||
sourceMode: VoiceSettings.getEffectiveScreenShareAudioSourceMode(),
|
||||
});
|
||||
if (applied && requestedWindowAudioScope != null) {
|
||||
ActiveScreenShareSource.setWindowAudioScope(requestedWindowAudioScope);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
@@ -426,11 +461,16 @@ async function runConfiguredDisplayScreenShare(
|
||||
(electronApi.platform === 'darwin' || electronApi.platform === 'win32') &&
|
||||
sourceId?.startsWith('screen:');
|
||||
const requestedNativePickerAudioOnLinux = requestedAudio && electronApi.platform === 'linux' && useWaylandPortal;
|
||||
const linuxDesktopAudioSourceMode =
|
||||
electronApi.platform === 'linux' && (requestedDesktopAudio || requestedNativePickerAudioOnLinux)
|
||||
const appShareAudioScope = requestedAppAudioOnLinux
|
||||
? await resolveAppShareAudioScope(ActiveScreenShareSource.getPendingWindowAudioScope())
|
||||
: null;
|
||||
const appShareAudioRoute = appShareAudioScope == null ? null : selectAppShareAudioRouteForScope(appShareAudioScope);
|
||||
const linuxAudioSourceMode = requestedAppAudioOnLinux
|
||||
? appShareAudioRouteToSourceMode(appShareAudioRoute)
|
||||
: electronApi.platform === 'linux' && (requestedDesktopAudio || requestedNativePickerAudioOnLinux)
|
||||
? VoiceSettings.getEffectiveScreenShareAudioSourceMode()
|
||||
: null;
|
||||
if (requestedAppAudioOnLinux) {
|
||||
if (requestedAppAudioOnLinux && appShareAudioRoute === 'window') {
|
||||
try {
|
||||
nativeAudioArmed = await armNativeAudioForNextCapture(sourceId ?? '');
|
||||
} catch (error) {
|
||||
@@ -467,10 +507,10 @@ async function runConfiguredDisplayScreenShare(
|
||||
logger.warn('Desktop audio unavailable; aborting screen share because audio was requested', debugInfo);
|
||||
failRequestedAudioCapture(debugInfo);
|
||||
}
|
||||
} else if (linuxDesktopAudioSourceMode === 'none') {
|
||||
} else if (linuxAudioSourceMode === 'none') {
|
||||
removeAudioFromCaptureOptions(captureOptions);
|
||||
} else if ((requestedDesktopAudio || requestedNativePickerAudioOnLinux) && electronApi.platform === 'linux') {
|
||||
const sourceMode = linuxDesktopAudioSourceMode ?? 'system';
|
||||
} else if (linuxAudioSourceMode !== null && electronApi.platform === 'linux') {
|
||||
const sourceMode = linuxAudioSourceMode;
|
||||
const userIncludeSources = VoiceSettings.getEffectiveScreenShareAudioIncludeSources().map((entry) => ({
|
||||
...entry,
|
||||
}));
|
||||
@@ -496,6 +536,7 @@ async function runConfiguredDisplayScreenShare(
|
||||
if (!nativeAudioArmed) {
|
||||
failRequestedAudioCapture(
|
||||
buildAudioCaptureFailureDebug({
|
||||
sourceId,
|
||||
sourceMode,
|
||||
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
|
||||
}),
|
||||
@@ -579,6 +620,7 @@ async function runConfiguredDisplayScreenShare(
|
||||
ActiveScreenShareSource.setPublishedSource(sourceId.startsWith('window:') ? 'app' : 'display', sourceId, {
|
||||
isOwnWindow: isOwnWindowShare,
|
||||
});
|
||||
ActiveScreenShareSource.setWindowAudioScope(appShareAudioScope ?? 'window');
|
||||
}
|
||||
if (captured && useWaylandPortal) {
|
||||
ActiveScreenShareSource.setPublishedSource('wayland', null);
|
||||
@@ -594,8 +636,8 @@ async function runConfiguredDisplayScreenShare(
|
||||
captured &&
|
||||
requestedAudio &&
|
||||
electronApi.platform === 'linux' &&
|
||||
linuxDesktopAudioSourceMode !== null &&
|
||||
linuxDesktopAudioSourceMode !== 'none'
|
||||
linuxAudioSourceMode !== null &&
|
||||
linuxAudioSourceMode !== 'none'
|
||||
) {
|
||||
const audioRelinked = await reconfigureActiveLinuxScreenShareAudioLink().catch((error) => {
|
||||
logger.warn('Failed to link Linux screen-share audio after capture start', {mode, error});
|
||||
@@ -603,7 +645,7 @@ async function runConfiguredDisplayScreenShare(
|
||||
});
|
||||
if (!audioRelinked) {
|
||||
const debugInfo = buildAudioCaptureFailureDebug({
|
||||
sourceMode: linuxDesktopAudioSourceMode,
|
||||
sourceMode: linuxAudioSourceMode,
|
||||
platform: electronApi.platform,
|
||||
reason: getLastNativeAudioArmFailure()?.reason ?? 'linux-system-audio-route-unavailable',
|
||||
});
|
||||
|
||||
@@ -5,10 +5,12 @@ import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
canSelectManualAudioSources,
|
||||
isLinuxDesktopAudioShare,
|
||||
maySupportManualScreenShareAudioSourceSelection,
|
||||
manualAudioSourcesGovernShare,
|
||||
resolveWindowShareAudioScope,
|
||||
routesManualAudioSources,
|
||||
selectAppShareAudioRoute,
|
||||
shouldReconfigureAudioForActiveStreamSettings,
|
||||
supportsManualScreenShareAudioSourceSelection,
|
||||
supportsWindowShareAudioScope,
|
||||
} from './StreamSettingsUpdatePolicy';
|
||||
|
||||
const LINUX_PIPEWIRE: NativeAudioAvailability = {
|
||||
@@ -88,90 +90,119 @@ describe('StreamSettingsUpdatePolicy', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
supportsManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: LINUX_PIPEWIRE}),
|
||||
).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({
|
||||
canSelectManualAudioSources({
|
||||
platform: 'linux',
|
||||
nativeAudioAvailability: {available: false, backend: 'linux-pipewire', reason: 'no-pipewire'},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsManualScreenShareAudioSourceSelection({
|
||||
canSelectManualAudioSources({
|
||||
platform: 'linux',
|
||||
nativeAudioAvailability: {available: true, capabilities: {process: false, system: true}},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the advanced toggle on Linux while the capture layer is still being probed', () => {
|
||||
expect(maySupportManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: null})).toBe(
|
||||
true,
|
||||
);
|
||||
it('offers manual audio source selection on every desktop share type, with no opt-in left to satisfy', () => {
|
||||
expect(
|
||||
maySupportManualScreenShareAudioSourceSelection({platform: 'linux', nativeAudioAvailability: LINUX_PIPEWIRE}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
maySupportManualScreenShareAudioSourceSelection({
|
||||
canSelectManualAudioSources({
|
||||
platform: 'linux',
|
||||
nativeAudioAvailability: {available: false, backend: 'linux-pipewire', reason: 'no-pipewire'},
|
||||
nativeAudioAvailability: LINUX_PIPEWIRE,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(maySupportManualScreenShareAudioSourceSelection({platform: 'win32', nativeAudioAvailability: null})).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
maySupportManualScreenShareAudioSourceSelection({platform: 'darwin', nativeAudioAvailability: MACOS_SCK}),
|
||||
).toBe(false);
|
||||
expect(maySupportManualScreenShareAudioSourceSelection({platform: null, nativeAudioAvailability: null})).toBe(
|
||||
false,
|
||||
);
|
||||
).toBe(true);
|
||||
for (const displayShareEnvironment of ['desktop-custom', 'desktop-wayland'] as const) {
|
||||
expect(manualAudioSourcesGovernShare({platform: 'linux', displayShareEnvironment})).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps manual audio source selection behind the advanced opt-in in every share context', () => {
|
||||
for (const shareContext of ['app', 'device', 'display'] as const) {
|
||||
expect(
|
||||
canSelectManualAudioSources({
|
||||
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('leaves the audio source decision to the platform wherever the stored mode cannot reach the capture', () => {
|
||||
expect(manualAudioSourcesGovernShare({platform: 'linux', displayShareEnvironment: 'web'})).toBe(false);
|
||||
for (const platform of ['win32', 'darwin', null, undefined]) {
|
||||
expect(manualAudioSourcesGovernShare({platform, displayShareEnvironment: 'desktop-custom'})).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', () => {
|
||||
const base = {
|
||||
platform: 'linux',
|
||||
shareContext: 'device',
|
||||
nativeAudioAvailability: LINUX_PIPEWIRE,
|
||||
manualOptIn: true,
|
||||
} as const;
|
||||
expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 1})).toBe(true);
|
||||
expect(routesManualAudioSources({...base, audioSourceMode: 'specific', selectedSourceCount: 0})).toBe(false);
|
||||
@@ -180,7 +211,8 @@ describe('StreamSettingsUpdatePolicy', () => {
|
||||
expect(
|
||||
routesManualAudioSources({
|
||||
...base,
|
||||
manualOptIn: false,
|
||||
platform: 'win32',
|
||||
nativeAudioAvailability: WINDOWS_WASAPI,
|
||||
audioSourceMode: 'specific',
|
||||
selectedSourceCount: 2,
|
||||
}),
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {NativeAudioAvailability} from '@app/types/electron.d';
|
||||
|
||||
export type StreamSettingsShareContext = 'app' | 'device' | 'display';
|
||||
export type ScreenShareAudioSourceMode = 'none' | 'system' | 'specific';
|
||||
export type WindowShareAudioScope = 'window' | 'system';
|
||||
export type AppShareAudioRoute = 'none' | 'window' | 'apps' | 'system';
|
||||
|
||||
export interface StreamSettingsUpdatePolicyInput {
|
||||
platform?: string | null;
|
||||
@@ -16,16 +18,44 @@ export interface ManualAudioSourceSelectionInput {
|
||||
platform?: string | null;
|
||||
shareContext: StreamSettingsShareContext;
|
||||
nativeAudioAvailability?: NativeAudioAvailability | null;
|
||||
manualOptIn?: boolean;
|
||||
audioSourceMode?: ScreenShareAudioSourceMode;
|
||||
selectedSourceCount?: number;
|
||||
}
|
||||
|
||||
export function manualAudioSourcesGovernShare(input: {
|
||||
export interface WindowShareAudioScopeInput {
|
||||
shareContext: StreamSettingsShareContext;
|
||||
displayShareEnvironment?: DisplayShareEnvironment;
|
||||
windowAudioScope?: WindowShareAudioScope;
|
||||
}
|
||||
|
||||
export interface AppShareAudioRouteInput {
|
||||
audioSourceMode?: ScreenShareAudioSourceMode;
|
||||
selectedSourceCount?: number;
|
||||
windowAudioScope: WindowShareAudioScope;
|
||||
}
|
||||
|
||||
export function manualAudioSourcesGovernShare(input: {
|
||||
platform?: string | null;
|
||||
displayShareEnvironment?: DisplayShareEnvironment;
|
||||
}): 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(
|
||||
@@ -34,7 +64,7 @@ export function isLinuxDesktopAudioShare(
|
||||
return input.platform === 'linux' && input.shareContext !== 'device';
|
||||
}
|
||||
|
||||
export function supportsManualScreenShareAudioSourceSelection(
|
||||
export function canSelectManualAudioSources(
|
||||
input: Pick<ManualAudioSourceSelectionInput, 'platform' | 'nativeAudioAvailability'>,
|
||||
): boolean {
|
||||
if (input.platform !== 'linux') return false;
|
||||
@@ -43,18 +73,6 @@ export function supportsManualScreenShareAudioSourceSelection(
|
||||
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 {
|
||||
return (
|
||||
canSelectManualAudioSources(input) && input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0
|
||||
|
||||
Reference in New Issue
Block a user