From dd136f91e68f3f9b19b2b8346c311be8f71f9fca Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Fri, 10 Jul 2026 14:11:10 +1000 Subject: [PATCH] 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). --- src/i18n/locales/en.json | 1 + .../player/components/right-controls.tsx | 93 +++++++++++++++---- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cf5a4b9f1..9332059e2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -784,6 +784,7 @@ "artistConfiguration_description": "Configure what items are shown, and in what order, on the album artist page", "artistReleaseTypeConfiguration": "Artist release type configuration", "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": "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", diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index 555da28a2..998d64467 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -7,6 +7,7 @@ import { PlayerConfig } from '/@/renderer/features/player/components/player-conf import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; import { SleepTimerButton } from '/@/renderer/features/player/components/sleep-timer-button'; 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 { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; @@ -21,6 +22,8 @@ import { useFullScreenPlayerStore, useGeneralSettings, useHotkeySettings, + usePlaybackSettings, + usePlaybackType, usePlayerData, usePlayerMuted, usePlayerSong, @@ -35,6 +38,7 @@ import { import { useFullScreenPlayerStoreActions } from '/@/renderer/store/full-screen-player.store'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; +import { ContextMenu } from '/@/shared/components/context-menu/context-menu'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; 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 { 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) => { let volumeToSet: number; @@ -506,6 +511,28 @@ const VolumeButton = () => { const { decreaseVolume, increaseVolume, mediaToggleMute, setVolume } = usePlayer(); 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 throttledVolume = useThrottledValue(sliderValue, 100); @@ -561,24 +588,54 @@ const VolumeButton = () => { return ( <> - 50 ? 'volumeMax' : 'volumeNormal'} - iconProps={{ - color: muted ? 'muted' : undefined, - size: 'xl', - }} - onClick={(e) => { - e.stopPropagation(); - handleMute(); - }} - onWheel={handleVolumeWheel} - size="sm" - tooltip={{ - label: muted ? t('player.muted') : volume, - openDelay: 0, - }} - variant="subtle" - /> + + + {/* + * ActionIcon renders a Mantine Tooltip wrapper, which does not + * forward the onContextMenu/ref that Radix injects via asChild to + * the underlying button. Wrap in a real DOM node so right-click + * reliably opens the menu. + */} +
+ 50 ? 'volumeMax' : 'volumeNormal'} + iconProps={{ + color: muted ? 'muted' : undefined, + size: 'xl', + }} + onClick={(e) => { + e.stopPropagation(); + handleMute(); + }} + onWheel={handleVolumeWheel} + size="sm" + tooltip={{ + label: muted ? t('player.muted') : volume, + openDelay: 0, + }} + variant="subtle" + /> +
+
+ + handleSelectAudioDevice(null)} + > + {t('setting.audioDeviceDefault', { defaultValue: 'System default' })} + + {audioDevices.length > 0 && } + {audioDevices.map((device) => ( + handleSelectAudioDevice(device.value)} + > + {device.label || device.value} + + ))} + +
{!isMinWidth ? (