fix(voice): point the corner volume at the focused stream (#3157)

This commit is contained in:
Hampus
2026-10-03 12:37:24 +02:00
committed by GitHub
parent 07301adc6d
commit a9f7a23c0d
6 changed files with 93 additions and 156 deletions
@@ -1,60 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
import {
callVolumePercentToSliderVolume,
resolveCallVolumeMuteToggle,
resolveLastNonZeroCallVolume,
sliderVolumeToCallVolumePercent,
} from '@app/features/voice/components/CallVolumeState';
import {MediaVerticalVolumeControl} from '@app/features/voice/components/media_player/components/MediaVerticalVolumeControl';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {VOICE_OUTPUT_VOLUME_DESCRIPTOR} from '@app/features/voice/utils/VoiceMessageDescriptors';
import {VOICE_VOLUME_MAX_SLIDER_VOLUME} from '@app/features/voice/utils/VoiceVolumeUtils';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useRef} from 'react';
interface CallVolumeControlProps {
className?: string;
position?: 'above' | 'below';
iconSize?: number;
}
export const CallVolumeControl: React.FC<CallVolumeControlProps> = observer(function CallVolumeControl({
className,
position = 'above',
iconSize = 18,
}) {
const {i18n} = useLingui();
const outputVolume = VoiceSettings.outputVolume;
const lastNonZeroVolumeRef = useRef(resolveLastNonZeroCallVolume(outputVolume, 0));
useEffect(() => {
lastNonZeroVolumeRef.current = resolveLastNonZeroCallVolume(outputVolume, lastNonZeroVolumeRef.current);
}, [outputVolume]);
const handleVolumeChange = useCallback((volume: number) => {
const nextPercent = sliderVolumeToCallVolumePercent(volume);
if (nextPercent === VoiceSettings.outputVolume) return;
VoiceSettingsCommands.update({outputVolume: nextPercent});
}, []);
const handleToggleMute = useCallback(() => {
VoiceSettingsCommands.update({
outputVolume: resolveCallVolumeMuteToggle(VoiceSettings.outputVolume, lastNonZeroVolumeRef.current),
});
}, []);
return (
<MediaVerticalVolumeControl
volume={callVolumePercentToSliderVolume(outputVolume)}
isMuted={outputVolume === 0}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleVolumeChange}
onToggleMute={handleToggleMute}
iconSize={iconSize}
className={className}
position={position}
ariaLabel={i18n._(VOICE_OUTPUT_VOLUME_DESCRIPTOR)}
data-flx="voice.call-volume-control.media-vertical-volume-control"
/>
);
});
@@ -1,43 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {VOICE_VOLUME_MAX_PERCENT} from '@app/features/voice/utils/VoiceVolumeUtils';
export const CALL_VOLUME_DEFAULT_PERCENT = 100;
export const CALL_VOLUME_UNITY_PERCENT = 100;
export const CALL_VOLUME_SLIDER_MAX_PERCENT = VOICE_VOLUME_MAX_PERCENT;
export function callVolumePercentToSliderVolume(percent: number): number {
if (!Number.isFinite(percent)) {
return CALL_VOLUME_DEFAULT_PERCENT / CALL_VOLUME_UNITY_PERCENT;
}
const clamped = Math.max(0, Math.min(CALL_VOLUME_SLIDER_MAX_PERCENT, percent));
return clamped / CALL_VOLUME_UNITY_PERCENT;
}
export function sliderVolumeToCallVolumePercent(volume: number): number {
if (!Number.isFinite(volume)) {
return CALL_VOLUME_DEFAULT_PERCENT;
}
const percent = Math.round(volume * CALL_VOLUME_UNITY_PERCENT);
return Math.max(0, Math.min(CALL_VOLUME_SLIDER_MAX_PERCENT, percent));
}
export function resolveLastNonZeroCallVolume(currentPercent: number, previousLastNonZero: number): number {
if (currentPercent > 0) {
return currentPercent;
}
if (previousLastNonZero > 0) {
return previousLastNonZero;
}
return CALL_VOLUME_DEFAULT_PERCENT;
}
export function resolveCallVolumeMuteToggle(currentPercent: number, lastNonZeroPercent: number): number {
if (currentPercent > 0) {
return 0;
}
if (lastNonZeroPercent > 0) {
return lastNonZeroPercent;
}
return CALL_VOLUME_DEFAULT_PERCENT;
}
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {STREAM_VOLUME_DESCRIPTOR} from '@app/features/ui/action_menu/items/voice_participant_menu_data/shared';
import {MediaVerticalVolumeControl} from '@app/features/voice/components/media_player/components/MediaVerticalVolumeControl';
import {getStreamKey} from '@app/features/voice/components/StreamKeys';
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import {
asVoiceTrackSource,
isScreenShareAudioPublicationLike,
VoiceTrackSource,
} from '@app/features/voice/engine/VoiceTrackSource';
import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs';
import {parseVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
import {VOICE_VOLUME_MAX_SLIDER_VOLUME} from '@app/features/voice/utils/VoiceVolumeUtils';
import {useLingui} from '@lingui/react/macro';
import type {TrackReferenceOrPlaceholder} from '@livekit/components-react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
interface FocusedStreamVolumeControlProps {
track: TrackReferenceOrPlaceholder | null;
guildId: string | null | undefined;
channelId: string;
className?: string;
}
export const FocusedStreamVolumeControl: React.FC<FocusedStreamVolumeControlProps> = observer(
function FocusedStreamVolumeControl({track, guildId, channelId, className}) {
const {i18n} = useLingui();
useMediaEngineVersion();
const isRemoteScreenShare =
track != null && asVoiceTrackSource(track.source) === VoiceTrackSource.ScreenShare && !track.participant.isLocal;
const {userId, connectionId} = isRemoteScreenShare
? parseVoiceParticipantIdentity(track.participant.identity)
: {userId: null, connectionId: null};
const streamKey = connectionId ? getStreamKey(guildId, channelId, connectionId) : '';
const hasStreamAudio =
isRemoteScreenShare &&
[...track.participant.audioTrackPublications.values()].some((publication) =>
isScreenShareAudioPublicationLike(publication),
);
const isMuted = streamKey ? StreamAudioPrefs.isMuted(streamKey) : false;
const handleToggleMute = useCallback(() => {
if (!streamKey || !userId) return;
StreamAudioPrefs.setMuted(streamKey, !StreamAudioPrefs.isMuted(streamKey));
MediaEngine.applyLocalAudioPreferencesForUser(userId);
}, [streamKey, userId]);
const handleVolumeChange = useCallback(
(volume: number) => {
if (!streamKey || !userId) return;
StreamAudioPrefs.setVolume(streamKey, Math.round(volume * 100));
MediaEngine.applyLocalAudioPreferencesForUser(userId);
},
[streamKey, userId],
);
if (!streamKey || !userId || !hasStreamAudio) return null;
return (
<MediaVerticalVolumeControl
volume={StreamAudioPrefs.getVolume(streamKey) / 100}
isMuted={isMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleVolumeChange}
onToggleMute={handleToggleMute}
iconSize={18}
className={className}
position="above"
ariaLabel={i18n._(STREAM_VOLUME_DESCRIPTOR)}
data-flx="voice.focused-stream-volume-control.media-vertical-volume-control"
/>
);
},
);
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
import {CallVolumeControl} from '@app/features/voice/components/CallVolumeControl';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import type {Icon} from '@phosphor-icons/react';
@@ -54,12 +53,7 @@ export const VoiceCallCornerControls: React.FC<VoiceCallCornerControlsProps> = (
}, []);
return (
<div className={wrapClassName} data-flx="voice.voice-call-corner-controls.wrap">
{volumeControl ?? (
<CallVolumeControl
className={buttonClassName}
data-flx="voice.voice-call-corner-controls.call-volume-control"
/>
)}
{volumeControl}
{showPopout && onPopOut && (
<ChannelHeaderIcon
icon={PopOutIcon}
@@ -15,7 +15,6 @@ 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';
@@ -29,7 +28,7 @@ import Popout from '@app/features/ui/state/Popout';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {CompactVoiceCallView} from '@app/features/voice/components/CompactVoiceCallView';
import {MediaVerticalVolumeControl} from '@app/features/voice/components/media_player/components/MediaVerticalVolumeControl';
import {FocusedStreamVolumeControl} from '@app/features/voice/components/FocusedStreamVolumeControl';
import {PoppedOutOverlay} from '@app/features/voice/components/popout/PoppedOutOverlay';
import {
selectPoppedOutOverlayTransition,
@@ -57,26 +56,20 @@ import {
import {VoiceRegionTeleportOverlay} from '@app/features/voice/components/VoiceRegionTeleportOverlay';
import {VoiceDetailsPopout} from '@app/features/voice/components/voice_connection_status/VoiceDetailsPopout';
import {VoiceDebugStatsForwarder} from '@app/features/voice/diagnostics/VoiceDebugStatsForwarder';
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import {
asVoiceEngineConnectionState,
VoiceEngineConnectionState,
} from '@app/features/voice/engine/VoiceConnectionStateMachine';
import {
asVoiceTrackSource,
isScreenShareAudioPublicationLike,
VoiceTrackSource,
} from '@app/features/voice/engine/VoiceTrackSource';
import {asVoiceTrackSource, VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import PopoutWindowManager, {
getVoiceCallPopoutKey,
isVoicePopoutSupported,
} from '@app/features/voice/state/PopoutWindowManager';
import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs';
import VoiceCallLayout from '@app/features/voice/state/VoiceCallLayout';
import {hasValidRoomForVoiceCallContext} from '@app/features/voice/utils/VoiceCallContext';
import {VOICE_CALL_DESCRIPTOR} from '@app/features/voice/utils/VoiceMessageDescriptors';
import {parseVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
import {VOICE_VOLUME_MAX_SLIDER_VOLUME} from '@app/features/voice/utils/VoiceVolumeUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
import {msg, plural} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -282,13 +275,6 @@ const VoiceCallViewInner = observer(
return getStreamKey(channel.guildId, channel.id, focusedStreamInfo.connectionId);
}, [focusedStreamInfo, channel.guildId, channel.id]);
const focusedStreamTrackInfo = useStreamTrackInfo(isFocusedOnScreenShare ? effectiveFocusMainTrack : null);
const hasFocusedStreamAudio = Boolean(
effectiveFocusMainTrack &&
[...effectiveFocusMainTrack.participant.audioTrackPublications.values()].some((publication) =>
isScreenShareAudioPublicationLike(publication),
),
);
const canControlFocusedStreamVolume = focusedStreamInfo !== null && !effectiveFocusMainTrack?.participant.isLocal;
const focusedStreamerDisplayName = useMemo(() => {
if (!focusedStreamerUser) return '';
return NicknameUtils.getNickname(focusedStreamerUser, channel.guildId, channel.id);
@@ -297,21 +283,6 @@ const VoiceCallViewInner = observer(
focusedStreamKey,
focusedStreamInfo?.userId,
);
const focusedStreamVolume = StreamAudioPrefs.getVolume(focusedStreamKey);
const isFocusedStreamMuted = StreamAudioPrefs.isMuted(focusedStreamKey);
const handleFocusedStreamToggleMute = useCallback(() => {
if (!focusedStreamKey || !focusedStreamInfo) return;
StreamAudioPrefs.setMuted(focusedStreamKey, !isFocusedStreamMuted);
MediaEngine.applyLocalAudioPreferencesForUser(focusedStreamInfo.userId);
}, [focusedStreamKey, isFocusedStreamMuted, focusedStreamInfo]);
const handleFocusedStreamVolumeChange = useCallback(
(newVolume: number) => {
if (!focusedStreamKey || !focusedStreamInfo) return;
StreamAudioPrefs.setVolume(focusedStreamKey, Math.round(newVolume * 100));
MediaEngine.applyLocalAudioPreferencesForUser(focusedStreamInfo.userId);
},
[focusedStreamKey, focusedStreamInfo],
);
const handleSpectatorsPopoutOpenChange = useCallback((open: boolean) => {
setIsSpectatorsPopoutOpen(open);
}, []);
@@ -695,20 +666,13 @@ const VoiceCallViewInner = observer(
fullscreenIcon={FullscreenButtonIcon}
onToggleFullscreen={handleToggleVoiceCallAppFullscreen}
volumeControl={
isFocusedOnScreenShare && focusedStreamKey && hasFocusedStreamAudio && canControlFocusedStreamVolume ? (
<MediaVerticalVolumeControl
volume={focusedStreamVolume / 100}
isMuted={isFocusedStreamMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleFocusedStreamVolumeChange}
onToggleMute={handleFocusedStreamToggleMute}
iconSize={18}
className={styles.voiceHeaderIconButton}
position="above"
ariaLabel={i18n._(STREAM_VOLUME_DESCRIPTOR)}
data-flx="voice.voice-call-view.voice-call-view-inner.stream-volume-control"
/>
) : undefined
<FocusedStreamVolumeControl
track={isFocusedOnScreenShare ? effectiveFocusMainTrack : null}
guildId={channel.guildId}
channelId={channel.id}
className={styles.voiceHeaderIconButton}
data-flx="voice.voice-call-view.voice-call-view-inner.focused-stream-volume-control"
/>
}
data-flx="voice.voice-call-view.voice-call-view-inner.voice-call-corner-controls"
/>
@@ -27,6 +27,7 @@ import {
} from '@app/features/voice/components/compact_voice_call_view/shared';
import {useCompactVoiceCallResize} from '@app/features/voice/components/compact_voice_call_view/useCompactVoiceCallResize';
import {useConnectionLabel} from '@app/features/voice/components/compact_voice_call_view/useConnectionLabel';
import {FocusedStreamVolumeControl} from '@app/features/voice/components/FocusedStreamVolumeControl';
import {PoppedOutOverlay} from '@app/features/voice/components/popout/PoppedOutOverlay';
import {
selectPoppedOutOverlayTransition,
@@ -523,6 +524,14 @@ export const CompactVoiceCallViewInner: React.FC<CompactVoiceCallViewProps> = ob
fullscreenLabel={fullscreenButtonLabel}
fullscreenIcon={FullscreenButtonIcon}
onToggleFullscreen={handleToggleVoiceCallAppFullscreen}
volumeControl={
<FocusedStreamVolumeControl
track={layoutMode === 'focus' ? focusMainTrack : null}
guildId={channel.guildId}
channelId={channel.id}
data-flx="voice.compact-voice-call-view.compact-voice-call-view-inner.focused-stream-volume-control"
/>
}
data-flx="voice.compact-voice-call-view.compact-voice-call-view-inner.voice-call-corner-controls"
/>
{shouldRenderPoppedOutOverlay(poppedOutTransition.snapshot) && (