Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/tray-paused.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "pear-music",
"desktopName": "com.github.th_ch.pear_music",
"productName": "Pear Desktop",
"version": "3.11.0",
"version": "3.12.0",
"description": "Pear Desktop App - including custom plugins",
"main": "./dist/main/index.js",
"type": "module",
Expand Down
23 changes: 22 additions & 1 deletion src/i18n/resources/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,27 @@
"description": "Makes the volume slider exponential so it's easier to select lower volumes.",
"name": "Exponential Volume"
},
"global-keybinds": {
"name": "Global Keybinds",
"description": "Set global keybinds to control playback even when the app is not focused",
"management": "Manage Global Keybinds",
"dubleTapToogleWindowVisibility": {
"label": "Enable double tap on Play/Pause to toggle window visibility",
"tooltip": "When enabled, double tapping the Play/Pause keybind will show/hide the app window"
},
"prompt": {
"title": "Global Keybinds",
"label": "Choose Global Keybinds:",
"volume-up": "Volume Up",
"volume-down": "Volume Down",
"next-track": "Next Track",
"previous-track": "Previous Track",
"like-track": "Like Track",
"dislike-track": "Dislike Track",
"toogle-play": "Play / Pause",
"toogle-window-visibility": "Show/Hide Window (Double Click)"
}
},
"in-app-menu": {
"description": "Gives menu-bars a fancy, dark or album-color look",
"menu": {
Expand Down Expand Up @@ -947,4 +968,4 @@
"name": "Visualizer"
}
}
}
}
23 changes: 22 additions & 1 deletion src/i18n/resources/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,27 @@
"description": "Torna o controle deslizante de volume exponencial para que seja mais fΓ‘cil selecionar volumes mais baixos.",
"name": "Volume Exponencial"
},
"global-keybinds": {
"name": "Teclas de atalho globais",
"description": "Defina teclas de atalho globais para controlar a reproduΓ§Γ£o mesmo quando o aplicativo nΓ£o estiver em foco",
"management": "Gerenciar teclas de atalho globais",
"dubleTapToogleWindowVisibility": {
"label": "Ativar toque duplo em Play/Pause para alternar a visibilidade da janela",
"tooltip": "Quando ativado, tocar duas vezes na tecla Play/Pause mostrarΓ‘/ocultarΓ‘ a janela do aplicativo"
},
"prompt": {
"title": "Teclas de atalho globais",
"label": "Escolha as teclas de atalho globais:",
"volume-up": "Aumentar volume",
"volume-down": "Diminuir volume",
"next-track": "PrΓ³xima faixa",
"previous-track": "Faixa anterior",
"like-track": "Curtir faixa",
"dislike-track": "NΓ£o curtir faixa",
"toogle-play": "Reproduzir / Pausar",
"toogle-window-visibility": "Mostrar/Ocultar janela (Toque duplo)"
}
},
"in-app-menu": {
"description": "DΓ‘ Γ s barras de menu uma aparΓͺncia elegante, escura ou com a cor do Γ‘lbum",
"menu": {
Expand Down Expand Up @@ -947,4 +968,4 @@
"name": "Visualizador"
}
}
}
}
242 changes: 242 additions & 0 deletions src/plugins/global-keybinds/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { globalShortcut, ipcMain } from 'electron';
import prompt, { type KeybindOptions } from 'custom-electron-prompt';

import { eventRace } from './utils';
import { createPlugin } from '@/utils';

import promptOptions from '@/providers/prompt-options';
import { onPlayerApiReady } from './renderer';
import { t } from '@/i18n';

import type { BackendContext } from '@/types/contexts';

export type GlobalKeybindsPluginConfig = {
enabled: boolean;
dubleTapToogleWindowVisibility: boolean;
volumeUp: KeybindsOptions;
volumeDown: KeybindsOptions;
tooglePlay: KeybindsOptions;
nextTrack: KeybindsOptions;
previousTrack: KeybindsOptions;
likeTrack: KeybindsOptions;
dislikeTrack: KeybindsOptions;
};

export type KeybindsOptions = {
value: string;
dobleTap?: boolean;
};

const KeybindsOptionsFactory = (value = ''): KeybindsOptions => ({
value: value,
dobleTap: false,
});

const defaultConfig: GlobalKeybindsPluginConfig = {
enabled: false,
dubleTapToogleWindowVisibility: true,
volumeUp: KeybindsOptionsFactory('Shift+Ctrl+Up'),
volumeDown: KeybindsOptionsFactory('Shift+Ctrl+Down'),
tooglePlay: KeybindsOptionsFactory('Shift+Ctrl+Space'),
nextTrack: KeybindsOptionsFactory('Shift+Ctrl+Right'),
previousTrack: KeybindsOptionsFactory('Shift+Ctrl+Left'),
likeTrack: KeybindsOptionsFactory('Shift+Ctrl+='),
dislikeTrack: KeybindsOptionsFactory('Shift+Ctrl+-'),
};

const fields: Record<string, string> = {
volumeUp: 'volume-up',
volumeDown: 'volume-down',
tooglePlay: 'toogle-play',
nextTrack: 'next-track',
previousTrack: 'previous-track',
likeTrack: 'like-track',
dislikeTrack: 'dislike-track',
};

export default createPlugin({
name: () => t('plugins.global-keybinds.name'),
description: () => t('plugins.global-keybinds.description'),
addedVersion: '3.12.x',
restartNeeded: false,
config: Object.assign({}, defaultConfig),
menu: async ({ setConfig, getConfig, window }) => {
const config = await getConfig();

function changeOptions(
changedOptions: Partial<GlobalKeybindsPluginConfig>,
options: GlobalKeybindsPluginConfig,
) {
for (const option in changedOptions) {
// HACK: Weird TypeScript error
(options as Record<string, unknown>)[option] = (
changedOptions as Record<string, unknown>
)[option];
}

setConfig(options);
}

// Helper function for globalShortcuts prompt
const kb = (
label_: string,
value_: string,
default_: string,
): KeybindOptions => ({
value: value_,
label: label_,
default: default_ || undefined,
});

async function promptGlobalShortcuts(options: GlobalKeybindsPluginConfig) {
ipcMain.emit('global-keybinds:disable-all');
const output = await prompt(
{
width: 500,
title: t('plugins.global-keybinds.prompt.title'),
label: t('plugins.global-keybinds.prompt.label'),
type: 'keybind',
keybindOptions: Object.entries(fields).map(([key, field]) =>
kb(
t(`plugins.global-keybinds.prompt.${field}`),
key,
(
options[
key as keyof GlobalKeybindsPluginConfig
] as KeybindsOptions
)?.value || '',
),
),
...promptOptions(),
},
window,
);

if (output) {
const newGlobalShortcuts: Partial<GlobalKeybindsPluginConfig> =
Object.assign({}, defaultConfig, options);
for (const { value, accelerator } of output) {
if (!value) continue;
const key = value as keyof GlobalKeybindsPluginConfig;
if (key !== 'enabled') {
(newGlobalShortcuts[key] as KeybindsOptions).value = accelerator;
}
}
changeOptions({ ...newGlobalShortcuts }, options);
}
if (config.enabled) {
console.log('Global Keybinds Plugin: Re-registering shortcuts');
ipcMain.emit('global-keybinds:refresh');
}
}

return [
{
label: t(
'plugins.global-keybinds.dubleTapToogleWindowVisibility.label',
),
toolTip: t(
'plugins.global-keybinds.dubleTapToogleWindowVisibility.tooltip',
),
checked: config.dubleTapToogleWindowVisibility,
type: 'checkbox',
click: (item) => {
setConfig({
dubleTapToogleWindowVisibility: item.checked,
});
ipcMain.emit('global-keybinds:refresh');
},
},
{
label: t('plugins.global-keybinds.management'),
click: () => promptGlobalShortcuts(config),
},
];
},

backend: {
async start({ ipc, getConfig, window }) {
async function registerShortcuts({
getConfig,
ipc,
window,
}: BackendContext<GlobalKeybindsPluginConfig>) {
globalShortcut.unregisterAll();
const config = await getConfig();

if (!config.enabled) {
console.log(
'Global Keybinds Plugin: Plugin is disabled, skipping shortcut registration',
);
return;
}

function parseAcelerator(accelerator: string) {
return accelerator.replace(/'(.)'/g, '$1');
}

Object.entries(config).forEach(([key, value]) => {
if (key === 'enabled' || key === 'dubleTapToogleWindowVisibility')
return;
const keybind = value as KeybindsOptions;

try {
if (!keybind?.value) return;
if (key === 'tooglePlay' && config.dubleTapToogleWindowVisibility) {
globalShortcut.register(
parseAcelerator(keybind.value),
eventRace({
single: () => {
ipc.send(key, true);
},
double: () => {
if (window.isVisible()) window.hide();
else window.show();
},
}),
);
return;
}

globalShortcut.register(parseAcelerator(keybind.value), () => {
console.log(
`Global Keybinds Plugin: Triggered shortcut for ${key}`,
);
ipc.send(key, true);
});
} catch (error) {
console.error(
`Global Keybinds Plugin: Error registering shortcut ${keybind.value}:`,
error,
);
}
});
}

ipcMain.on('global-keybinds:disable-all', () => {
globalShortcut.unregisterAll();
});

ipcMain.on('global-keybinds:refresh', () => {
registerShortcuts({
getConfig,
ipc,
window,
} as BackendContext<GlobalKeybindsPluginConfig>);
});

await registerShortcuts({
getConfig,
ipc,
window,
} as BackendContext<GlobalKeybindsPluginConfig>);
},
stop() {
globalShortcut.unregisterAll();
},
},

renderer: {
onPlayerApiReady,
},
});
50 changes: 50 additions & 0 deletions src/plugins/global-keybinds/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Global Keybinds Plugin

This plugin enables system-wide **global keyboard shortcuts** for the music player. It allows you to control media playback, volume, and track ratings (like/dislike) from anywhere in your operating system, even when the application is minimized or not in focus.

## πŸš€ Features

* **Global Control:** Control your music while working in other applications.
* **Customizable:** Remap any action to your preferred key combination via the plugin settings.
* **Smart Double-Tap:** A unique feature that allows you to toggle the player window's visibility by double-pressing the Play/Pause shortcut.

## 🎹 Default Shortcuts

By default, the plugin is configured with the following key combinations (using `Shift` + `Ctrl` to avoid conflicts with common system shortcuts):

| Action | Default Shortcut |
| :--- | :--- |
| **Play / Pause** | `Shift` + `Ctrl` + `Space` |
| **Next Track** | `Shift` + `Ctrl` + `Right Arrow` |
| **Previous Track** | `Shift` + `Ctrl` + `Left Arrow` |
| **Volume Up** | `Shift` + `Ctrl` + `Up Arrow` |
| **Volume Down** | `Shift` + `Ctrl` + `Down Arrow` |
| **Like Track** | `Shift` + `Ctrl` + `=` |
| **Dislike Track** | `Shift` + `Ctrl` + `-` |

## βš™οΈ Configuration & Options

You can customize the behavior and key assignments through the plugin menu.

### Remapping Keys
Click on the **"Keybinds Management"** option in the plugin menu. A prompt will appear allowing you to press the new key combination you wish to assign to each action.

### Window Visibility Toggle (Double Tap)
* **Option:** `Double Tap to Toggle Window Visibility`
* **Default:** `Enabled`
* **How it works:**
* **Single Press** on the Play/Pause shortcut: Toggles media playback.
* **Double Press** (quickly): Shows or Hides the application window.

> **Note:** If you disable this option, the Play/Pause shortcut will only control playback, regardless of how fast you press it.

## πŸ‘¨β€πŸ’» Author

**Gabriel Pastori**
* πŸ‡§πŸ‡· Brazilian Developer
* GitHub: [gabrielpastori1](https://github.com/gabrielpastori1)


## 🀝 Credits

This plugin was developed using the **precise-volume** plugin as a base and reference.
Loading