feat(player): right-click volume icon to switch audio output device (#2189)

* feat(player): add right-click audio output device menu to volume icon

Right-clicking the volume/speaker icon in the player bar now opens a
context menu listing available audio output devices, with the active
device checkmarked and a System default reset option.

Selecting a device writes to the existing audioDeviceId (web) or
mpvAudioDeviceId (mpv) playback settings, so the choice is applied live
and persisted across restarts via the settings store. Unlike the
settings-page selector, the menu is not disabled during playback.

* refactor(player): drop redundant audio menu label, document wrapper div

Address review feedback: remove the self-explanatory ContextMenu.Label
from the audio output menu, and add a comment explaining why the volume
icon is wrapped in a div (Mantine Tooltip does not forward Radix's
asChild onContextMenu/ref to the button).
This commit is contained in:
Logan Rupe
2026-07-10 14:11:10 +10:00
committed by GitHub
parent c64807cf94
commit dd136f91e6
2 changed files with 76 additions and 18 deletions
+1
View File
@@ -784,6 +784,7 @@
"artistConfiguration_description": "Configure what items are shown, and in what order, on the album artist page", "artistConfiguration_description": "Configure what items are shown, and in what order, on the album artist page",
"artistReleaseTypeConfiguration": "Artist release type configuration", "artistReleaseTypeConfiguration": "Artist release type configuration",
"artistReleaseTypeConfiguration_description": "Configure what release types are shown, and in what order, on the album artist page", "artistReleaseTypeConfiguration_description": "Configure what release types are shown, and in what order, on the album artist page",
"audioDeviceDefault": "System default",
"audioDevice_description": "Select the audio device to use for playback", "audioDevice_description": "Select the audio device to use for playback",
"audioDevice": "Audio device", "audioDevice": "Audio device",
"audioExclusiveMode_description": "Enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio. Visualizer system audio capture will not work while this is enabled", "audioExclusiveMode_description": "Enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio. Visualizer system audio capture will not work while this is enabled",
@@ -7,6 +7,7 @@ import { PlayerConfig } from '/@/renderer/features/player/components/player-conf
import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { SleepTimerButton } from '/@/renderer/features/player/components/sleep-timer-button'; import { SleepTimerButton } from '/@/renderer/features/player/components/sleep-timer-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useAudioDevices } from '/@/renderer/features/settings/components/playback/audio-settings';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating'; import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
@@ -21,6 +22,8 @@ import {
useFullScreenPlayerStore, useFullScreenPlayerStore,
useGeneralSettings, useGeneralSettings,
useHotkeySettings, useHotkeySettings,
usePlaybackSettings,
usePlaybackType,
usePlayerData, usePlayerData,
usePlayerMuted, usePlayerMuted,
usePlayerSong, usePlayerSong,
@@ -35,6 +38,7 @@ import {
import { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store'; import { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { Flex } from '/@/shared/components/flex/flex'; import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
@@ -50,6 +54,7 @@ import { useMediaQuery } from '/@/shared/hooks/use-media-query';
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback'; import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { useThrottledValue } from '/@/shared/hooks/use-throttled-value'; import { useThrottledValue } from '/@/shared/hooks/use-throttled-value';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; 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) => {
let volumeToSet: number; let volumeToSet: number;
@@ -506,6 +511,28 @@ const VolumeButton = () => {
const { decreaseVolume, increaseVolume, mediaToggleMute, setVolume } = usePlayer(); const { decreaseVolume, increaseVolume, mediaToggleMute, setVolume } = usePlayer();
const isMinWidth = useMediaQuery('(max-width: 480px)'); const isMinWidth = useMediaQuery('(max-width: 480px)');
const playbackType = usePlaybackType();
const playbackSettings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const audioDevices = useAudioDevices(playbackType);
const currentAudioDeviceId =
playbackType === PlayerType.LOCAL
? playbackSettings.mpvAudioDeviceId
: playbackSettings.audioDeviceId;
const handleSelectAudioDevice = useCallback(
(deviceId: null | string) => {
setSettings({
playback:
playbackType === PlayerType.LOCAL
? { mpvAudioDeviceId: deviceId }
: { audioDeviceId: deviceId },
});
},
[playbackType, setSettings],
);
const [sliderValue, setSliderValue] = useState(volume); const [sliderValue, setSliderValue] = useState(volume);
const throttledVolume = useThrottledValue(sliderValue, 100); const throttledVolume = useThrottledValue(sliderValue, 100);
@@ -561,24 +588,54 @@ const VolumeButton = () => {
return ( return (
<> <>
<ActionIcon <ContextMenu>
icon={muted ? 'volumeMute' : volume > 50 ? 'volumeMax' : 'volumeNormal'} <ContextMenu.Target>
iconProps={{ {/*
color: muted ? 'muted' : undefined, * ActionIcon renders a Mantine Tooltip wrapper, which does not
size: 'xl', * forward the onContextMenu/ref that Radix injects via asChild to
}} * the underlying button. Wrap in a real DOM node so right-click
onClick={(e) => { * reliably opens the menu.
e.stopPropagation(); */}
handleMute(); <div style={{ alignItems: 'center', display: 'flex' }}>
}} <ActionIcon
onWheel={handleVolumeWheel} icon={muted ? 'volumeMute' : volume > 50 ? 'volumeMax' : 'volumeNormal'}
size="sm" iconProps={{
tooltip={{ color: muted ? 'muted' : undefined,
label: muted ? t('player.muted') : volume, size: 'xl',
openDelay: 0, }}
}} onClick={(e) => {
variant="subtle" e.stopPropagation();
/> handleMute();
}}
onWheel={handleVolumeWheel}
size="sm"
tooltip={{
label: muted ? t('player.muted') : volume,
openDelay: 0,
}}
variant="subtle"
/>
</div>
</ContextMenu.Target>
<ContextMenu.Content>
<ContextMenu.Item
isSelected={!currentAudioDeviceId}
onSelect={() => handleSelectAudioDevice(null)}
>
{t('setting.audioDeviceDefault', { defaultValue: 'System default' })}
</ContextMenu.Item>
{audioDevices.length > 0 && <ContextMenu.Divider />}
{audioDevices.map((device) => (
<ContextMenu.Item
isSelected={device.value === currentAudioDeviceId}
key={device.value}
onSelect={() => handleSelectAudioDevice(device.value)}
>
{device.label || device.value}
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu>
{!isMinWidth ? ( {!isMinWidth ? (
<CustomPlayerbarSlider <CustomPlayerbarSlider
max={100} max={100}