fix(voice): stop the in call speaker slider clamping a boosted output volume (#1785)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-20 17:42:59 +02:00
1 parent 17eab43245
commit ef6536644c
5 files changed
+48 -11

No files matched your search

@@ -9,6 +9,7 @@ import {
} 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_VOLUME_MAX_SLIDER_VOLUME} from '@app/features/voice/utils/VoiceVolumeUtils';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useRef} from 'react';
@@ -30,7 +31,9 @@ export const CallVolumeControl: React.FC<CallVolumeControlProps> = observer(func
lastNonZeroVolumeRef.current = resolveLastNonZeroCallVolume(outputVolume, lastNonZeroVolumeRef.current);
}, [outputVolume]);
const handleVolumeChange = useCallback((volume: number) => {
VoiceSettingsCommands.update({outputVolume: sliderVolumeToCallVolumePercent(volume)});
const nextPercent = sliderVolumeToCallVolumePercent(volume);
if (nextPercent === VoiceSettings.outputVolume) return;
VoiceSettingsCommands.update({outputVolume: nextPercent});
}, []);
const handleToggleMute = useCallback(() => {
VoiceSettingsCommands.update({
@@ -41,6 +44,7 @@ export const CallVolumeControl: React.FC<CallVolumeControlProps> = observer(func
<MediaVerticalVolumeControl
volume={callVolumePercentToSliderVolume(outputVolume)}
isMuted={outputVolume === 0}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleVolumeChange}
onToggleMute={handleToggleMute}
iconSize={iconSize}
@@ -23,6 +23,11 @@ describe('callVolumePercentToSliderVolume', () => {
it('clamps negative percent to zero', () => {
expect(callVolumePercentToSliderVolume(-10)).toBe(0);
});
it('reaches the boosted ceiling and clamps past it', () => {
expect(callVolumePercentToSliderVolume(200)).toBe(2);
expect(callVolumePercentToSliderVolume(250)).toBe(2);
});
});
describe('sliderVolumeToCallVolumePercent', () => {
@@ -34,7 +39,12 @@ describe('sliderVolumeToCallVolumePercent', () => {
it('clamps out-of-range slider values', () => {
expect(sliderVolumeToCallVolumePercent(-0.5)).toBe(0);
expect(sliderVolumeToCallVolumePercent(2)).toBe(100);
expect(sliderVolumeToCallVolumePercent(2.5)).toBe(200);
});
it('maps the boosted range above unity', () => {
expect(sliderVolumeToCallVolumePercent(1.5)).toBe(150);
expect(sliderVolumeToCallVolumePercent(2)).toBe(200);
});
it('falls back to the default for non-finite input', () => {
@@ -70,3 +80,18 @@ describe('resolveCallVolumeMuteToggle', () => {
expect(resolveCallVolumeMuteToggle(0, 0)).toBe(CALL_VOLUME_DEFAULT_PERCENT);
});
});
describe('call volume round trip', () => {
it('preserves a stored boosted volume through the slider', () => {
expect(sliderVolumeToCallVolumePercent(callVolumePercentToSliderVolume(200))).toBe(200);
});
it('steps a boosted volume down by one keyboard step instead of collapsing to unity', () => {
expect(sliderVolumeToCallVolumePercent(Math.max(0, callVolumePercentToSliderVolume(200) - 0.1))).toBe(190);
});
it('restores a boosted volume after a mute toggle', () => {
expect(resolveCallVolumeMuteToggle(200, 200)).toBe(0);
expect(resolveCallVolumeMuteToggle(0, resolveLastNonZeroCallVolume(0, 200))).toBe(200);
});
});
@@ -1,20 +1,24 @@
// 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_SLIDER_MAX_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_SLIDER_MAX_PERCENT;
return CALL_VOLUME_DEFAULT_PERCENT / CALL_VOLUME_UNITY_PERCENT;
}
return Math.max(0, percent / CALL_VOLUME_SLIDER_MAX_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_SLIDER_MAX_PERCENT);
const percent = Math.round(volume * CALL_VOLUME_UNITY_PERCENT);
return Math.max(0, Math.min(CALL_VOLUME_SLIDER_MAX_PERCENT, percent));
}
@@ -68,6 +68,7 @@ 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';
@@ -620,6 +621,7 @@ const VoiceCallViewInner = observer(
<MediaVerticalVolumeControl
volume={focusedStreamVolume / 100}
isMuted={isFocusedStreamMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleFocusedStreamVolumeChange}
onToggleMute={handleFocusedStreamToggleMute}
iconSize={18}
@@ -48,6 +48,7 @@ interface MediaVerticalVolumeControlProps {
iconSize?: number;
className?: string;
position?: 'above' | 'below';
maxVolume?: number;
}
function getVolumeIcon(volume: number, isMuted: boolean) {
@@ -73,6 +74,7 @@ export function MediaVerticalVolumeControl({
iconSize = 18,
className,
position = 'above',
maxVolume = 1,
}: MediaVerticalVolumeControlProps) {
const {i18n} = useLingui();
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -178,9 +180,9 @@ export function MediaVerticalVolumeControl({
}, [isDragging, scheduleClose]);
const handleSliderValueChange = useCallback(
(nextValue: number) => {
onVolumeChange(Math.max(0, Math.min(1, nextValue / 100)));
onVolumeChange(Math.max(0, Math.min(maxVolume, nextValue / 100)));
},
[onVolumeChange],
[maxVolume, onVolumeChange],
);
const handleSliderInteractionChange = useCallback(
(nextIsDragging: boolean) => {
@@ -209,7 +211,7 @@ export function MediaVerticalVolumeControl({
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
newVolume = Math.min(1, volume + step);
newVolume = Math.min(maxVolume, volume + step);
break;
case 'ArrowDown':
case 'ArrowLeft':
@@ -228,7 +230,7 @@ export function MediaVerticalVolumeControl({
}
onVolumeChange(newVolume);
},
[volume, onVolumeChange, onToggleMute],
[maxVolume, volume, onVolumeChange, onToggleMute],
);
useEffect(() => {
return () => {
@@ -264,7 +266,7 @@ export function MediaVerticalVolumeControl({
defaultValue={sliderValue}
factoryDefaultValue={100}
minValue={0}
maxValue={100}
maxValue={maxVolume * 100}
step={1}
value={sliderValue}
orientation="vertical"