Skip to content

Commit 2e17757

Browse files
plugin: global keybinds
1 parent b7e43e3 commit 2e17757

File tree

9 files changed

+368
-3
lines changed

9 files changed

+368
-3
lines changed

assets/icon.png

58.6 KB
Loading

assets/tray-paused.png

1.55 KB
Loading

assets/tray.png

1.44 KB
Loading

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "pear-music",
33
"desktopName": "com.github.th_ch.pear_music",
44
"productName": "Pear Desktop",
5-
"version": "3.11.0",
5+
"version": "3.12.0",
66
"description": "Pear Desktop App - including custom plugins",
77
"main": "./dist/main/index.js",
88
"type": "module",

src/i18n/resources/en.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,27 @@
564564
"description": "Makes the volume slider exponential so it's easier to select lower volumes.",
565565
"name": "Exponential Volume"
566566
},
567+
"global-keybinds": {
568+
"name": "Global Keybinds",
569+
"description": "Set global keybinds to control playback even when the app is not focused",
570+
"management": "Manage Global Keybinds",
571+
"dubleTapToogleWindowVisibility": {
572+
"label": "Enable double tap on Play/Pause to toggle window visibility",
573+
"tooltip": "When enabled, double tapping the Play/Pause keybind will show/hide the app window"
574+
},
575+
"prompt": {
576+
"title": "Global Keybinds",
577+
"label": "Choose Global Keybinds:",
578+
"volume-up": "Volume Up",
579+
"volume-down": "Volume Down",
580+
"next-track": "Next Track",
581+
"previous-track": "Previous Track",
582+
"like-track": "Like Track",
583+
"dislike-track": "Dislike Track",
584+
"toogle-play": "Play / Pause",
585+
"toogle-window-visibility": "Show/Hide Window (Double Click)"
586+
}
587+
},
567588
"in-app-menu": {
568589
"description": "Gives menu-bars a fancy, dark or album-color look",
569590
"menu": {
@@ -947,4 +968,4 @@
947968
"name": "Visualizer"
948969
}
949970
}
950-
}
971+
}

src/i18n/resources/pt-BR.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,27 @@
564564
"description": "Torna o controle deslizante de volume exponencial para que seja mais fácil selecionar volumes mais baixos.",
565565
"name": "Volume Exponencial"
566566
},
567+
"global-keybinds": {
568+
"name": "Teclas de atalho globais",
569+
"description": "Defina teclas de atalho globais para controlar a reprodução mesmo quando o aplicativo não estiver em foco",
570+
"management": "Gerenciar teclas de atalho globais",
571+
"dubleTapToogleWindowVisibility": {
572+
"label": "Ativar toque duplo em Play/Pause para alternar a visibilidade da janela",
573+
"tooltip": "Quando ativado, tocar duas vezes na tecla Play/Pause mostrará/ocultará a janela do aplicativo"
574+
},
575+
"prompt": {
576+
"title": "Teclas de atalho globais",
577+
"label": "Escolha as teclas de atalho globais:",
578+
"volume-up": "Aumentar volume",
579+
"volume-down": "Diminuir volume",
580+
"next-track": "Próxima faixa",
581+
"previous-track": "Faixa anterior",
582+
"like-track": "Curtir faixa",
583+
"dislike-track": "Não curtir faixa",
584+
"toogle-play": "Reproduzir / Pausar",
585+
"toogle-window-visibility": "Mostrar/Ocultar janela (Toque duplo)"
586+
}
587+
},
567588
"in-app-menu": {
568589
"description": "Dá às barras de menu uma aparência elegante, escura ou com a cor do álbum",
569590
"menu": {
@@ -947,4 +968,4 @@
947968
"name": "Visualizador"
948969
}
949970
}
950-
}
971+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { globalShortcut, ipcMain, type MenuItem } from 'electron';
2+
import prompt, { type KeybindOptions } from 'custom-electron-prompt';
3+
import { eventRace } from './utils';
4+
5+
import { createPlugin } from '@/utils';
6+
7+
import promptOptions from '@/providers/prompt-options';
8+
import { onPlayerApiReady } from './renderer';
9+
import { t } from '@/i18n';
10+
import { restart } from '@/providers/app-controls';
11+
12+
export type GlobalKeybindsPluginConfig = {
13+
enabled: boolean;
14+
dubleTapToogleWindowVisibility: boolean;
15+
volumeUp: KeybindsOptions;
16+
volumeDown: KeybindsOptions;
17+
tooglePlay: KeybindsOptions;
18+
nextTrack: KeybindsOptions;
19+
previousTrack: KeybindsOptions;
20+
likeTrack: KeybindsOptions;
21+
dislikeTrack: KeybindsOptions;
22+
};
23+
24+
export type KeybindsOptions = {
25+
value: string;
26+
dobleTap?: boolean;
27+
};
28+
29+
const KeybindsOptionsFactory = (value = ''): KeybindsOptions => ({
30+
value: value,
31+
dobleTap: false,
32+
});
33+
34+
const defaultConfig: GlobalKeybindsPluginConfig = {
35+
enabled: false,
36+
dubleTapToogleWindowVisibility: true,
37+
volumeUp: KeybindsOptionsFactory('Shift+Ctrl+Up'),
38+
volumeDown: KeybindsOptionsFactory('Shift+Ctrl+Down'),
39+
tooglePlay: KeybindsOptionsFactory('Shift+Ctrl+Space'),
40+
nextTrack: KeybindsOptionsFactory('Shift+Ctrl+Right'),
41+
previousTrack: KeybindsOptionsFactory('Shift+Ctrl+Left'),
42+
likeTrack: KeybindsOptionsFactory('Shift+Ctrl+='),
43+
dislikeTrack: KeybindsOptionsFactory('Shift+Ctrl+-'),
44+
};
45+
46+
const fields: Record<string, string> = {
47+
volumeUp: 'volume-up',
48+
volumeDown: 'volume-down',
49+
tooglePlay: 'toogle-play',
50+
nextTrack: 'next-track',
51+
previousTrack: 'previous-track',
52+
likeTrack: 'like-track',
53+
dislikeTrack: 'dislike-track',
54+
};
55+
56+
export default createPlugin({
57+
name: () => t('plugins.global-keybinds.name'),
58+
description: () => t('plugins.global-keybinds.description'),
59+
addedVersion: '3.12.x',
60+
restartNeeded: false,
61+
config: Object.assign({}, defaultConfig),
62+
menu: async ({ setConfig, getConfig, window, refresh }) => {
63+
const config = await getConfig();
64+
65+
function changeOptions(
66+
changedOptions: Partial<GlobalKeybindsPluginConfig>,
67+
options: GlobalKeybindsPluginConfig,
68+
) {
69+
for (const option in changedOptions) {
70+
// HACK: Weird TypeScript error
71+
(options as Record<string, unknown>)[option] = (
72+
changedOptions as Record<string, unknown>
73+
)[option];
74+
}
75+
76+
setConfig(options);
77+
}
78+
79+
// Helper function for globalShortcuts prompt
80+
const kb = (
81+
label_: string,
82+
value_: string,
83+
default_: string,
84+
): KeybindOptions => ({
85+
value: value_,
86+
label: label_,
87+
default: default_ || undefined,
88+
});
89+
90+
async function promptGlobalShortcuts(options: GlobalKeybindsPluginConfig) {
91+
ipcMain.emit('global-keybinds:disable-all');
92+
const output = await prompt(
93+
{
94+
width: 500,
95+
title: t('plugins.global-keybinds.prompt.title'),
96+
label: t('plugins.global-keybinds.prompt.label'),
97+
type: 'keybind',
98+
keybindOptions: Object.entries(fields).map(([key, field]) =>
99+
kb(
100+
t(`plugins.global-keybinds.prompt.${field}`),
101+
key,
102+
(
103+
options[
104+
key as keyof GlobalKeybindsPluginConfig
105+
] as KeybindsOptions
106+
)?.value || '',
107+
),
108+
),
109+
...promptOptions(),
110+
},
111+
window,
112+
);
113+
114+
if (output) {
115+
const newGlobalShortcuts: Partial<GlobalKeybindsPluginConfig> =
116+
Object.assign({}, defaultConfig, options);
117+
for (const { value, accelerator } of output) {
118+
if (!value) continue;
119+
const key = value as keyof GlobalKeybindsPluginConfig;
120+
if (key !== 'enabled') {
121+
(newGlobalShortcuts[key] as KeybindsOptions).value = accelerator;
122+
}
123+
}
124+
changeOptions({ ...newGlobalShortcuts }, options);
125+
}
126+
if (config.enabled) {
127+
console.log('Global Keybinds Plugin: Re-registering shortcuts');
128+
ipcMain.emit('global-keybinds:refresh');
129+
}
130+
}
131+
132+
return [
133+
{
134+
label: t(
135+
'plugins.global-keybinds.dubleTapToogleWindowVisibility.label',
136+
),
137+
toolTip: t(
138+
'plugins.global-keybinds.dubleTapToogleWindowVisibility.tooltip',
139+
),
140+
checked: config.dubleTapToogleWindowVisibility,
141+
type: 'checkbox',
142+
click: (item) => {
143+
setConfig({
144+
dubleTapToogleWindowVisibility: item.checked,
145+
});
146+
ipcMain.emit('global-keybinds:refresh');
147+
},
148+
},
149+
{
150+
label: t('plugins.global-keybinds.management'),
151+
click: () => promptGlobalShortcuts(config),
152+
},
153+
];
154+
},
155+
156+
backend: {
157+
async start({ ipc, getConfig, window }) {
158+
async function registerShortcuts({
159+
getConfig,
160+
ipc,
161+
window,
162+
}: {
163+
getConfig: () =>
164+
| Promise<GlobalKeybindsPluginConfig>
165+
| GlobalKeybindsPluginConfig;
166+
ipc: any;
167+
window: Electron.BrowserWindow;
168+
}) {
169+
globalShortcut.unregisterAll();
170+
const config = await getConfig();
171+
172+
if (!config.enabled) {
173+
console.log(
174+
'Global Keybinds Plugin: Plugin is disabled, skipping shortcut registration',
175+
);
176+
return;
177+
}
178+
179+
function parseAcelerator(accelerator: string) {
180+
return accelerator.replace(/'(.)'/g, '$1');
181+
}
182+
183+
Object.entries(config).forEach(([key, value]: [string, any]) => {
184+
try {
185+
if (key === 'enabled') return;
186+
if (!value?.value) return;
187+
if (key === 'tooglePlay' && config.dubleTapToogleWindowVisibility) {
188+
globalShortcut.register(
189+
parseAcelerator(value.value),
190+
eventRace({
191+
single: () => {
192+
ipc.send(key, true);
193+
},
194+
double: () => {
195+
if (window.isVisible()) window.hide();
196+
else window.show();
197+
},
198+
}),
199+
);
200+
return;
201+
}
202+
203+
globalShortcut.register(parseAcelerator(value.value), () => {
204+
console.log(
205+
`Global Keybinds Plugin: Triggered shortcut for ${key}`,
206+
);
207+
ipc.send(key, true);
208+
});
209+
} catch (error) {
210+
console.error(
211+
`Global Keybinds Plugin: Error registering shortcut ${value.value}: ${error}`,
212+
);
213+
}
214+
});
215+
}
216+
217+
ipcMain.on('global-keybinds:disable-all', () => {
218+
globalShortcut.unregisterAll();
219+
});
220+
221+
ipcMain.on('global-keybinds:refresh', async () => {
222+
registerShortcuts({ getConfig, ipc, window });
223+
});
224+
225+
await registerShortcuts({ getConfig, ipc, window });
226+
},
227+
stop() {
228+
globalShortcut.unregisterAll();
229+
},
230+
},
231+
232+
renderer: {
233+
onPlayerApiReady,
234+
},
235+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { type GlobalKeybindsPluginConfig } from './index';
2+
3+
import type { RendererContext } from '@/types/contexts';
4+
import type { MusicPlayer } from '@/types/music-player';
5+
6+
function $<E extends Element = Element>(selector: string) {
7+
return document.querySelector<E>(selector);
8+
}
9+
10+
let api: MusicPlayer;
11+
12+
export const onPlayerApiReady = async (
13+
playerApi: MusicPlayer,
14+
context: RendererContext<GlobalKeybindsPluginConfig>,
15+
) => {
16+
console.log('Global Keybinds Plugin: onPlayerApiReady called');
17+
api = playerApi;
18+
19+
function updateVolumeSlider(volume: number) {
20+
// Slider value automatically rounds to multiples of 5
21+
for (const slider of ['#volume-slider', '#expand-volume-slider']) {
22+
const silderElement = $<HTMLInputElement>(slider);
23+
if (silderElement) {
24+
silderElement.value = String(volume > 0 && volume < 5 ? 5 : volume);
25+
}
26+
}
27+
}
28+
29+
context.ipc.on('volumeUp', () => {
30+
const volume = Math.min(api.getVolume());
31+
api.setVolume(Math.min(volume + 5, 100));
32+
if (api.isMuted()) api.unMute();
33+
updateVolumeSlider(volume);
34+
});
35+
context.ipc.on('volumeDown', () => {
36+
const volume = Math.max(api.getVolume() - 5, 0);
37+
api.setVolume(volume);
38+
updateVolumeSlider(volume);
39+
});
40+
context.ipc.on('nextTrack', () => {
41+
api.nextVideo();
42+
});
43+
context.ipc.on('previousTrack', () => {
44+
api.previousVideo();
45+
});
46+
context.ipc.on('likeTrack', () => {
47+
const button = document.querySelector('#button-shape-like button');
48+
if (button)
49+
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
50+
});
51+
context.ipc.on('dislikeTrack', () => {
52+
const button = document.querySelector('#button-shape-dislike button');
53+
if (button)
54+
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
55+
});
56+
57+
context.ipc.on('tooglePlay', () => {
58+
switch (api.getPlayerState()) {
59+
case 1: // Playing
60+
api.pauseVideo();
61+
break;
62+
case 2: // Paused
63+
api.playVideo();
64+
break;
65+
default:
66+
break;
67+
}
68+
});
69+
};

0 commit comments

Comments
 (0)