fix(app): make the media volume slider track loudness (#1847)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:30:13 +02:00
1 parent 5cb893245f
commit 7c5cab2144
1 file changed
+116 -37
@@ -6,7 +6,22 @@ import {
MUTE_STORAGE_KEY,
VOLUME_STORAGE_KEY,
} from '@app/features/voice/components/media_player/utils/MediaConstants';
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCallback, useEffect, useRef, useState, useSyncExternalStore} from 'react';
const VOLUME_CURVE_EXPONENT = 2.8;
const VOLUME_SETTING_EPSILON = 1e-6;
export function mediaGainFromVolumeSetting(setting: number): number {
if (!Number.isFinite(setting) || setting <= 0) return 0;
if (setting >= 1) return 1;
return setting ** VOLUME_CURVE_EXPONENT;
}
export function volumeSettingFromMediaGain(gain: number): number {
if (!Number.isFinite(gain) || gain <= 0) return 0;
if (gain >= 1) return 1;
return gain ** (1 / VOLUME_CURVE_EXPONENT);
}
interface UseMediaVolumeOptions {
mediaRef: React.RefObject<HTMLMediaElement | null>;
@@ -34,7 +49,7 @@ function getStoredVolume(): number {
if (stored !== null) {
const value = parseFloat(stored);
if (Number.isFinite(value) && value >= 0 && value <= 1) {
return value;
return volumeSettingFromMediaGain(value);
}
}
} catch {}
@@ -49,9 +64,9 @@ function getStoredMuted(): boolean {
}
}
function storeVolume(volume: number): void {
function storeVolume(setting: number): void {
try {
AppStorage.setItem(VOLUME_STORAGE_KEY, volume.toString());
AppStorage.setItem(VOLUME_STORAGE_KEY, mediaGainFromVolumeSetting(setting).toString());
} catch {}
}
@@ -61,65 +76,129 @@ function storeMuted(muted: boolean): void {
} catch {}
}
interface StoredVolumeSetting {
volume: number;
muted: boolean;
}
let storedVolumeSetting: StoredVolumeSetting | null = null;
const storedVolumeListeners = new Set<() => void>();
function readStoredVolumeSetting(): StoredVolumeSetting {
storedVolumeSetting ??= {volume: getStoredVolume(), muted: getStoredMuted()};
return storedVolumeSetting;
}
function subscribeStoredVolumeSetting(listener: () => void): () => void {
storedVolumeListeners.add(listener);
return () => {
storedVolumeListeners.delete(listener);
};
}
function publishStoredVolumeSetting(next: StoredVolumeSetting): void {
const current = readStoredVolumeSetting();
if (Math.abs(current.volume - next.volume) < VOLUME_SETTING_EPSILON && current.muted === next.muted) {
return;
}
storedVolumeSetting = next;
storeVolume(next.volume);
storeMuted(next.muted);
for (const listener of Array.from(storedVolumeListeners)) {
listener();
}
}
export function resetStoredVolumeSettingForTests(): void {
storedVolumeSetting = null;
storedVolumeListeners.clear();
}
export function useMediaVolume(options: UseMediaVolumeOptions): UseMediaVolumeReturn {
const {mediaRef, initialVolume, initialMuted, persist = true, onVolumeChange, onMuteChange} = options;
const [volume, setVolumeState] = useState(() => initialVolume ?? (persist ? getStoredVolume() : DEFAULT_VOLUME));
const [isMuted, setIsMutedState] = useState(() => initialMuted ?? (persist ? getStoredMuted() : false));
const tracksSharedSetting = persist && initialVolume === undefined && initialMuted === undefined;
const sharedSetting = useSyncExternalStore(
subscribeStoredVolumeSetting,
readStoredVolumeSetting,
readStoredVolumeSetting,
);
const [ownVolume, setOwnVolume] = useState(() => initialVolume ?? (persist ? getStoredVolume() : DEFAULT_VOLUME));
const [ownMuted, setOwnMuted] = useState(() => initialMuted ?? (persist ? getStoredMuted() : false));
const volume = tracksSharedSetting ? sharedSetting.volume : ownVolume;
const isMuted = tracksSharedSetting ? sharedSetting.muted : ownMuted;
const previousVolumeRef = useRef(volume > 0 ? volume : DEFAULT_VOLUME);
const settingRef = useRef({volume, isMuted});
settingRef.current = {volume, isMuted};
const commitSetting = useCallback(
(nextVolume: number, nextMuted: boolean) => {
if (tracksSharedSetting) {
publishStoredVolumeSetting({volume: nextVolume, muted: nextMuted});
return;
}
setOwnVolume(nextVolume);
setOwnMuted(nextMuted);
if (persist) {
storeVolume(nextVolume);
storeMuted(nextMuted);
}
},
[persist, tracksSharedSetting],
);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
media.volume = volume;
media.volume = mediaGainFromVolumeSetting(volume);
media.muted = isMuted;
const handleVolumeChange = () => {
const newVolume = media.volume;
const newMuted = media.muted;
setVolumeState(newVolume);
setIsMutedState(newMuted);
if (newVolume > 0) {
previousVolumeRef.current = newVolume;
const setting = volumeSettingFromMediaGain(media.volume);
if (setting > 0) {
previousVolumeRef.current = setting;
}
const current = settingRef.current;
if (Math.abs(current.volume - setting) < VOLUME_SETTING_EPSILON && current.isMuted === media.muted) {
return;
}
commitSetting(setting, media.muted);
};
media.addEventListener('volumechange', handleVolumeChange);
return () => {
media.removeEventListener('volumechange', handleVolumeChange);
};
}, [mediaRef, volume, isMuted]);
}, [mediaRef, volume, isMuted, commitSetting]);
const setMuted = useCallback(
(muted: boolean) => {
const media = mediaRef.current;
let nextVolume = settingRef.current.volume;
if (!muted && nextVolume === 0) {
nextVolume = previousVolumeRef.current;
}
if (media) {
media.muted = muted;
media.volume = mediaGainFromVolumeSetting(nextVolume);
}
commitSetting(nextVolume, muted);
onMuteChange?.(muted);
},
[mediaRef, commitSetting, onMuteChange],
);
const setVolume = useCallback(
(newVolume: number) => {
const media = mediaRef.current;
const clampedVolume = Math.max(0, Math.min(1, newVolume));
if (media) {
media.volume = clampedVolume;
media.volume = mediaGainFromVolumeSetting(clampedVolume);
}
setVolumeState(clampedVolume);
if (clampedVolume > 0) {
previousVolumeRef.current = clampedVolume;
}
if (persist) {
storeVolume(clampedVolume);
const nextMuted = clampedVolume === 0;
if (media) {
media.muted = nextMuted;
}
commitSetting(clampedVolume, nextMuted);
onVolumeChange?.(clampedVolume);
},
[mediaRef, persist, onVolumeChange],
);
const setMuted = useCallback(
(muted: boolean) => {
const media = mediaRef.current;
if (media) {
media.muted = muted;
if (!muted && media.volume === 0) {
media.volume = previousVolumeRef.current;
setVolumeState(previousVolumeRef.current);
}
}
setIsMutedState(muted);
if (persist) {
storeMuted(muted);
}
onMuteChange?.(muted);
},
[mediaRef, persist, onMuteChange],
[mediaRef, commitSetting, onVolumeChange],
);
const toggleMute = useCallback(() => {
setMuted(!isMuted);