From d7f24262fdb5a58ae89755290d4460251b0f6b65 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 13 May 2023 00:58:32 -0700 Subject: [PATCH] Add hotkeys manager - Add configuration to settings store - Initialize global hotkeys on startup from renderer --- src/main/main.ts | 99 +++++++- src/main/preload/mpv-player.ts | 40 ++++ src/main/utils.ts | 21 ++ src/renderer/app.tsx | 17 +- .../hotkeys/hotkey-manager-settings.tsx | 222 ++++++++++++++++++ .../components/hotkeys/hotkeys-tab.tsx | 5 +- src/renderer/store/settings.store.ts | 40 ++++ 7 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 src/renderer/features/settings/components/hotkeys/hotkey-manager-settings.tsx diff --git a/src/main/main.ts b/src/main/main.ts index ca484392..0799a66a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -27,7 +27,7 @@ import MpvAPI from 'node-mpv'; import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys'; import { store } from './features/core/settings/index'; import MenuBuilder from './menu'; -import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils'; +import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils'; import './features'; declare module 'node-mpv'; @@ -97,7 +97,6 @@ export const getMainWindow = () => { const createWinThumbarButtons = () => { if (isWindows()) { - console.log('setting buttons'); getMainWindow()?.setThumbarButtons([ { click: () => getMainWindow()?.webContents.send('renderer-player-previous'), @@ -308,7 +307,6 @@ const createWindow = async () => { app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService'); const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined; -const MPV_PARAMETERS = store.get('mpv_parameters') as Array | undefined; const prefetchPlaylistParams = [ '--prefetch-playlist=no', @@ -316,10 +314,10 @@ const prefetchPlaylistParams = [ '--prefetch-playlist', ]; -const DEFAULT_MPV_PARAMETERS = () => { +const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => { const parameters = []; - if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) { + if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) { parameters.push('--prefetch-playlist=yes'); } @@ -331,6 +329,8 @@ let mpvInstance: MpvAPI | null = null; const createMpv = (data: { extraParameters?: string[]; properties?: Record }) => { const { extraParameters, properties } = data; + const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]); + mpvInstance = new MpvAPI( { audio_only: true, @@ -338,9 +338,7 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record }) => { + 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 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, + ) => { + // 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', () => { getMpvInstance()?.stop(); }); diff --git a/src/main/preload/mpv-player.ts b/src/main/preload/mpv-player.ts index f6c04296..9e116cdc 100644 --- a/src/main/preload/mpv-player.ts +++ b/src/main/preload/mpv-player.ts @@ -1,6 +1,10 @@ import { ipcRenderer, IpcRendererEvent } from 'electron'; import { PlayerData } from '/@/renderer/store'; +const initialize = (data: { extraParameters?: string[]; properties?: Record }) => { + ipcRenderer.send('player-initialize', data); +}; + const restart = (data: { extraParameters?: string[]; properties?: Record }) => { ipcRenderer.send('player-restart', data); }; @@ -98,6 +102,34 @@ const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) = 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) => { ipcRenderer.on('renderer-player-quit', cb); }; @@ -105,6 +137,7 @@ const rendererQuit = (cb: (event: IpcRendererEvent) => void) => { export const mpvPlayer = { autoNext, currentTime, + initialize, mute, next, pause, @@ -130,5 +163,12 @@ export const mpvPlayerListener = { rendererPlayPause, rendererPrevious, rendererQuit, + rendererSkipBackward, + rendererSkipForward, rendererStop, + rendererToggleRepeat, + rendererToggleShuffle, + rendererVolumeDown, + rendererVolumeMute, + rendererVolumeUp, }; diff --git a/src/main/utils.ts b/src/main/utils.ts index 5dd58c57..9ca5f52a 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -29,3 +29,24 @@ export const isWindows = () => { export const isLinux = () => { 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; +}; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index e93df154..f1518f5d 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -8,7 +8,7 @@ import { initSimpleImg } from 'react-simple-img'; import { BaseContextModal } from './components'; import { useTheme } from './hooks'; import { AppRouter } from './router/app-router'; -import { useSettingsStore } from './store/settings.store'; +import { useHotkeySettings, useSettingsStore } from './store/settings.store'; import './styles/global.scss'; import '@ag-grid-community/styles/ag-grid.css'; import { ContextMenuProvider } from '/@/renderer/features/context-menu'; @@ -24,11 +24,12 @@ ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule initSimpleImg({ threshold: 0.05 }, true); const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; +const ipc = isElectron() ? window.electron.ipc : null; export const App = () => { const theme = useTheme(); const contentFont = useSettingsStore((state) => state.general.fontContent); - + const { bindings } = useHotkeySettings(); const handlePlayQueueAdd = useHandlePlayQueueAdd(); useEffect(() => { @@ -44,12 +45,22 @@ export const App = () => { volume: usePlayerStore.getState().volume, }; - mpvPlayer?.restart({ + mpvPlayer?.initialize({ extraParameters, properties, }); + + return () => { + mpvPlayer?.quit(); + }; }, []); + useEffect(() => { + if (isElectron()) { + ipc?.send('set-global-shortcuts', bindings); + } + }, [bindings]); + return ( = { + 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(null); + + const handleSetHotkey = useCallback( + (binding: BindingActions, e: React.KeyboardEvent) => { + 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) => { + 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); + + const duplicateKeys = Object.keys(countPerHotkey).filter((key) => countPerHotkey[key] > 1); + + return duplicateKeys; + }, [bindings]); + + return ( + <> + } + description="Configure application hotkeys. Toggle the checkbox to set as a global hotkey (desktop only)" + title="Application hotkeys" + /> + + {Object.keys(bindings) + .filter((binding) => BINDINGS_MAP[binding as keyof typeof BINDINGS_MAP]) + .map((binding) => ( + + + } + 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() && ( + handleSetGlobalHotkey(binding as BindingActions, e)} + /> + )} + + + + ))} + + + ); +}; diff --git a/src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx b/src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx index 3016208a..9d426e7b 100644 --- a/src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx +++ b/src/renderer/features/settings/components/hotkeys/hotkeys-tab.tsx @@ -1,10 +1,13 @@ -import { Stack } from '@mantine/core'; +import { Divider, Stack } from '@mantine/core'; import { WindowHotkeySettings } from './window-hotkey-settings'; +import { HotkeyManagerSettings } from '/@/renderer/features/settings/components/hotkeys/hotkey-manager-settings'; export const HotkeysTab = () => { return ( + + ); }; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index b7a39e84..e29279df 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -42,6 +42,26 @@ type MpvSettings = { 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 { general: { followSystemTheme: boolean; @@ -60,6 +80,7 @@ export interface SettingsState { volumeWheelStep: number; }; hotkeys: { + bindings: Record; globalMediaHotkeys: boolean; }; playback: { @@ -118,6 +139,25 @@ const initialState: SettingsState = { volumeWheelStep: 5, }, 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, }, playback: {