Add hotkeys manager
- Add configuration to settings store - Initialize global hotkeys on startup from renderer
This commit is contained in:
parent
6056504f00
commit
d7f24262fd
7 changed files with 432 additions and 12 deletions
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Reference in a new issue