From ddd45ad5ba94fa5c3dbe1757ccc966d7f87effd7 Mon Sep 17 00:00:00 2001 From: Simon Slamka Date: Thu, 16 Jul 2026 08:43:43 +0300 Subject: [PATCH] feat(player): set right ctrl vol max to mpv's vol max if player==local (#2198) --- src/main/features/core/player/index.ts | 7 ++- src/main/features/linux/mpris.ts | 12 ++-- .../audio-player/engine/mpv-player-engine.tsx | 6 +- .../player/audio-player/utils/volume.ts | 58 +++++++++++++++++++ .../player/components/right-controls.tsx | 16 ++--- .../features/player/hooks/use-mpris.ts | 6 +- src/renderer/store/player.store.ts | 5 +- src/renderer/store/settings.store.ts | 7 +++ src/shared/constants/volume.ts | 10 ++++ 9 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 src/renderer/features/player/audio-player/utils/volume.ts create mode 100644 src/shared/constants/volume.ts diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index f3961984e..836e5645b 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -11,6 +11,7 @@ import { createLog } from '../../../utils'; import { store } from '../settings'; import { isMacOS, isWindows } from '/@/main/env'; +import { MPV_VOLUME_MAX_CEILING } from '/@/shared/constants/volume'; import { PlayerData } from '/@/shared/types/domain-types'; declare module 'node-mpv'; @@ -493,10 +494,12 @@ ipcMain.on('player-auto-next', async (_event, url?: string) => { } }); -// Sets the volume to the given value (0-100) +// Sets the volume to the given value. mpv clamps to its effective --volume-max, +// so the upper bound here is just a sanity guard; mpv itself is the final +// authority on how loud it will actually go. ipcMain.on('player-volume', async (_event, value: number) => { try { - if (!value || value < 0 || value > 100) { + if (value == null || Number.isNaN(value) || value < 0 || value > MPV_VOLUME_MAX_CEILING) { return; } diff --git a/src/main/features/linux/mpris.ts b/src/main/features/linux/mpris.ts index e1e1940d2..950dcc74f 100644 --- a/src/main/features/linux/mpris.ts +++ b/src/main/features/linux/mpris.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron'; import Player from 'mpris-service'; import { getMainWindow } from '/@/main/index'; +import { MPV_VOLUME_MAX_CEILING } from '/@/shared/constants/volume'; import { QueueSong } from '/@/shared/types/domain-types'; import { PlayerRepeat, PlayerStatus } from '/@/shared/types/types'; @@ -69,14 +70,11 @@ mprisPlayer.on('previous', () => { } }); +// The renderer clamps to the active backend's maximum (mpv's --volume-max when +// mpv is selected), since that range is derived from settings the renderer owns. +// Only a sanity range is enforced here. mprisPlayer.on('volume', (vol: number) => { - let volume = Math.round(vol * 100); - - if (volume > 100) { - volume = 100; - } else if (volume < 0) { - volume = 0; - } + const volume = Math.min(MPV_VOLUME_MAX_CEILING, Math.max(0, Math.round(vol * 100))); getMainWindow()?.webContents.send('request-volume', { volume, diff --git a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx index ad963156f..3b95a774f 100644 --- a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx @@ -7,6 +7,7 @@ import { eventEmitter } from '/@/renderer/events/event-emitter'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { getSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url'; import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types'; +import { resolveVolumeMax } from '/@/renderer/features/player/audio-player/utils/volume'; import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player'; import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-properties'; import { @@ -16,7 +17,7 @@ import { usePlayerStore, useSettingsStore, } from '/@/renderer/store'; -import { PlayerStatus } from '/@/shared/types/types'; +import { PlayerStatus, PlayerType } from '/@/shared/types/types'; export interface MpvPlayerEngineHandle extends AudioPlayer {} @@ -334,7 +335,8 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { } }, increaseVolume(by: number) { - const newVol = Math.min(1, internalVolume + by / 100); + const maxVol = resolveVolumeMax(PlayerType.LOCAL, mpvExtraParameters) / 100; + const newVol = Math.min(maxVol, internalVolume + by / 100); setInternalVolume(newVol); if (mpvPlayer) { mpvPlayer.volume(newVol * 100); diff --git a/src/renderer/features/player/audio-player/utils/volume.ts b/src/renderer/features/player/audio-player/utils/volume.ts new file mode 100644 index 000000000..97b4602db --- /dev/null +++ b/src/renderer/features/player/audio-player/utils/volume.ts @@ -0,0 +1,58 @@ +import { + DEFAULT_VOLUME_MAX, + MPV_VOLUME_MAX_CEILING, + MPV_VOLUME_MAX_DEFAULT, +} from '/@/shared/constants/volume'; +import { PlayerType } from '/@/shared/types/types'; + +const VOLUME_MAX_FLAG = '--volume-max'; + +// mpv accepts either "--volume-max=200" or "--volume-max 200" (as two argv +// entries). Repeated options are last-wins, so we scan from the end. +export const parseMpvVolumeMax = (extraParameters: string[] = []): null | number => { + for (let i = extraParameters.length - 1; i >= 0; i -= 1) { + const param = extraParameters[i]?.trim(); + if (!param?.startsWith(VOLUME_MAX_FLAG)) { + continue; + } + + const raw = param.includes('=') + ? param.slice(param.indexOf('=') + 1) + : extraParameters[i + 1]; + + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + return null; + } + + return Math.min(MPV_VOLUME_MAX_CEILING, Math.max(1, parsed)); + } + + return null; +}; + +// The highest value the volume control may reach for the active backend. +export const resolveVolumeMax = ( + playbackType: PlayerType, + extraParameters: string[] = [], +): number => { + if (playbackType !== PlayerType.LOCAL) { + return DEFAULT_VOLUME_MAX; + } + + const configured = parseMpvVolumeMax(extraParameters) ?? MPV_VOLUME_MAX_DEFAULT; + return Math.min(MPV_VOLUME_MAX_CEILING, Math.max(DEFAULT_VOLUME_MAX, configured)); +}; + +// Constrains an externally sourced volume (mpris, remote) to the active backend's range. +export const clampVolume = ( + volume: number, + playbackType: PlayerType, + extraParameters: string[] = [], +): number => { + if (!Number.isFinite(volume)) { + return 0; + } + + return Math.min(resolveVolumeMax(playbackType, extraParameters), Math.max(0, volume)); +}; diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index 998d64467..b07d5a64b 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -32,6 +32,7 @@ import { useSettingsStoreActions, useSidebarRightExpanded, useSideQueueType, + useVolumeMax, useVolumeWheelStep, useVolumeWidth, } from '/@/renderer/store'; @@ -56,11 +57,11 @@ import { useThrottledValue } from '/@/shared/hooks/use-throttled-value'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { PlayerType } from '/@/shared/types/types'; -const calculateVolumeUp = (volume: number, volumeWheelStep: number) => { +const calculateVolumeUp = (volume: number, volumeWheelStep: number, volumeMax: number) => { let volumeToSet: number; - const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100; - if (newVolumeGreaterThanHundred) { - volumeToSet = 100; + const newVolumeGreaterThanMax = volume + volumeWheelStep > volumeMax; + if (newVolumeGreaterThanMax) { + volumeToSet = volumeMax; } else { volumeToSet = volume + volumeWheelStep; } @@ -508,6 +509,7 @@ const VolumeButton = () => { const muted = usePlayerMuted(); const volumeWheelStep = useVolumeWheelStep(); const volumeWidth = useVolumeWidth(); + const volumeMax = useVolumeMax(); const { decreaseVolume, increaseVolume, mediaToggleMute, setVolume } = usePlayer(); const isMinWidth = useMediaQuery('(max-width: 480px)'); @@ -569,12 +571,12 @@ const VolumeButton = () => { if (e.deltaY > 0 || e.deltaX > 0) { volumeToSet = calculateVolumeDown(volume, volumeWheelStep); } else { - volumeToSet = calculateVolumeUp(volume, volumeWheelStep); + volumeToSet = calculateVolumeUp(volume, volumeWheelStep, volumeMax); } setVolume(volumeToSet); }, - [setVolume, volume, volumeWheelStep], + [setVolume, volume, volumeWheelStep, volumeMax], ); const handleVolumeDownThrottled = useThrottledCallback(handleVolumeDown, 100); @@ -638,7 +640,7 @@ const VolumeButton = () => { {!isMinWidth ? ( { diff --git a/src/renderer/features/player/hooks/use-mpris.ts b/src/renderer/features/player/hooks/use-mpris.ts index bdec1c386..08e6c38db 100644 --- a/src/renderer/features/player/hooks/use-mpris.ts +++ b/src/renderer/features/player/hooks/use-mpris.ts @@ -3,11 +3,12 @@ import React, { useEffect, useMemo } from 'react'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; +import { clampVolume } from '/@/renderer/features/player/audio-player/utils/volume'; import { useIsRadioActive, useRadioPlayer, } from '/@/renderer/features/radio/hooks/use-radio-player'; -import { usePlayerSong, usePlayerStore } from '/@/renderer/store'; +import { usePlayerSong, usePlayerStore, useSettingsStore } from '/@/renderer/store'; import { LibraryItem, QueueSong } from '/@/shared/types/domain-types'; import { PlayerShuffle, ServerType } from '/@/shared/types/types'; @@ -134,7 +135,8 @@ export const useMPRIS = () => { }); mpris?.requestVolume((data: { volume: number }) => { - player.setVolume(data.volume); + const { mpvExtraParameters, type } = useSettingsStore.getState().playback; + player.setVolume(clampVolume(data.volume, type, mpvExtraParameters)); }); return () => { diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index 565159eb4..d9da80a11 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -7,6 +7,7 @@ import { useShallow } from 'zustand/react/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; import { eventEmitter } from '/@/renderer/events/event-emitter'; +import { resolveVolumeMax } from '/@/renderer/features/player/audio-player/utils/volume'; import { useRadioStore as useRadioPlayerStore } from '/@/renderer/features/radio/hooks/use-radio-player'; import { createSelectors } from '/@/renderer/lib/zustand'; import { useSettingsStore } from '/@/renderer/store/settings.store'; @@ -907,8 +908,10 @@ export const usePlayerStoreBase = createWithEqualityFn()( }; }, increaseVolume: (value: number) => { + const { mpvExtraParameters, type } = useSettingsStore.getState().playback; + const volumeMax = resolveVolumeMax(type, mpvExtraParameters); set((state) => { - state.player.volume = Math.min(100, state.player.volume + value); + state.player.volume = Math.min(volumeMax, state.player.volume + value); }); }, isFirstTrackInQueue: () => { diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 4813cf46d..41bacd476 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -21,6 +21,7 @@ import { PLAYLIST_TABLE_COLUMNS, SONG_TABLE_COLUMNS, } from '/@/renderer/components/item-list/item-table-list/default-columns'; +import { resolveVolumeMax } from '/@/renderer/features/player/audio-player/utils/volume'; import { audiomotionanalyzerPresets } from '/@/renderer/features/visualizer/components/audiomotionanalyzer/presets'; import { AppRoute } from '/@/renderer/router/routes'; import { getEnvSettingsOverrides } from '/@/renderer/store/env-settings-overrides'; @@ -2638,6 +2639,12 @@ export const useGeneralSettings = () => useSettingsStore((state) => state.genera export const usePlaybackType = () => useSettingsStore((state) => state.playback.type, shallow); +export const useVolumeMax = () => + useSettingsStore( + (state) => resolveVolumeMax(state.playback.type, state.playback.mpvExtraParameters), + shallow, + ); + export const usePlayButtonBehavior = () => useSettingsStore((state) => state.general.playButtonBehavior, shallow); diff --git a/src/shared/constants/volume.ts b/src/shared/constants/volume.ts new file mode 100644 index 000000000..29fc629b8 --- /dev/null +++ b/src/shared/constants/volume.ts @@ -0,0 +1,10 @@ +// Defines the volume limits shared by the main and renderer processes. +// The active maximum is resolved from these in the renderer, which owns the +// playback settings that determine it. + +// The web-audio backend has no headroom above unity gain, so it stays at 100. +export const DEFAULT_VOLUME_MAX = 100; +// mpv's default --volume-max when the user passes nothing. +export const MPV_VOLUME_MAX_DEFAULT = 130; +// mpv's own hard ceiling for --volume-max. +export const MPV_VOLUME_MAX_CEILING = 1000;