Add hotkeys manager

- Add configuration to settings store
- Initialize global hotkeys on startup from renderer
This commit is contained in:
jeffvli 2023-05-13 00:58:32 -07:00 committed by Jeff
parent 6056504f00
commit d7f24262fd
7 changed files with 432 additions and 12 deletions

View file

@ -27,7 +27,7 @@ import MpvAPI from 'node-mpv';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { store } from './features/core/settings/index'; import { store } from './features/core/settings/index';
import MenuBuilder from './menu'; import MenuBuilder from './menu';
import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils'; import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
import './features'; import './features';
declare module 'node-mpv'; declare module 'node-mpv';
@ -97,7 +97,6 @@ export const getMainWindow = () => {
const createWinThumbarButtons = () => { const createWinThumbarButtons = () => {
if (isWindows()) { if (isWindows()) {
console.log('setting buttons');
getMainWindow()?.setThumbarButtons([ getMainWindow()?.setThumbarButtons([
{ {
click: () => getMainWindow()?.webContents.send('renderer-player-previous'), click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
@ -308,7 +307,6 @@ const createWindow = async () => {
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService'); app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined; const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
const prefetchPlaylistParams = [ const prefetchPlaylistParams = [
'--prefetch-playlist=no', '--prefetch-playlist=no',
@ -316,10 +314,10 @@ const prefetchPlaylistParams = [
'--prefetch-playlist', '--prefetch-playlist',
]; ];
const DEFAULT_MPV_PARAMETERS = () => { const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
const parameters = []; const parameters = [];
if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) { if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
parameters.push('--prefetch-playlist=yes'); parameters.push('--prefetch-playlist=yes');
} }
@ -331,6 +329,8 @@ let mpvInstance: MpvAPI | null = null;
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => { const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
const { extraParameters, properties } = data; const { extraParameters, properties } = data;
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
mpvInstance = new MpvAPI( mpvInstance = new MpvAPI(
{ {
audio_only: true, audio_only: true,
@ -338,9 +338,7 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record<strin
binary: MPV_BINARY_PATH || '', binary: MPV_BINARY_PATH || '',
time_update: 1, time_update: 1,
}, },
MPV_PARAMETERS || extraParameters params,
? uniq([...DEFAULT_MPV_PARAMETERS(), ...(MPV_PARAMETERS || []), ...(extraParameters || [])])
: DEFAULT_MPV_PARAMETERS(),
); );
mpvInstance.setMultipleProperties(properties || {}); mpvInstance.setMultipleProperties(properties || {});
@ -402,6 +400,91 @@ ipcMain.on(
}, },
); );
ipcMain.on(
'player-initialize',
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
createMpv(data);
},
);
// Must duplicate with the one in renderer process settings.store.ts
enum BindingActions {
GLOBAL_SEARCH = 'globalSearch',
LOCAL_SEARCH = 'localSearch',
MUTE = 'volumeMute',
NEXT = 'next',
PAUSE = 'pause',
PLAY = 'play',
PLAY_PAUSE = 'playPause',
PREVIOUS = 'previous',
SHUFFLE = 'toggleShuffle',
SKIP_BACKWARD = 'skipBackward',
SKIP_FORWARD = 'skipForward',
STOP = 'stop',
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
TOGGLE_QUEUE = 'toggleQueue',
TOGGLE_REPEAT = 'toggleRepeat',
VOLUME_DOWN = 'volumeDown',
VOLUME_UP = 'volumeUp',
}
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'),
[BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'),
[BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'),
[BindingActions.PLAY]: () => getMainWindow()?.webContents.send('renderer-player-play'),
[BindingActions.PLAY_PAUSE]: () =>
getMainWindow()?.webContents.send('renderer-player-play-pause'),
[BindingActions.PREVIOUS]: () => getMainWindow()?.webContents.send('renderer-player-previous'),
[BindingActions.SHUFFLE]: () =>
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle'),
[BindingActions.SKIP_BACKWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-backward'),
[BindingActions.SKIP_FORWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-forward'),
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
[BindingActions.TOGGLE_REPEAT]: () =>
getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),
[BindingActions.VOLUME_UP]: () => getMainWindow()?.webContents.send('renderer-player-volume-up'),
[BindingActions.VOLUME_DOWN]: () =>
getMainWindow()?.webContents.send('renderer-player-volume-down'),
[BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {},
[BindingActions.TOGGLE_QUEUE]: () => {},
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
};
ipcMain.on(
'set-global-shortcuts',
(
_event,
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
) => {
// Since we're not tracking the previous shortcuts, we need to unregister all of them
globalShortcut.unregisterAll();
for (const shortcut of Object.keys(data)) {
const isGlobalHotkey = data[shortcut as BindingActions].isGlobal;
const isValidHotkey =
data[shortcut as BindingActions].hotkey && data[shortcut as BindingActions].hotkey !== '';
if (isGlobalHotkey && isValidHotkey) {
const accelerator = hotkeyToElectronAccelerator(data[shortcut as BindingActions].hotkey);
globalShortcut.register(accelerator, () => {
HOTKEY_ACTIONS[shortcut as BindingActions]();
});
}
}
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
if (globalMediaKeysEnabled) {
enableMediaKeys(mainWindow);
}
},
);
app.on('before-quit', () => { app.on('before-quit', () => {
getMpvInstance()?.stop(); getMpvInstance()?.stop();
}); });

View file

@ -1,6 +1,10 @@
import { ipcRenderer, IpcRendererEvent } from 'electron'; import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '/@/renderer/store'; import { PlayerData } from '/@/renderer/store';
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-initialize', data);
};
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => { const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-restart', data); ipcRenderer.send('player-restart', data);
}; };
@ -98,6 +102,34 @@ const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) =
ipcRenderer.on('renderer-player-stop', cb); ipcRenderer.on('renderer-player-stop', cb);
}; };
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-forward', cb);
};
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-backward', cb);
};
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-up', cb);
};
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-down', cb);
};
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-mute', cb);
};
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-repeat', cb);
};
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
};
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => { const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-quit', cb); ipcRenderer.on('renderer-player-quit', cb);
}; };
@ -105,6 +137,7 @@ const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
export const mpvPlayer = { export const mpvPlayer = {
autoNext, autoNext,
currentTime, currentTime,
initialize,
mute, mute,
next, next,
pause, pause,
@ -130,5 +163,12 @@ export const mpvPlayerListener = {
rendererPlayPause, rendererPlayPause,
rendererPrevious, rendererPrevious,
rendererQuit, rendererQuit,
rendererSkipBackward,
rendererSkipForward,
rendererStop, rendererStop,
rendererToggleRepeat,
rendererToggleShuffle,
rendererVolumeDown,
rendererVolumeMute,
rendererVolumeUp,
}; };

View file

@ -29,3 +29,24 @@ export const isWindows = () => {
export const isLinux = () => { export const isLinux = () => {
return process.platform === 'linux'; return process.platform === 'linux';
}; };
export const hotkeyToElectronAccelerator = (hotkey: string) => {
let accelerator = hotkey;
const replacements = {
mod: 'CmdOrCtrl',
numpad: 'num',
numpadadd: 'numadd',
numpaddecimal: 'numdec',
numpaddivide: 'numdiv',
numpadenter: 'numenter',
numpadmultiply: 'nummult',
numpadsubtract: 'numsub',
};
Object.keys(replacements).forEach((key) => {
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
});
return accelerator;
};

View file

@ -8,7 +8,7 @@ import { initSimpleImg } from 'react-simple-img';
import { BaseContextModal } from './components'; import { BaseContextModal } from './components';
import { useTheme } from './hooks'; import { useTheme } from './hooks';
import { AppRouter } from './router/app-router'; import { AppRouter } from './router/app-router';
import { useSettingsStore } from './store/settings.store'; import { useHotkeySettings, useSettingsStore } from './store/settings.store';
import './styles/global.scss'; import './styles/global.scss';
import '@ag-grid-community/styles/ag-grid.css'; import '@ag-grid-community/styles/ag-grid.css';
import { ContextMenuProvider } from '/@/renderer/features/context-menu'; import { ContextMenuProvider } from '/@/renderer/features/context-menu';
@ -24,11 +24,12 @@ ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule
initSimpleImg({ threshold: 0.05 }, true); initSimpleImg({ threshold: 0.05 }, true);
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const ipc = isElectron() ? window.electron.ipc : null;
export const App = () => { export const App = () => {
const theme = useTheme(); const theme = useTheme();
const contentFont = useSettingsStore((state) => state.general.fontContent); const contentFont = useSettingsStore((state) => state.general.fontContent);
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd(); const handlePlayQueueAdd = useHandlePlayQueueAdd();
useEffect(() => { useEffect(() => {
@ -44,12 +45,22 @@ export const App = () => {
volume: usePlayerStore.getState().volume, volume: usePlayerStore.getState().volume,
}; };
mpvPlayer?.restart({ mpvPlayer?.initialize({
extraParameters, extraParameters,
properties, properties,
}); });
return () => {
mpvPlayer?.quit();
};
}, []); }, []);
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
return ( return (
<MantineProvider <MantineProvider
withGlobalStyles withGlobalStyles

View file

@ -0,0 +1,222 @@
import { Group } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { RiDeleteBinLine, RiEditLine, RiKeyboardBoxLine } from 'react-icons/ri';
import styled from 'styled-components';
import { Button, TextInput, Checkbox } from '/@/renderer/components';
import isElectron from 'is-electron';
import { BindingActions, useHotkeySettings, useSettingsStoreActions } from '/@/renderer/store';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
const ipc = isElectron() ? window.electron.ipc : null;
const BINDINGS_MAP: Record<BindingActions, string> = {
globalSearch: 'Global search',
localSearch: 'In-page search',
next: 'Next track',
pause: 'Pause',
play: 'Play',
playPause: 'Play / Pause',
previous: 'Previous track',
skipBackward: 'Skip backward',
skipForward: 'Skip forward',
stop: 'Stop',
toggleFullscreenPlayer: 'Toggle fullscreen player',
toggleQueue: 'Toggle queue',
toggleRepeat: 'Toggle repeat',
toggleShuffle: 'Toggle shuffle',
volumeDown: 'Volume down',
volumeMute: 'Volume mute',
volumeUp: 'Volume up',
};
const HotkeysContainer = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: center;
width: 100%;
button {
padding: 0 1rem;
}
`;
export const HotkeyManagerSettings = () => {
const { bindings, globalMediaHotkeys } = useHotkeySettings();
const { setSettings } = useSettingsStoreActions();
const [selected, setSelected] = useState<BindingActions | null>(null);
const handleSetHotkey = useCallback(
(binding: BindingActions, e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
const IGNORED_KEYS = ['Control', 'Alt', 'Shift', 'Meta', ' ', 'Escape'];
const keys = [];
if (e.ctrlKey) keys.push('mod');
if (e.altKey) keys.push('alt');
if (e.shiftKey) keys.push('shift');
if (e.metaKey) keys.push('meta');
if (e.key === ' ') keys.push('space');
if (!IGNORED_KEYS.includes(e.key)) {
if (e.code.includes('Numpad')) {
if (e.key === '+') keys.push('numpadadd');
else if (e.key === '-') keys.push('numpadsubtract');
else if (e.key === '*') keys.push('numpadmultiply');
else if (e.key === '/') keys.push('numpaddivide');
else if (e.key === '.') keys.push('numpaddecimal');
else keys.push(`numpad${e.key}`.toLowerCase());
} else {
keys.push(e.key?.toLowerCase());
}
}
const bindingString = keys.join('+');
const updatedBindings = {
...bindings,
[binding]: { ...bindings[binding], hotkey: bindingString },
};
setSettings({
hotkeys: {
bindings: updatedBindings,
globalMediaHotkeys,
},
});
ipc?.send('set-global-shortcuts', updatedBindings);
},
[bindings, globalMediaHotkeys, setSettings],
);
const handleSetGlobalHotkey = useCallback(
(binding: BindingActions, e: React.ChangeEvent<HTMLInputElement>) => {
const updatedBindings = {
...bindings,
[binding]: { ...bindings[binding], isGlobal: e.currentTarget.checked },
};
console.log('updatedBindings :>> ', updatedBindings);
setSettings({
hotkeys: {
bindings: updatedBindings,
globalMediaHotkeys,
},
});
ipc?.send('set-global-shortcuts', updatedBindings);
},
[bindings, globalMediaHotkeys, setSettings],
);
const handleClearHotkey = useCallback(
(binding: BindingActions) => {
const updatedBindings = {
...bindings,
[binding]: { ...bindings[binding], hotkey: '', isGlobal: false },
};
setSettings({
hotkeys: {
bindings: updatedBindings,
globalMediaHotkeys,
},
});
ipc?.send('set-global-shortcuts', updatedBindings);
},
[bindings, globalMediaHotkeys, setSettings],
);
const duplicateHotkeyMap = useMemo(() => {
const countPerHotkey = Object.values(bindings).reduce((acc, key) => {
const hotkey = key.hotkey;
if (!hotkey) return acc;
if (acc[hotkey]) {
acc[hotkey] += 1;
} else {
acc[hotkey] = 1;
}
return acc;
}, {} as Record<string, number>);
const duplicateKeys = Object.keys(countPerHotkey).filter((key) => countPerHotkey[key] > 1);
return duplicateKeys;
}, [bindings]);
return (
<>
<SettingsOptions
control={<></>}
description="Configure application hotkeys. Toggle the checkbox to set as a global hotkey (desktop only)"
title="Application hotkeys"
/>
<HotkeysContainer>
{Object.keys(bindings)
.filter((binding) => BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP])
.map((binding) => (
<Group
key={`hotkey-${binding}`}
noWrap
>
<TextInput
readOnly
style={{ userSelect: 'none' }}
value={BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]}
/>
<TextInput
readOnly
icon={<RiKeyboardBoxLine />}
id={`hotkey-${binding}`}
style={{
opacity: selected === (binding as BindingActions) ? 0.8 : 1,
outline: duplicateHotkeyMap.includes(
bindings[binding as keyof typeof BINDINGS_MAP].hotkey!,
)
? '1px dashed red'
: undefined,
}}
value={bindings[binding as keyof typeof BINDINGS_MAP].hotkey}
onBlur={() => setSelected(null)}
onChange={() => {}}
onKeyDownCapture={(e) => {
if (selected !== (binding as BindingActions)) return;
handleSetHotkey(binding as BindingActions, e);
}}
/>
{isElectron() && (
<Checkbox
checked={bindings[binding as keyof typeof BINDINGS_MAP].isGlobal}
disabled={bindings[binding as keyof typeof BINDINGS_MAP].hotkey === ''}
size="xl"
style={{
opacity: bindings[binding as keyof typeof BINDINGS_MAP].allowGlobal ? 1 : 0,
}}
onChange={(e) => handleSetGlobalHotkey(binding as BindingActions, e)}
/>
)}
<Button
variant="default"
w={100}
onClick={() => {
setSelected(binding as BindingActions);
document.getElementById(`hotkey-${binding}`)?.focus();
}}
>
<RiEditLine />
</Button>
<Button
variant="default"
onClick={() => handleClearHotkey(binding as BindingActions)}
>
<RiDeleteBinLine />
</Button>
</Group>
))}
</HotkeysContainer>
</>
);
};

View file

@ -1,10 +1,13 @@
import { Stack } from '@mantine/core'; import { Divider, Stack } from '@mantine/core';
import { WindowHotkeySettings } from './window-hotkey-settings'; import { WindowHotkeySettings } from './window-hotkey-settings';
import { HotkeyManagerSettings } from '/@/renderer/features/settings/components/hotkeys/hotkey-manager-settings';
export const HotkeysTab = () => { export const HotkeysTab = () => {
return ( return (
<Stack spacing="md"> <Stack spacing="md">
<WindowHotkeySettings /> <WindowHotkeySettings />
<Divider />
<HotkeyManagerSettings />
</Stack> </Stack>
); );
}; };

View file

@ -42,6 +42,26 @@ type MpvSettings = {
replayGainPreampDB?: number; replayGainPreampDB?: number;
}; };
export enum BindingActions {
GLOBAL_SEARCH = 'globalSearch',
LOCAL_SEARCH = 'localSearch',
MUTE = 'volumeMute',
NEXT = 'next',
PAUSE = 'pause',
PLAY = 'play',
PLAY_PAUSE = 'playPause',
PREVIOUS = 'previous',
SHUFFLE = 'toggleShuffle',
SKIP_BACKWARD = 'skipBackward',
SKIP_FORWARD = 'skipForward',
STOP = 'stop',
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
TOGGLE_QUEUE = 'toggleQueue',
TOGGLE_REPEAT = 'toggleRepeat',
VOLUME_DOWN = 'volumeDown',
VOLUME_UP = 'volumeUp',
}
export interface SettingsState { export interface SettingsState {
general: { general: {
followSystemTheme: boolean; followSystemTheme: boolean;
@ -60,6 +80,7 @@ export interface SettingsState {
volumeWheelStep: number; volumeWheelStep: number;
}; };
hotkeys: { hotkeys: {
bindings: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>;
globalMediaHotkeys: boolean; globalMediaHotkeys: boolean;
}; };
playback: { playback: {
@ -118,6 +139,25 @@ const initialState: SettingsState = {
volumeWheelStep: 5, volumeWheelStep: 5,
}, },
hotkeys: { hotkeys: {
bindings: {
globalSearch: { allowGlobal: false, hotkey: 'mod+k', isGlobal: false },
localSearch: { allowGlobal: false, hotkey: 'mod+f', isGlobal: false },
next: { allowGlobal: true, hotkey: '', isGlobal: false },
pause: { allowGlobal: true, hotkey: '', isGlobal: false },
play: { allowGlobal: true, hotkey: '', isGlobal: false },
playPause: { allowGlobal: true, hotkey: '', isGlobal: false },
previous: { allowGlobal: true, hotkey: '', isGlobal: false },
skipBackward: { allowGlobal: true, hotkey: '', isGlobal: false },
skipForward: { allowGlobal: true, hotkey: '', isGlobal: false },
stop: { allowGlobal: true, hotkey: '', isGlobal: false },
toggleFullscreenPlayer: { allowGlobal: false, hotkey: '', isGlobal: false },
toggleQueue: { allowGlobal: false, hotkey: '', isGlobal: false },
toggleRepeat: { allowGlobal: true, hotkey: '', isGlobal: false },
toggleShuffle: { allowGlobal: true, hotkey: '', isGlobal: false },
volumeDown: { allowGlobal: true, hotkey: '', isGlobal: false },
volumeMute: { allowGlobal: true, hotkey: '', isGlobal: false },
volumeUp: { allowGlobal: true, hotkey: '', isGlobal: false },
},
globalMediaHotkeys: false, globalMediaHotkeys: false,
}, },
playback: { playback: {