add audio device selection for mpv

This commit is contained in:
jeffvli
2026-01-14 19:12:36 -08:00
parent d95204513f
commit 41054ed819
5 changed files with 132 additions and 23 deletions
+57
View File
@@ -525,6 +525,63 @@ ipcMain.handle(
},
);
ipcMain.handle(
'player-get-audio-devices',
async (): Promise<{ label: string; value: string }[]> => {
try {
const instance = getMpvInstance();
let tempInstance: MpvAPI | null = null;
let mpvToUse: MpvAPI | null = null;
if (instance && instance.isRunning()) {
mpvToUse = instance;
} else {
try {
tempInstance = await createMpv({});
mpvToUse = tempInstance;
} catch (err: any | NodeMpvError) {
mpvLog(
{ action: 'Failed to create temporary MPV instance for audio device list' },
err,
);
return [];
}
}
try {
const deviceList = await mpvToUse.getProperty('audio-device-list');
if (!deviceList || !Array.isArray(deviceList)) {
return [];
}
const devices = deviceList.map((device: any) => {
const name = device.name || device.description || 'Unknown Device';
const description = device.description || '';
const label = description ? `${name} (${description})` : name;
return {
label,
value: name,
};
});
return devices;
} finally {
if (tempInstance && tempInstance !== instance) {
try {
await quit(tempInstance);
} catch {
// Ignore
}
}
}
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to get audio devices' }, err);
return [];
}
},
);
enum MpvState {
STARTED,
IN_PROGRESS,
+5
View File
@@ -98,6 +98,10 @@ const getStreamMetadata = async () => {
return ipcRenderer.invoke('player-stream-metadata');
};
const getAudioDevices = async () => {
return ipcRenderer.invoke('player-get-audio-devices');
};
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', cb);
};
@@ -174,6 +178,7 @@ export const mpvPlayer = {
autoNext,
cleanup,
currentTime,
getAudioDevices,
getCurrentTime,
getMetadata,
getStreamMetadata,
@@ -56,7 +56,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const hasPopulatedQueueRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true);
const { transcode } = usePlaybackSettings();
const { audioDeviceId, transcode } = usePlaybackSettings();
const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);
const mpvProperties = useSettingsStore((store) => store.playback.mpvProperties);
const [reloadTrigger, setReloadTrigger] = useState(0);
@@ -106,8 +106,14 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
volume: volume,
};
const extraParameters: string[] = [...mpvExtraParameters];
if (audioDeviceId) {
extraParameters.push(`--audio-device=${audioDeviceId}`);
}
await mpvPlayer?.initialize({
extraParameters: mpvExtraParameters,
extraParameters,
properties,
});
@@ -148,7 +154,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
// update callbacks in usePlayerEvents.
// reloadTrigger is included to allow manual reload via MPV_RELOAD event.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mpvExtraParameters, mpvProperties, reloadTrigger]);
}, [mpvExtraParameters, mpvProperties, audioDeviceId, reloadTrigger]);
// Update volume
useEffect(() => {
@@ -89,8 +89,16 @@ export const AudioPlayers = () => {
useEffect(() => {
// Not standard, just used in chromium-based browsers. See
// https://developer.chrome.com/blog/audiocontext-setsinkid/.
// If the isElectron() check is every removed, fix this.
if (isElectron() && audioContext && 'setSinkId' in audioContext.context && audioDeviceId) {
if (!isElectron()) {
return;
}
if (playbackType !== PlayerType.WEB) {
return;
}
if (audioContext && 'setSinkId' in audioContext.context && audioDeviceId) {
const setSink = async () => {
try {
if (audioContext.context.state !== 'closed') {
@@ -103,7 +111,7 @@ export const AudioPlayers = () => {
setSink();
}
}, [audioContext, audioDeviceId]);
}, [audioContext, audioDeviceId, playbackType]);
// Listen to favorite and rating events to update queue songs
useEffect(() => {
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { ListConfigTable } from '/@/renderer/features/shared/components/list-config-menu';
import {
usePlaybackType,
usePlayerActions,
usePlayerData,
usePlayerProperties,
@@ -35,18 +36,33 @@ import {
} from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const getAudioDevice = async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
return (devices || []).filter((dev: MediaDeviceInfo) => dev.kind === 'audiooutput');
};
const getMpvAudioDevices = async () => {
if (!mpvPlayer) {
return [];
}
try {
return await mpvPlayer.getAudioDevices();
} catch (error) {
console.error('Failed to get MPV audio devices:', error);
return [];
}
};
export const PlayerConfig = () => {
const { t } = useTranslation();
const { currentSong } = usePlayerData();
const speed = usePlayerSpeed();
const queueType = usePlayerQueueType();
const status = usePlayerStatus();
const playbackType = usePlaybackType();
const { crossfadeDuration, crossfadeStyle, transitionType } = usePlayerProperties();
const { setCrossfadeDuration, setCrossfadeStyle, setQueueType, setSpeed, setTransitionType } =
usePlayerActions();
@@ -70,22 +86,39 @@ export const PlayerConfig = () => {
const [audioDevices, setAudioDevices] = useState<{ label: string; value: string }[]>([]);
useEffect(() => {
const fetchAudioDevices = () => {
getAudioDevice()
.then((dev) =>
setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))),
)
.catch(() =>
const fetchAudioDevices = async () => {
if (!isElectron()) {
return;
}
if (playbackType === PlayerType.WEB) {
getAudioDevice()
.then((dev) =>
setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))),
)
.catch(() =>
toast.error({
message: t('error.audioDeviceFetchError', {
postProcess: 'sentenceCase',
}),
}),
);
} else if (playbackType === PlayerType.LOCAL && mpvPlayer) {
try {
const devices = await getMpvAudioDevices();
setAudioDevices(devices);
} catch {
toast.error({
message: t('error.audioDeviceFetchError', { postProcess: 'sentenceCase' }),
}),
);
message: t('error.audioDeviceFetchError', {
postProcess: 'sentenceCase',
}),
});
}
}
};
if (playbackSettings.type === PlayerType.WEB) {
fetchAudioDevices();
}
}, [playbackSettings.type, t]);
fetchAudioDevices();
}, [playbackType, t]);
const options = useMemo(() => {
const formatPlaybackSpeedSliderLabel = (value: number) => {
@@ -161,15 +194,15 @@ export const PlayerConfig = () => {
comboboxProps={{ withinPortal: false }}
data={audioDevices}
defaultValue={playbackSettings.audioDeviceId}
disabled={playbackSettings.type !== PlayerType.WEB}
onChange={(e) =>
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({
playback: {
...playbackSettings,
audioDeviceId: e,
},
})
}
});
}}
width="100%"
/>
),