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 { enum MpvState {
STARTED, STARTED,
IN_PROGRESS, IN_PROGRESS,
+5
View File
@@ -98,6 +98,10 @@ const getStreamMetadata = async () => {
return ipcRenderer.invoke('player-stream-metadata'); return ipcRenderer.invoke('player-stream-metadata');
}; };
const getAudioDevices = async () => {
return ipcRenderer.invoke('player-get-audio-devices');
};
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => { const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', cb); ipcRenderer.on('renderer-player-auto-next', cb);
}; };
@@ -174,6 +178,7 @@ export const mpvPlayer = {
autoNext, autoNext,
cleanup, cleanup,
currentTime, currentTime,
getAudioDevices,
getCurrentTime, getCurrentTime,
getMetadata, getMetadata,
getStreamMetadata, getStreamMetadata,
@@ -56,7 +56,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
const hasPopulatedQueueRef = useRef<boolean>(false); const hasPopulatedQueueRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true); const isMountedRef = useRef<boolean>(true);
const { transcode } = usePlaybackSettings(); const { audioDeviceId, transcode } = usePlaybackSettings();
const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters); const mpvExtraParameters = useSettingsStore((store) => store.playback.mpvExtraParameters);
const mpvProperties = useSettingsStore((store) => store.playback.mpvProperties); const mpvProperties = useSettingsStore((store) => store.playback.mpvProperties);
const [reloadTrigger, setReloadTrigger] = useState(0); const [reloadTrigger, setReloadTrigger] = useState(0);
@@ -106,8 +106,14 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
volume: volume, volume: volume,
}; };
const extraParameters: string[] = [...mpvExtraParameters];
if (audioDeviceId) {
extraParameters.push(`--audio-device=${audioDeviceId}`);
}
await mpvPlayer?.initialize({ await mpvPlayer?.initialize({
extraParameters: mpvExtraParameters, extraParameters,
properties, properties,
}); });
@@ -148,7 +154,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
// update callbacks in usePlayerEvents. // update callbacks in usePlayerEvents.
// reloadTrigger is included to allow manual reload via MPV_RELOAD event. // reloadTrigger is included to allow manual reload via MPV_RELOAD event.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mpvExtraParameters, mpvProperties, reloadTrigger]); }, [mpvExtraParameters, mpvProperties, audioDeviceId, reloadTrigger]);
// Update volume // Update volume
useEffect(() => { useEffect(() => {
@@ -89,8 +89,16 @@ export const AudioPlayers = () => {
useEffect(() => { useEffect(() => {
// Not standard, just used in chromium-based browsers. See // Not standard, just used in chromium-based browsers. See
// https://developer.chrome.com/blog/audiocontext-setsinkid/. // 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 () => { const setSink = async () => {
try { try {
if (audioContext.context.state !== 'closed') { if (audioContext.context.state !== 'closed') {
@@ -103,7 +111,7 @@ export const AudioPlayers = () => {
setSink(); setSink();
} }
}, [audioContext, audioDeviceId]); }, [audioContext, audioDeviceId, playbackType]);
// Listen to favorite and rating events to update queue songs // Listen to favorite and rating events to update queue songs
useEffect(() => { useEffect(() => {
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { ListConfigTable } from '/@/renderer/features/shared/components/list-config-menu'; import { ListConfigTable } from '/@/renderer/features/shared/components/list-config-menu';
import { import {
usePlaybackType,
usePlayerActions, usePlayerActions,
usePlayerData, usePlayerData,
usePlayerProperties, usePlayerProperties,
@@ -35,18 +36,33 @@ import {
} from '/@/shared/types/types'; } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const getAudioDevice = async () => { const getAudioDevice = async () => {
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
return (devices || []).filter((dev: MediaDeviceInfo) => dev.kind === 'audiooutput'); 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 = () => { export const PlayerConfig = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { currentSong } = usePlayerData(); const { currentSong } = usePlayerData();
const speed = usePlayerSpeed(); const speed = usePlayerSpeed();
const queueType = usePlayerQueueType(); const queueType = usePlayerQueueType();
const status = usePlayerStatus(); const status = usePlayerStatus();
const playbackType = usePlaybackType();
const { crossfadeDuration, crossfadeStyle, transitionType } = usePlayerProperties(); const { crossfadeDuration, crossfadeStyle, transitionType } = usePlayerProperties();
const { setCrossfadeDuration, setCrossfadeStyle, setQueueType, setSpeed, setTransitionType } = const { setCrossfadeDuration, setCrossfadeStyle, setQueueType, setSpeed, setTransitionType } =
usePlayerActions(); usePlayerActions();
@@ -70,22 +86,39 @@ export const PlayerConfig = () => {
const [audioDevices, setAudioDevices] = useState<{ label: string; value: string }[]>([]); const [audioDevices, setAudioDevices] = useState<{ label: string; value: string }[]>([]);
useEffect(() => { useEffect(() => {
const fetchAudioDevices = () => { const fetchAudioDevices = async () => {
getAudioDevice() if (!isElectron()) {
.then((dev) => return;
setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))), }
)
.catch(() => 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({ toast.error({
message: t('error.audioDeviceFetchError', { postProcess: 'sentenceCase' }), message: t('error.audioDeviceFetchError', {
}), postProcess: 'sentenceCase',
); }),
});
}
}
}; };
if (playbackSettings.type === PlayerType.WEB) { fetchAudioDevices();
fetchAudioDevices(); }, [playbackType, t]);
}
}, [playbackSettings.type, t]);
const options = useMemo(() => { const options = useMemo(() => {
const formatPlaybackSpeedSliderLabel = (value: number) => { const formatPlaybackSpeedSliderLabel = (value: number) => {
@@ -161,15 +194,15 @@ export const PlayerConfig = () => {
comboboxProps={{ withinPortal: false }} comboboxProps={{ withinPortal: false }}
data={audioDevices} data={audioDevices}
defaultValue={playbackSettings.audioDeviceId} defaultValue={playbackSettings.audioDeviceId}
disabled={playbackSettings.type !== PlayerType.WEB} disabled={status === PlayerStatus.PLAYING}
onChange={(e) => onChange={(e) => {
setSettings({ setSettings({
playback: { playback: {
...playbackSettings, ...playbackSettings,
audioDeviceId: e, audioDeviceId: e,
}, },
}) });
} }}
width="100%" width="100%"
/> />
), ),