diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000..871036c9c2 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/tray-paused.png b/assets/tray-paused.png new file mode 100644 index 0000000000..70e10c079a Binary files /dev/null and b/assets/tray-paused.png differ diff --git a/assets/tray.png b/assets/tray.png new file mode 100644 index 0000000000..bd53e7dc72 Binary files /dev/null and b/assets/tray.png differ diff --git a/package.json b/package.json index 7d2a014a69..5895778abe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 4cf482aa3b..1220e96581 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -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": { @@ -947,4 +968,4 @@ "name": "Visualizer" } } -} +} \ No newline at end of file diff --git a/src/i18n/resources/pt-BR.json b/src/i18n/resources/pt-BR.json index d890e1c88d..65d00ca2bd 100644 --- a/src/i18n/resources/pt-BR.json +++ b/src/i18n/resources/pt-BR.json @@ -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": { @@ -947,4 +968,4 @@ "name": "Visualizador" } } -} +} \ No newline at end of file diff --git a/src/plugins/global-keybinds/index.ts b/src/plugins/global-keybinds/index.ts new file mode 100644 index 0000000000..b69d244e3f --- /dev/null +++ b/src/plugins/global-keybinds/index.ts @@ -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 = { + 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, + options: GlobalKeybindsPluginConfig, + ) { + for (const option in changedOptions) { + // HACK: Weird TypeScript error + (options as Record)[option] = ( + changedOptions as Record + )[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 = + 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) { + 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); + }); + + await registerShortcuts({ + getConfig, + ipc, + window, + } as BackendContext); + }, + stop() { + globalShortcut.unregisterAll(); + }, + }, + + renderer: { + onPlayerApiReady, + }, +}); diff --git a/src/plugins/global-keybinds/readme.md b/src/plugins/global-keybinds/readme.md new file mode 100644 index 0000000000..89050f43bc --- /dev/null +++ b/src/plugins/global-keybinds/readme.md @@ -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. \ No newline at end of file diff --git a/src/plugins/global-keybinds/renderer.ts b/src/plugins/global-keybinds/renderer.ts new file mode 100644 index 0000000000..0b20c9b5ef --- /dev/null +++ b/src/plugins/global-keybinds/renderer.ts @@ -0,0 +1,69 @@ +import { type GlobalKeybindsPluginConfig } from './index'; + +import type { RendererContext } from '@/types/contexts'; +import type { MusicPlayer } from '@/types/music-player'; + +function $(selector: string) { + return document.querySelector(selector); +} + +let api: MusicPlayer; + +export const onPlayerApiReady = ( + playerApi: MusicPlayer, + context: RendererContext, +) => { + console.log('Global Keybinds Plugin: onPlayerApiReady called'); + api = playerApi; + + function updateVolumeSlider(volume: number) { + // Slider value automatically rounds to multiples of 5 + for (const slider of ['#volume-slider', '#expand-volume-slider']) { + const silderElement = $(slider); + if (silderElement) { + silderElement.value = String(volume > 0 && volume < 5 ? 5 : volume); + } + } + } + + context.ipc.on('volumeUp', () => { + const volume = Math.min(api.getVolume()); + api.setVolume(Math.min(volume + 5, 100)); + if (api.isMuted()) api.unMute(); + updateVolumeSlider(volume); + }); + context.ipc.on('volumeDown', () => { + const volume = Math.max(api.getVolume() - 5, 0); + api.setVolume(volume); + updateVolumeSlider(volume); + }); + context.ipc.on('nextTrack', () => { + api.nextVideo(); + }); + context.ipc.on('previousTrack', () => { + api.previousVideo(); + }); + context.ipc.on('likeTrack', () => { + const button = document.querySelector('#button-shape-like button'); + if (button) + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + context.ipc.on('dislikeTrack', () => { + const button = document.querySelector('#button-shape-dislike button'); + if (button) + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + context.ipc.on('tooglePlay', () => { + switch (api.getPlayerState()) { + case 1: // Playing + api.pauseVideo(); + break; + case 2: // Paused + api.playVideo(); + break; + default: + break; + } + }); +}; diff --git a/src/plugins/global-keybinds/utils.ts b/src/plugins/global-keybinds/utils.ts new file mode 100644 index 0000000000..aed8d4f63a --- /dev/null +++ b/src/plugins/global-keybinds/utils.ts @@ -0,0 +1,19 @@ +export function eventRace( + { single, double }: { single: () => void; double: () => void }, + time = 200, +) { + let timeout: NodeJS.Timeout | null = null; + + return () => { + if (timeout) { + if (timeout) clearTimeout(timeout); + timeout = null; + double(); + } else { + timeout = setTimeout(() => { + single(); + timeout = null; + }, time); + } + }; +}