mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): make the media volume slider track loudness (#1847)
This commit is contained in:
1 parent
5cb893245f
commit
7c5cab2144
1 file changed
+116
-37
@@ -6,7 +6,22 @@ import {
|
|||||||
MUTE_STORAGE_KEY,
|
MUTE_STORAGE_KEY,
|
||||||
VOLUME_STORAGE_KEY,
|
VOLUME_STORAGE_KEY,
|
||||||
} from '@app/features/voice/components/media_player/utils/MediaConstants';
|
} 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 {
|
interface UseMediaVolumeOptions {
|
||||||
mediaRef: React.RefObject<HTMLMediaElement | null>;
|
mediaRef: React.RefObject<HTMLMediaElement | null>;
|
||||||
@@ -34,7 +49,7 @@ function getStoredVolume(): number {
|
|||||||
if (stored !== null) {
|
if (stored !== null) {
|
||||||
const value = parseFloat(stored);
|
const value = parseFloat(stored);
|
||||||
if (Number.isFinite(value) && value >= 0 && value <= 1) {
|
if (Number.isFinite(value) && value >= 0 && value <= 1) {
|
||||||
return value;
|
return volumeSettingFromMediaGain(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -49,9 +64,9 @@ function getStoredMuted(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function storeVolume(volume: number): void {
|
function storeVolume(setting: number): void {
|
||||||
try {
|
try {
|
||||||
AppStorage.setItem(VOLUME_STORAGE_KEY, volume.toString());
|
AppStorage.setItem(VOLUME_STORAGE_KEY, mediaGainFromVolumeSetting(setting).toString());
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,65 +76,129 @@ function storeMuted(muted: boolean): void {
|
|||||||
} catch {}
|
} 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 {
|
export function useMediaVolume(options: UseMediaVolumeOptions): UseMediaVolumeReturn {
|
||||||
const {mediaRef, initialVolume, initialMuted, persist = true, onVolumeChange, onMuteChange} = options;
|
const {mediaRef, initialVolume, initialMuted, persist = true, onVolumeChange, onMuteChange} = options;
|
||||||
const [volume, setVolumeState] = useState(() => initialVolume ?? (persist ? getStoredVolume() : DEFAULT_VOLUME));
|
const tracksSharedSetting = persist && initialVolume === undefined && initialMuted === undefined;
|
||||||
const [isMuted, setIsMutedState] = useState(() => initialMuted ?? (persist ? getStoredMuted() : false));
|
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 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(() => {
|
useEffect(() => {
|
||||||
const media = mediaRef.current;
|
const media = mediaRef.current;
|
||||||
if (!media) return;
|
if (!media) return;
|
||||||
media.volume = volume;
|
media.volume = mediaGainFromVolumeSetting(volume);
|
||||||
media.muted = isMuted;
|
media.muted = isMuted;
|
||||||
const handleVolumeChange = () => {
|
const handleVolumeChange = () => {
|
||||||
const newVolume = media.volume;
|
const setting = volumeSettingFromMediaGain(media.volume);
|
||||||
const newMuted = media.muted;
|
if (setting > 0) {
|
||||||
setVolumeState(newVolume);
|
previousVolumeRef.current = setting;
|
||||||
setIsMutedState(newMuted);
|
|
||||||
if (newVolume > 0) {
|
|
||||||
previousVolumeRef.current = newVolume;
|
|
||||||
}
|
}
|
||||||
|
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);
|
media.addEventListener('volumechange', handleVolumeChange);
|
||||||
return () => {
|
return () => {
|
||||||
media.removeEventListener('volumechange', handleVolumeChange);
|
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(
|
const setVolume = useCallback(
|
||||||
(newVolume: number) => {
|
(newVolume: number) => {
|
||||||
const media = mediaRef.current;
|
const media = mediaRef.current;
|
||||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||||||
if (media) {
|
if (media) {
|
||||||
media.volume = clampedVolume;
|
media.volume = mediaGainFromVolumeSetting(clampedVolume);
|
||||||
}
|
}
|
||||||
setVolumeState(clampedVolume);
|
|
||||||
if (clampedVolume > 0) {
|
if (clampedVolume > 0) {
|
||||||
previousVolumeRef.current = clampedVolume;
|
previousVolumeRef.current = clampedVolume;
|
||||||
}
|
}
|
||||||
if (persist) {
|
const nextMuted = clampedVolume === 0;
|
||||||
storeVolume(clampedVolume);
|
if (media) {
|
||||||
|
media.muted = nextMuted;
|
||||||
}
|
}
|
||||||
|
commitSetting(clampedVolume, nextMuted);
|
||||||
onVolumeChange?.(clampedVolume);
|
onVolumeChange?.(clampedVolume);
|
||||||
},
|
},
|
||||||
[mediaRef, persist, onVolumeChange],
|
[mediaRef, commitSetting, 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],
|
|
||||||
);
|
);
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
setMuted(!isMuted);
|
setMuted(!isMuted);
|
||||||
|
|||||||
Reference in new issue
Block a user