From 7c5cab2144eec8d7a40c431583e86bc16a985928 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:30:13 +0200 Subject: [PATCH] fix(app): make the media volume slider track loudness (#1847) --- .../media_player/hooks/useMediaVolume.ts | 153 +++++++++++++----- 1 file changed, 116 insertions(+), 37 deletions(-) diff --git a/fluxer_app/src/features/voice/components/media_player/hooks/useMediaVolume.ts b/fluxer_app/src/features/voice/components/media_player/hooks/useMediaVolume.ts index 921157008..1e11c7db3 100644 --- a/fluxer_app/src/features/voice/components/media_player/hooks/useMediaVolume.ts +++ b/fluxer_app/src/features/voice/components/media_player/hooks/useMediaVolume.ts @@ -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; @@ -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);