diff --git a/fluxer_app/src/features/voice/components/CallVolumeControl.tsx b/fluxer_app/src/features/voice/components/CallVolumeControl.tsx index 7d5837954..d06dc1c5d 100644 --- a/fluxer_app/src/features/voice/components/CallVolumeControl.tsx +++ b/fluxer_app/src/features/voice/components/CallVolumeControl.tsx @@ -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 = 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 = observer(func { 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); + }); +}); diff --git a/fluxer_app/src/features/voice/components/CallVolumeState.ts b/fluxer_app/src/features/voice/components/CallVolumeState.ts index e4ce76a70..ec13ba285 100644 --- a/fluxer_app/src/features/voice/components/CallVolumeState.ts +++ b/fluxer_app/src/features/voice/components/CallVolumeState.ts @@ -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)); } diff --git a/fluxer_app/src/features/voice/components/VoiceCallView.tsx b/fluxer_app/src/features/voice/components/VoiceCallView.tsx index 32342d1fd..8b92ab83b 100644 --- a/fluxer_app/src/features/voice/components/VoiceCallView.tsx +++ b/fluxer_app/src/features/voice/components/VoiceCallView.tsx @@ -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( (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"