fix(app): correct shortcuts, nagbar, stream menu, share audio (#2599)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-08 19:09:59 +02:00
1 parent 55846b24ea
commit fc2b6b5299
37 files changed
+1074 -268

No files matched your search

@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {COPIED_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors';
import {buildStatsForNerdsCopyPayload, collectStatsForNerdsSnapshot} from '@app/features/voice/utils/StatsForNerdsCopy';
import type {I18n} from '@lingui/core';
export async function copyVoiceDiagnostics(i18n: I18n): Promise<void> {
const data = collectStatsForNerdsSnapshot();
let payload: Record<string, unknown>;
try {
payload = await buildStatsForNerdsCopyPayload(data);
} catch {
payload = {
schemaVersion: 1,
createdAt: new Date().toISOString(),
statsForNerds: data,
};
}
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
ToastCommands.createToast({type: 'success', children: i18n._(COPIED_STATS_JSON_DESCRIPTOR)});
}
@@ -79,6 +79,7 @@ type VoiceSettingsPatch = Partial<{
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
screenShareAudioIncludeSources: Array<Record<string, string>>;
screenShareAudioExcludeSources: Array<Record<string, string>>;
screenShareDeviceAudioUsesMicrophone: boolean;
openH264Enabled: boolean;
}>;
@@ -141,6 +141,7 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
const sourceMode = VoiceSettings.getScreenShareAudioSourceMode();
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
const usesDeviceMicrophone = VoiceSettings.getScreenShareDeviceAudioUsesMicrophone();
const granular = VoiceSettings.getLinuxAudioCaptureGranularSelect();
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
@@ -167,12 +168,13 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
onSelectionChange?.('window');
}, [onSelectionChange]);
const handlePickSystem = useCallback(() => {
VoiceSettingsCommands.update({
screenShareAudioSourceMode: 'system',
screenShareAudioIncludeSources: [],
});
VoiceSettingsCommands.update(
isDeviceShare
? {screenShareDeviceAudioUsesMicrophone: true}
: {screenShareAudioSourceMode: 'system', screenShareAudioIncludeSources: []},
);
onSelectionChange?.(widenedScope);
}, [onSelectionChange, widenedScope]);
}, [isDeviceShare, onSelectionChange, widenedScope]);
const handlePickNone = useCallback(() => {
VoiceSettingsCommands.update({
screenShareAudioSourceMode: 'none',
@@ -189,10 +191,11 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
VoiceSettingsCommands.update({
screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system',
screenShareAudioIncludeSources: nextSources,
...(isDeviceShare ? {screenShareDeviceAudioUsesMicrophone: false} : {}),
});
onSelectionChange?.(widenedScope);
},
[includeSources, onSelectionChange, widenedScope],
[includeSources, isDeviceShare, onSelectionChange, widenedScope],
);
const handleToggleExcludeApp = useCallback(
(item: LinuxAudioSourceItem) => {
@@ -217,9 +220,10 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
microphoneLabel,
displayShareEnvironment,
windowAudioScope,
usesDeviceMicrophone,
});
const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system';
const wideSourceIsSelected = isDeviceShare ? !routesSelectedSources : sourceMode === 'system';
const wideSourceIsSelected = isDeviceShare ? usesDeviceMicrophone || !routesSelectedSources : sourceMode === 'system';
if (!snapshot.available && !snapshot.loading) {
return null;
}
@@ -2,13 +2,9 @@
import {msg} from '@lingui/core/macro';
export const COPY_AS_JSON_DESCRIPTOR = msg({
message: 'Copy as JSON',
comment: 'Aria label for a button that copies stats for nerds data as JSON to the clipboard.',
});
export const COPY_STATS_JSON_DESCRIPTOR = msg({
message: 'Copy stats JSON',
comment: 'Button label in audio and video settings. Copies stats for nerds data as JSON to the clipboard.',
comment: 'Menu item label in the screen share menu. Copies stats for nerds data as JSON to the clipboard.',
});
export const COPIED_STATS_JSON_DESCRIPTOR = msg({
message: 'Copied stats JSON to clipboard',
@@ -15,6 +15,7 @@ import {
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Favorites from '@app/features/messaging/state/Favorites';
import {goBackOr} from '@app/features/platform/components/router/NavigationAdapter';
import {STREAM_VOLUME_DESCRIPTOR} from '@app/features/ui/action_menu/items/voice_participant_menu_data/shared';
import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
@@ -636,6 +637,7 @@ const VoiceCallViewInner = observer(
iconSize={18}
className={styles.voiceHeaderIconButton}
position="below"
ariaLabel={i18n._(STREAM_VOLUME_DESCRIPTOR)}
data-flx="voice.voice-call-view.voice-call-view-inner.hud-stream-volume-control"
/>
)}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {LongPressable} from '@app/features/app/components/LongPressable';
import Channels from '@app/features/channel/state/Channels';
import {WATCH_STREAM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
@@ -7,6 +8,7 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import Permission from '@app/features/permissions/state/Permission';
import {dimColor} from '@app/features/theme/utils/ColorUtils';
import type {VoiceParticipantMenuSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes';
import {STREAM_VOLUME_DESCRIPTOR} from '@app/features/ui/action_menu/items/voice_participant_menu_data/shared';
import {UserContextMenu} from '@app/features/ui/action_menu/UserContextMenu';
import {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu';
import {Button} from '@app/features/ui/button/Button';
@@ -57,6 +59,7 @@ import {
selectVoiceParticipantTileCameraActive,
selectVoiceParticipantTileScreenShareState,
shouldShowCameraBuffering,
shouldShowTileStreamAudioControls,
type VoiceParticipantTileScreenShareSignals,
} from '@app/features/voice/components/VoiceParticipantTileStateMachine';
import {useVoiceTileGroup} from '@app/features/voice/components/VoiceTileGroupContext';
@@ -296,6 +299,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
: getVoiceDeafenedStatusLabel(i18n, isCurrentUser);
const deafenStatusClassName = isModeratorDeafened ? styles.participantIconRed : styles.participantIconMuted;
const isActuallySpeaking = displayState.speaking;
const shouldAnimateTileAvatar = isActuallySpeaking && !Accessibility.useReducedMotion;
const isMobileExperience = isMobileExperienceEnabled();
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
const tileGroup = useVoiceTileGroup();
@@ -632,7 +636,21 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
const participantDisplayName =
(participantUser ? NicknameUtils.getNickname(participantUser, guildId, channelId) : participant.name) ||
i18n._(UNKNOWN_USER_DESCRIPTOR);
const showStreamAudioControls = isScreenShare && !isOwnScreenShare && isWatching && hasScreenShareAudio;
const showStreamAudioControls = shouldShowTileStreamAudioControls({
isScreenShare,
isOwnScreenShare,
isWatching,
hasScreenShareAudio,
isFocusedPlaceholderTile,
presentation,
});
const showTileSpectatorPill = !isFocusPresentationTile && isScreenShare && viewerUsers.length > 0;
const showTileControlPill =
!isFocusedPlaceholderTile &&
(showStreamAudioControls ||
showTileSpectatorPill ||
(isGridTile && groupHiddenCount > 0) ||
showDeviceCollapseControl);
const viewerStreamCount = graphViewerStreamKeys.length;
const addStreamTooltipText = plural(
{count: viewerStreamCount},
@@ -940,6 +958,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
user={participantUser}
size={TILE_AVATAR_BASE}
mediaSize={TILE_AVATAR_MEDIA_SIZE}
forceAnimate={shouldAnimateTileAvatar}
className={styles.avatarFlexShrink}
style={TILE_AVATAR_STYLE}
guildId={guildId}
@@ -962,6 +981,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
placeholderStyle,
previewUrl,
screenSharePlaceholderStyle,
shouldAnimateTileAvatar,
trackRef,
shouldHideOwnScreenShareVideo,
]);
@@ -1195,154 +1215,150 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
/>
</div>
)}
{!isFocusPresentationTile &&
!isFocusedPlaceholderTile &&
(showStreamAudioControls ||
(isScreenShare && viewerUsers.length > 0) ||
(isGridTile && groupHiddenCount > 0) ||
showDeviceCollapseControl) && (
<div
className={clsx(
voiceCallStyles.tileControlPill,
isScreenShare && viewerUsers.length > 0 && voiceCallStyles.tileControlPillPersistent,
)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-control-pill"
>
{showStreamAudioControls && (
<div
className={clsx(voiceCallStyles.tileControlPillSlot, isStreamMuted && styles.streamAudioSlotMuted)}
role="group"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume"
{showTileControlPill && (
<div
className={clsx(
voiceCallStyles.tileControlPill,
showTileSpectatorPill && voiceCallStyles.tileControlPillPersistent,
)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-control-pill"
>
{showStreamAudioControls && (
<div
className={clsx(voiceCallStyles.tileControlPillSlot, isStreamMuted && styles.streamAudioSlotMuted)}
role="group"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume"
>
<MediaVerticalVolumeControl
volume={streamVolume / 100}
isMuted={isStreamMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleStreamVolumeChange}
onToggleMute={handleStreamAudioToggle}
iconSize={14}
position="below"
ariaLabel={i18n._(STREAM_VOLUME_DESCRIPTOR)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume-control"
/>
</div>
)}
{showDeviceCollapseControl && (
<Tooltip
text={groupCollapseTooltip}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-tooltip"
>
<FocusRing
offset={-2}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-focus-ring"
>
<MediaVerticalVolumeControl
volume={streamVolume / 100}
isMuted={isStreamMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleStreamVolumeChange}
onToggleMute={handleStreamAudioToggle}
iconSize={14}
position="below"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume-control"
<div
role="button"
tabIndex={0}
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
onClick={handleExpandGroup}
onKeyDown={(event) => {
if (!isKeyboardActivationKey(event.key)) return;
event.preventDefault();
handleExpandGroup(event);
}}
aria-label={groupCollapseTooltip}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-pill"
>
<span
className={styles.groupExpandPillSign}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill-sign"
>
-
</span>
{groupDeviceConnectionCount}
</div>
</FocusRing>
</Tooltip>
)}
{isGridTile && groupHiddenCount > 0 && (
<Tooltip
text={groupExpandTooltip}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-tooltip"
>
<FocusRing
offset={-2}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-focus-ring"
>
<div
role="button"
tabIndex={0}
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
onClick={handleExpandGroup}
onKeyDown={(event) => {
if (!isKeyboardActivationKey(event.key)) return;
event.preventDefault();
handleExpandGroup(event);
}}
aria-label={groupExpandTooltip}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill"
>
<span
className={styles.groupExpandPillSign}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill-sign--2"
>
+
</span>
{groupHiddenCount}
</div>
</FocusRing>
</Tooltip>
)}
{isOwnScreenShare && viewerUsers.length > 0 && streamUnderperformanceReason && (
<Tooltip
text={getStreamUnderperformanceLabel(i18n, streamUnderperformanceReason)}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tooltip"
>
<div
className={clsx(voiceCallStyles.tileControlPillSlot, styles.streamUnderperformanceSlot)}
role="img"
aria-label={i18n._(STREAM_NOT_KEEPING_UP_DESCRIPTOR)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-underperformance-slot"
>
<WarningIcon
weight="fill"
className={styles.tilePillIcon}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-pill-icon"
/>
</div>
)}
{showDeviceCollapseControl && (
<Tooltip
text={groupCollapseTooltip}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-tooltip"
</Tooltip>
)}
{showTileSpectatorPill && (
<StreamSpectatorsPopout
viewerUsers={viewerUsers}
spectatorEntries={spectatorEntries}
guildId={guildId}
channelId={channelId}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-spectators-popout"
>
<div
className={clsx(voiceCallStyles.tileControlPillSlot, voiceCallStyles.tileControlPillViewerSlot)}
role="img"
aria-label={i18n._(WATCHING_DESCRIPTOR, {length: viewerUsers.length})}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count"
>
<FocusRing
offset={-2}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-focus-ring"
>
<div
role="button"
tabIndex={0}
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
onClick={handleExpandGroup}
onKeyDown={(event) => {
if (!isKeyboardActivationKey(event.key)) return;
event.preventDefault();
handleExpandGroup(event);
}}
aria-label={groupCollapseTooltip}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-pill"
>
<span
className={styles.groupExpandPillSign}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill-sign"
>
-
</span>
{groupDeviceConnectionCount}
</div>
</FocusRing>
</Tooltip>
)}
{isGridTile && groupHiddenCount > 0 && (
<Tooltip
text={groupExpandTooltip}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-tooltip"
>
<FocusRing
offset={-2}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-focus-ring"
>
<div
role="button"
tabIndex={0}
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
onClick={handleExpandGroup}
onKeyDown={(event) => {
if (!isKeyboardActivationKey(event.key)) return;
event.preventDefault();
handleExpandGroup(event);
}}
aria-label={groupExpandTooltip}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill"
>
<span
className={styles.groupExpandPillSign}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill-sign--2"
>
+
</span>
{groupHiddenCount}
</div>
</FocusRing>
</Tooltip>
)}
{isOwnScreenShare && viewerUsers.length > 0 && streamUnderperformanceReason && (
<Tooltip
text={getStreamUnderperformanceLabel(i18n, streamUnderperformanceReason)}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tooltip"
>
<div
className={clsx(voiceCallStyles.tileControlPillSlot, styles.streamUnderperformanceSlot)}
role="img"
aria-label={i18n._(STREAM_NOT_KEEPING_UP_DESCRIPTOR)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-underperformance-slot"
>
<WarningIcon
weight="fill"
className={styles.tilePillIcon}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-pill-icon"
/>
</div>
</Tooltip>
)}
{isScreenShare && viewerUsers.length > 0 && (
<StreamSpectatorsPopout
viewerUsers={viewerUsers}
spectatorEntries={spectatorEntries}
guildId={guildId}
channelId={channelId}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-spectators-popout"
>
<div
className={clsx(voiceCallStyles.tileControlPillSlot, voiceCallStyles.tileControlPillViewerSlot)}
role="img"
aria-label={i18n._(WATCHING_DESCRIPTOR, {length: viewerUsers.length})}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count"
>
<EyeIcon
weight="fill"
className={styles.tilePillIcon}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-icon"
/>
<span data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count-text">
{viewerUsers.length}
</span>
</div>
</StreamSpectatorsPopout>
)}
</div>
)}
<EyeIcon
weight="fill"
className={styles.tilePillIcon}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-icon"
/>
<span data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count-text">
{viewerUsers.length}
</span>
</div>
</StreamSpectatorsPopout>
)}
</div>
)}
{showParticipantMetadata && (
<div
className={voiceCallStyles.lkParticipantMetadata}
@@ -30,6 +30,23 @@ describe('VoiceParticipantTile stability', () => {
expect(css).not.toMatch(/transform:\s*scale/);
expect(css).not.toContain('will-change: transform');
});
it('drives the tile avatar animation from the speaking signal so animated avatars play without hover', () => {
const tileSource = sourceFile('VoiceParticipantTile.tsx');
const avatarElement = tileSource.match(/<Avatar\b[\s\S]*?\/>/)?.[0];
expect(avatarElement).toBeDefined();
const forceAnimateExpression = avatarElement?.match(/forceAnimate=\{([^}]+)\}/)?.[1]?.trim();
expect(forceAnimateExpression).toBeDefined();
const animateFlagSource =
forceAnimateExpression === 'isActuallySpeaking'
? 'isActuallySpeaking'
: tileSource.match(new RegExp(`const ${forceAnimateExpression} = ([^;]+);`))?.[1];
expect(animateFlagSource).toContain('isActuallySpeaking');
expect(animateFlagSource).toContain('!Accessibility.useReducedMotion');
expect(tileSource).toContain("import Accessibility from '@app/features/accessibility/state/Accessibility';");
const afterAvatarElement = tileSource.slice(tileSource.indexOf(avatarElement!) + avatarElement!.length);
const mediaNodeDeps = afterAvatarElement.match(/\}, \[([\s\S]*?)\]\);/)?.[1] ?? '';
expect(mediaNodeDeps).toContain(forceAnimateExpression);
});
it('keeps the fullscreen call surface mounted while the media room catches up to a channel switch', () => {
const voiceCallViewSource = sourceFile('VoiceCallView.tsx');
const guildChannelViewSource = appSourceFile('features/channel/components/channel_view/GuildChannelView.tsx');
@@ -6,10 +6,12 @@ import {
selectVoiceParticipantTileCameraActive,
selectVoiceParticipantTileScreenShareState,
shouldShowCameraBuffering,
shouldShowTileStreamAudioControls,
shouldShowWatchFailed,
type VoiceParticipantTileCameraActiveSignals,
type VoiceParticipantTileCameraBufferingSignals,
type VoiceParticipantTileScreenShareSignals,
type VoiceParticipantTileStreamAudioSignals,
} from '@app/features/voice/components/VoiceParticipantTileStateMachine';
import {
getAppliedScreenShareFrameRate,
@@ -84,6 +86,50 @@ function cameraActiveSignals(
};
}
function streamAudioSignals(
overrides: Partial<VoiceParticipantTileStreamAudioSignals> = {},
): VoiceParticipantTileStreamAudioSignals {
return {
isScreenShare: true,
isOwnScreenShare: false,
isWatching: true,
hasScreenShareAudio: true,
isFocusedPlaceholderTile: false,
presentation: 'grid',
...overrides,
};
}
describe('VoiceParticipantTileStateMachine stream audio controls', () => {
it('keeps the per-stream volume control on the watched stream once it becomes the focused tile', () => {
expect(shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'focus-main'}))).toBe(true);
});
it('shows the per-stream volume control on a watched grid tile', () => {
expect(shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'grid'}))).toBe(true);
});
it('leaves the control off carousel thumbnails, own shares, and placeholder tiles', () => {
expect(shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'focus-secondary'}))).toBe(false);
expect(shouldShowTileStreamAudioControls(streamAudioSignals({isOwnScreenShare: true}))).toBe(false);
expect(
shouldShowTileStreamAudioControls(
streamAudioSignals({presentation: 'focus-main', isFocusedPlaceholderTile: true}),
),
).toBe(false);
expect(shouldShowTileStreamAudioControls(streamAudioSignals({isScreenShare: false}))).toBe(false);
});
it('leaves the control off streams that are not watched or carry no audio', () => {
expect(shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'focus-main', isWatching: false}))).toBe(
false,
);
expect(
shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'focus-main', hasScreenShareAudio: false})),
).toBe(false);
});
});
describe('VoiceParticipantTileStateMachine camera buffering state', () => {
it('shows buffering while an active camera publication has no video', () => {
expect(shouldShowCameraBuffering(cameraSignals())).toBe(true);
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {VoiceParticipantTilePresentation} from '@app/features/voice/components/voice_participant_tile/shared';
import type {VoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState';
import {assign, getInitialSnapshot, setup, transition} from 'xstate';
@@ -31,6 +32,15 @@ export interface VoiceParticipantTileCameraBufferingSignals {
hasRenderedVideoFrame: boolean;
}
export interface VoiceParticipantTileStreamAudioSignals {
isScreenShare: boolean;
isOwnScreenShare: boolean;
isWatching: boolean;
hasScreenShareAudio: boolean;
isFocusedPlaceholderTile: boolean;
presentation: VoiceParticipantTilePresentation;
}
export interface VoiceParticipantTileCameraActiveSignals {
isCameraTile: boolean;
isOwnContent: boolean;
@@ -141,6 +151,15 @@ export function shouldShowWatchPrompt(signals: VoiceParticipantTileScreenShareSi
return !signals.isFocusPresentationTile;
}
export function shouldShowTileStreamAudioControls(signals: VoiceParticipantTileStreamAudioSignals): boolean {
if (!signals.isScreenShare) return false;
if (signals.isOwnScreenShare) return false;
if (signals.isFocusedPlaceholderTile) return false;
if (!signals.isWatching) return false;
if (!signals.hasScreenShareAudio) return false;
return signals.presentation === 'grid' || signals.presentation === 'focus-main';
}
export function shouldShowCameraBuffering(signals: VoiceParticipantTileCameraBufferingSignals): boolean {
if (signals.isScreenShare) return false;
if (signals.isFocusedPlaceholderTile) return false;
@@ -1492,6 +1492,7 @@ const ScreenSharePickerModalLoadedContent = observer(
microphoneLabel,
displayShareEnvironment,
windowAudioScope,
usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(),
})
: activeTab === 'devices'
? i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR)
@@ -122,6 +122,7 @@ type VoiceSettingsUpdate = Partial<{
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
screenShareAudioIncludeSources: Array<Record<string, string>>;
screenShareAudioExcludeSources: Array<Record<string, string>>;
screenShareDeviceAudioUsesMicrophone: boolean;
openH264Enabled: boolean;
lastScreenShareSource: LastScreenShareSource | null;
}>;
@@ -434,6 +435,7 @@ class VoiceSettings {
screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system';
screenShareAudioIncludeSources: Array<Record<string, string>> = [];
screenShareAudioExcludeSources: Array<Record<string, string>> = [];
screenShareDeviceAudioUsesMicrophone = false;
openH264Enabled = true;
lastScreenShareSource: LastScreenShareSource | null = null;
prioritizeSpeakingParticipants = false;
@@ -510,6 +512,7 @@ class VoiceSettings {
getScreenShareAudioSourceMode: false,
getScreenShareAudioIncludeSources: false,
getScreenShareAudioExcludeSources: false,
getScreenShareDeviceAudioUsesMicrophone: false,
getOpenH264Enabled: false,
getLastScreenShareSource: false,
getPrioritizeSpeakingParticipants: false,
@@ -623,6 +626,7 @@ class VoiceSettings {
'screenShareAudioSourceMode',
'screenShareAudioIncludeSources',
'screenShareAudioExcludeSources',
'screenShareDeviceAudioUsesMicrophone',
'openH264Enabled',
'lastScreenShareSource',
'prioritizeSpeakingParticipants',
@@ -1008,6 +1012,10 @@ class VoiceSettings {
return this.screenShareAudioExcludeSources;
}
getScreenShareDeviceAudioUsesMicrophone(): boolean {
return this.screenShareDeviceAudioUsesMicrophone;
}
getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
return this.getScreenShareAudioSourceMode();
}
@@ -1126,6 +1134,8 @@ class VoiceSettings {
this.screenShareAudioIncludeSources = validated.screenShareAudioIncludeSources;
if (validated.screenShareAudioExcludeSources !== undefined)
this.screenShareAudioExcludeSources = validated.screenShareAudioExcludeSources;
if (validated.screenShareDeviceAudioUsesMicrophone !== undefined)
this.screenShareDeviceAudioUsesMicrophone = validated.screenShareDeviceAudioUsesMicrophone;
if (validated.openH264Enabled !== undefined) this.openH264Enabled = validated.openH264Enabled;
if (validated.lastScreenShareSource !== undefined) this.lastScreenShareSource = validated.lastScreenShareSource;
this.notifyListeners();
@@ -1257,6 +1267,8 @@ class VoiceSettings {
validateSourceList(data.screenShareAudioIncludeSources) ?? this.screenShareAudioIncludeSources,
screenShareAudioExcludeSources:
validateSourceList(data.screenShareAudioExcludeSources) ?? this.screenShareAudioExcludeSources,
screenShareDeviceAudioUsesMicrophone:
data.screenShareDeviceAudioUsesMicrophone ?? this.screenShareDeviceAudioUsesMicrophone,
openH264Enabled: data.openH264Enabled ?? this.openH264Enabled,
lastScreenShareSource:
data.lastScreenShareSource === undefined
@@ -143,6 +143,30 @@ describe('resolveScreenShareAudioSummary', () => {
});
});
it('names the microphone on a device share that picked it over the stored application selection', () => {
expect(
summary({
shareContext: 'device',
microphoneLabel: 'Elgato 4K X Analog Stereo',
sourceMode: 'specific',
includeSources: [{'application.name': 'mpv'}],
usesDeviceMicrophone: true,
}),
).toEqual({
kind: 'message',
descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR,
values: {deviceLabel: 'Elgato 4K X Analog Stereo'},
});
expect(
summary({
shareContext: 'display',
sourceMode: 'specific',
includeSources: [{'application.name': 'mpv'}],
usesDeviceMicrophone: true,
}),
).toEqual({kind: 'sourceName', name: 'mpv'});
});
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({
@@ -54,6 +54,7 @@ export interface ScreenShareAudioSummaryInput {
microphoneLabel?: string | null;
displayShareEnvironment?: DisplayShareEnvironment;
windowAudioScope?: WindowShareAudioScope;
usesDeviceMicrophone?: boolean;
}
export type ScreenShareAudioSummary =
@@ -83,7 +84,9 @@ export function resolveScreenShareAudioSummary(input: ScreenShareAudioSummaryInp
const selected = filterRoutableLinuxAudioSources(input.includeSources);
const routesSelectedSources = input.sourceMode === 'specific' && selected.length > 0;
if (input.shareContext === 'device') {
return routesSelectedSources ? summariseSelectedSources(selected) : summariseMicrophone(input.microphoneLabel);
return routesSelectedSources && input.usesDeviceMicrophone !== true
? summariseSelectedSources(selected)
: summariseMicrophone(input.microphoneLabel);
}
if (supportsWindowShareAudioScope(input)) {
const route = selectAppShareAudioRoute({
@@ -9,6 +9,7 @@ const voiceSettings = {
includeSources: [] as Array<Record<string, string>>,
excludeSources: [] as Array<Record<string, string>>,
audioDeviceId: 'default',
deviceUsesMicrophone: false,
};
const activeShareContext = {current: null as 'app' | 'device' | 'display' | null};
@@ -149,6 +150,7 @@ vi.mock('@app/features/voice/state/VoiceSettings', () => ({
getScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
getScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
getScreenShareDeviceAudioUsesMicrophone: () => voiceSettings.deviceUsesMicrophone,
getEffectiveScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
getEffectiveScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
getEffectiveScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
@@ -178,6 +180,7 @@ beforeEach(() => {
voiceSettings.includeSources = [];
voiceSettings.excludeSources = [];
voiceSettings.audioDeviceId = 'default';
voiceSettings.deviceUsesMicrophone = false;
activeShareContext.current = null;
activeShareSourceId.current = null;
activeShareOwnWindow.current = false;
@@ -362,6 +365,37 @@ describe('sharing a video device', () => {
expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledTimes(1);
});
test('keeps the microphone the device picker chose while the shared application selection stands', async () => {
platform.current = 'linux';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
voiceSettings.deviceUsesMicrophone = true;
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBe('mic-1');
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
expect(await reconfigureActiveDeviceShareAudio()).toBe(true);
expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledWith('mic-1');
expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled();
expect(voiceSettings.audioSourceMode).toBe('specific');
expect(voiceSettings.includeSources).toEqual([{'application.name': 'mpv'}]);
});
test('routes the selected applications again once the device picker leaves the microphone', async () => {
platform.current = 'linux';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
voiceSettings.deviceUsesMicrophone = false;
expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true);
expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBeUndefined();
expect(ensureLinuxScreenShareAudioPublication).toHaveBeenCalledTimes(1);
});
test('keeps the paired capture card input when the live share rebinds its microphone', async () => {
platform.current = 'linux';
activeShareVideoDeviceId.current = 'camera-1';
@@ -418,6 +452,20 @@ describe('sharing a whole display', () => {
});
});
test('keeps the stored include list while a device share of its own is on the microphone', async () => {
platform.current = 'linux';
voiceSettings.audioSourceMode = 'specific';
voiceSettings.includeSources = [{'application.name': 'mpv'}];
voiceSettings.deviceUsesMicrophone = true;
expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true);
expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({
include: [{'application.name': 'mpv'}],
ignoreInputMedia: true,
});
});
test('arms native system audio on Windows and macOS', async () => {
for (const current of ['win32', 'darwin']) {
vi.clearAllMocks();
@@ -156,6 +156,7 @@ async function getManualAudioSourceSelectionInput(shareContext: StreamSettingsSh
nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null,
audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(),
selectedSourceCount: countRoutableAudioSources(),
usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(),
};
}
@@ -20,6 +20,7 @@ export interface ManualAudioSourceSelectionInput {
nativeAudioAvailability?: NativeAudioAvailability | null;
audioSourceMode?: ScreenShareAudioSourceMode;
selectedSourceCount?: number;
usesDeviceMicrophone?: boolean;
}
export interface WindowShareAudioScopeInput {
@@ -74,6 +75,7 @@ export function canSelectManualAudioSources(
}
export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean {
if (input.shareContext === 'device' && input.usesDeviceMicrophone === true) return false;
return (
canSelectManualAudioSources(input) && input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0
);