From 794d00ce9e2a3f31da09e3232f9d01d31217fbb7 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 18:02:22 +0900 Subject: [PATCH] feat: migrate to new plugin api Co-authored-by: Su-Yong --- src/config/defaults.ts | 2 +- src/config/dynamic-renderer.ts | 182 -------- src/config/dynamic.ts | 183 -------- src/index.ts | 44 +- src/menu.ts | 1 + src/plugins/adblocker/blocker.ts | 16 +- src/plugins/adblocker/config.ts | 14 - src/plugins/adblocker/index.ts | 52 +++ src/plugins/adblocker/inject.d.ts | 1 - .../{ => injectors}/inject-cliqz-preload.ts | 0 src/plugins/adblocker/injectors/inject.d.ts | 3 + .../adblocker/{ => injectors}/inject.js | 6 + src/plugins/adblocker/main.ts | 52 ++- src/plugins/adblocker/menu.ts | 17 +- src/plugins/adblocker/preload.ts | 36 +- .../{blocker-types.ts => types/index.ts} | 0 src/plugins/album-color-theme/index.ts | 1 + src/plugins/album-color-theme/renderer.ts | 18 +- src/plugins/ambient-mode/index.ts | 1 + src/plugins/ambient-mode/menu.ts | 2 +- src/plugins/ambient-mode/renderer.ts | 76 ++-- src/plugins/audio-compressor/index.ts | 1 + src/plugins/audio-compressor/renderer.ts | 2 +- src/plugins/blur-nav-bar/index.ts | 1 + src/plugins/bypass-age-restrictions/index.ts | 1 + .../bypass-age-restrictions/renderer.ts | 2 +- .../captions-selector/config-renderer.ts | 4 - src/plugins/captions-selector/config.ts | 4 - src/plugins/captions-selector/index.ts | 20 + src/plugins/captions-selector/main.ts | 33 +- src/plugins/captions-selector/menu.ts | 40 +- src/plugins/captions-selector/renderer.ts | 74 ++-- src/plugins/compact-sidebar/index.ts | 17 + src/plugins/compact-sidebar/renderer.ts | 35 +- src/plugins/crossfade/config-renderer.ts | 4 - src/plugins/crossfade/config.ts | 4 - src/plugins/crossfade/fader.ts | 2 - src/plugins/crossfade/index.ts | 50 +++ src/plugins/crossfade/main.ts | 23 +- src/plugins/crossfade/menu.ts | 153 +++---- src/plugins/crossfade/renderer.ts | 248 ++++++----- src/plugins/disable-autoplay/index.ts | 23 ++ src/plugins/disable-autoplay/menu.ts | 35 +- src/plugins/disable-autoplay/renderer.ts | 51 ++- src/plugins/discord/index.ts | 55 +++ src/plugins/discord/main.ts | 251 +++++------ src/plugins/discord/menu.ts | 48 ++- src/plugins/downloader/config.ts | 4 - src/plugins/downloader/index.ts | 36 ++ .../downloader/{main.ts => main/index.ts} | 89 ++-- src/plugins/downloader/{ => main}/utils.ts | 0 src/plugins/downloader/menu.ts | 75 ++-- src/plugins/downloader/renderer.ts | 130 +++--- src/plugins/exponential-volume/index.ts | 17 + src/plugins/exponential-volume/renderer.ts | 15 +- src/plugins/in-app-menu/index.ts | 3 +- src/plugins/in-app-menu/main.ts | 12 +- src/plugins/in-app-menu/menu.ts | 2 +- src/plugins/last-fm/index.ts | 50 +++ src/plugins/last-fm/main.ts | 77 ++-- src/plugins/lumiastream/index.ts | 17 + src/plugins/lumiastream/main.ts | 68 +-- src/plugins/lyrics-genius/index.ts | 26 ++ src/plugins/lyrics-genius/main.ts | 42 +- src/plugins/lyrics-genius/menu.ts | 31 +- src/plugins/lyrics-genius/renderer.ts | 160 +++---- src/plugins/navigation/index.ts | 3 +- src/plugins/navigation/renderer.ts | 2 +- src/plugins/no-google-login/front.ts | 37 -- src/plugins/no-google-login/index.ts | 20 + src/plugins/no-google-login/main.ts | 2 +- src/plugins/no-google-login/renderer.ts | 39 ++ src/plugins/notifications/config.ts | 5 - src/plugins/notifications/index.ts | 36 ++ src/plugins/notifications/interactive.ts | 390 +++++++++--------- src/plugins/notifications/main.ts | 35 +- src/plugins/notifications/menu.ts | 164 ++++---- src/plugins/notifications/utils.ts | 15 +- src/plugins/picture-in-picture/index.ts | 40 ++ src/plugins/picture-in-picture/main.ts | 188 +++++---- src/plugins/picture-in-picture/menu.ts | 121 +++--- src/plugins/picture-in-picture/renderer.ts | 40 +- src/plugins/playback-speed/index.ts | 17 + src/plugins/playback-speed/renderer.ts | 88 ++-- src/plugins/precise-volume/index.ts | 1 + src/plugins/precise-volume/main.ts | 28 +- src/plugins/precise-volume/menu.ts | 4 +- src/plugins/precise-volume/renderer.ts | 100 ++--- src/plugins/quality-changer/index.ts | 3 +- src/plugins/quality-changer/renderer.ts | 53 +-- src/plugins/shortcuts/index.ts | 40 ++ src/plugins/shortcuts/main.ts | 88 ++-- src/plugins/shortcuts/menu.ts | 104 +++-- src/plugins/shortcuts/mpris.ts | 2 +- src/plugins/skip-silences/index.ts | 23 ++ src/plugins/skip-silences/renderer.ts | 202 +++++---- src/plugins/sponsorblock/index.ts | 32 ++ src/plugins/sponsorblock/main.ts | 29 +- src/plugins/sponsorblock/renderer.ts | 70 ++-- src/plugins/taskbar-mediacontrol/index.ts | 17 + src/plugins/taskbar-mediacontrol/main.ts | 122 +++--- src/plugins/touchbar/index.ts | 17 + src/plugins/touchbar/main.ts | 152 +++---- src/plugins/tuna-obs/index.ts | 17 + src/plugins/tuna-obs/main.ts | 62 +-- src/plugins/utils/builder.ts | 8 +- src/plugins/utils/main/fetch.ts | 21 + src/plugins/utils/main/index.ts | 1 + src/plugins/video-toggle/button-switcher.css | 20 +- src/plugins/video-toggle/force-hide.css | 4 +- src/plugins/video-toggle/index.ts | 36 ++ src/plugins/video-toggle/main.ts | 16 - src/plugins/video-toggle/menu.ts | 145 +++---- src/plugins/video-toggle/renderer.ts | 311 +++++++------- src/plugins/visualizer/index.ts | 132 ++++++ src/plugins/visualizer/main.ts | 9 - src/plugins/visualizer/menu.ts | 38 +- src/plugins/visualizer/renderer.ts | 135 +++--- .../visualizer/visualizers/butterchurn.ts | 4 +- .../visualizer/visualizers/visualizer.ts | 4 +- src/plugins/visualizer/visualizers/vudio.ts | 4 +- src/plugins/visualizer/visualizers/wave.ts | 5 +- src/preload.ts | 38 +- .../plugin-virtual-module-generator.ts | 2 +- 124 files changed, 3363 insertions(+), 2720 deletions(-) delete mode 100644 src/config/dynamic-renderer.ts delete mode 100644 src/config/dynamic.ts delete mode 100644 src/plugins/adblocker/config.ts create mode 100644 src/plugins/adblocker/index.ts delete mode 100644 src/plugins/adblocker/inject.d.ts rename src/plugins/adblocker/{ => injectors}/inject-cliqz-preload.ts (100%) create mode 100644 src/plugins/adblocker/injectors/inject.d.ts rename src/plugins/adblocker/{ => injectors}/inject.js (99%) rename src/plugins/adblocker/{blocker-types.ts => types/index.ts} (100%) delete mode 100644 src/plugins/captions-selector/config-renderer.ts delete mode 100644 src/plugins/captions-selector/config.ts create mode 100644 src/plugins/captions-selector/index.ts create mode 100644 src/plugins/compact-sidebar/index.ts delete mode 100644 src/plugins/crossfade/config-renderer.ts delete mode 100644 src/plugins/crossfade/config.ts create mode 100644 src/plugins/crossfade/index.ts create mode 100644 src/plugins/disable-autoplay/index.ts create mode 100644 src/plugins/discord/index.ts delete mode 100644 src/plugins/downloader/config.ts create mode 100644 src/plugins/downloader/index.ts rename src/plugins/downloader/{main.ts => main/index.ts} (89%) rename src/plugins/downloader/{ => main}/utils.ts (100%) create mode 100644 src/plugins/exponential-volume/index.ts create mode 100644 src/plugins/last-fm/index.ts create mode 100644 src/plugins/lumiastream/index.ts create mode 100644 src/plugins/lyrics-genius/index.ts delete mode 100644 src/plugins/no-google-login/front.ts create mode 100644 src/plugins/no-google-login/index.ts create mode 100644 src/plugins/no-google-login/renderer.ts delete mode 100644 src/plugins/notifications/config.ts create mode 100644 src/plugins/notifications/index.ts create mode 100644 src/plugins/picture-in-picture/index.ts create mode 100644 src/plugins/playback-speed/index.ts create mode 100644 src/plugins/shortcuts/index.ts create mode 100644 src/plugins/skip-silences/index.ts create mode 100644 src/plugins/sponsorblock/index.ts create mode 100644 src/plugins/taskbar-mediacontrol/index.ts create mode 100644 src/plugins/touchbar/index.ts create mode 100644 src/plugins/tuna-obs/index.ts create mode 100644 src/plugins/utils/main/fetch.ts create mode 100644 src/plugins/video-toggle/index.ts delete mode 100644 src/plugins/video-toggle/main.ts create mode 100644 src/plugins/visualizer/index.ts delete mode 100644 src/plugins/visualizer/main.ts diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 04946e6f..ff49b9cb 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,4 +1,4 @@ -import { blockers } from '../plugins/adblocker/blocker-types'; +import { blockers } from '../plugins/adblocker/types'; import { DefaultPresetList } from '../plugins/downloader/types'; diff --git a/src/config/dynamic-renderer.ts b/src/config/dynamic-renderer.ts deleted file mode 100644 index 1772a722..00000000 --- a/src/config/dynamic-renderer.ts +++ /dev/null @@ -1,182 +0,0 @@ -import defaultConfig from './defaults'; - -import { Entries } from '../utils/type-utils'; - -import type { OneOfDefaultConfigKey, ConfigType, PluginConfigOptions } from './dynamic'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig } = {}; - -export const getActivePlugins - = async () => await window.ipcRenderer.invoke('get-active-plugins') as Promise; - -export const isActive - = async (plugin: string) => plugin in (await window.ipcRenderer.invoke('get-active-plugins')); - -/** - * This class is used to create a dynamic synced config for plugins. - * - * @param {string} name - The name of the plugin. - * @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false. - * @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store. - * - * @example - * const { PluginConfig } = require("../../config/dynamic-renderer"); - * const config = new PluginConfig("plugin-name", { enableFront: true }); - * module.exports = { ...config }; - * - * // or - * - * module.exports = (win, options) => { - * const config = new PluginConfig("plugin-name", { - * enableFront: true, - * initialOptions: options, - * }); - * setupMyPlugin(win, config); - * }; - */ -type ValueOf = T[keyof T]; - -export class PluginConfig { - private readonly name: string; - private readonly config: ConfigType; - private readonly defaultConfig: ConfigType; - private readonly enableFront: boolean; - - private subscribers: { [key in keyof ConfigType]?: (config: ConfigType) => void } = {}; - private allSubscribers: ((config: ConfigType) => void)[] = []; - - constructor( - name: T, - options: PluginConfigOptions = { - enableFront: false, - }, - ) { - const pluginDefaultConfig = defaultConfig.plugins[name] ?? {}; - const pluginConfig = options.initialOptions || window.mainConfig.plugins.getOptions(name) || {}; - - this.name = name; - this.enableFront = options.enableFront; - this.defaultConfig = pluginDefaultConfig; - this.config = { ...pluginDefaultConfig, ...pluginConfig }; - - if (this.enableFront) { - this.setupFront(); - } - - activePlugins[name] = this; - } - - get = keyof ConfigType>(key: Key): ConfigType[Key] { - return this.config?.[key]; - } - - set(key: keyof ConfigType, value: ValueOf>) { - this.config[key] = value; - this.onChange(key); - this.save(); - } - - getAll(): ConfigType { - return { ...this.config }; - } - - setAll(options: Partial>) { - if (!options || typeof options !== 'object') { - throw new Error('Options must be an object.'); - } - - let changed = false; - for (const [key, value] of Object.entries(options) as Entries) { - if (this.config[key] !== value) { - if (value !== undefined) this.config[key] = value; - this.onChange(key, false); - changed = true; - } - } - - if (changed) { - for (const fn of this.allSubscribers) { - fn(this.config); - } - } - - this.save(); - } - - getDefaultConfig() { - return this.defaultConfig; - } - - /** - * Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true` - * - * Used for options that require a restart to take effect. - */ - setAndMaybeRestart(key: keyof ConfigType, value: ValueOf>) { - this.config[key] = value; - window.mainConfig.plugins.setMenuOptions(this.name, this.config); - this.onChange(key); - } - - subscribe(valueName: keyof ConfigType, fn: (config: ConfigType) => void) { - this.subscribers[valueName] = fn; - } - - subscribeAll(fn: (config: ConfigType) => void) { - this.allSubscribers.push(fn); - } - - /** Called only from back */ - private save() { - window.mainConfig.plugins.setOptions(this.name, this.config); - } - - private onChange(valueName: keyof ConfigType, single: boolean = true) { - this.subscribers[valueName]?.(this.config[valueName] as ConfigType); - if (single) { - for (const fn of this.allSubscribers) { - fn(this.config); - } - } - } - - private setupFront() { - const ignoredMethods = ['subscribe', 'subscribeAll']; - - for (const [fnName, fn] of Object.entries(this) as Entries) { - if (typeof fn !== 'function' || fn.name in ignoredMethods) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-return - this[fnName] = (async (...args: any) => await window.ipcRenderer.invoke( - `${this.name}-config-${String(fnName)}`, - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - ...args, - )) as typeof this[keyof this]; - - this.subscribe = (valueName, fn: (config: ConfigType) => void) => { - if (valueName in this.subscribers) { - console.error(`Already subscribed to ${String(valueName)}`); - } - - this.subscribers[valueName] = fn; - window.ipcRenderer.on( - `${this.name}-config-changed-${String(valueName)}`, - (_, value: ConfigType) => { - fn(value); - }, - ); - window.ipcRenderer.send(`${this.name}-config-subscribe`, valueName); - }; - - this.subscribeAll = (fn: (config: ConfigType) => void) => { - window.ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType) => { - fn(value); - }); - window.ipcRenderer.send(`${this.name}-config-subscribe-all`); - }; - } - } -} diff --git a/src/config/dynamic.ts b/src/config/dynamic.ts deleted file mode 100644 index 12d34f19..00000000 --- a/src/config/dynamic.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { ipcMain } from 'electron'; - -import defaultConfig from './defaults'; - -import { getOptions, setMenuOptions, setOptions } from './plugins'; - -import { sendToFront } from '../providers/app-controls'; -import { Entries } from '../utils/type-utils'; - -export type DefaultPluginsConfig = typeof defaultConfig.plugins; -export type OneOfDefaultConfigKey = keyof DefaultPluginsConfig; -export type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey]; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig } = {}; - -export const getActivePlugins = () => activePlugins; - -if (process.type === 'browser') { - ipcMain.handle('get-active-plugins', getActivePlugins); -} - -export const isActive = (plugin: string): boolean => plugin in activePlugins; - -export interface PluginConfigOptions { - enableFront: boolean; - initialOptions?: OneOfDefaultConfig; -} - -/** - * This class is used to create a dynamic synced config for plugins. - * - * @param {string} name - The name of the plugin. - * @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false. - * @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store. - * - * @example - * const { PluginConfig } = require("../../config/dynamic"); - * const config = new PluginConfig("plugin-name", { enableFront: true }); - * module.exports = { ...config }; - * - * // or - * - * module.exports = (win, options) => { - * const config = new PluginConfig("plugin-name", { - * enableFront: true, - * initialOptions: options, - * }); - * setupMyPlugin(win, config); - * }; - */ -export type ConfigType = typeof defaultConfig.plugins[T]; -type ValueOf = T[keyof T]; - -export class PluginConfig { - private readonly name: string; - private readonly config: ConfigType; - private readonly defaultConfig: ConfigType; - private readonly enableFront: boolean; - - private subscribers: { [key in keyof ConfigType]?: (config: ConfigType) => void } = {}; - private allSubscribers: ((config: ConfigType) => void)[] = []; - - constructor( - name: T, - options: PluginConfigOptions = { - enableFront: false, - }, - ) { - const pluginDefaultConfig = defaultConfig.plugins[name] ?? {}; - const pluginConfig = options.initialOptions || getOptions(name) || {}; - - this.name = name; - this.enableFront = options.enableFront; - this.defaultConfig = pluginDefaultConfig; - this.config = { ...pluginDefaultConfig, ...pluginConfig }; - - if (this.enableFront) { - this.setupFront(); - } - - activePlugins[name] = this; - } - - get = keyof ConfigType>(key: Key): ConfigType[Key] { - return this.config?.[key]; - } - - set(key: keyof ConfigType, value: ValueOf>) { - this.config[key] = value; - this.onChange(key); - this.save(); - } - - getAll(): ConfigType { - return { ...this.config }; - } - - setAll(options: Partial>) { - if (!options || typeof options !== 'object') { - throw new Error('Options must be an object.'); - } - - let changed = false; - for (const [key, value] of Object.entries(options) as Entries) { - if (this.config[key] !== value) { - if (value !== undefined) this.config[key] = value; - this.onChange(key, false); - changed = true; - } - } - - if (changed) { - for (const fn of this.allSubscribers) { - fn(this.config); - } - } - - this.save(); - } - - getDefaultConfig() { - return this.defaultConfig; - } - - /** - * Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true` - * - * Used for options that require a restart to take effect. - */ - setAndMaybeRestart(key: keyof ConfigType, value: ValueOf>) { - this.config[key] = value; - setMenuOptions(this.name, this.config); - this.onChange(key); - } - - subscribe(valueName: keyof ConfigType, fn: (config: ConfigType) => void) { - this.subscribers[valueName] = fn; - } - - subscribeAll(fn: (config: ConfigType) => void) { - this.allSubscribers.push(fn); - } - - /** Called only from back */ - private save() { - setOptions(this.name, this.config); - } - - private onChange(valueName: keyof ConfigType, single: boolean = true) { - this.subscribers[valueName]?.(this.config[valueName] as ConfigType); - if (single) { - for (const fn of this.allSubscribers) { - fn(this.config); - } - } - } - - private setupFront() { - const ignoredMethods = ['subscribe', 'subscribeAll']; - - for (const [fnName, fn] of Object.entries(this) as Entries) { - if (typeof fn !== 'function' || fn.name in ignoredMethods) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-return - ipcMain.handle(`${this.name}-config-${String(fnName)}`, (_, ...args) => fn(...args)); - } - - ipcMain.on(`${this.name}-config-subscribe`, (_, valueName: keyof ConfigType) => { - this.subscribe(valueName, (value) => { - sendToFront(`${this.name}-config-changed-${String(valueName)}`, value); - }); - }); - - ipcMain.on(`${this.name}-config-subscribe-all`, () => { - this.subscribeAll((value) => { - sendToFront(`${this.name}-config-changed`, value); - }); - }); - } -} diff --git a/src/index.ts b/src/index.ts index 1eeba193..e126d11b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,9 +25,7 @@ import { mainPlugins } from 'virtual:MainPlugins'; import { pluginBuilders } from 'virtual:PluginBuilders'; /* eslint-enable import/order */ -import { setOptions as pipSetOptions } from './plugins/picture-in-picture/main'; - -import youtubeMusicCSS from './youtube-music.css'; +import youtubeMusicCSS from './youtube-music.css?inline'; import { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder'; // Catch errors and log them @@ -156,6 +154,9 @@ async function loadPlugins(win: BrowserWindow) { handle: (event: string, listener) => { ipcMain.handle(event, async (_, ...args) => listener(...args as never)); }, + on: (event: string, listener) => { + ipcMain.on(event, async (_, ...args) => listener(...args as never)); + }, }); @@ -273,39 +274,22 @@ async function createMainWindow() { : config.defaultConfig.url; win.on('closed', onClosed); - type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture']; - const setPiPOptions = config.plugins.isEnabled('picture-in-picture') - ? (key: string, value: unknown) => pipSetOptions({ [key]: value }) - : () => {}; - win.on('move', () => { if (win.isMaximized()) { return; } - const position = win.getPosition(); - const isPiPEnabled: boolean - = config.plugins.isEnabled('picture-in-picture') - && config.plugins.getOptions('picture-in-picture').isInPiP; - if (!isPiPEnabled) { - - lateSave('window-position', { x: position[0], y: position[1] }); - } else if (config.plugins.getOptions('picture-in-picture').savePosition) { - lateSave('pip-position', position, setPiPOptions); - } + const [x, y] = win.getPosition(); + lateSave('window-position', { x, y }); }); let winWasMaximized: boolean; win.on('resize', () => { - const windowSize = win.getSize(); + const [width, height] = win.getSize(); const isMaximized = win.isMaximized(); - const isPiPEnabled - = config.plugins.isEnabled('picture-in-picture') - && config.plugins.getOptions('picture-in-picture').isInPiP; - - if (!isPiPEnabled && winWasMaximized !== isMaximized) { + if (winWasMaximized !== isMaximized) { winWasMaximized = isMaximized; config.set('window-maximized', isMaximized); } @@ -314,14 +298,10 @@ async function createMainWindow() { return; } - if (!isPiPEnabled) { - lateSave('window-size', { - width: windowSize[0], - height: windowSize[1], - }); - } else if (config.plugins.getOptions('picture-in-picture').saveSize) { - lateSave('pip-size', windowSize, setPiPOptions); - } + lateSave('window-size', { + width, + height, + }); }); const savedTimeouts: Record = {}; diff --git a/src/menu.ts b/src/menu.ts index 503aad1f..356ec8d4 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -57,6 +57,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise refreshMenu(win), }); const availablePlugins = getAvailablePluginNames(); diff --git a/src/plugins/adblocker/blocker.ts b/src/plugins/adblocker/blocker.ts index 82d2986f..2deddf3f 100644 --- a/src/plugins/adblocker/blocker.ts +++ b/src/plugins/adblocker/blocker.ts @@ -17,10 +17,12 @@ const SOURCES = [ 'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt', ]; +let blocker: ElectronBlocker | undefined; + export const loadAdBlockerEngine = async ( session: Electron.Session | undefined = undefined, - cache = true, - additionalBlockLists = [], + cache: boolean = true, + additionalBlockLists: string[] = [], disableDefaultLists: boolean | unknown[] = false, ) => { // Only use cache if no additional blocklists are passed @@ -45,7 +47,7 @@ export const loadAdBlockerEngine = async ( ]; try { - const blocker = await ElectronBlocker.fromLists( + blocker = await ElectronBlocker.fromLists( (url: string) => net.fetch(url), lists, { @@ -64,4 +66,10 @@ export const loadAdBlockerEngine = async ( } }; -export default { loadAdBlockerEngine }; +export const unloadAdBlockerEngine = (session: Electron.Session) => { + if (blocker) { + blocker.disableBlockingInSession(session); + } +}; + +export const isBlockerEnabled = (session: Electron.Session) => blocker !== undefined && blocker.isBlockingEnabled(session); diff --git a/src/plugins/adblocker/config.ts b/src/plugins/adblocker/config.ts deleted file mode 100644 index 0ff54eb3..00000000 --- a/src/plugins/adblocker/config.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* renderer */ - -import { blockers } from './blocker-types'; - -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('adblocker', { enableFront: true }); - -export const shouldUseBlocklists = () => config.get('blocker') !== blockers.InPlayer; - -export default Object.assign(config, { - shouldUseBlocklists, - blockers, -}); diff --git a/src/plugins/adblocker/index.ts b/src/plugins/adblocker/index.ts new file mode 100644 index 00000000..0f803ede --- /dev/null +++ b/src/plugins/adblocker/index.ts @@ -0,0 +1,52 @@ +import { blockers } from './types'; + +import { createPluginBuilder } from '../utils/builder'; + +interface AdblockerConfig { + /** + * Whether to enable the adblocker. + * @default true + */ + enabled: boolean; + /** + * When enabled, the adblocker will cache the blocklists. + * @default true + */ + cache: boolean; + /** + * Which adblocker to use. + * @default blockers.InPlayer + */ + blocker: typeof blockers[keyof typeof blockers]; + /** + * Additional list of filters to use. + * @example ["https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"] + * @default [] + */ + additionalBlockLists: string[]; + /** + * Disable the default blocklists. + * @default false + */ + disableDefaultLists: boolean; +} + +const builder = createPluginBuilder('adblocker', { + name: 'Adblocker', + restartNeeded: false, + config: { + enabled: true, + cache: true, + blocker: blockers.InPlayer, + additionalBlockLists: [], + disableDefaultLists: false, + } as AdblockerConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/adblocker/inject.d.ts b/src/plugins/adblocker/inject.d.ts deleted file mode 100644 index 435d23f7..00000000 --- a/src/plugins/adblocker/inject.d.ts +++ /dev/null @@ -1 +0,0 @@ -export const inject: () => void; diff --git a/src/plugins/adblocker/inject-cliqz-preload.ts b/src/plugins/adblocker/injectors/inject-cliqz-preload.ts similarity index 100% rename from src/plugins/adblocker/inject-cliqz-preload.ts rename to src/plugins/adblocker/injectors/inject-cliqz-preload.ts diff --git a/src/plugins/adblocker/injectors/inject.d.ts b/src/plugins/adblocker/injectors/inject.d.ts new file mode 100644 index 00000000..8078bbf0 --- /dev/null +++ b/src/plugins/adblocker/injectors/inject.d.ts @@ -0,0 +1,3 @@ +export const inject: () => void; + +export const isInjected: () => boolean; diff --git a/src/plugins/adblocker/inject.js b/src/plugins/adblocker/injectors/inject.js similarity index 99% rename from src/plugins/adblocker/inject.js rename to src/plugins/adblocker/injectors/inject.js index edc8b03b..12ded09a 100644 --- a/src/plugins/adblocker/inject.js +++ b/src/plugins/adblocker/injectors/inject.js @@ -7,7 +7,13 @@ Parts of this code is derived from set-constant.js: https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704 */ + +let injected = false; + +export const isInjected = () => isInjected; + export const inject = () => { + injected = true; { const pruner = function (o) { delete o.playerAds; diff --git a/src/plugins/adblocker/main.ts b/src/plugins/adblocker/main.ts index ef05889c..90c97827 100644 --- a/src/plugins/adblocker/main.ts +++ b/src/plugins/adblocker/main.ts @@ -1,19 +1,43 @@ import { BrowserWindow } from 'electron'; -import { loadAdBlockerEngine } from './blocker'; -import { shouldUseBlocklists } from './config'; +import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from './blocker'; -import type { ConfigType } from '../../config/dynamic'; +import builder from './index'; +import { blockers } from './types'; -type AdBlockOptions = ConfigType<'adblocker'>; +export default builder.createMain(({ getConfig }) => { + let mainWindow: BrowserWindow | undefined; -export default async (win: BrowserWindow, options: AdBlockOptions) => { - if (shouldUseBlocklists()) { - await loadAdBlockerEngine( - win.webContents.session, - options.cache, - options.additionalBlockLists, - options.disableDefaultLists, - ); - } -}; + return { + async onLoad(window) { + const config = await getConfig(); + mainWindow = window; + + if (config.blocker === blockers.WithBlocklists) { + await loadAdBlockerEngine( + window.webContents.session, + config.cache, + config.additionalBlockLists, + config.disableDefaultLists, + ); + } + }, + onUnload(window) { + if (isBlockerEnabled(window.webContents.session)) { + unloadAdBlockerEngine(window.webContents.session); + } + }, + async onConfigChange(newConfig) { + if (mainWindow) { + if (newConfig.blocker === blockers.WithBlocklists && !isBlockerEnabled(mainWindow.webContents.session)) { + await loadAdBlockerEngine( + mainWindow.webContents.session, + newConfig.cache, + newConfig.additionalBlockLists, + newConfig.disableDefaultLists, + ); + } + } + } + }; +}); diff --git a/src/plugins/adblocker/menu.ts b/src/plugins/adblocker/menu.ts index f2eb1182..a88ec7e1 100644 --- a/src/plugins/adblocker/menu.ts +++ b/src/plugins/adblocker/menu.ts @@ -1,21 +1,22 @@ -import config from './config'; +import { blockers } from './types'; +import builder from './index'; -import { blockers } from './blocker-types'; +import type { MenuTemplate } from '../../menu'; -import { MenuTemplate } from '../../menu'; +export default builder.createMenu(async ({ getConfig, setConfig }): Promise => { + const config = await getConfig(); -export default (): MenuTemplate => { return [ { label: 'Blocker', - submenu: Object.values(blockers).map((blocker: string) => ({ + submenu: Object.values(blockers).map((blocker) => ({ label: blocker, type: 'radio', - checked: (config.get('blocker') || blockers.WithBlocklists) === blocker, + checked: (config.blocker || blockers.WithBlocklists) === blocker, click() { - config.set('blocker', blocker); + setConfig({ blocker }); }, })), }, ]; -}; +}); diff --git a/src/plugins/adblocker/preload.ts b/src/plugins/adblocker/preload.ts index 57ab0247..f22596ab 100644 --- a/src/plugins/adblocker/preload.ts +++ b/src/plugins/adblocker/preload.ts @@ -1,15 +1,27 @@ -import config, { shouldUseBlocklists } from './config'; -import { inject } from './inject'; -import injectCliqzPreload from './inject-cliqz-preload'; +import { inject, isInjected } from './injectors/inject'; +import injectCliqzPreload from './injectors/inject-cliqz-preload'; -import { blockers } from './blocker-types'; +import { blockers } from './types'; +import builder from './index'; -export default async () => { - if (shouldUseBlocklists()) { - // Preload adblocker to inject scripts/styles - await injectCliqzPreload(); - // eslint-disable-next-line @typescript-eslint/await-thenable - } else if ((config.get('blocker')) === blockers.InPlayer) { - inject(); +export default builder.createPreload(({ getConfig }) => ({ + async onLoad() { + const config = await getConfig(); + + if (config.blocker === blockers.WithBlocklists) { + // Preload adblocker to inject scripts/styles + await injectCliqzPreload(); + } else if (config.blocker === blockers.InPlayer) { + inject(); + } + }, + async onConfigChange(newConfig) { + if (newConfig.blocker === blockers.WithBlocklists) { + await injectCliqzPreload(); + } else if (newConfig.blocker === blockers.InPlayer) { + if (!isInjected()) { + inject(); + } + } } -}; +})); diff --git a/src/plugins/adblocker/blocker-types.ts b/src/plugins/adblocker/types/index.ts similarity index 100% rename from src/plugins/adblocker/blocker-types.ts rename to src/plugins/adblocker/types/index.ts diff --git a/src/plugins/album-color-theme/index.ts b/src/plugins/album-color-theme/index.ts index d7b474f4..9fbb2a8a 100644 --- a/src/plugins/album-color-theme/index.ts +++ b/src/plugins/album-color-theme/index.ts @@ -4,6 +4,7 @@ import { createPluginBuilder } from '../utils/builder'; const builder = createPluginBuilder('album-color-theme', { name: 'Album Color Theme', + restartNeeded: true, config: { enabled: false, }, diff --git a/src/plugins/album-color-theme/renderer.ts b/src/plugins/album-color-theme/renderer.ts index e217bf81..2d79bf85 100644 --- a/src/plugins/album-color-theme/renderer.ts +++ b/src/plugins/album-color-theme/renderer.ts @@ -1,6 +1,6 @@ import { FastAverageColor } from 'fast-average-color'; -import builder from './'; +import builder from './index'; export default builder.createRenderer(() => { function hexToHSL(H: string) { @@ -27,7 +27,7 @@ export default builder.createRenderer(() => { let h: number; let s: number; let l: number; - + if (delta == 0) { h = 0; } else if (cmax == r) { @@ -37,32 +37,32 @@ export default builder.createRenderer(() => { } else { h = ((r - g) / delta) + 4; } - + h = Math.round(h * 60); - + if (h < 0) { h += 360; } - + l = (cmax + cmin) / 2; s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1)); s = +(s * 100).toFixed(1); l = +(l * 100).toFixed(1); - + //return "hsl(" + h + "," + s + "%," + l + "%)"; return [h,s,l]; } - + let hue = 0; let saturation = 0; let lightness = 0; - + function changeElementColor(element: HTMLElement | null, hue: number, saturation: number, lightness: number){ if (element) { element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`; } } - + return { onLoad() { const playerPage = document.querySelector('#player-page'); diff --git a/src/plugins/ambient-mode/index.ts b/src/plugins/ambient-mode/index.ts index bc2f25c2..38114249 100644 --- a/src/plugins/ambient-mode/index.ts +++ b/src/plugins/ambient-mode/index.ts @@ -14,6 +14,7 @@ export type AmbientModePluginConfig = { }; const builder = createPluginBuilder('ambient-mode', { name: 'Ambient Mode', + restartNeeded: true, config: { enabled: false, quality: 50, diff --git a/src/plugins/ambient-mode/menu.ts b/src/plugins/ambient-mode/menu.ts index 9909a7dc..7b9923c3 100644 --- a/src/plugins/ambient-mode/menu.ts +++ b/src/plugins/ambient-mode/menu.ts @@ -1,4 +1,4 @@ -import builder from './'; +import builder from './index'; const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000]; const qualityList = [10, 25, 50, 100, 200, 500, 1000]; diff --git a/src/plugins/ambient-mode/renderer.ts b/src/plugins/ambient-mode/renderer.ts index ff930ec4..91a92fc1 100644 --- a/src/plugins/ambient-mode/renderer.ts +++ b/src/plugins/ambient-mode/renderer.ts @@ -10,44 +10,44 @@ export default builder.createRenderer(async ({ getConfig }) => { let blur = initConfigData.blur; let opacity = initConfigData.opacity; let isFullscreen = initConfigData.fullscreen; - + let update: (() => void) | null = null; - + return { onLoad() { let unregister: (() => void) | null = null; - + const injectBlurVideo = (): (() => void) | null => { const songVideo = document.querySelector('#song-video'); const video = document.querySelector('#song-video .html5-video-container > video'); const wrapper = document.querySelector('#song-video > .player-wrapper'); - + if (!songVideo) return null; if (!video) return null; if (!wrapper) return null; - + console.log('injectBlurVideo', songVideo, video, wrapper); const blurCanvas = document.createElement('canvas'); blurCanvas.classList.add('html5-blur-canvas'); - + const context = blurCanvas.getContext('2d', { willReadFrequently: true }); - + /* effect */ let lastEffectWorkId: number | null = null; let lastImageData: ImageData | null = null; - + const onSync = () => { if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId); - + lastEffectWorkId = requestAnimationFrame(() => { // console.log('context', context); if (!context) return; - + const width = qualityRatio; let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1); if (!Number.isFinite(height)) height = width; if (!height) return; - + context.globalAlpha = 1; if (lastImageData) { const frameOffset = (1 / buffer) * (1000 / interpolationTime); @@ -56,29 +56,29 @@ export default builder.createRenderer(async ({ getConfig }) => { context.globalAlpha = frameOffset; } context.drawImage(video, 0, 0, width, height); - + lastImageData = context.getImageData(0, 0, width, height); // current image data - + lastEffectWorkId = null; }); }; - + const applyVideoAttributes = () => { const rect = video.getBoundingClientRect(); - + const newWidth = Math.floor(video.width || rect.width); const newHeight = Math.floor(video.height || rect.height); - + if (newWidth === 0 || newHeight === 0) return; - + blurCanvas.width = qualityRatio; blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio); blurCanvas.style.width = `${newWidth * sizeRatio}px`; blurCanvas.style.height = `${newHeight * sizeRatio}px`; - + if (isFullscreen) blurCanvas.classList.add('fullscreen'); else blurCanvas.classList.remove('fullscreen'); - + const leftOffset = newWidth * (sizeRatio - 1) / 2; const topOffset = newHeight * (sizeRatio - 1) / 2; blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`); @@ -87,7 +87,7 @@ export default builder.createRenderer(async ({ getConfig }) => { blurCanvas.style.setProperty('--opacity', `${opacity}`); }; update = applyVideoAttributes; - + const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes') { @@ -98,7 +98,7 @@ export default builder.createRenderer(async ({ getConfig }) => { const resizeObserver = new ResizeObserver(() => { applyVideoAttributes(); }); - + /* hooking */ let canvasInterval: NodeJS.Timeout | null = null; canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer))); @@ -106,7 +106,7 @@ export default builder.createRenderer(async ({ getConfig }) => { observer.observe(songVideo, { attributes: true }); resizeObserver.observe(songVideo); window.addEventListener('resize', applyVideoAttributes); - + const onPause = () => { if (canvasInterval) clearInterval(canvasInterval); canvasInterval = null; @@ -117,29 +117,29 @@ export default builder.createRenderer(async ({ getConfig }) => { }; songVideo.addEventListener('pause', onPause); songVideo.addEventListener('play', onPlay); - + /* injecting */ wrapper.prepend(blurCanvas); - + /* cleanup */ return () => { if (canvasInterval) clearInterval(canvasInterval); - + songVideo.removeEventListener('pause', onPause); songVideo.removeEventListener('play', onPlay); - + observer.disconnect(); resizeObserver.disconnect(); window.removeEventListener('resize', applyVideoAttributes); - + wrapper.removeChild(blurCanvas); }; }; - - + + const playerPage = document.querySelector('#player-page'); const ytmusicAppLayout = document.querySelector('#layout'); - + const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'attributes') { @@ -154,19 +154,19 @@ export default builder.createRenderer(async ({ getConfig }) => { } } }); - + if (playerPage) { observer.observe(playerPage, { attributes: true }); } }, onConfigChange(newConfig) { - if (typeof newConfig.interpolationTime === 'number') interpolationTime = newConfig.interpolationTime; - if (typeof newConfig.buffer === 'number') buffer = newConfig.buffer; - if (typeof newConfig.quality === 'number') qualityRatio = newConfig.quality; - if (typeof newConfig.size === 'number') sizeRatio = newConfig.size / 100; - if (typeof newConfig.blur === 'number') blur = newConfig.blur; - if (typeof newConfig.opacity === 'number') opacity = newConfig.opacity; - if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen; + interpolationTime = newConfig.interpolationTime; + buffer = newConfig.buffer; + qualityRatio = newConfig.quality; + sizeRatio = newConfig.size / 100; + blur = newConfig.blur; + opacity = newConfig.opacity; + isFullscreen = newConfig.fullscreen; update?.(); }, diff --git a/src/plugins/audio-compressor/index.ts b/src/plugins/audio-compressor/index.ts index 880fbc99..433b75f6 100644 --- a/src/plugins/audio-compressor/index.ts +++ b/src/plugins/audio-compressor/index.ts @@ -2,6 +2,7 @@ import { createPluginBuilder } from '../utils/builder'; const builder = createPluginBuilder('audio-compressor', { name: 'Audio Compressor', + restartNeeded: false, config: { enabled: false, }, diff --git a/src/plugins/audio-compressor/renderer.ts b/src/plugins/audio-compressor/renderer.ts index 5b102840..69402b44 100644 --- a/src/plugins/audio-compressor/renderer.ts +++ b/src/plugins/audio-compressor/renderer.ts @@ -1,4 +1,4 @@ -import builder from '.'; +import builder from './index'; export default builder.createRenderer(() => { return { diff --git a/src/plugins/blur-nav-bar/index.ts b/src/plugins/blur-nav-bar/index.ts index 386bad63..3fcaad03 100644 --- a/src/plugins/blur-nav-bar/index.ts +++ b/src/plugins/blur-nav-bar/index.ts @@ -4,6 +4,7 @@ import { createPluginBuilder } from '../utils/builder'; const builder = createPluginBuilder('blur-nav-bar', { name: 'Blur Navigation Bar', + restartNeeded: true, config: { enabled: false, }, diff --git a/src/plugins/bypass-age-restrictions/index.ts b/src/plugins/bypass-age-restrictions/index.ts index aa1bbc8a..5fb4b6db 100644 --- a/src/plugins/bypass-age-restrictions/index.ts +++ b/src/plugins/bypass-age-restrictions/index.ts @@ -2,6 +2,7 @@ import { createPluginBuilder } from '../utils/builder'; const builder = createPluginBuilder('bypass-age-restrictions', { name: 'Bypass Age Restrictions', + restartNeeded: true, config: { enabled: false, }, diff --git a/src/plugins/bypass-age-restrictions/renderer.ts b/src/plugins/bypass-age-restrictions/renderer.ts index 4cd51eac..3a468762 100644 --- a/src/plugins/bypass-age-restrictions/renderer.ts +++ b/src/plugins/bypass-age-restrictions/renderer.ts @@ -1,4 +1,4 @@ -import builder from '.'; +import builder from './index'; export default builder.createRenderer(() => ({ async onLoad() { diff --git a/src/plugins/captions-selector/config-renderer.ts b/src/plugins/captions-selector/config-renderer.ts deleted file mode 100644 index 867ab9dc..00000000 --- a/src/plugins/captions-selector/config-renderer.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic-renderer'; - -const configRenderer = new PluginConfig('captions-selector', { enableFront: true }); -export default configRenderer; diff --git a/src/plugins/captions-selector/config.ts b/src/plugins/captions-selector/config.ts deleted file mode 100644 index f7878eb9..00000000 --- a/src/plugins/captions-selector/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('captions-selector', { enableFront: true }); -export default config; diff --git a/src/plugins/captions-selector/index.ts b/src/plugins/captions-selector/index.ts new file mode 100644 index 00000000..4ff6b094 --- /dev/null +++ b/src/plugins/captions-selector/index.ts @@ -0,0 +1,20 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('captions-selector', { + name: 'Captions Selector', + restartNeeded: false, + config: { + enabled: false, + disableCaptions: false, + autoload: false, + lastCaptionsCode: '', + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/captions-selector/main.ts b/src/plugins/captions-selector/main.ts index 8073ddde..b56fb0b8 100644 --- a/src/plugins/captions-selector/main.ts +++ b/src/plugins/captions-selector/main.ts @@ -1,19 +1,22 @@ -import { BrowserWindow, ipcMain } from 'electron'; import prompt from 'custom-electron-prompt'; +import builder from './index'; + import promptOptions from '../../providers/prompt-options'; -export default (win: BrowserWindow) => { - ipcMain.handle('captionsSelector', async (_, captionLabels: Record, currentIndex: string) => await prompt( - { - title: 'Choose Caption', - label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`, - type: 'select', - value: currentIndex, - selectOptions: captionLabels, - resizable: true, - ...promptOptions(), - }, - win, - )); -}; +export default builder.createMain(({ handle }) => ({ + onLoad(window) { + handle('captionsSelector', async (_, captionLabels: Record, currentIndex: string) => await prompt( + { + title: 'Choose Caption', + label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`, + type: 'select', + value: currentIndex, + selectOptions: captionLabels, + resizable: true, + ...promptOptions(), + }, + window, + )); + } +})); diff --git a/src/plugins/captions-selector/menu.ts b/src/plugins/captions-selector/menu.ts index 65b34008..f56f56c9 100644 --- a/src/plugins/captions-selector/menu.ts +++ b/src/plugins/captions-selector/menu.ts @@ -1,22 +1,26 @@ -import config from './config'; +import builder from './index'; -import { MenuTemplate } from '../../menu'; +import type { MenuTemplate } from '../../menu'; -export default (): MenuTemplate => [ - { - label: 'Automatically select last used caption', - type: 'checkbox', - checked: config.get('autoload'), - click(item) { - config.set('autoload', item.checked); +export default builder.createMenu(async ({ getConfig, setConfig }): Promise => { + const config = await getConfig(); + + return [ + { + label: 'Automatically select last used caption', + type: 'checkbox', + checked: config.autoload, + click(item) { + setConfig({ autoload: item.checked }); + }, }, - }, - { - label: 'No captions by default', - type: 'checkbox', - checked: config.get('disableCaptions'), - click(item) { - config.set('disableCaptions', item.checked); + { + label: 'No captions by default', + type: 'checkbox', + checked: config.disableCaptions, + click(item) { + setConfig({ disableCaptions: item.checked }); + }, }, - }, -]; + ]; +}); diff --git a/src/plugins/captions-selector/renderer.ts b/src/plugins/captions-selector/renderer.ts index cd4c0833..26aa9d59 100644 --- a/src/plugins/captions-selector/renderer.ts +++ b/src/plugins/captions-selector/renderer.ts @@ -1,7 +1,7 @@ -import configProvider from './config-renderer'; - import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw'; +import builder from './index'; + import { ElementFromHtml } from '../utils/renderer'; import { YoutubePlayer } from '../../types/youtube-player'; @@ -20,28 +20,17 @@ interface LanguageOptions { vss_id: string; } -let captionsSelectorConfig: ConfigType<'captions-selector'>; - const $ = (selector: string): Element => document.querySelector(selector)!; const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML); -export default () => { - captionsSelectorConfig = configProvider.getAll(); +export default builder.createRenderer(({ getConfig, setConfig }) => { + let config: Awaited>; + let captionTrackList: LanguageOptions[] | null = null; + let api: YoutubePlayer; - configProvider.subscribeAll((newConfig) => { - captionsSelectorConfig = newConfig; - }); - document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true }); -}; - -function setup(api: YoutubePlayer) { - $('.right-controls-buttons').append(captionsSettingsButton); - - let captionTrackList = api.getOption('captions', 'tracklist') ?? []; - - $('video').addEventListener('srcChanged', () => { - if (captionsSelectorConfig.disableCaptions) { + const videoChangeListener = () => { + if (config.disableCaptions) { setTimeout(() => api.unloadModule('captions'), 100); captionsSettingsButton.style.display = 'none'; return; @@ -52,9 +41,9 @@ function setup(api: YoutubePlayer) { setTimeout(() => { captionTrackList = api.getOption('captions', 'tracklist') ?? []; - if (captionsSelectorConfig.autoload && captionsSelectorConfig.lastCaptionsCode) { + if (config.autoload && config.lastCaptionsCode) { api.setOption('captions', 'track', { - languageCode: captionsSelectorConfig.lastCaptionsCode, + languageCode: config.lastCaptionsCode, }); } @@ -62,9 +51,9 @@ function setup(api: YoutubePlayer) { ? 'inline-block' : 'none'; }, 250); - }); + }; - captionsSettingsButton.addEventListener('click', async () => { + const captionsButtonClickListener = async () => { if (captionTrackList?.length) { const currentCaptionTrack = api.getOption('captions', 'track')!; let currentIndex = currentCaptionTrack @@ -82,7 +71,7 @@ function setup(api: YoutubePlayer) { } const newCaptions = captionTrackList[currentIndex]; - configProvider.set('lastCaptionsCode', newCaptions?.languageCode); + setConfig({ lastCaptionsCode: newCaptions?.languageCode }); if (newCaptions) { api.setOption('captions', 'track', { languageCode: newCaptions.languageCode }); } else { @@ -91,5 +80,38 @@ function setup(api: YoutubePlayer) { setTimeout(() => api.playVideo()); } - }); -} + }; + + const listener = ({ detail }: { + detail: YoutubePlayer; + }) => { + api = detail; + $('.right-controls-buttons').append(captionsSettingsButton); + + captionTrackList = api.getOption('captions', 'tracklist') ?? []; + + $('video').addEventListener('srcChanged', videoChangeListener); + captionsSettingsButton.addEventListener('click', captionsButtonClickListener); + }; + + const removeListener = () => { + $('.right-controls-buttons').removeChild(captionsSettingsButton); + $('#movie_player').unloadModule('captions'); + + document.removeEventListener('apiLoaded', listener); + }; + + return { + async onLoad() { + config = await getConfig(); + + document.addEventListener('apiLoaded', listener, { once: true, passive: true }); + }, + onUnload() { + removeListener(); + }, + onConfigChange(newConfig) { + config = newConfig; + } + }; +}); diff --git a/src/plugins/compact-sidebar/index.ts b/src/plugins/compact-sidebar/index.ts new file mode 100644 index 00000000..da8035f6 --- /dev/null +++ b/src/plugins/compact-sidebar/index.ts @@ -0,0 +1,17 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('compact-sidebar', { + name: 'Compact Sidebar', + restartNeeded: false, + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/compact-sidebar/renderer.ts b/src/plugins/compact-sidebar/renderer.ts index ad2aab97..c2268aa8 100644 --- a/src/plugins/compact-sidebar/renderer.ts +++ b/src/plugins/compact-sidebar/renderer.ts @@ -1,10 +1,27 @@ -export default () => { - const compactSidebar = document.querySelector('#mini-guide'); - const isCompactSidebarDisabled - = compactSidebar === null - || window.getComputedStyle(compactSidebar).display === 'none'; +import builder from './index'; - if (isCompactSidebarDisabled) { - document.querySelector('#button')?.click(); - } -}; +export default builder.createRenderer(() => { + const getCompactSidebar = () => document.querySelector('#mini-guide'); + const isCompactSidebarDisabled = () => { + const compactSidebar = getCompactSidebar(); + return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none'; + }; + + return { + onLoad() { + if (isCompactSidebarDisabled()) { + document.querySelector('#button')?.click(); + } + }, + onUnload() { + if (!isCompactSidebarDisabled()) { + document.querySelector('#button')?.click(); + } + }, + onConfigChange() { + if (isCompactSidebarDisabled()) { + document.querySelector('#button')?.click(); + } + } + }; +}); diff --git a/src/plugins/crossfade/config-renderer.ts b/src/plugins/crossfade/config-renderer.ts deleted file mode 100644 index d9c1af27..00000000 --- a/src/plugins/crossfade/config-renderer.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic-renderer'; - -const config = new PluginConfig('crossfade', { enableFront: true }); -export default config; diff --git a/src/plugins/crossfade/config.ts b/src/plugins/crossfade/config.ts deleted file mode 100644 index ffe2232d..00000000 --- a/src/plugins/crossfade/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('crossfade', { enableFront: true }); -export default config; diff --git a/src/plugins/crossfade/fader.ts b/src/plugins/crossfade/fader.ts index c9442ba0..0066f8c2 100644 --- a/src/plugins/crossfade/fader.ts +++ b/src/plugins/crossfade/fader.ts @@ -15,8 +15,6 @@ * v0.2.0, 07/2016 */ -'use strict'; - // Internal utility: check if value is a valid volume level and throw if not const validateVolumeLevel = (value: number) => { // Number between 0 and 1? diff --git a/src/plugins/crossfade/index.ts b/src/plugins/crossfade/index.ts new file mode 100644 index 00000000..4ce07055 --- /dev/null +++ b/src/plugins/crossfade/index.ts @@ -0,0 +1,50 @@ +import { createPluginBuilder } from '../utils/builder'; + +export type CrossfadePluginConfig = { + enabled: boolean; + fadeInDuration: number; + fadeOutDuration: number; + secondsBeforeEnd: number; + fadeScaling: 'linear' | 'logarithmic' | number; +} + +const builder = createPluginBuilder('crossfade', { + name: 'Crossfade [beta]', + restartNeeded: true, + config: { + enabled: false, + /** + * The duration of the fade in and fade out in milliseconds. + * + * @default 1500ms + */ + fadeInDuration: 1500, + /** + * The duration of the fade in and fade out in milliseconds. + * + * @default 5000ms + */ + fadeOutDuration: 5000, + /** + * The duration of the fade in and fade out in seconds. + * + * @default 10s + */ + secondsBeforeEnd: 10, + /** + * The scaling algorithm to use for the fade. + * (or a positive number in dB) + * + * @default 'linear' + */ + fadeScaling: 'linear', + } as CrossfadePluginConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/crossfade/main.ts b/src/plugins/crossfade/main.ts index 56a1355f..a2d69678 100644 --- a/src/plugins/crossfade/main.ts +++ b/src/plugins/crossfade/main.ts @@ -1,11 +1,18 @@ -import { ipcMain } from 'electron'; import { Innertube } from 'youtubei.js'; -export default async () => { - const yt = await Innertube.create(); +import builder from './index'; - ipcMain.handle('audio-url', async (_, videoID: string) => { - const info = await yt.getBasicInfo(videoID); - return info.streaming_data?.formats[0].decipher(yt.session.player); - }); -}; +import { getNetFetchAsFetch } from '../utils/main'; + +export default builder.createMain(({ handle }) => ({ + async onLoad() { + const yt = await Innertube.create({ + fetch: getNetFetchAsFetch(), + }); + + handle('audio-url', async (_, videoID: string) => { + const info = await yt.getBasicInfo(videoID); + return info.streaming_data?.formats[0].decipher(yt.session.player); + }); + } +})); diff --git a/src/plugins/crossfade/menu.ts b/src/plugins/crossfade/menu.ts index d20d9def..ac92d717 100644 --- a/src/plugins/crossfade/menu.ts +++ b/src/plugins/crossfade/menu.ts @@ -2,85 +2,90 @@ import prompt from 'custom-electron-prompt'; import { BrowserWindow } from 'electron'; -import config from './config'; +import builder, { CrossfadePluginConfig } from './index'; import promptOptions from '../../providers/prompt-options'; -import configOptions from '../../config/defaults'; -import { MenuTemplate } from '../../menu'; - -import type { ConfigType } from '../../config/dynamic'; - -const defaultOptions = configOptions.plugins.crossfade; - -export default (win: BrowserWindow): MenuTemplate => [ - { - label: 'Advanced', - async click() { - const newOptions = await promptCrossfadeValues(win, config.getAll()); - if (newOptions) { - config.setAll(newOptions); - } - }, - }, -]; - -async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'crossfade'>): Promise> | undefined> { - const res = await prompt( - { - title: 'Crossfade Options', - type: 'multiInput', - multiInputOptions: [ - { - label: 'Fade in duration (ms)', - value: options.fadeInDuration || defaultOptions.fadeInDuration, - inputAttrs: { - type: 'number', - required: true, - min: '0', - step: '100', +export default builder.createMenu(({ window, getConfig, setConfig }) => { + const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise | undefined> => { + const res = await prompt( + { + title: 'Crossfade Options', + type: 'multiInput', + multiInputOptions: [ + { + label: 'Fade in duration (ms)', + value: options.fadeInDuration, + inputAttrs: { + type: 'number', + required: true, + min: '0', + step: '100', + }, }, - }, - { - label: 'Fade out duration (ms)', - value: options.fadeOutDuration || defaultOptions.fadeOutDuration, - inputAttrs: { - type: 'number', - required: true, - min: '0', - step: '100', + { + label: 'Fade out duration (ms)', + value: options.fadeOutDuration, + inputAttrs: { + type: 'number', + required: true, + min: '0', + step: '100', + }, }, - }, - { - label: 'Crossfade x seconds before end', - value: - options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd, - inputAttrs: { - type: 'number', - required: true, - min: '0', + { + label: 'Crossfade x seconds before end', + value: + options.secondsBeforeEnd, + inputAttrs: { + type: 'number', + required: true, + min: '0', + }, }, - }, - { - label: 'Fade scaling', - selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' }, - value: options.fadeScaling || defaultOptions.fadeScaling, - }, - ], - resizable: true, - height: 360, - ...promptOptions(), - }, - win, - ).catch(console.error); - if (!res) { - return undefined; - } + { + label: 'Fade scaling', + selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' }, + value: options.fadeScaling, + }, + ], + resizable: true, + height: 360, + ...promptOptions(), + }, + win, + ).catch(console.error); - return { - fadeInDuration: Number(res[0]), - fadeOutDuration: Number(res[1]), - secondsBeforeEnd: Number(res[2]), - fadeScaling: res[3], + if (!res) { + return undefined; + } + + let fadeScaling: 'linear' | 'logarithmic' | number; + if (res[3] === 'linear' || res[3] === 'logarithmic') { + fadeScaling = res[3]; + } else if (isFinite(Number(res[3]))) { + fadeScaling = Number(res[3]); + } else { + fadeScaling = options.fadeScaling; + } + + return { + fadeInDuration: Number(res[0]), + fadeOutDuration: Number(res[1]), + secondsBeforeEnd: Number(res[2]), + fadeScaling, + }; }; -} + + return [ + { + label: 'Advanced', + async click() { + const newOptions = await promptCrossfadeValues(window, await getConfig()); + if (newOptions) { + setConfig(newOptions); + } + }, + }, + ]; +}); diff --git a/src/plugins/crossfade/renderer.ts b/src/plugins/crossfade/renderer.ts index cc96d4e6..d84aa45b 100644 --- a/src/plugins/crossfade/renderer.ts +++ b/src/plugins/crossfade/renderer.ts @@ -3,158 +3,154 @@ import { Howl } from 'howler'; // Extracted from https://github.com/bitfasching/VolumeFader import { VolumeFader } from './fader'; -import configProvider from './config-renderer'; +import builder, { CrossfadePluginConfig } from './index'; -import defaultConfigs from '../../config/defaults'; +export default builder.createRenderer(({ getConfig, invoke }) => { + let config: CrossfadePluginConfig; -import type { ConfigType } from '../../config/dynamic'; + let transitionAudio: Howl; // Howler audio used to fade out the current music + let firstVideo = true; + let waitForTransition: Promise; -let transitionAudio: Howl; // Howler audio used to fade out the current music -let firstVideo = true; -let waitForTransition: Promise; + const getStreamURL = async (videoID: string): Promise => invoke('audio-url', videoID); -const defaultConfig = defaultConfigs.plugins.crossfade; + const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v'); -let crossfadeConfig: ConfigType<'crossfade'>; + const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded'; -const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(crossfadeConfig[key]) || (defaultConfig[key] as number); + const watchVideoIDChanges = (cb: (id: string) => void) => { + window.navigation.addEventListener('navigate', (event) => { + const currentVideoID = getVideoIDFromURL( + (event.currentTarget as Navigation).currentEntry?.url ?? '', + ); + const nextVideoID = getVideoIDFromURL(event.destination.url ?? ''); -const getStreamURL = async (videoID: string) => window.ipcRenderer.invoke('audio-url', videoID) as Promise; - -const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v'); - -const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded'; - -const watchVideoIDChanges = (cb: (id: string) => void) => { - window.navigation.addEventListener('navigate', (event) => { - const currentVideoID = getVideoIDFromURL( - (event.currentTarget as Navigation).currentEntry?.url ?? '', - ); - const nextVideoID = getVideoIDFromURL(event.destination.url ?? ''); - - if ( - nextVideoID - && currentVideoID - && (firstVideo || nextVideoID !== currentVideoID) - ) { - if (isReadyToCrossfade()) { - crossfade(() => { + if ( + nextVideoID + && currentVideoID + && (firstVideo || nextVideoID !== currentVideoID) + ) { + if (isReadyToCrossfade()) { + crossfade(() => { + cb(nextVideoID); + }); + } else { cb(nextVideoID); - }); - } else { - cb(nextVideoID); - firstVideo = false; + firstVideo = false; + } } + }); + }; + + const createAudioForCrossfade = (url: string) => { + if (transitionAudio) { + transitionAudio.unload(); } - }); -}; -const createAudioForCrossfade = (url: string) => { - if (transitionAudio) { - transitionAudio.unload(); - } + transitionAudio = new Howl({ + src: url, + html5: true, + volume: 0, + }); + syncVideoWithTransitionAudio(); + }; - transitionAudio = new Howl({ - src: url, - html5: true, - volume: 0, - }); - syncVideoWithTransitionAudio(); -}; + const syncVideoWithTransitionAudio = () => { + const video = document.querySelector('video')!; -const syncVideoWithTransitionAudio = () => { - const video = document.querySelector('video')!; + const videoFader = new VolumeFader(video, { + fadeScaling: config.fadeScaling, + fadeDuration: config.fadeInDuration, + }); - const videoFader = new VolumeFader(video, { - fadeScaling: configGetNumber('fadeScaling'), - fadeDuration: configGetNumber('fadeInDuration'), - }); - - transitionAudio.play(); - transitionAudio.seek(video.currentTime); - - video.addEventListener('seeking', () => { - transitionAudio.seek(video.currentTime); - }); - - video.addEventListener('pause', () => { - transitionAudio.pause(); - }); - - video.addEventListener('play', () => { transitionAudio.play(); transitionAudio.seek(video.currentTime); - // Fade in - const videoVolume = video.volume; - video.volume = 0; - videoFader.fadeTo(videoVolume); - }); + video.addEventListener('seeking', () => { + transitionAudio.seek(video.currentTime); + }); - // Exit just before the end for the transition - const transitionBeforeEnd = () => { - if ( - video.currentTime >= video.duration - configGetNumber('secondsBeforeEnd') - && isReadyToCrossfade() - ) { - video.removeEventListener('timeupdate', transitionBeforeEnd); + video.addEventListener('pause', () => { + transitionAudio.pause(); + }); - // Go to next video - XXX: does not support "repeat 1" mode - document.querySelector('.next-button')?.click(); - } + video.addEventListener('play', () => { + transitionAudio.play(); + transitionAudio.seek(video.currentTime); + + // Fade in + const videoVolume = video.volume; + video.volume = 0; + videoFader.fadeTo(videoVolume); + }); + + // Exit just before the end for the transition + const transitionBeforeEnd = async () => { + if ( + video.currentTime >= video.duration - config.secondsBeforeEnd + && isReadyToCrossfade() + ) { + video.removeEventListener('timeupdate', transitionBeforeEnd); + + // Go to next video - XXX: does not support "repeat 1" mode + document.querySelector('.next-button')?.click(); + } + }; + + video.addEventListener('timeupdate', transitionBeforeEnd); }; - video.addEventListener('timeupdate', transitionBeforeEnd); -}; + const onApiLoaded = () => { + watchVideoIDChanges(async (videoID) => { + await waitForTransition; + const url = await getStreamURL(videoID); + if (!url) { + return; + } -const onApiLoaded = () => { - watchVideoIDChanges(async (videoID) => { - await waitForTransition; - const url = await getStreamURL(videoID); - if (!url) { + createAudioForCrossfade(url); + }); + }; + + const crossfade = (cb: () => void) => { + if (!isReadyToCrossfade()) { + cb(); return; } - createAudioForCrossfade(url); - }); -}; + let resolveTransition: () => void; + waitForTransition = new Promise((resolve) => { + resolveTransition = resolve; + }); -const crossfade = (cb: () => void) => { - if (!isReadyToCrossfade()) { - cb(); - return; - } + const video = document.querySelector('video')!; - let resolveTransition: () => void; - waitForTransition = new Promise((resolve) => { - resolveTransition = resolve; - }); + const fader = new VolumeFader(transitionAudio._sounds[0]._node, { + initialVolume: video.volume, + fadeScaling: config.fadeScaling, + fadeDuration: config.fadeOutDuration, + }); - const video = document.querySelector('video')!; + // Fade out the music + video.volume = 0; + fader.fadeOut(() => { + resolveTransition(); + cb(); + }); + }; - const fader = new VolumeFader(transitionAudio._sounds[0]._node, { - initialVolume: video.volume, - fadeScaling: configGetNumber('fadeScaling'), - fadeDuration: configGetNumber('fadeOutDuration'), - }); - - // Fade out the music - video.volume = 0; - fader.fadeOut(() => { - resolveTransition(); - cb(); - }); -}; - -export default () => { - crossfadeConfig = configProvider.getAll(); - - configProvider.subscribeAll((newConfig) => { - crossfadeConfig = newConfig; - }); - - document.addEventListener('apiLoaded', onApiLoaded, { - once: true, - passive: true, - }); -}; + return { + onLoad() { + document.addEventListener('apiLoaded', async () => { + config = await getConfig(); + onApiLoaded(); + }, { + once: true, + passive: true, + }); + }, + onConfigChange(newConfig) { + config = newConfig; + }, + }; +}); diff --git a/src/plugins/disable-autoplay/index.ts b/src/plugins/disable-autoplay/index.ts new file mode 100644 index 00000000..06187017 --- /dev/null +++ b/src/plugins/disable-autoplay/index.ts @@ -0,0 +1,23 @@ +import { createPluginBuilder } from '../utils/builder'; + +export type DisableAutoPlayPluginConfig = { + enabled: boolean; + applyOnce: boolean; +} + +const builder = createPluginBuilder('disable-autoplay', { + name: 'Disable Autoplay', + restartNeeded: false, + config: { + enabled: false, + applyOnce: false, + } as DisableAutoPlayPluginConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/disable-autoplay/menu.ts b/src/plugins/disable-autoplay/menu.ts index 44575929..2fb132bf 100644 --- a/src/plugins/disable-autoplay/menu.ts +++ b/src/plugins/disable-autoplay/menu.ts @@ -1,20 +1,19 @@ -import { BrowserWindow } from 'electron'; +import builder from './index'; -import { setMenuOptions } from '../../config/plugins'; +export default builder.createMenu(async ({ getConfig, setConfig }) => { + const config = await getConfig(); -import { MenuTemplate } from '../../menu'; - -import type { ConfigType } from '../../config/dynamic'; - -export default (_: BrowserWindow, options: ConfigType<'disable-autoplay'>): MenuTemplate => [ - { - label: 'Applies only on startup', - type: 'checkbox', - checked: options.applyOnce, - click() { - setMenuOptions('disable-autoplay', { - applyOnce: !options.applyOnce, - }); - } - } -]; + return [ + { + label: 'Applies only on startup', + type: 'checkbox', + checked: config.applyOnce, + async click() { + const nowConfig = await getConfig(); + setConfig({ + applyOnce: !nowConfig.applyOnce, + }); + }, + }, + ]; +}); diff --git a/src/plugins/disable-autoplay/renderer.ts b/src/plugins/disable-autoplay/renderer.ts index 0af23a4d..71f974b2 100644 --- a/src/plugins/disable-autoplay/renderer.ts +++ b/src/plugins/disable-autoplay/renderer.ts @@ -1,23 +1,44 @@ -import type { ConfigType } from '../../config/dynamic'; +import builder from './index'; + +import type { YoutubePlayer } from '../../types/youtube-player'; + +export default builder.createRenderer(({ getConfig }) => { + let config: Awaited>; + + let apiEvent: CustomEvent; -export default (options: ConfigType<'disable-autoplay'>) => { const timeUpdateListener = (e: Event) => { if (e.target instanceof HTMLVideoElement) { e.target.pause(); } }; - document.addEventListener('apiLoaded', (apiEvent) => { - const eventListener = (name: string) => { - if (options.applyOnce) { - apiEvent.detail.removeEventListener('videodatachange', eventListener); - } + const eventListener = async (name: string) => { + if (config.applyOnce) { + apiEvent.detail.removeEventListener('videodatachange', eventListener); + } - if (name === 'dataloaded') { - apiEvent.detail.pauseVideo(); - document.querySelector('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true }); - } - }; - apiEvent.detail.addEventListener('videodatachange', eventListener); - }, { once: true, passive: true }); -}; + if (name === 'dataloaded') { + apiEvent.detail.pauseVideo(); + document.querySelector('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true }); + } + }; + + return { + async onLoad() { + config = await getConfig(); + + document.addEventListener('apiLoaded', (api) => { + apiEvent = api; + + apiEvent.detail.addEventListener('videodatachange', eventListener); + }, { once: true, passive: true }); + }, + onUnload() { + apiEvent.detail.removeEventListener('videodatachange', eventListener); + }, + onConfigChange(newConfig) { + config = newConfig; + } + }; +}); diff --git a/src/plugins/discord/index.ts b/src/plugins/discord/index.ts new file mode 100644 index 00000000..cd07454c --- /dev/null +++ b/src/plugins/discord/index.ts @@ -0,0 +1,55 @@ +import { createPluginBuilder } from '../utils/builder'; + +export type DiscordPluginConfig = { + enabled: boolean; + /** + * If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect + * + * @default true + */ + autoReconnect: boolean; + /** + * If enabled, the discord rich presence gets cleared when music paused after the time specified below + */ + activityTimoutEnabled: boolean; + /** + * The time in milliseconds after which the discord rich presence gets cleared when music paused + * + * @default 10 * 60 * 1000 (10 minutes) + */ + activityTimoutTime: number; + /** + * Add a "Play on YouTube Music" button to rich presence + */ + playOnYouTubeMusic: boolean; + /** + * Hide the "View App On GitHub" button in the rich presence + */ + hideGitHubButton: boolean; + /** + * Hide the "duration left" in the rich presence + */ + hideDurationLeft: boolean; +} + +const builder = createPluginBuilder('discord', { + name: 'Discord Rich Presence', + restartNeeded: false, + config: { + enabled: false, + autoReconnect: true, + activityTimoutEnabled: true, + activityTimoutTime: 10 * 60 * 1000, + playOnYouTubeMusic: true, + hideGitHubButton: false, + hideDurationLeft: false, + } as DiscordPluginConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/discord/main.ts b/src/plugins/discord/main.ts index 5fdd1565..4dbab833 100644 --- a/src/plugins/discord/main.ts +++ b/src/plugins/discord/main.ts @@ -4,9 +4,9 @@ import { dev } from 'electron-is'; import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser'; -import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info'; +import builder from './index'; -import type { ConfigType } from '../../config/dynamic'; +import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info'; // Application ID registered by @Zo-Bro-23 const clientId = '1043858434585526382'; @@ -51,7 +51,6 @@ const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => { info.rpc.login().then(resolve).catch(reject); }, 5000)); - const connectRecursive = () => { if (!info.autoReconnect || info.rpc.isConnected) { return; @@ -94,129 +93,133 @@ export const connect = (showError = false) => { let clearActivity: NodeJS.Timeout | undefined; let updateActivity: SongInfoCallback; -type DiscordOptions = ConfigType<'discord'>; +export default builder.createMain(({ getConfig }) => { + return { + async onLoad(win) { + const options = await getConfig(); -export default ( - win: Electron.BrowserWindow, - options: DiscordOptions, -) => { - info.rpc.on('connected', () => { - if (dev()) { - console.log('discord connected'); - } - - for (const cb of refreshCallbacks) { - cb(); - } - }); - - info.rpc.on('ready', () => { - info.ready = true; - if (info.lastSongInfo) { - updateActivity(info.lastSongInfo); - } - }); - - info.rpc.on('disconnected', () => { - resetInfo(); - - if (info.autoReconnect) { - connectTimeout(); - } - }); - - info.autoReconnect = options.autoReconnect; - - window = win; - // We get multiple events - // Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1) - // Skip time: PAUSE(N), PLAY(N) - updateActivity = (songInfo) => { - if (songInfo.title.length === 0 && songInfo.artist.length === 0) { - return; - } - - info.lastSongInfo = songInfo; - - // Stop the clear activity timout - clearTimeout(clearActivity); - - // Stop early if discord connection is not ready - // do this after clearTimeout to avoid unexpected clears - if (!info.rpc || !info.ready) { - return; - } - - // Clear directly if timeout is 0 - if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) { - info.rpc.user?.clearActivity().catch(console.error); - return; - } - - // Song information changed, so lets update the rich presence - // @see https://discord.com/developers/docs/topics/gateway#activity-object - // not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530 - const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character - if (songInfo.title.length < 2) { - songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length); - } - if (songInfo.artist.length < 2) { - songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length); - } - - const activityInfo: SetActivity = { - details: songInfo.title, - state: songInfo.artist, - largeImageKey: songInfo.imageSrc ?? '', - largeImageText: songInfo.album ?? '', - buttons: [ - ...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []), - ...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]), - ], - }; - - if (songInfo.isPaused) { - // Add a paused icon to show that the song is paused - activityInfo.smallImageKey = 'paused'; - activityInfo.smallImageText = 'Paused'; - // Set start the timer so the activity gets cleared after a while if enabled - if (options.activityTimoutEnabled) { - clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000); - } - } else if (!options.hideDurationLeft) { - // Add the start and end time of the song - const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000); - activityInfo.startTimestamp = songStartTime; - activityInfo.endTimestamp - = songStartTime + (songInfo.songDuration * 1000); - } - - info.rpc.user?.setActivity(activityInfo).catch(console.error); - }; - - // If the page is ready, register the callback - win.once('ready-to-show', () => { - let lastSongInfo: SongInfo; - registerCallback((songInfo) => { - lastSongInfo = songInfo; - updateActivity(songInfo); - }); - connect(); - let lastSent = Date.now(); - ipcMain.on('timeChanged', (_, t: number) => { - const currentTime = Date.now(); - // if lastSent is more than 5 seconds ago, send the new time - if (currentTime - lastSent > 5000) { - lastSent = currentTime; - if (lastSongInfo) { - lastSongInfo.elapsedSeconds = t; - updateActivity(lastSongInfo); + info.rpc.on('connected', () => { + if (dev()) { + console.log('discord connected'); } - } - }); - }); - app.on('window-all-closed', clear); -}; + + for (const cb of refreshCallbacks) { + cb(); + } + }); + + info.rpc.on('ready', () => { + info.ready = true; + if (info.lastSongInfo) { + updateActivity(info.lastSongInfo); + } + }); + + info.rpc.on('disconnected', () => { + resetInfo(); + + if (info.autoReconnect) { + connectTimeout(); + } + }); + + info.autoReconnect = options.autoReconnect; + + window = win; + // We get multiple events + // Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1) + // Skip time: PAUSE(N), PLAY(N) + updateActivity = (songInfo) => { + if (songInfo.title.length === 0 && songInfo.artist.length === 0) { + return; + } + + info.lastSongInfo = songInfo; + + // Stop the clear activity timout + clearTimeout(clearActivity); + + // Stop early if discord connection is not ready + // do this after clearTimeout to avoid unexpected clears + if (!info.rpc || !info.ready) { + return; + } + + // Clear directly if timeout is 0 + if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) { + info.rpc.user?.clearActivity().catch(console.error); + return; + } + + // Song information changed, so lets update the rich presence + // @see https://discord.com/developers/docs/topics/gateway#activity-object + // not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530 + const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character + if (songInfo.title.length < 2) { + songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length); + } + if (songInfo.artist.length < 2) { + songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length); + } + + const activityInfo: SetActivity = { + details: songInfo.title, + state: songInfo.artist, + largeImageKey: songInfo.imageSrc ?? '', + largeImageText: songInfo.album ?? '', + buttons: [ + ...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []), + ...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]), + ], + }; + + if (songInfo.isPaused) { + // Add a paused icon to show that the song is paused + activityInfo.smallImageKey = 'paused'; + activityInfo.smallImageText = 'Paused'; + // Set start the timer so the activity gets cleared after a while if enabled + if (options.activityTimoutEnabled) { + clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000); + } + } else if (!options.hideDurationLeft) { + // Add the start and end time of the song + const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000); + activityInfo.startTimestamp = songStartTime; + activityInfo.endTimestamp + = songStartTime + (songInfo.songDuration * 1000); + } + + info.rpc.user?.setActivity(activityInfo).catch(console.error); + }; + + // If the page is ready, register the callback + win.once('ready-to-show', () => { + let lastSongInfo: SongInfo; + registerCallback((songInfo) => { + lastSongInfo = songInfo; + updateActivity(songInfo); + }); + connect(); + let lastSent = Date.now(); + ipcMain.on('timeChanged', (_, t: number) => { + const currentTime = Date.now(); + // if lastSent is more than 5 seconds ago, send the new time + if (currentTime - lastSent > 5000) { + lastSent = currentTime; + if (lastSongInfo) { + lastSongInfo.elapsedSeconds = t; + updateActivity(lastSongInfo); + } + } + }); + }); + app.on('window-all-closed', clear); + }, + onUnload() { + resetInfo(); + }, + }; +}); export const clear = () => { if (info.rpc) { diff --git a/src/plugins/discord/menu.ts b/src/plugins/discord/menu.ts index def34b2f..d0b021a6 100644 --- a/src/plugins/discord/menu.ts +++ b/src/plugins/discord/menu.ts @@ -2,11 +2,13 @@ import prompt from 'custom-electron-prompt'; import { clear, connect, isConnected, registerRefresh } from './main'; +import builder from './index'; + import { setMenuOptions } from '../../config/plugins'; import promptOptions from '../../providers/prompt-options'; import { singleton } from '../../providers/decorators'; -import { MenuTemplate } from '../../menu'; +import type { MenuTemplate } from '../../menu'; import type { ConfigType } from '../../config/dynamic'; const registerRefreshOnce = singleton((refreshMenu: () => void) => { @@ -15,8 +17,9 @@ const registerRefreshOnce = singleton((refreshMenu: () => void) => { type DiscordOptions = ConfigType<'discord'>; -export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => { - registerRefreshOnce(refreshMenu); +export default builder.createMenu(async ({ window, getConfig, setConfig, refresh }): Promise => { + const config = await getConfig(); + registerRefreshOnce(refresh); return [ { @@ -27,10 +30,11 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen { label: 'Auto reconnect', type: 'checkbox', - checked: options.autoReconnect, + checked: config.autoReconnect, click(item: Electron.MenuItem) { - options.autoReconnect = item.checked; - setMenuOptions('discord', options); + setConfig({ + autoReconnect: item.checked, + }); }, }, { @@ -40,45 +44,49 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen { label: 'Clear activity after timeout', type: 'checkbox', - checked: options.activityTimoutEnabled, + checked: config.activityTimoutEnabled, click(item: Electron.MenuItem) { - options.activityTimoutEnabled = item.checked; - setMenuOptions('discord', options); + setConfig({ + activityTimoutEnabled: item.checked, + }); }, }, { label: 'Play on YouTube Music', type: 'checkbox', - checked: options.playOnYouTubeMusic, + checked: config.playOnYouTubeMusic, click(item: Electron.MenuItem) { - options.playOnYouTubeMusic = item.checked; - setMenuOptions('discord', options); + setConfig({ + playOnYouTubeMusic: item.checked, + }); }, }, { label: 'Hide GitHub link Button', type: 'checkbox', - checked: options.hideGitHubButton, + checked: config.hideGitHubButton, click(item: Electron.MenuItem) { - options.hideGitHubButton = item.checked; - setMenuOptions('discord', options); + setConfig({ + hideGitHubButton: item.checked, + }); }, }, { label: 'Hide duration left', type: 'checkbox', - checked: options.hideDurationLeft, + checked: config.hideDurationLeft, click(item: Electron.MenuItem) { - options.hideDurationLeft = item.checked; - setMenuOptions('discord', options); + setConfig({ + hideGitHubButton: item.checked, + }); }, }, { label: 'Set inactivity timeout', - click: () => setInactivityTimeout(win, options), + click: () => setInactivityTimeout(window, config), }, ]; -}; +}); async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) { const output = await prompt({ diff --git a/src/plugins/downloader/config.ts b/src/plugins/downloader/config.ts deleted file mode 100644 index 69b1cb78..00000000 --- a/src/plugins/downloader/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('downloader'); -export default config; diff --git a/src/plugins/downloader/index.ts b/src/plugins/downloader/index.ts new file mode 100644 index 00000000..5cbb3f15 --- /dev/null +++ b/src/plugins/downloader/index.ts @@ -0,0 +1,36 @@ +import { DefaultPresetList, Preset } from './types'; + +import style from './style.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +export type DownloaderPluginConfig = { + enabled: boolean; + downloadFolder?: string; + selectedPreset: string; + customPresetSetting: Preset; + skipExisting: boolean; + playlistMaxItems?: number; +} + +const builder = createPluginBuilder('downloader', { + name: 'Downloader', + restartNeeded: true, + config: { + enabled: false, + downloadFolder: undefined, + selectedPreset: 'mp3 (256kbps)', // Selected preset + customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets + skipExisting: false, + playlistMaxItems: undefined, + } as DownloaderPluginConfig, + styles: [style], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/downloader/main.ts b/src/plugins/downloader/main/index.ts similarity index 89% rename from src/plugins/downloader/main.ts rename to src/plugins/downloader/main/index.ts index 815262b6..2e945441 100644 --- a/src/plugins/downloader/main.ts +++ b/src/plugins/downloader/main/index.ts @@ -7,7 +7,7 @@ import { import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { app, BrowserWindow, dialog, ipcMain, net } from 'electron'; +import { app, BrowserWindow, dialog } from 'electron'; import { ClientType, Innertube, @@ -27,16 +27,16 @@ import { sendFeedback as sendFeedback_, setBadge, } from './utils'; -import config from './config'; -import { YoutubeFormatList, type Preset, DefaultPresetList } from './types'; -import style from './style.css'; +import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types'; -import { fetchFromGenius } from '../lyrics-genius/main'; -import { isEnabled } from '../../config/plugins'; -import { cleanupName, getImage, SongInfo } from '../../providers/song-info'; -import { injectCSS } from '../utils/main'; -import { cache } from '../../providers/decorators'; +import builder, { DownloaderPluginConfig } from '../index'; + +import { fetchFromGenius } from '../../lyrics-genius/main'; +import { isEnabled } from '../../../config/plugins'; +import { cleanupName, getImage, SongInfo } from '../../../providers/song-info'; +import { getNetFetchAsFetch } from '../../utils/main'; +import { cache } from '../../../providers/decorators'; import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils'; import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage'; @@ -44,7 +44,7 @@ import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic'; import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube'; import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo'; -import type { GetPlayerResponse } from '../../types/get-player-response'; +import type { GetPlayerResponse } from '../../../types/get-player-response'; type CustomSongInfo = SongInfo & { trackId?: string }; @@ -89,42 +89,30 @@ export const getCookieFromWindow = async (win: BrowserWindow) => { .join(';'); }; -export default async (win_: BrowserWindow) => { - win = win_; - injectCSS(win.webContents, style); +let config: DownloaderPluginConfig = builder.config; - yt = await Innertube.create({ - cache: new UniversalCache(false), - cookie: await getCookieFromWindow(win), - generate_session_locally: true, - fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = - typeof input === 'string' - ? new URL(input) - : input instanceof URL - ? input - : new URL(input.url); +export default builder.createMain(({ handle, getConfig, on }) => { + return { + async onLoad(win) { + config = await getConfig(); - if (init?.body && !init.method) { - init.method = 'POST'; - } - - const request = new Request( - url, - input instanceof Request ? input : undefined, - ); - - return net.fetch(request, init); - }) as typeof fetch, - }); - ipcMain.on('download-song', (_, url: string) => downloadSong(url)); - ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => { - playingUrl = data.microformat.microformatDataRenderer.urlCanonical; - }); - ipcMain.on('download-playlist-request', async (_event, url: string) => - downloadPlaylist(url), - ); -}; + yt = await Innertube.create({ + cache: new UniversalCache(false), + cookie: await getCookieFromWindow(win), + generate_session_locally: true, + fetch: getNetFetchAsFetch(), + }); + handle('download-song', (_, url: string) => downloadSong(url)); + on('video-src-changed', (_, data: GetPlayerResponse) => { + playingUrl = data.microformat.microformatDataRenderer.urlCanonical; + }); + handle('download-playlist-request', async (_event, url: string) => downloadPlaylist(url)); + }, + onConfigChange(newConfig) { + config = newConfig; + } + }; +}); export async function downloadSong( url: string, @@ -209,7 +197,7 @@ async function downloadSongUnsafe( metadata.trackId = trackId; const dir = - playlistFolder || config.get('downloadFolder') || app.getPath('downloads'); + playlistFolder || config.downloadFolder || app.getPath('downloads'); const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${ metadata.title }`; @@ -239,11 +227,11 @@ async function downloadSongUnsafe( ); } - const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)'; + const selectedPreset = config.selectedPreset ?? 'mp3 (256kbps)'; let presetSetting: Preset; if (selectedPreset === 'Custom') { presetSetting = - config.get('customPresetSetting') ?? DefaultPresetList['Custom']; + config.customPresetSetting ?? DefaultPresetList['Custom']; } else if (selectedPreset === 'Source') { presetSetting = DefaultPresetList['Source']; } else { @@ -276,7 +264,7 @@ async function downloadSongUnsafe( } const filePath = join(dir, filename); - if (config.get('skipExisting') && existsSync(filePath)) { + if (config.skipExisting && existsSync(filePath)) { sendFeedback(null, -1); return; } @@ -517,10 +505,10 @@ export async function downloadPlaylist(givenUrl?: string | URL) { safePlaylistTitle = safePlaylistTitle.normalize('NFC'); } - const folder = getFolder(config.get('downloadFolder') ?? ''); + const folder = getFolder(config.downloadFolder ?? ''); const playlistFolder = join(folder, safePlaylistTitle); if (existsSync(playlistFolder)) { - if (!config.get('skipExisting')) { + if (!config.skipExisting) { sendError(new Error(`The folder ${playlistFolder} already exists`)); return; } @@ -637,6 +625,7 @@ const getAndroidTvInfo = async (id: string): Promise => { client_type: ClientType.TV_EMBEDDED, generate_session_locally: true, retrieve_player: true, + fetch: getNetFetchAsFetch(), }); // GetInfo 404s with the bypass, so we use getBasicInfo instead // that's fine as we only need the streaming data diff --git a/src/plugins/downloader/utils.ts b/src/plugins/downloader/main/utils.ts similarity index 100% rename from src/plugins/downloader/utils.ts rename to src/plugins/downloader/main/utils.ts diff --git a/src/plugins/downloader/menu.ts b/src/plugins/downloader/menu.ts index 199612a4..19a2c444 100644 --- a/src/plugins/downloader/menu.ts +++ b/src/plugins/downloader/menu.ts @@ -1,46 +1,49 @@ import { dialog } from 'electron'; import { downloadPlaylist } from './main'; -import { defaultMenuDownloadLabel, getFolder } from './utils'; +import { defaultMenuDownloadLabel, getFolder } from './main/utils'; import { DefaultPresetList } from './types'; -import config from './config'; -import { MenuTemplate } from '../../menu'; +import builder from './index'; -export default (): MenuTemplate => [ - { - label: defaultMenuDownloadLabel, - click: () => downloadPlaylist(), - }, - { - label: 'Choose download folder', - click() { - const result = dialog.showOpenDialogSync({ - properties: ['openDirectory', 'createDirectory'], - defaultPath: getFolder(config.get('downloadFolder') ?? ''), - }); - if (result) { - config.set('downloadFolder', result[0]); - } // Else = user pressed cancel +export default builder.createMenu(async ({ getConfig, setConfig }) => { + const config = await getConfig(); + + return [ + { + label: defaultMenuDownloadLabel, + click: () => downloadPlaylist(), }, - }, - { - label: 'Presets', - submenu: Object.keys(DefaultPresetList).map((preset) => ({ - label: preset, - type: 'radio', - checked: config.get('selectedPreset') === preset, + { + label: 'Choose download folder', click() { - config.set('selectedPreset', preset); + const result = dialog.showOpenDialogSync({ + properties: ['openDirectory', 'createDirectory'], + defaultPath: getFolder(config.downloadFolder ?? ''), + }); + if (result) { + setConfig({ downloadFolder: result[0] }); + } // Else = user pressed cancel }, - })), - }, - { - label: 'Skip existing files', - type: 'checkbox', - checked: config.get('skipExisting'), - click(item) { - config.set('skipExisting', item.checked); }, - }, -]; + { + label: 'Presets', + submenu: Object.keys(DefaultPresetList).map((preset) => ({ + label: preset, + type: 'radio', + checked: config.selectedPreset === preset, + click() { + setConfig({ selectedPreset: preset }); + }, + })), + }, + { + label: 'Skip existing files', + type: 'checkbox', + checked: config.skipExisting, + click(item) { + setConfig({ skipExisting: item.checked }); + }, + }, + ]; +}); diff --git a/src/plugins/downloader/renderer.ts b/src/plugins/downloader/renderer.ts index 5c6bcd50..49518e75 100644 --- a/src/plugins/downloader/renderer.ts +++ b/src/plugins/downloader/renderer.ts @@ -1,5 +1,7 @@ import downloadHTML from './templates/download.html?raw'; +import builder from './index'; + import defaultConfig from '../../config/defaults'; import { getSongMenu } from '../../providers/dom-elements'; import { ElementFromHtml } from '../utils/renderer'; @@ -11,67 +13,71 @@ const downloadButton = ElementFromHtml(downloadHTML); let doneFirstLoad = false; -export default () => { - const menuObserver = new MutationObserver(() => { - if (!menu) { - menu = getSongMenu(); - if (!menu) { - return; - } +export default builder.createRenderer(() => { + return { + onLoad() { + const menuObserver = new MutationObserver(() => { + if (!menu) { + menu = getSongMenu(); + if (!menu) { + return; + } + } + + if (menu.contains(downloadButton)) { + return; + } + + const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href; + if (!menuUrl?.includes('watch?') && doneFirstLoad) { + return; + } + + menu.prepend(downloadButton); + progress = document.querySelector('#ytmcustom-download'); + + if (doneFirstLoad) { + return; + } + + setTimeout(() => doneFirstLoad ||= true, 500); + }); + + window.download = () => { + let videoUrl = getSongMenu() + // Selector of first button which is always "Start Radio" + ?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint') + ?.getAttribute('href'); + if (videoUrl) { + if (videoUrl.startsWith('watch?')) { + videoUrl = defaultConfig.url + '/' + videoUrl; + } + + if (videoUrl.includes('?playlist=')) { + window.ipcRenderer.send('download-playlist-request', videoUrl); + return; + } + } else { + videoUrl = getSongInfo().url || window.location.href; + } + + window.ipcRenderer.send('download-song', videoUrl); + }; + + document.addEventListener('apiLoaded', () => { + menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { + childList: true, + subtree: true, + }); + }, { once: true, passive: true }); + + window.ipcRenderer.on('downloader-feedback', (_, feedback: string) => { + if (progress) { + progress.innerHTML = feedback || 'Download'; + } else { + console.warn('Cannot update progress'); + } + }); } - - if (menu.contains(downloadButton)) { - return; - } - - const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href; - if (!menuUrl?.includes('watch?') && doneFirstLoad) { - return; - } - - menu.prepend(downloadButton); - progress = document.querySelector('#ytmcustom-download'); - - if (doneFirstLoad) { - return; - } - - setTimeout(() => doneFirstLoad ||= true, 500); - }); - - window.download = () => { - let videoUrl = getSongMenu() - // Selector of first button which is always "Start Radio" - ?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint') - ?.getAttribute('href'); - if (videoUrl) { - if (videoUrl.startsWith('watch?')) { - videoUrl = defaultConfig.url + '/' + videoUrl; - } - - if (videoUrl.includes('?playlist=')) { - window.ipcRenderer.send('download-playlist-request', videoUrl); - return; - } - } else { - videoUrl = getSongInfo().url || window.location.href; - } - - window.ipcRenderer.send('download-song', videoUrl); }; - - document.addEventListener('apiLoaded', () => { - menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { - childList: true, - subtree: true, - }); - }, { once: true, passive: true }); - - window.ipcRenderer.on('downloader-feedback', (_, feedback: string) => { - if (progress) { - progress.innerHTML = feedback || 'Download'; - } else { - console.warn('Cannot update progress'); - } - }); -}; +}); diff --git a/src/plugins/exponential-volume/index.ts b/src/plugins/exponential-volume/index.ts new file mode 100644 index 00000000..765bb090 --- /dev/null +++ b/src/plugins/exponential-volume/index.ts @@ -0,0 +1,17 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('exponential-volume', { + name: 'Exponential Volume', + restartNeeded: true, + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/exponential-volume/renderer.ts b/src/plugins/exponential-volume/renderer.ts index 3f2c5d0c..05abbfc4 100644 --- a/src/plugins/exponential-volume/renderer.ts +++ b/src/plugins/exponential-volume/renderer.ts @@ -1,6 +1,8 @@ // "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer // https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/ +import builder from './index'; + const exponentialVolume = () => { // Manipulation exponent, higher value = lower volume // 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a @@ -38,8 +40,11 @@ const exponentialVolume = () => { }); }; -export default () => - document.addEventListener('apiLoaded', exponentialVolume, { - once: true, - passive: true, - }); +export default builder.createRenderer(() => ({ + onLoad() { + return document.addEventListener('apiLoaded', exponentialVolume, { + once: true, + passive: true, + }); + }, +})); diff --git a/src/plugins/in-app-menu/index.ts b/src/plugins/in-app-menu/index.ts index ca76cb3c..be3921a4 100644 --- a/src/plugins/in-app-menu/index.ts +++ b/src/plugins/in-app-menu/index.ts @@ -2,8 +2,9 @@ import titlebarStyle from './titlebar.css?inline'; import { createPluginBuilder } from '../utils/builder'; -export const builder = createPluginBuilder('in-app-menu', { +const builder = createPluginBuilder('in-app-menu', { name: 'In-App Menu', + restartNeeded: true, config: { enabled: false, hideDOMWindowControls: false, diff --git a/src/plugins/in-app-menu/main.ts b/src/plugins/in-app-menu/main.ts index 8fc335e7..d0058ce3 100644 --- a/src/plugins/in-app-menu/main.ts +++ b/src/plugins/in-app-menu/main.ts @@ -2,19 +2,19 @@ import { register } from 'electron-localshortcut'; import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron'; -import builder from './'; +import builder from './index'; -export default builder.createMain(({ handle }) => { +export default builder.createMain(({ handle, send }) => { return { onLoad(win) { win.on('close', () => { - win.webContents.send('close-all-in-app-menu-panel'); + send('close-all-in-app-menu-panel'); }); win.once('ready-to-show', () => { register(win, '`', () => { - win.webContents.send('toggle-in-app-menu'); + send('toggle-in-app-menu'); }); }); @@ -63,9 +63,9 @@ export default builder.createMain(({ handle }) => { handle('window-close', () => win.close()); handle('window-minimize', () => win.minimize()); handle('window-maximize', () => win.maximize()); - win.on('maximize', () => win.webContents.send('window-maximize')); + win.on('maximize', () => send('window-maximize')); handle('window-unmaximize', () => win.unmaximize()); - win.on('unmaximize', () => win.webContents.send('window-unmaximize')); + win.on('unmaximize', () => send('window-unmaximize')); handle('image-path-to-data-url', (_, imagePath: string) => { const nativeImageIcon = nativeImage.createFromPath(imagePath); diff --git a/src/plugins/in-app-menu/menu.ts b/src/plugins/in-app-menu/menu.ts index cd69780e..9c91889d 100644 --- a/src/plugins/in-app-menu/menu.ts +++ b/src/plugins/in-app-menu/menu.ts @@ -1,6 +1,6 @@ import is from 'electron-is'; -import builder from './'; +import builder from './index'; import { setMenuOptions } from '../../config/plugins'; diff --git a/src/plugins/last-fm/index.ts b/src/plugins/last-fm/index.ts new file mode 100644 index 00000000..c1026f03 --- /dev/null +++ b/src/plugins/last-fm/index.ts @@ -0,0 +1,50 @@ +import { createPluginBuilder } from '../utils/builder'; + +export interface LastFmPluginConfig { + enabled: boolean; + /** + * Token used for authentication + */ + token?: string; + /** + * Session key used for scrabbling + */ + session_key?: string; + /** + * Root of the Last.fm API + * + * @default 'http://ws.audioscrobbler.com/2.0/' + */ + api_root: string; + /** + * Last.fm api key registered by @semvis123 + * + * @default '04d76faaac8726e60988e14c105d421a' + */ + api_key: string; + /** + * Last.fm api secret registered by @semvis123 + * + * @default 'a5d2a36fdf64819290f6982481eaffa2' + */ + secret: string; +} + +const builder = createPluginBuilder('last-fm', { + name: 'Last.fm', + restartNeeded: true, + config: { + enabled: false, + api_root: 'http://ws.audioscrobbler.com/2.0/', + api_key: '04d76faaac8726e60988e14c105d421a', + secret: 'a5d2a36fdf64819290f6982481eaffa2', + } as LastFmPluginConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/last-fm/main.ts b/src/plugins/last-fm/main.ts index b406bae2..0ef0c941 100644 --- a/src/plugins/last-fm/main.ts +++ b/src/plugins/last-fm/main.ts @@ -1,14 +1,11 @@ import crypto from 'node:crypto'; -import { BrowserWindow, net, shell } from 'electron'; +import { net, shell } from 'electron'; + +import builder, { type LastFmPluginConfig } from './index'; import { setOptions } from '../../config/plugins'; -import registerCallback, { SongInfo } from '../../providers/song-info'; -import defaultConfig from '../../config/defaults'; - -import type { ConfigType } from '../../config/dynamic'; - -type LastFMOptions = ConfigType<'last-fm'>; +import registerCallback, { type SongInfo } from '../../providers/song-info'; interface LastFmData { method: string, @@ -68,7 +65,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => { return sig; }; -const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFMOptions) => { +const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFmPluginConfig) => { // Creates and stores the auth token const data = { method: 'auth.gettoken', @@ -81,12 +78,12 @@ const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastF return json?.token; }; -const authenticate = async (config: LastFMOptions) => { +const authenticate = async (config: LastFmPluginConfig) => { // Asks the user for authentication await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`); }; -const getAndSetSessionKey = async (config: LastFMOptions) => { +const getAndSetSessionKey = async (config: LastFmPluginConfig) => { // Get and store the session key const data = { api_key: config.api_key, @@ -114,7 +111,7 @@ const getAndSetSessionKey = async (config: LastFMOptions) => { return config; }; -const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data: LastFmData) => { +const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData) => { // This sends a post request to the api, and adds the common data if (!config.session_key) { await getAndSetSessionKey(config); @@ -151,7 +148,7 @@ const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data }); }; -const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => { +const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig) => { // This adds one scrobbled song to last.fm const data = { method: 'track.scrobble', @@ -160,7 +157,7 @@ const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => { postSongDataToAPI(songInfo, config, data); }; -const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => { +const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig) => { // This sets the now playing status in last.fm const data = { method: 'track.updateNowPlaying', @@ -171,33 +168,33 @@ const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => { // This will store the timeout that will trigger addScrobble let scrobbleTimer: NodeJS.Timeout | undefined; -const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => { - if (!config.api_root) { - // Settings are not present, creating them with the default values - config = defaultConfig.plugins['last-fm']; - config.enabled = true; - setOptions('last-fm', config); - } +export default builder.createMain(({ getConfig, send }) => ({ + async onLoad(_win) { + let config = await getConfig(); - if (!config.session_key) { - // Not authenticated - config = await getAndSetSessionKey(config); - } - - registerCallback((songInfo) => { - // Set remove the old scrobble timer - clearTimeout(scrobbleTimer); - if (!songInfo.isPaused) { - setNowPlaying(songInfo, config); - // Scrobble when the song is halfway through, or has passed the 4-minute mark - const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60); - if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) { - // Scrobble still needs to happen - const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000; - scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config); - } + if (!config.api_root) { + config.enabled = true; + setOptions('last-fm', config); } - }); -}; -export default lastfm; + if (!config.session_key) { + // Not authenticated + config = await getAndSetSessionKey(config); + } + + registerCallback((songInfo) => { + // Set remove the old scrobble timer + clearTimeout(scrobbleTimer); + if (!songInfo.isPaused) { + setNowPlaying(songInfo, config); + // Scrobble when the song is halfway through, or has passed the 4-minute mark + const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60); + if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) { + // Scrobble still needs to happen + const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000; + scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config); + } + } + }); + } +})); diff --git a/src/plugins/lumiastream/index.ts b/src/plugins/lumiastream/index.ts new file mode 100644 index 00000000..b3af3375 --- /dev/null +++ b/src/plugins/lumiastream/index.ts @@ -0,0 +1,17 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('lumiastream', { + name: 'Lumia Stream', + restartNeeded: true, + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/lumiastream/main.ts b/src/plugins/lumiastream/main.ts index a026dbac..e98d8bf7 100644 --- a/src/plugins/lumiastream/main.ts +++ b/src/plugins/lumiastream/main.ts @@ -1,4 +1,6 @@ -import { BrowserWindow , net } from 'electron'; +import { net } from 'electron'; + +import builder from './index'; import registerCallback from '../../providers/song-info'; @@ -36,8 +38,8 @@ const post = (data: LumiaData) => { 'Accept': 'application/json', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Origin': '*', - }; - const url = `http://localhost:${port}/api/media`; + } as const; + const url = `http://127.0.0.1:${port}/api/media`; net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers }) .catch((error: { code: number, errno: number }) => { @@ -49,34 +51,38 @@ const post = (data: LumiaData) => { }); }; -export default (_: BrowserWindow) => { - registerCallback((songInfo) => { - if (!songInfo.title && !songInfo.artist) { - return; +export default builder.createMain(() => { + return { + onLoad() { + registerCallback((songInfo) => { + if (!songInfo.title && !songInfo.artist) { + return; + } + + if (previousStatePaused === null) { + data.eventType = 'switchSong'; + } else if (previousStatePaused !== songInfo.isPaused) { + data.eventType = 'playPause'; + } + + data.duration = secToMilisec(songInfo.songDuration); + data.progress = secToMilisec(songInfo.elapsedSeconds); + data.url = songInfo.url; + data.videoId = songInfo.videoId; + data.playlistId = songInfo.playlistId; + data.cover = songInfo.imageSrc; + data.cover_url = songInfo.imageSrc; + data.album_url = songInfo.imageSrc; + data.title = songInfo.title; + data.artists = [songInfo.artist]; + data.status = songInfo.isPaused ? 'stopped' : 'playing'; + data.isPaused = songInfo.isPaused; + data.album = songInfo.album; + data.views = songInfo.views; + post(data); + }); } - - if (previousStatePaused === null) { - data.eventType = 'switchSong'; - } else if (previousStatePaused !== songInfo.isPaused) { - data.eventType = 'playPause'; - } - - data.duration = secToMilisec(songInfo.songDuration); - data.progress = secToMilisec(songInfo.elapsedSeconds); - data.url = songInfo.url; - data.videoId = songInfo.videoId; - data.playlistId = songInfo.playlistId; - data.cover = songInfo.imageSrc; - data.cover_url = songInfo.imageSrc; - data.album_url = songInfo.imageSrc; - data.title = songInfo.title; - data.artists = [songInfo.artist]; - data.status = songInfo.isPaused ? 'stopped' : 'playing'; - data.isPaused = songInfo.isPaused; - data.album = songInfo.album; - data.views = songInfo.views; - post(data); - }); -}; + }; +}); diff --git a/src/plugins/lyrics-genius/index.ts b/src/plugins/lyrics-genius/index.ts new file mode 100644 index 00000000..754db66d --- /dev/null +++ b/src/plugins/lyrics-genius/index.ts @@ -0,0 +1,26 @@ +import style from './style.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +export type LyricsGeniusPluginConfig = { + enabled: boolean; + romanizedLyrics: boolean; +} + +const builder = createPluginBuilder('lyrics-genius', { + name: 'Lyrics Genius', + restartNeeded: true, + config: { + enabled: false, + romanizedLyrics: false, + } as LyricsGeniusPluginConfig, + styles: [style], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/lyrics-genius/main.ts b/src/plugins/lyrics-genius/main.ts index d8b58371..3b20b69a 100644 --- a/src/plugins/lyrics-genius/main.ts +++ b/src/plugins/lyrics-genius/main.ts @@ -1,37 +1,35 @@ -import { BrowserWindow, ipcMain, net } from 'electron'; +import { net } from 'electron'; import is from 'electron-is'; import { convert } from 'html-to-text'; -import style from './style.css'; import { GetGeniusLyric } from './types'; -import { cleanupName, SongInfo } from '../../providers/song-info'; +import builder from './index'; -import { injectCSS } from '../utils/main'; - -import type { ConfigType } from '../../config/dynamic'; +import { cleanupName, type SongInfo } from '../../providers/song-info'; const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u; let revRomanized = false; -export type LyricGeniusType = ConfigType<'lyrics-genius'>; +export default builder.createMain(({ handle, getConfig }) =>{ + return { + async onLoad() { + const config = await getConfig(); -export default (win: BrowserWindow, options: LyricGeniusType) => { - if (options.romanizedLyrics) { - revRomanized = true; - } + if (config.romanizedLyrics) { + revRomanized = true; + } - injectCSS(win.webContents, style); - - ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => { - const metadata = extractedSongInfo; - return await fetchFromGenius(metadata); - }); -}; - -export const toggleRomanized = () => { - revRomanized = !revRomanized; -}; + handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => { + const metadata = extractedSongInfo; + return await fetchFromGenius(metadata); + }); + }, + onConfigChange(newConfig) { + revRomanized = newConfig.romanizedLyrics; + } + }; +}); export const fetchFromGenius = async (metadata: SongInfo) => { const songTitle = `${cleanupName(metadata.title)}`; diff --git a/src/plugins/lyrics-genius/menu.ts b/src/plugins/lyrics-genius/menu.ts index d96e686d..d6339d5e 100644 --- a/src/plugins/lyrics-genius/menu.ts +++ b/src/plugins/lyrics-genius/menu.ts @@ -1,19 +1,18 @@ -import { BrowserWindow, MenuItem } from 'electron'; +import builder from './index'; -import { LyricGeniusType, toggleRomanized } from './main'; +export default builder.createMenu(async ({ getConfig, setConfig }) => { + const config = await getConfig(); -import { setOptions } from '../../config/plugins'; -import { MenuTemplate } from '../../menu'; - -export default (_: BrowserWindow, options: LyricGeniusType): MenuTemplate => [ - { - label: 'Romanized Lyrics', - type: 'checkbox', - checked: options.romanizedLyrics, - click(item: MenuItem) { - options.romanizedLyrics = item.checked; - setOptions('lyrics-genius', options); - toggleRomanized(); + return [ + { + label: 'Romanized Lyrics', + type: 'checkbox', + checked: config.romanizedLyrics, + click(item) { + setConfig({ + romanizedLyrics: item.checked, + }); + }, }, - }, -]; + ]; +}); diff --git a/src/plugins/lyrics-genius/renderer.ts b/src/plugins/lyrics-genius/renderer.ts index c86b0ddd..ea13334c 100644 --- a/src/plugins/lyrics-genius/renderer.ts +++ b/src/plugins/lyrics-genius/renderer.ts @@ -1,8 +1,11 @@ +import builder from './index'; + import type { SongInfo } from '../../providers/song-info'; -export default () => { - const setLyrics = (lyricsContainer: Element, lyrics: string | null) => { - lyricsContainer.innerHTML = ` +export default builder.createRenderer(({ on, invoke }) => ({ + onLoad() { + const setLyrics = (lyricsContainer: Element, lyrics: string | null) => { + lyricsContainer.innerHTML = `
${lyrics?.replaceAll(/\r\n|\r|\n/g, '
') ?? 'Could not retrieve lyrics from genius'}
@@ -10,96 +13,97 @@ export default () => { `; - if (lyrics) { - const footer = lyricsContainer.querySelector('.footer'); + if (lyrics) { + const footer = lyricsContainer.querySelector('.footer'); - if (footer) { - footer.textContent = 'Source: Genius'; + if (footer) { + footer.textContent = 'Source: Genius'; + } } - } - }; + }; - let unregister: (() => void) | null = null; + let unregister: (() => void) | null = null; - window.ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => { - unregister?.(); + on('update-song-info', (_, extractedSongInfo: SongInfo) => { + unregister?.(); - setTimeout(async () => { - const tabList = document.querySelectorAll('tp-yt-paper-tab'); - const tabs = { - upNext: tabList[0], - lyrics: tabList[1], - discover: tabList[2], - }; + setTimeout(async () => { + const tabList = document.querySelectorAll('tp-yt-paper-tab'); + const tabs = { + upNext: tabList[0], + lyrics: tabList[1], + discover: tabList[2], + }; - // Check if disabled - if (!tabs.lyrics?.hasAttribute('disabled')) return; + // Check if disabled + if (!tabs.lyrics?.hasAttribute('disabled')) return; - const lyrics = await window.ipcRenderer.invoke( - 'search-genius-lyrics', - extractedSongInfo, - ) as string | null; - - if (!lyrics) { - // Delete previous lyrics if tab is open and couldn't get new lyrics - tabs.upNext.click(); - - return; - } - - if (window.electronIs.dev()) { - console.log('Fetched lyrics from Genius'); - } - - const tryToInjectLyric = (callback?: () => void) => { - const lyricsContainer = document.querySelector( - '[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer', + const lyrics = await invoke( + 'search-genius-lyrics', + extractedSongInfo, ); - if (lyricsContainer) { - callback?.(); + if (!lyrics) { + // Delete previous lyrics if tab is open and couldn't get new lyrics + tabs.upNext.click(); - setLyrics(lyricsContainer, lyrics); - applyLyricsTabState(); + return; } - }; - const applyLyricsTabState = () => { - if (lyrics) { - tabs.lyrics.removeAttribute('disabled'); - tabs.lyrics.removeAttribute('aria-disabled'); - } else { - tabs.lyrics.setAttribute('disabled', ''); - tabs.lyrics.setAttribute('aria-disabled', ''); + + if (window.electronIs.dev()) { + console.log('Fetched lyrics from Genius'); } - }; - const lyricsTabHandler = () => { - const tabContainer = document.querySelector('ytmusic-tab-renderer'); - if (!tabContainer) return; - const observer = new MutationObserver((_, observer) => { - tryToInjectLyric(() => observer.disconnect()); - }); + const tryToInjectLyric = (callback?: () => void) => { + const lyricsContainer = document.querySelector( + '[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer', + ); - observer.observe(tabContainer, { - attributes: true, - childList: true, - subtree: true, - }); - }; + if (lyricsContainer) { + callback?.(); - applyLyricsTabState(); + setLyrics(lyricsContainer, lyrics); + applyLyricsTabState(); + } + }; + const applyLyricsTabState = () => { + if (lyrics) { + tabs.lyrics.removeAttribute('disabled'); + tabs.lyrics.removeAttribute('aria-disabled'); + } else { + tabs.lyrics.setAttribute('disabled', ''); + tabs.lyrics.setAttribute('aria-disabled', ''); + } + }; + const lyricsTabHandler = () => { + const tabContainer = document.querySelector('ytmusic-tab-renderer'); + if (!tabContainer) return; - tabs.discover.addEventListener('click', applyLyricsTabState); - tabs.lyrics.addEventListener('click', lyricsTabHandler); - tabs.upNext.addEventListener('click', applyLyricsTabState); + const observer = new MutationObserver((_, observer) => { + tryToInjectLyric(() => observer.disconnect()); + }); - tryToInjectLyric(); + observer.observe(tabContainer, { + attributes: true, + childList: true, + subtree: true, + }); + }; - unregister = () => { - tabs.discover.removeEventListener('click', applyLyricsTabState); - tabs.lyrics.removeEventListener('click', lyricsTabHandler); - tabs.upNext.removeEventListener('click', applyLyricsTabState); - }; - }, 500); - }); -}; + applyLyricsTabState(); + + tabs.discover.addEventListener('click', applyLyricsTabState); + tabs.lyrics.addEventListener('click', lyricsTabHandler); + tabs.upNext.addEventListener('click', applyLyricsTabState); + + tryToInjectLyric(); + + unregister = () => { + tabs.discover.removeEventListener('click', applyLyricsTabState); + tabs.lyrics.removeEventListener('click', lyricsTabHandler); + tabs.upNext.removeEventListener('click', applyLyricsTabState); + }; + }, 500); + }); + } +})); diff --git a/src/plugins/navigation/index.ts b/src/plugins/navigation/index.ts index 46ac3ab6..aa17cfc2 100644 --- a/src/plugins/navigation/index.ts +++ b/src/plugins/navigation/index.ts @@ -2,8 +2,9 @@ import style from './style.css?inline'; import { createPluginBuilder } from '../utils/builder'; -export const builder = createPluginBuilder('navigation', { +const builder = createPluginBuilder('navigation', { name: 'Navigation', + restartNeeded: true, config: { enabled: false, }, diff --git a/src/plugins/navigation/renderer.ts b/src/plugins/navigation/renderer.ts index ad67a29c..40b5c8d9 100644 --- a/src/plugins/navigation/renderer.ts +++ b/src/plugins/navigation/renderer.ts @@ -1,7 +1,7 @@ import forwardHTML from './templates/forward.html?raw'; import backHTML from './templates/back.html?raw'; -import builder from '.'; +import builder from './index'; import { ElementFromHtml } from '../utils/renderer'; diff --git a/src/plugins/no-google-login/front.ts b/src/plugins/no-google-login/front.ts deleted file mode 100644 index b0f4158d..00000000 --- a/src/plugins/no-google-login/front.ts +++ /dev/null @@ -1,37 +0,0 @@ -function removeLoginElements() { - const elementsToRemove = [ - '.sign-in-link.ytmusic-nav-bar', - '.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]', - ]; - - for (const selector of elementsToRemove) { - const node = document.querySelector(selector); - if (node) { - node.remove(); - } - } - - // Remove the library button - const libraryIconPath - = 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z'; - const observer = new MutationObserver(() => { - const menuEntries = document.querySelectorAll( - '#items ytmusic-guide-entry-renderer', - ); - menuEntries.forEach((item) => { - const icon = item.querySelector('path'); - if (icon) { - observer.disconnect(); - if (icon.getAttribute('d') === libraryIconPath) { - item.remove(); - } - } - }); - }); - observer.observe(document.documentElement, { - childList: true, - subtree: true, - }); -} - -export default removeLoginElements; diff --git a/src/plugins/no-google-login/index.ts b/src/plugins/no-google-login/index.ts new file mode 100644 index 00000000..c39f528c --- /dev/null +++ b/src/plugins/no-google-login/index.ts @@ -0,0 +1,20 @@ +import style from './style.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('no-google-login', { + name: 'Remove Google Login', + restartNeeded: true, + config: { + enabled: false, + }, + styles: [style], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/no-google-login/main.ts b/src/plugins/no-google-login/main.ts index 1d313bf0..71af5356 100644 --- a/src/plugins/no-google-login/main.ts +++ b/src/plugins/no-google-login/main.ts @@ -1,6 +1,6 @@ import { BrowserWindow } from 'electron'; -import style from './style.css'; +import style from './style.css?inline'; import { injectCSS } from '../utils/main'; diff --git a/src/plugins/no-google-login/renderer.ts b/src/plugins/no-google-login/renderer.ts new file mode 100644 index 00000000..7b7a1989 --- /dev/null +++ b/src/plugins/no-google-login/renderer.ts @@ -0,0 +1,39 @@ +import builder from './index'; + +export default builder.createRenderer(() => ({ + onLoad() { + const elementsToRemove = [ + '.sign-in-link.ytmusic-nav-bar', + '.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]', + ]; + + for (const selector of elementsToRemove) { + const node = document.querySelector(selector); + if (node) { + node.remove(); + } + } + + // Remove the library button + const libraryIconPath + = 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z'; + const observer = new MutationObserver(() => { + const menuEntries = document.querySelectorAll( + '#items ytmusic-guide-entry-renderer', + ); + menuEntries.forEach((item) => { + const icon = item.querySelector('path'); + if (icon) { + observer.disconnect(); + if (icon.getAttribute('d') === libraryIconPath) { + item.remove(); + } + } + }); + }); + observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); + } +})); diff --git a/src/plugins/notifications/config.ts b/src/plugins/notifications/config.ts deleted file mode 100644 index 91605f7a..00000000 --- a/src/plugins/notifications/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('notifications'); - -export default config; diff --git a/src/plugins/notifications/index.ts b/src/plugins/notifications/index.ts new file mode 100644 index 00000000..5db116d3 --- /dev/null +++ b/src/plugins/notifications/index.ts @@ -0,0 +1,36 @@ +import { createPluginBuilder } from '../utils/builder'; + +export interface NotificationsPluginConfig { + enabled: boolean; + unpauseNotification: boolean; + urgency: 'low' | 'normal' | 'critical'; + interactive: boolean; + toastStyle: number; + refreshOnPlayPause: boolean; + trayControls: boolean; + hideButtonText: boolean; +} + +const builder = createPluginBuilder('notifications', { + name: 'Notifications', + restartNeeded: true, + config: { + enabled: false, + unpauseNotification: false, + urgency: 'normal', // Has effect only on Linux + // the following has effect only on Windows + interactive: true, + toastStyle: 1, // See plugins/notifications/utils for more info + refreshOnPlayPause: false, + trayControls: true, + hideButtonText: false, + } as NotificationsPluginConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/notifications/interactive.ts b/src/plugins/notifications/interactive.ts index df483f8b..90c09067 100644 --- a/src/plugins/notifications/interactive.ts +++ b/src/plugins/notifications/interactive.ts @@ -1,7 +1,6 @@ -import { app, BrowserWindow, ipcMain, Notification } from 'electron'; +import { app, BrowserWindow, Notification } from 'electron'; import { notificationImage, secondsToMinutes, ToastStyles } from './utils'; -import config from './config'; import getSongControls from '../../providers/song-controls'; import registerCallback, { SongInfo } from '../../providers/song-info'; @@ -14,16 +13,209 @@ import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnp import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack'; import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack'; +import { MainPluginContext } from '../utils/builder'; + +import type { NotificationsPluginConfig } from './index'; + let songControls: ReturnType; let savedNotification: Notification | undefined; -export default (win: BrowserWindow) => { +type Accessor = () => T; + +export default ( + win: BrowserWindow, + config: Accessor, + { on, send }: MainPluginContext, +) => { + const sendNotification = (songInfo: SongInfo) => { + const iconSrc = notificationImage(songInfo, config()); + + savedNotification?.close(); + + let icon: string; + if (typeof iconSrc === 'object') { + icon = iconSrc.toDataURL(); + } else { + icon = iconSrc; + } + + savedNotification = new Notification({ + title: songInfo.title || 'Playing', + body: songInfo.artist, + icon: iconSrc, + silent: true, + // https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root + // https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema + // https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml + // https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype + toastXml: getXml(songInfo, icon), + }); + + savedNotification.on('close', () => { + savedNotification = undefined; + }); + + savedNotification.show(); + }; + + const getXml = (songInfo: SongInfo, iconSrc: string) => { + switch (config().toastStyle) { + default: + case ToastStyles.logo: + case ToastStyles.legacy: { + return xmlLogo(songInfo, iconSrc); + } + + case ToastStyles.banner_top_custom: { + return xmlBannerTopCustom(songInfo, iconSrc); + } + + case ToastStyles.hero: { + return xmlHero(songInfo, iconSrc); + } + + case ToastStyles.banner_bottom: { + return xmlBannerBottom(songInfo, iconSrc); + } + + case ToastStyles.banner_centered_bottom: { + return xmlBannerCenteredBottom(songInfo, iconSrc); + } + + case ToastStyles.banner_centered_top: { + return xmlBannerCenteredTop(songInfo, iconSrc); + } + } + }; + + const selectIcon = (kind: keyof typeof mediaIcons): string => { + switch (kind) { + case 'play': + return playIcon; + case 'pause': + return pauseIcon; + case 'next': + return nextIcon; + case 'previous': + return previousIcon; + default: + return ''; + } + }; + + const display = (kind: keyof typeof mediaIcons) => { + if (config().toastStyle === ToastStyles.legacy) { + return `content="${mediaIcons[kind]}"`; + } + + return `\ + content="${config().toastStyle ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\ + imageUri="file:///${selectIcon(kind)}" + `; + }; + + const getButton = (kind: keyof typeof mediaIcons) => + ``; + + const getButtons = (isPaused: boolean) => `\ + + ${getButton('previous')} + ${isPaused ? getButton('play') : getButton('pause')} + ${getButton('next')} + \ +`; + + const toast = (content: string, isPaused: boolean) => `\ + + `; + + const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\ + + ${title} + ${artist}\ +`, isPaused ?? false); + + const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"'); + + const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"'); + + const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, ''); + + const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\ + + + + + ${songInfo.title} + ${songInfo.artist} + + ${xmlMoreData(songInfo)} + \ +`, songInfo.isPaused ?? false); + + const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\ + + ${album + ? `${album}` : ''} + ${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)} +\ +`; + + const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\ + + + + ${title} + ${artist} + + + \ +`, isPaused ?? false); + + const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\ + + + + + ${title} + ${artist} + + \ +`, isPaused ?? false); + + const titleFontPicker = (title: string) => { + if (title.length <= 13) { + return 'Header'; + } + + if (title.length <= 22) { + return 'Subheader'; + } + + if (title.length <= 26) { + return 'Title'; + } + + return 'Subtitle'; + }; + + songControls = getSongControls(win); let currentSeconds = 0; - ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); + on('apiLoaded', () => send('setupTimeChangedListener')); - ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t); + on('timeChanged', (t: number) => { + currentSeconds = t; + }); let savedSongInfo: SongInfo; let lastUrl: string | undefined; @@ -36,14 +228,14 @@ export default (win: BrowserWindow) => { savedSongInfo = { ...songInfo }; if (!songInfo.isPaused - && (songInfo.url !== lastUrl || config.get('unpauseNotification')) + && (songInfo.url !== lastUrl || config().unpauseNotification) ) { lastUrl = songInfo.url; sendNotification(songInfo); } }); - if (config.get('trayControls')) { + if (config().trayControls) { setTrayOnClick(() => { if (savedNotification) { savedNotification.close(); @@ -73,9 +265,9 @@ export default (win: BrowserWindow) => { (cmd) => { if (Object.keys(songControls).includes(cmd)) { songControls[cmd as keyof typeof songControls](); - if (config.get('refreshOnPlayPause') && ( + if (config().refreshOnPlayPause && ( cmd === 'pause' - || (cmd === 'play' && !config.get('unpauseNotification')) + || (cmd === 'play' && !config().unpauseNotification) ) ) { setImmediate(() => @@ -90,183 +282,3 @@ export default (win: BrowserWindow) => { }, ); }; - -function sendNotification(songInfo: SongInfo) { - const iconSrc = notificationImage(songInfo); - - savedNotification?.close(); - - let icon: string; - if (typeof iconSrc === 'object') { - icon = iconSrc.toDataURL(); - } else { - icon = iconSrc; - } - - savedNotification = new Notification({ - title: songInfo.title || 'Playing', - body: songInfo.artist, - icon: iconSrc, - silent: true, - // https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root - // https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema - // https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml - // https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype - toastXml: getXml(songInfo, icon), - }); - - savedNotification.on('close', () => { - savedNotification = undefined; - }); - - savedNotification.show(); -} - -const getXml = (songInfo: SongInfo, iconSrc: string) => { - switch (config.get('toastStyle')) { - default: - case ToastStyles.logo: - case ToastStyles.legacy: { - return xmlLogo(songInfo, iconSrc); - } - - case ToastStyles.banner_top_custom: { - return xmlBannerTopCustom(songInfo, iconSrc); - } - - case ToastStyles.hero: { - return xmlHero(songInfo, iconSrc); - } - - case ToastStyles.banner_bottom: { - return xmlBannerBottom(songInfo, iconSrc); - } - - case ToastStyles.banner_centered_bottom: { - return xmlBannerCenteredBottom(songInfo, iconSrc); - } - - case ToastStyles.banner_centered_top: { - return xmlBannerCenteredTop(songInfo, iconSrc); - } - } -}; - -const selectIcon = (kind: keyof typeof mediaIcons): string => { - switch (kind) { - case 'play': - return playIcon; - case 'pause': - return pauseIcon; - case 'next': - return nextIcon; - case 'previous': - return previousIcon; - default: - return ''; - } -}; - -const display = (kind: keyof typeof mediaIcons) => { - if (config.get('toastStyle') === ToastStyles.legacy) { - return `content="${mediaIcons[kind]}"`; - } - - return `\ - content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\ - imageUri="file:///${selectIcon(kind)}" - `; -}; - -const getButton = (kind: keyof typeof mediaIcons) => - ``; - -const getButtons = (isPaused: boolean) => `\ - - ${getButton('previous')} - ${isPaused ? getButton('play') : getButton('pause')} - ${getButton('next')} - \ -`; - -const toast = (content: string, isPaused: boolean) => `\ - - `; - -const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\ - - ${title} - ${artist}\ -`, isPaused ?? false); - -const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"'); - -const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"'); - -const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, ''); - -const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\ - - - - - ${songInfo.title} - ${songInfo.artist} - - ${xmlMoreData(songInfo)} - \ -`, songInfo.isPaused ?? false); - -const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\ - - ${album - ? `${album}` : ''} - ${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)} -\ -`; - -const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\ - - - - ${title} - ${artist} - - - \ -`, isPaused ?? false); - -const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\ - - - - - ${title} - ${artist} - - \ -`, isPaused ?? false); - -const titleFontPicker = (title: string) => { - if (title.length <= 13) { - return 'Header'; - } - - if (title.length <= 22) { - return 'Subheader'; - } - - if (title.length <= 26) { - return 'Title'; - } - - return 'Subtitle'; -}; diff --git a/src/plugins/notifications/main.ts b/src/plugins/notifications/main.ts index bc73819d..4eb8cf45 100644 --- a/src/plugins/notifications/main.ts +++ b/src/plugins/notifications/main.ts @@ -1,25 +1,24 @@ -import { BrowserWindow, Notification } from 'electron'; +import { Notification } from 'electron'; import is from 'electron-is'; import { notificationImage } from './utils'; -import config from './config'; import interactive from './interactive'; +import builder, { NotificationsPluginConfig } from './index'; + import registerCallback, { SongInfo } from '../../providers/song-info'; -import type { ConfigType } from '../../config/dynamic'; - -type NotificationOptions = ConfigType<'notifications'>; +let config: NotificationsPluginConfig = builder.config; const notify = (info: SongInfo) => { // Send the notification const currentNotification = new Notification({ title: info.title || 'Playing', body: info.artist, - icon: notificationImage(info), + icon: notificationImage(info, config), silent: true, - urgency: config.get('urgency') as 'normal' | 'critical' | 'low', + urgency: config.urgency, }); currentNotification.show(); @@ -31,7 +30,7 @@ const setup = () => { let currentUrl: string | undefined; registerCallback((songInfo: SongInfo) => { - if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) { + if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.unpauseNotification)) { // Close the old notification oldNotification?.close(); currentUrl = songInfo.url; @@ -43,9 +42,17 @@ const setup = () => { }); }; -export default (win: BrowserWindow, options: NotificationOptions) => { - // Register the callback for new song information - is.windows() && options.interactive - ? interactive(win) - : setup(); -}; +export default builder.createMain((context) => { + return { + async onLoad(win) { + config = await context.getConfig(); + + // Register the callback for new song information + if (is.windows() && config.interactive) interactive(win, () => config, context); + else setup(); + }, + onConfigChange(newConfig) { + config = newConfig; + } + }; +}); diff --git a/src/plugins/notifications/menu.ts b/src/plugins/notifications/menu.ts index 9fd51a03..77887777 100644 --- a/src/plugins/notifications/menu.ts +++ b/src/plugins/notifications/menu.ts @@ -1,93 +1,95 @@ import is from 'electron-is'; -import { BrowserWindow, MenuItem } from 'electron'; +import { MenuItem } from 'electron'; import { snakeToCamel, ToastStyles, urgencyLevels } from './utils'; -import config from './config'; +import builder, { NotificationsPluginConfig } from './index'; -import { MenuTemplate } from '../../menu'; +import type { MenuTemplate } from '../../menu'; -import type { ConfigType } from '../../config/dynamic'; +export default builder.createMenu(async ({ getConfig, setConfig }) => { + const config = await getConfig(); -const getMenu = (options: ConfigType<'notifications'>): MenuTemplate => { - if (is.linux()) { - return [ - { - label: 'Notification Priority', - submenu: urgencyLevels.map((level) => ({ - label: level.name, - type: 'radio', - checked: options.urgency === level.value, - click: () => config.set('urgency', level.value), - })), - } - ]; - } else if (is.windows()) { - return [ - { - label: 'Interactive Notifications', - type: 'checkbox', - checked: options.interactive, - // Doesn't update until restart - click: (item: MenuItem) => config.setAndMaybeRestart('interactive', item.checked), - }, - { - // Submenu with settings for interactive notifications (name shouldn't be too long) - label: 'Interactive Settings', - submenu: [ - { - label: 'Open/Close on tray click', - type: 'checkbox', - checked: options.trayControls, - click: (item: MenuItem) => config.set('trayControls', item.checked), - }, - { - label: 'Hide Button Text', - type: 'checkbox', - checked: options.hideButtonText, - click: (item: MenuItem) => config.set('hideButtonText', item.checked), - }, - { - label: 'Refresh on Play/Pause', - type: 'checkbox', - checked: options.refreshOnPlayPause, - click: (item: MenuItem) => config.set('refreshOnPlayPause', item.checked), - }, - ], - }, - { - label: 'Style', - submenu: getToastStyleMenuItems(options), - }, - ]; - } else { - return []; - } -}; + const getToastStyleMenuItems = (options: NotificationsPluginConfig) => { + const array = Array.from({ length: Object.keys(ToastStyles).length }); -export default (_win: BrowserWindow, options: ConfigType<'notifications'>): MenuTemplate => [ - ...getMenu(options), - { - label: 'Show notification on unpause', - type: 'checkbox', - checked: options.unpauseNotification, - click: (item: MenuItem) => config.set('unpauseNotification', item.checked), - }, -]; + // ToastStyles index starts from 1 + for (const [name, index] of Object.entries(ToastStyles)) { + array[index - 1] = { + label: snakeToCamel(name), + type: 'radio', + checked: options.toastStyle === index, + click: () => setConfig({ toastStyle: index }), + } satisfies Electron.MenuItemConstructorOptions; + } -export function getToastStyleMenuItems(options: ConfigType<'notifications'>) { - const array = Array.from({ length: Object.keys(ToastStyles).length }); - - // ToastStyles index starts from 1 - for (const [name, index] of Object.entries(ToastStyles)) { - array[index - 1] = { - label: snakeToCamel(name), - type: 'radio', - checked: options.toastStyle === index, - click: () => config.set('toastStyle', index), - } satisfies Electron.MenuItemConstructorOptions; + return array as Electron.MenuItemConstructorOptions[]; } - return array as Electron.MenuItemConstructorOptions[]; -} + const getMenu = (): MenuTemplate => { + if (is.linux()) { + return [ + { + label: 'Notification Priority', + submenu: urgencyLevels.map((level) => ({ + label: level.name, + type: 'radio', + checked: config.urgency === level.value, + click: () => setConfig({ urgency: level.value }), + })), + } + ]; + } else if (is.windows()) { + return [ + { + label: 'Interactive Notifications', + type: 'checkbox', + checked: config.interactive, + // Doesn't update until restart + click: (item: MenuItem) => setConfig({ interactive: item.checked }), + }, + { + // Submenu with settings for interactive notifications (name shouldn't be too long) + label: 'Interactive Settings', + submenu: [ + { + label: 'Open/Close on tray click', + type: 'checkbox', + checked: config.trayControls, + click: (item: MenuItem) => setConfig({ trayControls: item.checked }), + }, + { + label: 'Hide Button Text', + type: 'checkbox', + checked: config.hideButtonText, + click: (item: MenuItem) => setConfig({ hideButtonText: item.checked }), + }, + { + label: 'Refresh on Play/Pause', + type: 'checkbox', + checked: config.refreshOnPlayPause, + click: (item: MenuItem) => setConfig({ refreshOnPlayPause: item.checked }), + }, + ], + }, + { + label: 'Style', + submenu: getToastStyleMenuItems(config), + }, + ]; + } else { + return []; + } + }; + + return [ + ...getMenu(), + { + label: 'Show notification on unpause', + type: 'checkbox', + checked: config.unpauseNotification, + click: (item) => setConfig({ unpauseNotification: item.checked }), + }, + ]; +}); diff --git a/src/plugins/notifications/utils.ts b/src/plugins/notifications/utils.ts index d51bce2a..98aac1c8 100644 --- a/src/plugins/notifications/utils.ts +++ b/src/plugins/notifications/utils.ts @@ -3,12 +3,11 @@ import fs from 'node:fs'; import { app, NativeImage } from 'electron'; -import config from './config'; - import { cache } from '../../providers/decorators'; import { SongInfo } from '../../providers/song-info'; import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack'; +import {NotificationsPluginConfig} from "./index"; const userData = app.getPath('userData'); @@ -27,9 +26,9 @@ export const ToastStyles = { }; export const urgencyLevels = [ - { name: 'Low', value: 'low' }, - { name: 'Normal', value: 'normal' }, - { name: 'High', value: 'critical' }, + { name: 'Low', value: 'low' } as const, + { name: 'Normal', value: 'normal' } as const, + { name: 'High', value: 'critical' } as const, ]; const nativeImageToLogo = cache((nativeImage: NativeImage) => { @@ -44,16 +43,16 @@ const nativeImageToLogo = cache((nativeImage: NativeImage) => { }); }); -export const notificationImage = (songInfo: SongInfo) => { +export const notificationImage = (songInfo: SongInfo, config: NotificationsPluginConfig) => { if (!songInfo.image) { return youtubeMusicIcon; } - if (!config.get('interactive')) { + if (!config.interactive) { return nativeImageToLogo(songInfo.image); } - switch (config.get('toastStyle')) { + switch (config.toastStyle) { case ToastStyles.logo: case ToastStyles.legacy: { return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon); diff --git a/src/plugins/picture-in-picture/index.ts b/src/plugins/picture-in-picture/index.ts new file mode 100644 index 00000000..6783aa24 --- /dev/null +++ b/src/plugins/picture-in-picture/index.ts @@ -0,0 +1,40 @@ +import style from './style.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +export type PictureInPicturePluginConfig = { + 'enabled': boolean; + 'alwaysOnTop': boolean; + 'savePosition': boolean; + 'saveSize': boolean; + 'hotkey': 'P', + 'pip-position': [number, number]; + 'pip-size': [number, number]; + 'isInPiP': boolean; + 'useNativePiP': boolean; +} + +const builder = createPluginBuilder('picture-in-picture', { + name: 'Picture In Picture', + restartNeeded: true, + config: { + 'enabled': false, + 'alwaysOnTop': true, + 'savePosition': true, + 'saveSize': false, + 'hotkey': 'P', + 'pip-position': [10, 10], + 'pip-size': [450, 275], + 'isInPiP': false, + 'useNativePiP': true, + } as PictureInPicturePluginConfig, + styles: [style], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/picture-in-picture/main.ts b/src/plugins/picture-in-picture/main.ts index e1fad077..4b3cb59b 100644 --- a/src/plugins/picture-in-picture/main.ts +++ b/src/plugins/picture-in-picture/main.ts @@ -1,111 +1,121 @@ import { app, BrowserWindow, ipcMain } from 'electron'; -import style from './style.css'; +import style from './style.css?inline'; + +import builder, { PictureInPicturePluginConfig } from './index'; import { injectCSS } from '../utils/main'; -import { setOptions as setPluginOptions } from '../../config/plugins'; -import type { ConfigType } from '../../config/dynamic'; +export default builder.createMain(({ getConfig, setConfig, send, handle }) => { + let isInPiP = false; + let originalPosition: number[]; + let originalSize: number[]; + let originalFullScreen: boolean; + let originalMaximized: boolean; -let isInPiP = false; -let originalPosition: number[]; -let originalSize: number[]; -let originalFullScreen: boolean; -let originalMaximized: boolean; + let win: BrowserWindow; -let win: BrowserWindow; + let config: PictureInPicturePluginConfig; -type PiPOptions = ConfigType<'picture-in-picture'>; + const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10]; + const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275]; -let options: Partial; + const togglePiP = () => { + isInPiP = !isInPiP; + setConfig({ isInPiP }); -const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10]; -const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275]; + if (isInPiP) { + originalFullScreen = win.isFullScreen(); + if (originalFullScreen) { + win.setFullScreen(false); + } -const setLocalOptions = (_options: Partial) => { - options = { ...options, ..._options }; - setPluginOptions('picture-in-picture', _options); -}; + originalMaximized = win.isMaximized(); + if (originalMaximized) { + win.unmaximize(); + } -const togglePiP = () => { - isInPiP = !isInPiP; - setLocalOptions({ isInPiP }); + originalPosition = win.getPosition(); + originalSize = win.getSize(); - if (isInPiP) { - originalFullScreen = win.isFullScreen(); - if (originalFullScreen) { - win.setFullScreen(false); + handle('before-input-event', blockShortcutsInPiP); + + win.setMaximizable(false); + win.setFullScreenable(false); + + send('pip-toggle', true); + + app.dock?.hide(); + win.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + }); + app.dock?.show(); + if (config.alwaysOnTop) { + win.setAlwaysOnTop(true, 'screen-saver', 1); + } + } else { + win.webContents.removeListener('before-input-event', blockShortcutsInPiP); + win.setMaximizable(true); + win.setFullScreenable(true); + + send('pip-toggle', false); + + win.setVisibleOnAllWorkspaces(false); + win.setAlwaysOnTop(false); + + if (originalFullScreen) { + win.setFullScreen(true); + } + + if (originalMaximized) { + win.maximize(); + } } - originalMaximized = win.isMaximized(); - if (originalMaximized) { - win.unmaximize(); + const [x, y] = isInPiP ? pipPosition() : originalPosition; + const [w, h] = isInPiP ? pipSize() : originalSize; + win.setPosition(x, y); + win.setSize(w, h); + + win.setWindowButtonVisibility?.(!isInPiP); + }; + + const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => { + const key = input.key.toLowerCase(); + + if (key === 'f') { + event.preventDefault(); + } else if (key === 'escape') { + togglePiP(); + event.preventDefault(); } + }; - originalPosition = win.getPosition(); - originalSize = win.getSize(); + return ({ + async onLoad(window) { + config ??= await getConfig(); + win ??= window; + setConfig({ isInPiP }); + injectCSS(win.webContents, style); + ipcMain.on('picture-in-picture', () => { + togglePiP(); + }); - win.webContents.on('before-input-event', blockShortcutsInPiP); + window.on('move', () => { + if (config.isInPiP && !config.useNativePiP) { + setConfig({ 'pip-position': window.getPosition() as [number, number] }); + } + }); - win.setMaximizable(false); - win.setFullScreenable(false); - - win.webContents.send('pip-toggle', true); - - app.dock?.hide(); - win.setVisibleOnAllWorkspaces(true, { - visibleOnFullScreen: true, - }); - app.dock?.show(); - if (options.alwaysOnTop) { - win.setAlwaysOnTop(true, 'screen-saver', 1); + window.on('resize', () => { + if (config.isInPiP && !config.useNativePiP) { + setConfig({ 'pip-size': window.getSize() as [number, number] }); + } + }); + }, + onConfigChange(newConfig) { + config = newConfig; } - } else { - win.webContents.removeListener('before-input-event', blockShortcutsInPiP); - win.setMaximizable(true); - win.setFullScreenable(true); - - win.webContents.send('pip-toggle', false); - - win.setVisibleOnAllWorkspaces(false); - win.setAlwaysOnTop(false); - - if (originalFullScreen) { - win.setFullScreen(true); - } - - if (originalMaximized) { - win.maximize(); - } - } - - const [x, y] = isInPiP ? pipPosition() : originalPosition; - const [w, h] = isInPiP ? pipSize() : originalSize; - win.setPosition(x, y); - win.setSize(w, h); - - win.setWindowButtonVisibility?.(!isInPiP); -}; - -const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => { - const key = input.key.toLowerCase(); - - if (key === 'f') { - event.preventDefault(); - } else if (key === 'escape') { - togglePiP(); - event.preventDefault(); - } -}; - -export default (_win: BrowserWindow, _options: PiPOptions) => { - options ??= _options; - win ??= _win; - setLocalOptions({ isInPiP }); - injectCSS(win.webContents, style); - ipcMain.on('picture-in-picture', () => { - togglePiP(); }); -}; +}); -export const setOptions = setLocalOptions; diff --git a/src/plugins/picture-in-picture/menu.ts b/src/plugins/picture-in-picture/menu.ts index f62a9763..e06a2a59 100644 --- a/src/plugins/picture-in-picture/menu.ts +++ b/src/plugins/picture-in-picture/menu.ts @@ -1,75 +1,74 @@ import prompt from 'custom-electron-prompt'; -import { BrowserWindow } from 'electron'; - -import { setOptions } from './main'; +import builder from './index'; import promptOptions from '../../providers/prompt-options'; -import { MenuTemplate } from '../../menu'; -import type { ConfigType } from '../../config/dynamic'; +export default builder.createMenu(async ({ window, getConfig, setConfig }) => { + const config = await getConfig(); -export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [ - { - label: 'Always on top', - type: 'checkbox', - checked: options.alwaysOnTop, - click(item) { - setOptions({ alwaysOnTop: item.checked }); - win.setAlwaysOnTop(item.checked); + return [ + { + label: 'Always on top', + type: 'checkbox', + checked: config.alwaysOnTop, + click(item) { + setConfig({ alwaysOnTop: item.checked }); + window.setAlwaysOnTop(item.checked); + }, }, - }, - { - label: 'Save window position', - type: 'checkbox', - checked: options.savePosition, - click(item) { - setOptions({ savePosition: item.checked }); + { + label: 'Save window position', + type: 'checkbox', + checked: config.savePosition, + click(item) { + setConfig({ savePosition: item.checked }); + }, }, - }, - { - label: 'Save window size', - type: 'checkbox', - checked: options.saveSize, - click(item) { - setOptions({ saveSize: item.checked }); + { + label: 'Save window size', + type: 'checkbox', + checked: config.saveSize, + click(item) { + setConfig({ saveSize: item.checked }); + }, }, - }, - { - label: 'Hotkey', - type: 'checkbox', - checked: !!options.hotkey, - async click(item) { - const output = await prompt({ - title: 'Picture in Picture Hotkey', - label: 'Choose a hotkey for toggling Picture in Picture', - type: 'keybind', - keybindOptions: [{ - value: 'hotkey', - label: 'Hotkey', - default: options.hotkey, - }], - ...promptOptions(), - }, win); + { + label: 'Hotkey', + type: 'checkbox', + checked: !!config.hotkey, + async click(item) { + const output = await prompt({ + title: 'Picture in Picture Hotkey', + label: 'Choose a hotkey for toggling Picture in Picture', + type: 'keybind', + keybindOptions: [{ + value: 'hotkey', + label: 'Hotkey', + default: config.hotkey, + }], + ...promptOptions(), + }, window); - if (output) { - const { value, accelerator } = output[0]; - setOptions({ [value]: accelerator }); + if (output) { + const { value, accelerator } = output[0]; + setConfig({ [value]: accelerator }); - item.checked = !!accelerator; - } else { - // Reset checkbox if prompt was canceled - item.checked = !item.checked; - } + item.checked = !!accelerator; + } else { + // Reset checkbox if prompt was canceled + item.checked = !item.checked; + } + }, }, - }, - { - label: 'Use native PiP', - type: 'checkbox', - checked: options.useNativePiP, - click(item) { - setOptions({ useNativePiP: item.checked }); + { + label: 'Use native PiP', + type: 'checkbox', + checked: config.useNativePiP, + click(item) { + setConfig({ useNativePiP: item.checked }); + }, }, - }, -]; + ]; +}); diff --git a/src/plugins/picture-in-picture/renderer.ts b/src/plugins/picture-in-picture/renderer.ts index d75bf900..8a697237 100644 --- a/src/plugins/picture-in-picture/renderer.ts +++ b/src/plugins/picture-in-picture/renderer.ts @@ -3,14 +3,12 @@ import keyEventAreEqual from 'keyboardevents-areequal'; import pipHTML from './templates/picture-in-picture.html?raw'; +import builder, { PictureInPicturePluginConfig } from './index'; + import { getSongMenu } from '../../providers/dom-elements'; import { ElementFromHtml } from '../utils/renderer'; -import type { ConfigType } from '../../config/dynamic'; - -type PiPOptions = ConfigType<'picture-in-picture'>; - function $(selector: string) { return document.querySelector(selector); } @@ -135,7 +133,7 @@ const listenForToggle = () => { }); }; -function observeMenu(options: PiPOptions) { +function observeMenu(options: PictureInPicturePluginConfig) { useNativePiP = options.useNativePiP; document.addEventListener( 'apiLoaded', @@ -160,18 +158,24 @@ function observeMenu(options: PiPOptions) { ); } -export default (options: PiPOptions) => { - observeMenu(options); +export default builder.createRenderer(({ getConfig }) => { + return { + async onLoad() { + const config = await getConfig(); - if (options.hotkey) { - const hotkeyEvent = toKeyEvent(options.hotkey); - window.addEventListener('keydown', (event) => { - if ( - keyEventAreEqual(event, hotkeyEvent) - && !$('ytmusic-search-box')?.opened - ) { - togglePictureInPicture(); + observeMenu(config); + + if (config.hotkey) { + const hotkeyEvent = toKeyEvent(config.hotkey); + window.addEventListener('keydown', (event) => { + if ( + keyEventAreEqual(event, hotkeyEvent) + && !$('ytmusic-search-box')?.opened + ) { + togglePictureInPicture(); + } + }); } - }); - } -}; + } + }; +}); diff --git a/src/plugins/playback-speed/index.ts b/src/plugins/playback-speed/index.ts new file mode 100644 index 00000000..cd898898 --- /dev/null +++ b/src/plugins/playback-speed/index.ts @@ -0,0 +1,17 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('playback-speed', { + name: 'Playback Speed', + restartNeeded: false, + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/playback-speed/renderer.ts b/src/plugins/playback-speed/renderer.ts index b105a589..e2cef0fc 100644 --- a/src/plugins/playback-speed/renderer.ts +++ b/src/plugins/playback-speed/renderer.ts @@ -1,5 +1,7 @@ import sliderHTML from './templates/slider.html?raw'; +import builder from './index'; + import { getSongMenu } from '../../providers/dom-elements'; import { ElementFromHtml } from '../utils/renderer'; import { singleton } from '../../providers/decorators'; @@ -32,15 +34,17 @@ const updatePlayBackSpeed = () => { let menu: Element | null = null; -const setupSliderListener = singleton(() => { - $('#playback-speed-slider')?.addEventListener('immediate-value-changed', (e) => { - playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED; - if (isNaN(playbackSpeed)) { - playbackSpeed = 1; - } +const immediateValueChangedListener = (e: Event) => { + playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED; + if (isNaN(playbackSpeed)) { + playbackSpeed = 1; + } - updatePlayBackSpeed(); - }); + updatePlayBackSpeed(); +}; + +const setupSliderListener = singleton(() => { + $('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener); }); const observePopupContainer = () => { @@ -77,26 +81,28 @@ const observeVideo = () => { } }; +const wheelEventListener = (e: WheelEvent) => { + e.preventDefault(); + if (isNaN(playbackSpeed)) { + playbackSpeed = 1; + } + + // E.deltaY < 0 means wheel-up + playbackSpeed = roundToTwo(e.deltaY < 0 + ? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED) + : Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED), + ); + + updatePlayBackSpeed(); + // Update slider position + const playbackSpeedSilder = $('#playback-speed-slider'); + if (playbackSpeedSilder) { + playbackSpeedSilder.value = playbackSpeed; + } +}; + const setupWheelListener = () => { - slider.addEventListener('wheel', (e) => { - e.preventDefault(); - if (isNaN(playbackSpeed)) { - playbackSpeed = 1; - } - - // E.deltaY < 0 means wheel-up - playbackSpeed = roundToTwo(e.deltaY < 0 - ? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED) - : Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED), - ); - - updatePlayBackSpeed(); - // Update slider position - const playbackSpeedSilder = $('#playback-speed-slider'); - if (playbackSpeedSilder) { - playbackSpeedSilder.value = playbackSpeed; - } - }); + slider.addEventListener('wheel', wheelEventListener); }; function forcePlaybackRate(e: Event) { @@ -108,10 +114,24 @@ function forcePlaybackRate(e: Event) { } } -export default () => { - document.addEventListener('apiLoaded', () => { - observePopupContainer(); - observeVideo(); - setupWheelListener(); - }, { once: true, passive: true }); -}; +export default builder.createRenderer(() => { + return { + onLoad() { + document.addEventListener('apiLoaded', () => { + observePopupContainer(); + observeVideo(); + setupWheelListener(); + }, { once: true, passive: true }); + }, + onUnload() { + const video = $('video'); + if (video) { + video.removeEventListener('ratechange', forcePlaybackRate); + video.removeEventListener('srcChanged', forcePlaybackRate); + } + slider.removeEventListener('wheel', wheelEventListener); + getSongMenu()?.removeChild(slider); + $('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener); + } + }; +}); diff --git a/src/plugins/precise-volume/index.ts b/src/plugins/precise-volume/index.ts index 9f735423..e639ae6c 100644 --- a/src/plugins/precise-volume/index.ts +++ b/src/plugins/precise-volume/index.ts @@ -15,6 +15,7 @@ export type PreciseVolumePluginConfig = { const builder = createPluginBuilder('precise-volume', { name: 'Precise Volume', + restartNeeded: true, config: { enabled: false, steps: 1, // Percentage of volume to change diff --git a/src/plugins/precise-volume/main.ts b/src/plugins/precise-volume/main.ts index fc25b081..0582a9ab 100644 --- a/src/plugins/precise-volume/main.ts +++ b/src/plugins/precise-volume/main.ts @@ -1,19 +1,17 @@ import { globalShortcut } from 'electron'; -import builder from '.'; +import builder from './index'; -export default builder.createMain(({ getConfig, send }) => { - return { - async onLoad() { - const config = await getConfig(); +export default builder.createMain(({ getConfig, send }) => ({ + async onLoad() { + const config = await getConfig(); - if (config.globalShortcuts?.volumeUp) { - globalShortcut.register(config.globalShortcuts.volumeUp, () => send('changeVolume', true)); - } - - if (config.globalShortcuts?.volumeDown) { - globalShortcut.register(config.globalShortcuts.volumeDown, () => send('changeVolume', false)); - } - }, - }; -}); + if (config.globalShortcuts?.volumeUp) { + globalShortcut.register(config.globalShortcuts.volumeUp, () => send('changeVolume', true)); + } + + if (config.globalShortcuts?.volumeDown) { + globalShortcut.register(config.globalShortcuts.volumeDown, () => send('changeVolume', false)); + } + }, +})); diff --git a/src/plugins/precise-volume/menu.ts b/src/plugins/precise-volume/menu.ts index e828fffe..f449c0a5 100644 --- a/src/plugins/precise-volume/menu.ts +++ b/src/plugins/precise-volume/menu.ts @@ -2,13 +2,13 @@ import prompt, { KeybindOptions } from 'custom-electron-prompt'; import { BrowserWindow, MenuItem } from 'electron'; -import builder, { PreciseVolumePluginConfig } from '.'; +import builder, { PreciseVolumePluginConfig } from './index'; import promptOptions from '../../providers/prompt-options'; export default builder.createMenu(async ({ setConfig, getConfig, window }) => { const config = await getConfig(); - + function changeOptions(changedOptions: Partial, options: PreciseVolumePluginConfig, win: BrowserWindow) { for (const option in changedOptions) { // HACK: Weird TypeScript error diff --git a/src/plugins/precise-volume/renderer.ts b/src/plugins/precise-volume/renderer.ts index 774f140d..9dce69da 100644 --- a/src/plugins/precise-volume/renderer.ts +++ b/src/plugins/precise-volume/renderer.ts @@ -1,6 +1,6 @@ import { overrideListener } from './override'; -import builder, { type PreciseVolumePluginConfig } from './'; +import builder, { type PreciseVolumePluginConfig } from './index'; import { debounce } from '../../providers/decorators'; @@ -12,6 +12,17 @@ function $(selector: string) { let api: YoutubePlayer; +export const moveVolumeHud = debounce((showVideo: boolean) => { + const volumeHud = $('#volumeHud'); + if (!volumeHud) { + return; + } + + volumeHud.style.top = showVideo + ? `${($('ytmusic-player')!.clientHeight - $('video')!.clientHeight) / 2}px` + : '0'; +}, 250); + export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { let options: PreciseVolumePluginConfig = await getConfig(); @@ -19,41 +30,30 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { const writeOptions = debounce(() => { setConfig(options); }, 1000); - - const moveVolumeHud = debounce((showVideo: boolean) => { - const volumeHud = $('#volumeHud'); - if (!volumeHud) { - return; - } - - volumeHud.style.top = showVideo - ? `${($('ytmusic-player')!.clientHeight - $('video')!.clientHeight) / 2}px` - : '0'; - }, 250); - + const hideVolumeHud = debounce((volumeHud: HTMLElement) => { volumeHud.style.opacity = '0'; }, 2000); - + const hideVolumeSlider = debounce((slider: HTMLElement) => { slider.classList.remove('on-hover'); }, 2500); - + /** Restore saved volume and setup tooltip */ function firstRun() { if (typeof options.savedVolume === 'number') { // Set saved volume as tooltip setTooltip(options.savedVolume); - + if (api.getVolume() !== options.savedVolume) { setVolume(options.savedVolume); } } - + setupPlaybar(); - + setupLocalArrowShortcuts(); - + // Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none'; injectVolumeHud(noVid); @@ -66,12 +66,12 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { } } } - + function injectVolumeHud(noVid: boolean) { if (noVid) { const position = 'top: 18px; right: 60px;'; const mainStyle = 'font-size: xx-large;'; - + $('.center-content.ytmusic-nav-bar')?.insertAdjacentHTML( 'beforeend', ``, @@ -79,66 +79,66 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { } else { const position = 'top: 10px; left: 10px;'; const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;'; - + $('#song-video')?.insertAdjacentHTML( 'afterend', ``, ); } } - + function showVolumeHud(volume: number) { const volumeHud = $('#volumeHud'); if (!volumeHud) { return; } - + volumeHud.textContent = `${volume}%`; volumeHud.style.opacity = '1'; - + hideVolumeHud(volumeHud); } - + /** Add onwheel event to video player */ function setupVideoPlayerOnwheel() { const panel = $('#main-panel'); if (!panel) return; - + panel.addEventListener('wheel', (event) => { event.preventDefault(); // Event.deltaY < 0 means wheel-up changeVolume(event.deltaY < 0); }); } - + function saveVolume(volume: number) { options.savedVolume = volume; writeOptions(); } - + /** Add onwheel event to play bar and also track if play bar is hovered */ function setupPlaybar() { const playerbar = $('ytmusic-player-bar'); if (!playerbar) return; - + playerbar.addEventListener('wheel', (event) => { event.preventDefault(); // Event.deltaY < 0 means wheel-up changeVolume(event.deltaY < 0); }); - + // Keep track of mouse position for showVolumeSlider() playerbar.addEventListener('mouseenter', () => { playerbar.classList.add('on-hover'); }); - + playerbar.addEventListener('mouseleave', () => { playerbar.classList.remove('on-hover'); }); - + setupSliderObserver(); } - + /** Save volume + Update the volume tooltip when volume-slider is manually changed */ function setupSliderObserver() { const sliderObserver = new MutationObserver((mutations) => { @@ -156,25 +156,25 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { } } }); - + const slider = $('#volume-slider'); if (!slider) return; - + // Observing only changes in 'value' of volume-slider sliderObserver.observe(slider, { attributeFilter: ['value'], attributeOldValue: true, }); } - + function setVolume(value: number) { api.setVolume(value); // Save the new volume saveVolume(value); - + // Change slider position (important) updateVolumeSlider(); - + // Change tooltips to new value setTooltip(value); // Show volume slider @@ -182,7 +182,7 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { // Show volume HUD showVolumeHud(value); } - + /** If (toIncrease = false) then volume decrease */ function changeVolume(toIncrease: boolean) { // Apply volume change if valid @@ -191,7 +191,7 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { ? Math.min(api.getVolume() + steps, 100) : Math.max(api.getVolume() - steps, 0)); } - + function updateVolumeSlider() { const savedVolume = options.savedVolume ?? 0; // Slider value automatically rounds to multiples of 5 @@ -202,17 +202,17 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { } } } - + function showVolumeSlider() { const slider = $('#volume-slider'); if (!slider) return; - + // This class display the volume slider if not in minimized mode slider.classList.add('on-hover'); - + hideVolumeSlider(slider); } - + // Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small) const tooltipTargets = [ '#volume-slider', @@ -220,7 +220,7 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { '#expand-volume-slider', '#expand-volume', ]; - + function setTooltip(volume: number) { for (const target of tooltipTargets) { const tooltipTargetElement = $(target); @@ -229,21 +229,21 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { } } } - + function setupLocalArrowShortcuts() { if (options.arrowsShortcut) { window.addEventListener('keydown', (event) => { if ($('ytmusic-search-box')?.opened) { return; } - + switch (event.code) { case 'ArrowUp': { event.preventDefault(); changeVolume(true); break; } - + case 'ArrowDown': { event.preventDefault(); changeVolume(false); @@ -253,7 +253,7 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { }); } } - + return { onLoad() { diff --git a/src/plugins/quality-changer/index.ts b/src/plugins/quality-changer/index.ts index 6d541920..38f7d852 100644 --- a/src/plugins/quality-changer/index.ts +++ b/src/plugins/quality-changer/index.ts @@ -1,7 +1,8 @@ import { createPluginBuilder } from '../utils/builder'; const builder = createPluginBuilder('quality-changer', { - name: 'Quality Changer', + name: 'Video Quality Changer', + restartNeeded: false, config: { enabled: false, }, diff --git a/src/plugins/quality-changer/renderer.ts b/src/plugins/quality-changer/renderer.ts index f39ab9cc..1da2d7e7 100644 --- a/src/plugins/quality-changer/renderer.ts +++ b/src/plugins/quality-changer/renderer.ts @@ -1,13 +1,10 @@ import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw'; -import builder from './'; +import builder from './index'; import { ElementFromHtml } from '../utils/renderer'; -import { YoutubePlayer } from '../../types/youtube-player'; -// export default () => { -// document.addEventListener('apiLoaded', setup, { once: true, passive: true }); -// }; +import type { YoutubePlayer } from '../../types/youtube-player'; export default builder.createRenderer(({ invoke }) => { function $(selector: string): HTMLElement | null { @@ -16,34 +13,42 @@ export default builder.createRenderer(({ invoke }) => { const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate); + let api: YoutubePlayer; + + const chooseQuality = () => { + setTimeout(() => $('#player')?.click()); + + const qualityLevels = api.getAvailableQualityLevels(); + + const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality()); + + invoke<{ response: number }>('qualityChanger', api.getAvailableQualityLabels(), currentIndex) + .then((promise) => { + if (promise.response === -1) { + return; + } + + const newQuality = qualityLevels[promise.response]; + api.setPlaybackQualityRange(newQuality); + api.setPlaybackQuality(newQuality); + }); + } + function setup(event: CustomEvent) { - const api = event.detail; + api = event.detail; $('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton); - qualitySettingsButton.addEventListener('click', function chooseQuality() { - setTimeout(() => $('#player')?.click()); - - const qualityLevels = api.getAvailableQualityLevels(); - - const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality()); - - invoke<{ response: number }>('qualityChanger', api.getAvailableQualityLabels(), currentIndex) - .then((promise) => { - if (promise.response === -1) { - return; - } - - const newQuality = qualityLevels[promise.response]; - api.setPlaybackQualityRange(newQuality); - api.setPlaybackQuality(newQuality); - }); - }); + qualitySettingsButton.addEventListener('click', chooseQuality); } return { onLoad() { document.addEventListener('apiLoaded', setup, { once: true, passive: true }); + }, + onUnload() { + $('.top-row-buttons.ytmusic-player')?.removeChild(qualitySettingsButton); + qualitySettingsButton.removeEventListener('click', chooseQuality); } }; }); diff --git a/src/plugins/shortcuts/index.ts b/src/plugins/shortcuts/index.ts new file mode 100644 index 00000000..4f02c65f --- /dev/null +++ b/src/plugins/shortcuts/index.ts @@ -0,0 +1,40 @@ +import { createPluginBuilder } from '../utils/builder'; + +export type ShortcutMappingType = { + previous: string; + playPause: string; + next: string; +}; +export type ShortcutsPluginConfig = { + enabled: boolean; + overrideMediaKeys: boolean; + global: ShortcutMappingType; + local: ShortcutMappingType; +} + +const builder = createPluginBuilder('shortcuts', { + name: 'Shortcuts (& MPRIS)', + restartNeeded: true, + config: { + enabled: false, + overrideMediaKeys: false, + global: { + previous: '', + playPause: '', + next: '', + }, + local: { + previous: '', + playPause: '', + next: '', + }, + } as ShortcutsPluginConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/shortcuts/main.ts b/src/plugins/shortcuts/main.ts index 8447092f..6e8319b2 100644 --- a/src/plugins/shortcuts/main.ts +++ b/src/plugins/shortcuts/main.ts @@ -4,9 +4,10 @@ import electronLocalshortcut from 'electron-localshortcut'; import registerMPRIS from './mpris'; +import builder, { ShortcutMappingType } from './index'; + import getSongControls from '../../providers/song-controls'; -import type { ConfigType } from '../../config/dynamic'; function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) { globalShortcut.register(shortcut, () => { @@ -20,50 +21,57 @@ function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (w }); } -function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>) { - const songControls = getSongControls(win); - const { playPause, next, previous, search } = songControls; +export default builder.createMain(({ getConfig }) => { + return { + async onLoad(win) { + const config = await getConfig(); - if (options.overrideMediaKeys) { - _registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause); - _registerGlobalShortcut(win.webContents, 'MediaNextTrack', next); - _registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous); - } + const songControls = getSongControls(win); + const { playPause, next, previous, search } = songControls; - _registerLocalShortcut(win, 'CommandOrControl+F', search); - _registerLocalShortcut(win, 'CommandOrControl+L', search); - - if (is.linux()) { - registerMPRIS(win); - } - - const { global, local } = options; - const shortcutOptions = { global, local }; - - for (const optionType in shortcutOptions) { - registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType); - } - - function registerAllShortcuts(container: Record, type: string) { - for (const action in container) { - if (!container[action]) { - continue; // Action accelerator is empty + if (config.overrideMediaKeys) { + _registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause); + _registerGlobalShortcut(win.webContents, 'MediaNextTrack', next); + _registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous); } - console.debug(`Registering ${type} shortcut`, container[action], ':', action); - const actionCallback: () => void = songControls[action as keyof typeof songControls]; - if (typeof actionCallback !== 'function') { - console.warn('Invalid action', action); - continue; + _registerLocalShortcut(win, 'CommandOrControl+F', search); + _registerLocalShortcut(win, 'CommandOrControl+L', search); + + if (is.linux()) { + registerMPRIS(win); } - if (type === 'global') { - _registerGlobalShortcut(win.webContents, container[action], actionCallback); - } else { // Type === "local" - _registerLocalShortcut(win, local[action], actionCallback); + const { global, local } = config; + const shortcutOptions = { global, local }; + + for (const optionType in shortcutOptions) { + registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType); + } + + function registerAllShortcuts(container: ShortcutMappingType, type: string) { + for (const _action in container) { + // HACK: _action is detected as string, but it's actually a key of ShortcutMappingType + const action = _action as keyof ShortcutMappingType; + + if (!container[action]) { + continue; // Action accelerator is empty + } + + console.debug(`Registering ${type} shortcut`, container[action], ':', action); + const actionCallback: () => void = songControls[action]; + if (typeof actionCallback !== 'function') { + console.warn('Invalid action', action); + continue; + } + + if (type === 'global') { + _registerGlobalShortcut(win.webContents, container[action], actionCallback); + } else { // Type === "local" + _registerLocalShortcut(win, local[action], actionCallback); + } + } } } - } -} - -export default registerShortcuts; + }; +}); diff --git a/src/plugins/shortcuts/menu.ts b/src/plugins/shortcuts/menu.ts index 68854fe7..9065866d 100644 --- a/src/plugins/shortcuts/menu.ts +++ b/src/plugins/shortcuts/menu.ts @@ -1,67 +1,55 @@ import prompt, { KeybindOptions } from 'custom-electron-prompt'; -import { BrowserWindow } from 'electron'; - -import { setMenuOptions } from '../../config/plugins'; - +import builder, { ShortcutsPluginConfig } from './index'; import promptOptions from '../../providers/prompt-options'; -import { MenuTemplate } from '../../menu'; -import type { ConfigType } from '../../config/dynamic'; +import type { BrowserWindow } from 'electron'; -export default (win: BrowserWindow, options: ConfigType<'shortcuts'>): MenuTemplate => [ - { - label: 'Set Global Song Controls', - click: () => promptKeybind(options, win), - }, - { - label: 'Override MediaKeys', - type: 'checkbox', - checked: options.overrideMediaKeys, - click: (item) => setOption(options, 'overrideMediaKeys', item.checked), - }, -]; +export default builder.createMenu(async ({ window, getConfig, setConfig }) => { + const config = await getConfig(); -function setOption = keyof ConfigType<'shortcuts'>>( - options: ConfigType<'shortcuts'>, - key: Key | null = null, - newValue: ConfigType<'shortcuts'>[Key] | null = null, -) { - if (key && newValue !== null) { - options[key] = newValue; + /** + * Helper function for keybind prompt + */ + const kb = (label_: string, value_: string, default_?: string): KeybindOptions => ({ value: value_, label: label_, default: default_ }); + + async function promptKeybind(config: ShortcutsPluginConfig, win: BrowserWindow) { + const output = await prompt({ + title: 'Global Keybinds', + label: 'Choose Global Keybinds for Songs Control:', + type: 'keybind', + keybindOptions: [ // If default=undefined then no default is used + kb('Previous', 'previous', config.global?.previous), + kb('Play / Pause', 'playPause', config.global?.playPause), + kb('Next', 'next', config.global?.next), + ], + height: 270, + ...promptOptions(), + }, win); + + if (output) { + const newConfig = { ...config }; + + for (const { value, accelerator } of output) { + newConfig.global[value as keyof ShortcutsPluginConfig['global']] = accelerator; + } + + setConfig(config); + } + // Else -> pressed cancel } - setMenuOptions('shortcuts', options); -} - -// Helper function for keybind prompt -const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ value: value_, label: label_, default: default_ }); - -async function promptKeybind(options: ConfigType<'shortcuts'>, win: BrowserWindow) { - const output = await prompt({ - title: 'Global Keybinds', - label: 'Choose Global Keybinds for Songs Control:', - type: 'keybind', - keybindOptions: [ // If default=undefined then no default is used - kb('Previous', 'previous', options.global?.previous), - kb('Play / Pause', 'playPause', options.global?.playPause), - kb('Next', 'next', options.global?.next), - ], - height: 270, - ...promptOptions(), - }, win); - - if (output) { - if (!options.global) { - options.global = {}; - } - - for (const { value, accelerator } of output) { - options.global[value] = accelerator; - } - - setOption(options); - } - // Else -> pressed cancel -} + return [ + { + label: 'Set Global Song Controls', + click: () => promptKeybind(config, window), + }, + { + label: 'Override MediaKeys', + type: 'checkbox', + checked: config.overrideMediaKeys, + click: (item) => setConfig({ overrideMediaKeys: item.checked }), + }, + ]; +}); diff --git a/src/plugins/shortcuts/mpris.ts b/src/plugins/shortcuts/mpris.ts index 46109a77..4365ae56 100644 --- a/src/plugins/shortcuts/mpris.ts +++ b/src/plugins/shortcuts/mpris.ts @@ -32,7 +32,7 @@ function registerMPRIS(win: BrowserWindow) { const player = setupMPRIS(); - ipcMain.on('apiLoaded', () => { + ipcMain.handle('apiLoaded', () => { win.webContents.send('setupSeekedListener', 'mpris'); win.webContents.send('setupTimeChangedListener', 'mpris'); win.webContents.send('setupRepeatChangedListener', 'mpris'); diff --git a/src/plugins/skip-silences/index.ts b/src/plugins/skip-silences/index.ts new file mode 100644 index 00000000..a9a1b64d --- /dev/null +++ b/src/plugins/skip-silences/index.ts @@ -0,0 +1,23 @@ +import { createPluginBuilder } from '../utils/builder'; + +export type SkipSilencesPluginConfig = { + enabled: boolean; + onlySkipBeginning: boolean; +}; + +const builder = createPluginBuilder('skip-silences', { + name: 'Skip Silences', + restartNeeded: true, + config: { + enabled: false, + onlySkipBeginning: false, + } as SkipSilencesPluginConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/skip-silences/renderer.ts b/src/plugins/skip-silences/renderer.ts index b1db4199..3a894906 100644 --- a/src/plugins/skip-silences/renderer.ts +++ b/src/plugins/skip-silences/renderer.ts @@ -1,8 +1,8 @@ -import type { ConfigType } from '../../config/dynamic'; +import builder, { type SkipSilencesPluginConfig } from './index'; -type SkipSilencesOptions = ConfigType<'skip-silences'>; +export default builder.createRenderer(({ getConfig }) => { + let config: SkipSilencesPluginConfig; -export default (options: SkipSilencesOptions) => { let isSilent = false; let hasAudioStarted = false; @@ -12,109 +12,129 @@ export default (options: SkipSilencesOptions) => { const history = 10; const speakingHistory = Array.from({ length: history }).fill(0) as number[]; - document.addEventListener( - 'audioCanPlay', - (e) => { - const video = document.querySelector('video'); - const { audioContext } = e.detail; - const sourceNode = e.detail.audioSource; + let playOrSeekHandler: (() => void) | undefined; - // Use an audio analyser similar to Hark - // https://github.com/otalk/hark/blob/master/hark.bundle.js - const analyser = audioContext.createAnalyser(); - analyser.fftSize = 512; - analyser.smoothingTimeConstant = smoothing; - const fftBins = new Float32Array(analyser.frequencyBinCount); + const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => { + let maxVolume = Number.NEGATIVE_INFINITY; + analyser.getFloatFrequencyData(fftBins); - sourceNode.connect(analyser); - analyser.connect(audioContext.destination); + for (let i = 4, ii = fftBins.length; i < ii; i++) { + if (fftBins[i] > maxVolume && fftBins[i] < 0) { + maxVolume = fftBins[i]; + } + } - const looper = () => { - setTimeout(() => { - const currentVolume = getMaxVolume(analyser, fftBins); + return maxVolume; + }; - let history = 0; - if (currentVolume > threshold && isSilent) { - // Trigger quickly, short history - for ( - let i = speakingHistory.length - 3; - i < speakingHistory.length; - i++ - ) { - history += speakingHistory[i]; - } + const audioCanPlayListener = (e: CustomEvent) => { + const video = document.querySelector('video'); + const { audioContext } = e.detail; + const sourceNode = e.detail.audioSource; - if (history >= 2) { - // Not silent - isSilent = false; - hasAudioStarted = true; - } - } else if (currentVolume < threshold && !isSilent) { - for (const element of speakingHistory) { - history += element; - } + // Use an audio analyser similar to Hark + // https://github.com/otalk/hark/blob/master/hark.bundle.js + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = smoothing; + const fftBins = new Float32Array(analyser.frequencyBinCount); - if (history == 0 // Silent + sourceNode.connect(analyser); + analyser.connect(audioContext.destination); - && !( - video && ( - video.paused - || video.seeking - || video.ended - || video.muted - || video.volume === 0 - ) - ) - ) { - isSilent = true; - skipSilence(); - } + const looper = () => { + setTimeout(() => { + const currentVolume = getMaxVolume(analyser, fftBins); + + let history = 0; + if (currentVolume > threshold && isSilent) { + // Trigger quickly, short history + for ( + let i = speakingHistory.length - 3; + i < speakingHistory.length; + i++ + ) { + history += speakingHistory[i]; } - speakingHistory.shift(); - speakingHistory.push(Number(currentVolume > threshold)); + if (history >= 2) { + // Not silent + isSilent = false; + hasAudioStarted = true; + } + } else if (currentVolume < threshold && !isSilent) { + for (const element of speakingHistory) { + history += element; + } - looper(); - }, interval); - }; + if (history == 0 // Silent - looper(); - - const skipSilence = () => { - if (options.onlySkipBeginning && hasAudioStarted) { - return; + && !( + video && ( + video.paused + || video.seeking + || video.ended + || video.muted + || video.volume === 0 + ) + ) + ) { + isSilent = true; + skipSilence(); + } } - if (isSilent && video && !video.paused) { - video.currentTime += 0.2; // In s - } - }; + speakingHistory.shift(); + speakingHistory.push(Number(currentVolume > threshold)); - video?.addEventListener('play', () => { - hasAudioStarted = false; - skipSilence(); - }); + looper(); + }, interval); + }; - video?.addEventListener('seeked', () => { - hasAudioStarted = false; - skipSilence(); - }); + looper(); + + const skipSilence = () => { + if (config.onlySkipBeginning && hasAudioStarted) { + return; + } + + if (isSilent && video && !video.paused) { + video.currentTime += 0.2; // In s + } + }; + + playOrSeekHandler = () => { + hasAudioStarted = false; + skipSilence(); + }; + + video?.addEventListener('play', playOrSeekHandler); + video?.addEventListener('seeked', playOrSeekHandler); + }; + + return { + async onLoad() { + config = await getConfig(); + + document.addEventListener( + 'audioCanPlay', + audioCanPlayListener, + { + passive: true, + }, + ); }, - { - passive: true, - }, - ); -}; + onUnload() { + document.removeEventListener( + 'audioCanPlay', + audioCanPlayListener, + ); -function getMaxVolume(analyser: AnalyserNode, fftBins: Float32Array) { - let maxVolume = Number.NEGATIVE_INFINITY; - analyser.getFloatFrequencyData(fftBins); - - for (let i = 4, ii = fftBins.length; i < ii; i++) { - if (fftBins[i] > maxVolume && fftBins[i] < 0) { - maxVolume = fftBins[i]; + if (playOrSeekHandler) { + const video = document.querySelector('video'); + video?.removeEventListener('play', playOrSeekHandler); + video?.removeEventListener('seeked', playOrSeekHandler); + } } - } - - return maxVolume; -} + }; +}); diff --git a/src/plugins/sponsorblock/index.ts b/src/plugins/sponsorblock/index.ts new file mode 100644 index 00000000..ad735e0a --- /dev/null +++ b/src/plugins/sponsorblock/index.ts @@ -0,0 +1,32 @@ +import { createPluginBuilder } from '../utils/builder'; + +export type SponsorBlockPluginConfig = { + enabled: boolean; + apiURL: string; + categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[]; +}; + +const builder = createPluginBuilder('sponsorblock', { + name: 'SponsorBlock', + restartNeeded: true, + config: { + enabled: false, + apiURL: 'https://sponsor.ajay.app', + categories: [ + 'sponsor', + 'intro', + 'outro', + 'interaction', + 'selfpromo', + 'music_offtopic', + ], + } as SponsorBlockPluginConfig, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/sponsorblock/main.ts b/src/plugins/sponsorblock/main.ts index ae9a4441..46079242 100644 --- a/src/plugins/sponsorblock/main.ts +++ b/src/plugins/sponsorblock/main.ts @@ -1,26 +1,12 @@ -import { BrowserWindow, ipcMain } from 'electron'; import is from 'electron-is'; import { sortSegments } from './segments'; import { SkipSegment } from './types'; -import defaultConfig from '../../config/defaults'; +import builder from './index'; import type { GetPlayerResponse } from '../../types/get-player-response'; -import type { ConfigType } from '../../config/dynamic'; - -export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => { - const { apiURL, categories } = { - ...defaultConfig.plugins.sponsorblock, - ...options, - }; - - ipcMain.on('video-src-changed', async (_, data: GetPlayerResponse) => { - const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId); - win.webContents.send('sponsorblock-skip', segments); - }); -}; const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => { const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify( @@ -50,3 +36,16 @@ const fetchSegments = async (apiURL: string, categories: string[], videoId: stri return []; } }; + +export default builder.createMain(({ getConfig, on, send }) => ({ + async onLoad() { + const config = await getConfig(); + + const { apiURL, categories } = config; + + on('video-src-changed', async (_, data: GetPlayerResponse) => { + const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId); + send('sponsorblock-skip', segments); + }); + } +})); diff --git a/src/plugins/sponsorblock/renderer.ts b/src/plugins/sponsorblock/renderer.ts index 022ff950..e1772183 100644 --- a/src/plugins/sponsorblock/renderer.ts +++ b/src/plugins/sponsorblock/renderer.ts @@ -1,34 +1,50 @@ import { Segment } from './types'; +import builder from './index'; -let currentSegments: Segment[] = []; +export default builder.createRenderer(({ on }) => { + let currentSegments: Segment[] = []; -export default () => { - window.ipcRenderer.on('sponsorblock-skip', (_, segments: Segment[]) => { - currentSegments = segments; - }); + const timeUpdateListener = (e: Event) => { + if (e.target instanceof HTMLVideoElement) { + const target = e.target; - document.addEventListener('apiLoaded', () => { - const video = document.querySelector('video'); - if (!video) return; - - video.addEventListener('timeupdate', (e) => { - if (e.target instanceof HTMLVideoElement) { - const target = e.target; - - for (const segment of currentSegments) { - if ( - target.currentTime >= segment[0] - && target.currentTime < segment[1] - ) { - target.currentTime = segment[1]; - if (window.electronIs.dev()) { - console.log('SponsorBlock: skipping segment', segment); - } + for (const segment of currentSegments) { + if ( + target.currentTime >= segment[0] + && target.currentTime < segment[1] + ) { + target.currentTime = segment[1]; + if (window.electronIs.dev()) { + console.log('SponsorBlock: skipping segment', segment); } } } - }); - // Reset segments on song end - video.addEventListener('emptied', () => currentSegments = []); - }, { once: true, passive: true }); -}; + } + }; + + const resetSegments = () => currentSegments = []; + + return ({ + onLoad() { + on('sponsorblock-skip', (_, segments: Segment[]) => { + currentSegments = segments; + }); + + document.addEventListener('apiLoaded', () => { + const video = document.querySelector('video'); + if (!video) return; + + video.addEventListener('timeupdate', timeUpdateListener); + // Reset segments on song end + video.addEventListener('emptied', resetSegments); + }, { once: true, passive: true }); + }, + onUnload() { + const video = document.querySelector('video'); + if (!video) return; + + video.removeEventListener('timeupdate', timeUpdateListener); + video.removeEventListener('emptied', resetSegments); + } + }); +}); diff --git a/src/plugins/taskbar-mediacontrol/index.ts b/src/plugins/taskbar-mediacontrol/index.ts new file mode 100644 index 00000000..5eccbd42 --- /dev/null +++ b/src/plugins/taskbar-mediacontrol/index.ts @@ -0,0 +1,17 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('taskbar-mediacontrol', { + name: 'Taskbar Media Control', + restartNeeded: true, + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/taskbar-mediacontrol/main.ts b/src/plugins/taskbar-mediacontrol/main.ts index 17467734..d6ce30fb 100644 --- a/src/plugins/taskbar-mediacontrol/main.ts +++ b/src/plugins/taskbar-mediacontrol/main.ts @@ -1,5 +1,7 @@ import { BrowserWindow, nativeImage } from 'electron'; +import builder from './index'; + import getSongControls from '../../providers/song-controls'; import registerCallback, { SongInfo } from '../../providers/song-info'; import { mediaIcons } from '../../types/media-icons'; @@ -9,67 +11,71 @@ import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnp import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack'; import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack'; -export default (win: BrowserWindow) => { - let currentSongInfo: SongInfo; +export default builder.createMain(() => { + return { + onLoad(win) { + let currentSongInfo: SongInfo; - const { playPause, next, previous } = getSongControls(win); + const { playPause, next, previous } = getSongControls(win); - const setThumbar = (win: BrowserWindow, songInfo: SongInfo) => { - // Wait for song to start before setting thumbar - if (!songInfo?.title) { - return; - } + const setThumbar = (win: BrowserWindow, songInfo: SongInfo) => { + // Wait for song to start before setting thumbar + if (!songInfo?.title) { + return; + } - // Win32 require full rewrite of components - win.setThumbarButtons([ - { - tooltip: 'Previous', - icon: nativeImage.createFromPath(get('previous')), - click() { - previous(); - }, - }, { - tooltip: 'Play/Pause', - // Update icon based on play state - icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')), - click() { - playPause(); - }, - }, { - tooltip: 'Next', - icon: nativeImage.createFromPath(get('next')), - click() { - next(); - }, - }, - ]); - }; + // Win32 require full rewrite of components + win.setThumbarButtons([ + { + tooltip: 'Previous', + icon: nativeImage.createFromPath(get('previous')), + click() { + previous(); + }, + }, { + tooltip: 'Play/Pause', + // Update icon based on play state + icon: nativeImage.createFromPath(songInfo.isPaused ? get('play') : get('pause')), + click() { + playPause(); + }, + }, { + tooltip: 'Next', + icon: nativeImage.createFromPath(get('next')), + click() { + next(); + }, + }, + ]); + }; - // Util - const get = (kind: keyof typeof mediaIcons): string => { - switch (kind) { - case 'play': - return playIcon; - case 'pause': - return pauseIcon; - case 'next': - return nextIcon; - case 'previous': - return previousIcon; - default: - return ''; + // Util + const get = (kind: keyof typeof mediaIcons): string => { + switch (kind) { + case 'play': + return playIcon; + case 'pause': + return pauseIcon; + case 'next': + return nextIcon; + case 'previous': + return previousIcon; + default: + return ''; + } + }; + + registerCallback((songInfo) => { + // Update currentsonginfo for win.on('show') + currentSongInfo = songInfo; + // Update thumbar + setThumbar(win, songInfo); + }); + + // Need to set thumbar again after win.show + win.on('show', () => { + setThumbar(win, currentSongInfo); + }); } }; - - registerCallback((songInfo) => { - // Update currentsonginfo for win.on('show') - currentSongInfo = songInfo; - // Update thumbar - setThumbar(win, songInfo); - }); - - // Need to set thumbar again after win.show - win.on('show', () => { - setThumbar(win, currentSongInfo); - }); -}; +}); diff --git a/src/plugins/touchbar/index.ts b/src/plugins/touchbar/index.ts new file mode 100644 index 00000000..e5ca2c07 --- /dev/null +++ b/src/plugins/touchbar/index.ts @@ -0,0 +1,17 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('touchbar', { + name: 'TouchBar', + restartNeeded: true, + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/touchbar/main.ts b/src/plugins/touchbar/main.ts index e3a27f4b..de03fa2e 100644 --- a/src/plugins/touchbar/main.ts +++ b/src/plugins/touchbar/main.ts @@ -1,90 +1,96 @@ -import { TouchBar, NativeImage, BrowserWindow } from 'electron'; +import { TouchBar, NativeImage } from 'electron'; + +import builder from './index'; import registerCallback from '../../providers/song-info'; import getSongControls from '../../providers/song-controls'; -export default (win: BrowserWindow) => { - const { - TouchBarButton, - TouchBarLabel, - TouchBarSpacer, - TouchBarSegmentedControl, - TouchBarScrubber, - } = TouchBar; +export default builder.createMain(() => { + return { + onLoad(win) { + const { + TouchBarButton, + TouchBarLabel, + TouchBarSpacer, + TouchBarSegmentedControl, + TouchBarScrubber, + } = TouchBar; - // Songtitle label - const songTitle = new TouchBarLabel({ - label: '', - }); - // This will store the song controls once available - let controls: (() => void)[] = []; + // Songtitle label + const songTitle = new TouchBarLabel({ + label: '', + }); + // This will store the song controls once available + let controls: (() => void)[] = []; - // This will store the song image once available - const songImage: { - icon?: NativeImage; - } = {}; + // This will store the song image once available + const songImage: { + icon?: NativeImage; + } = {}; - // Pause/play button - const pausePlayButton = new TouchBarButton({}); + // Pause/play button + const pausePlayButton = new TouchBarButton({}); - // The song control buttons (control functions are in the same order) - const buttons = new TouchBarSegmentedControl({ - mode: 'buttons', - segments: [ - new TouchBarButton({ - label: '⏮', - }), - pausePlayButton, - new TouchBarButton({ - label: '⏭', - }), - new TouchBarButton({ - label: '👎', - }), - new TouchBarButton({ - label: '👍', - }), - ], - change: (i) => controls[i](), - }); + // The song control buttons (control functions are in the same order) + const buttons = new TouchBarSegmentedControl({ + mode: 'buttons', + segments: [ + new TouchBarButton({ + label: '⏮', + }), + pausePlayButton, + new TouchBarButton({ + label: '⏭', + }), + new TouchBarButton({ + label: '👎', + }), + new TouchBarButton({ + label: '👍', + }), + ], + change: (i) => controls[i](), + }); - // This is the touchbar object, this combines everything with proper layout - const touchBar = new TouchBar({ - items: [ - new TouchBarScrubber({ - items: [songImage, songTitle], - continuous: false, - }), - new TouchBarSpacer({ - size: 'flexible', - }), - buttons, - ], - }); + // This is the touchbar object, this combines everything with proper layout + const touchBar = new TouchBar({ + items: [ + new TouchBarScrubber({ + items: [songImage, songTitle], + continuous: false, + }), + new TouchBarSpacer({ + size: 'flexible', + }), + buttons, + ], + }); - const { playPause, next, previous, dislike, like } = getSongControls(win); + const { playPause, next, previous, dislike, like } = getSongControls(win); - // If the page is ready, register the callback - win.once('ready-to-show', () => { - controls = [previous, playPause, next, dislike, like]; + // If the page is ready, register the callback + win.once('ready-to-show', () => { + controls = [previous, playPause, next, dislike, like]; - // Register the callback - registerCallback((songInfo) => { - // Song information changed, so lets update the touchBar + // Register the callback + registerCallback((songInfo) => { + // Song information changed, so lets update the touchBar - // Set the song title - songTitle.label = songInfo.title; + // Set the song title + songTitle.label = songInfo.title; - // Changes the pause button if paused - pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸'; + // Changes the pause button if paused + pausePlayButton.label = songInfo.isPaused ? '▶️' : '⏸'; - // Get image source - songImage.icon = songInfo.image - ? songInfo.image.resize({ height: 23 }) - : undefined; + // Get image source + songImage.icon = songInfo.image + ? songInfo.image.resize({ height: 23 }) + : undefined; - win.setTouchBar(touchBar); - }); - }); -}; + win.setTouchBar(touchBar); + }); + }); + } + }; +}); diff --git a/src/plugins/tuna-obs/index.ts b/src/plugins/tuna-obs/index.ts new file mode 100644 index 00000000..84bdb5d8 --- /dev/null +++ b/src/plugins/tuna-obs/index.ts @@ -0,0 +1,17 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('tuna-obs', { + name: 'Tuna OBS', + restartNeeded: true, + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/tuna-obs/main.ts b/src/plugins/tuna-obs/main.ts index f08ae22d..4c1cc4aa 100644 --- a/src/plugins/tuna-obs/main.ts +++ b/src/plugins/tuna-obs/main.ts @@ -1,6 +1,8 @@ -import { ipcMain, net, BrowserWindow } from 'electron'; +import { net } from 'electron'; import is from 'electron-is'; +import builder from './index'; + import registerCallback from '../../providers/song-info'; const secToMilisec = (t: number) => Math.round(Number(t) * 1e3); @@ -49,31 +51,35 @@ const post = (data: Data) => { }); }; -export default (win: BrowserWindow) => { - ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); - ipcMain.on('timeChanged', (_, t: number) => { - if (!data.title) { - return; +export default builder.createMain(({ send, handle, on }) => { + return { + onLoad() { + on('apiLoaded', () => send('setupTimeChangedListener')); + on('timeChanged', (t: number) => { + if (!data.title) { + return; + } + + data.progress = secToMilisec(t); + post(data); + }); + + registerCallback((songInfo) => { + if (!songInfo.title && !songInfo.artist) { + return; + } + + data.duration = secToMilisec(songInfo.songDuration); + data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0); + data.cover = songInfo.imageSrc ?? ''; + data.cover_url = songInfo.imageSrc ?? ''; + data.album_url = songInfo.imageSrc ?? ''; + data.title = songInfo.title; + data.artists = [songInfo.artist]; + data.status = songInfo.isPaused ? 'stopped' : 'playing'; + data.album = songInfo.album; + post(data); + }); } - - data.progress = secToMilisec(t); - post(data); - }); - - registerCallback((songInfo) => { - if (!songInfo.title && !songInfo.artist) { - return; - } - - data.duration = secToMilisec(songInfo.songDuration); - data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0); - data.cover = songInfo.imageSrc ?? ''; - data.cover_url = songInfo.imageSrc ?? ''; - data.album_url = songInfo.imageSrc ?? ''; - data.title = songInfo.title; - data.artists = [songInfo.artist]; - data.status = songInfo.isPaused ? 'stopped' : 'playing'; - data.album = songInfo.album; - post(data); - }); -}; + }; +}); diff --git a/src/plugins/utils/builder.ts b/src/plugins/utils/builder.ts index 9416fe64..65340d44 100644 --- a/src/plugins/utils/builder.ts +++ b/src/plugins/utils/builder.ts @@ -8,11 +8,13 @@ export type PluginBaseConfig = { }; export type BasePlugin = { onLoad?: () => void; + onUnload?: () => void; onConfigChange?: (newConfig: Config) => void; } export type RendererPlugin = BasePlugin; -export type MainPlugin = Omit, 'onLoad'> & { +export type MainPlugin = Omit, 'onLoad' | 'onUnload'> & { onLoad?: (window: BrowserWindow) => void; + onUnload?: (window: BrowserWindow) => void; }; export type PreloadPlugin = BasePlugin; @@ -30,6 +32,7 @@ export type PluginContext = export type MainPluginContext = PluginContext & { send: (event: string, ...args: unknown[]) => void; handle: (event: string, listener: (...args: Arguments) => Promisable) => void; + on: (event: string, listener: (...args: Arguments) => Promisable) => void; }; export type RendererPluginContext = PluginContext & { invoke: (event: string, ...args: unknown[]) => Promise; @@ -37,6 +40,8 @@ export type RendererPluginContext = PluginContext & { window: BrowserWindow; + + refresh: () => void; }; export type RendererPluginFactory = (context: RendererPluginContext) => Promisable>; @@ -57,6 +62,7 @@ export type PluginBuilder = }; export type PluginBuilderOptions = { name?: string; + restartNeeded: boolean; config: Config; styles?: string[]; diff --git a/src/plugins/utils/main/fetch.ts b/src/plugins/utils/main/fetch.ts new file mode 100644 index 00000000..9c71c09f --- /dev/null +++ b/src/plugins/utils/main/fetch.ts @@ -0,0 +1,21 @@ +import { net } from 'electron'; + +export const getNetFetchAsFetch = () => (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? new URL(input) + : input instanceof URL + ? input + : new URL(input.url); + + if (init?.body && !init.method) { + init.method = 'POST'; + } + + const request = new Request( + url, + input instanceof Request ? input : undefined, + ); + + return net.fetch(request, init); +}) as typeof fetch; diff --git a/src/plugins/utils/main/index.ts b/src/plugins/utils/main/index.ts index 64e2016c..ad6ca97b 100644 --- a/src/plugins/utils/main/index.ts +++ b/src/plugins/utils/main/index.ts @@ -2,3 +2,4 @@ export * from './css'; export * from './fs'; export * from './plugin'; export * from './types'; +export * from './fetch'; diff --git a/src/plugins/video-toggle/button-switcher.css b/src/plugins/video-toggle/button-switcher.css index 76d22f85..7273e820 100644 --- a/src/plugins/video-toggle/button-switcher.css +++ b/src/plugins/video-toggle/button-switcher.css @@ -1,12 +1,12 @@ -#main-panel.ytmusic-player-page { +.video-toggle-custom-mode #main-panel.ytmusic-player-page { align-items: unset !important; } -#main-panel { +.video-toggle-custom-mode #main-panel { position: relative; } -.video-switch-button { +.video-toggle-custom-mode .video-switch-button { z-index: 999; box-sizing: border-box; padding: 0; @@ -24,7 +24,7 @@ position: absolute; } -.video-switch-button:before { +.video-toggle-custom-mode .video-switch-button:before { content: "Video"; position: absolute; top: 0; @@ -38,7 +38,7 @@ pointer-events: none; } -.video-switch-button-checkbox { +.video-toggle-custom-mode .video-switch-button-checkbox { cursor: pointer; position: absolute; top: 0; @@ -50,16 +50,16 @@ z-index: 2; } -.video-switch-button-label-span { +.video-toggle-custom-mode .video-switch-button-label-span { position: relative; } -.video-switch-button-checkbox:checked + .video-switch-button-label:before { +.video-toggle-custom-mode .video-switch-button-checkbox:checked + .video-switch-button-label:before { transform: translateX(10rem); transition: transform 300ms linear; } -.video-switch-button-checkbox + .video-switch-button-label { +.video-toggle-custom-mode .video-switch-button-checkbox + .video-switch-button-label { position: relative; padding: 15px 0; display: block; @@ -67,7 +67,7 @@ pointer-events: none; } -.video-switch-button-checkbox + .video-switch-button-label:before { +.video-toggle-custom-mode .video-switch-button-checkbox + .video-switch-button-label:before { content: ""; background: rgba(60, 60, 60, 0.4); height: 100%; @@ -81,6 +81,6 @@ } /* disable the native toggler */ -#av-id { +.video-toggle-custom-mode #av-id { display: none; } diff --git a/src/plugins/video-toggle/force-hide.css b/src/plugins/video-toggle/force-hide.css index 36f47f08..77d61892 100644 --- a/src/plugins/video-toggle/force-hide.css +++ b/src/plugins/video-toggle/force-hide.css @@ -1,10 +1,10 @@ /* Hide the video player */ -#main-panel { +.video-toggle-force-hide #main-panel { display: none !important; } /* Make the side-panel full width */ -.side-panel.ytmusic-player-page { +.video-toggle-force-hide .side-panel.ytmusic-player-page { max-width: 100% !important; width: 100% !important; margin: 0 !important; diff --git a/src/plugins/video-toggle/index.ts b/src/plugins/video-toggle/index.ts new file mode 100644 index 00000000..f70e9ade --- /dev/null +++ b/src/plugins/video-toggle/index.ts @@ -0,0 +1,36 @@ +import forceHideStyle from './force-hide.css?inline'; +import buttonSwitcherStyle from './button-switcher.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +export type VideoTogglePluginConfig = { + enabled: boolean; + hideVideo: boolean; + mode: 'custom' | 'native' | 'disabled'; + forceHide: boolean; + align: 'left' | 'middle' | 'right'; +} + +const builder = createPluginBuilder('video-toggle', { + name: 'Video Toggle', + restartNeeded: true, + config: { + enabled: false, + hideVideo: false, + mode: 'custom', + forceHide: false, + align: 'left', + } as VideoTogglePluginConfig, + styles: [ + buttonSwitcherStyle, + forceHideStyle, + ], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/video-toggle/main.ts b/src/plugins/video-toggle/main.ts deleted file mode 100644 index ea53b948..00000000 --- a/src/plugins/video-toggle/main.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import forceHideStyle from './force-hide.css'; -import buttonSwitcherStyle from './button-switcher.css'; - -import { injectCSS } from '../utils/main'; - -import type { ConfigType } from '../../config/dynamic'; - -export default (win: BrowserWindow, options: ConfigType<'video-toggle'>) => { - if (options.forceHide) { - injectCSS(win.webContents, forceHideStyle); - } else if (!options.mode || options.mode === 'custom') { - injectCSS(win.webContents, buttonSwitcherStyle); - } -}; diff --git a/src/plugins/video-toggle/menu.ts b/src/plugins/video-toggle/menu.ts index 2013d58f..e4722d42 100644 --- a/src/plugins/video-toggle/menu.ts +++ b/src/plugins/video-toggle/menu.ts @@ -1,83 +1,74 @@ -import { BrowserWindow } from 'electron'; +import builder from './index'; -import { setMenuOptions } from '../../config/plugins'; +export default builder.createMenu(async ({ getConfig, setConfig }) => { + const config = await getConfig(); -import { MenuTemplate } from '../../menu'; - -import type { ConfigType } from '../../config/dynamic'; - -export default (win: BrowserWindow, options: ConfigType<'video-toggle'>): MenuTemplate => [ - { - label: 'Mode', - submenu: [ - { - label: 'Custom toggle', - type: 'radio', - checked: options.mode === 'custom', - click() { - options.mode = 'custom'; - setMenuOptions('video-toggle', options); + return [ + { + label: 'Mode', + submenu: [ + { + label: 'Custom toggle', + type: 'radio', + checked: config.mode === 'custom', + click() { + setConfig({ mode: 'custom' }); + }, }, - }, - { - label: 'Native toggle', - type: 'radio', - checked: options.mode === 'native', - click() { - options.mode = 'native'; - setMenuOptions('video-toggle', options); + { + label: 'Native toggle', + type: 'radio', + checked: config.mode === 'native', + click() { + setConfig({ mode: 'native' }); + }, }, - }, - { - label: 'Disabled', - type: 'radio', - checked: options.mode === 'disabled', - click() { - options.mode = 'disabled'; - setMenuOptions('video-toggle', options); + { + label: 'Disabled', + type: 'radio', + checked: config.mode === 'disabled', + click() { + setConfig({ mode: 'disabled' }); + }, }, - }, - ], - }, - { - label: 'Alignment', - submenu: [ - { - label: 'Left', - type: 'radio', - checked: options.align === 'left', - click() { - options.align = 'left'; - setMenuOptions('video-toggle', options); - }, - }, - { - label: 'Middle', - type: 'radio', - checked: options.align === 'middle', - click() { - options.align = 'middle'; - setMenuOptions('video-toggle', options); - }, - }, - { - label: 'Right', - type: 'radio', - checked: options.align === 'right', - click() { - options.align = 'right'; - setMenuOptions('video-toggle', options); - }, - }, - ], - }, - { - label: 'Force Remove Video Tab', - type: 'checkbox', - checked: options.forceHide, - click(item) { - options.forceHide = item.checked; - setMenuOptions('video-toggle', options); + ], }, - }, -]; + { + label: 'Alignment', + submenu: [ + { + label: 'Left', + type: 'radio', + checked: config.align === 'left', + click() { + setConfig({ align: 'left' }); + }, + }, + { + label: 'Middle', + type: 'radio', + checked: config.align === 'middle', + click() { + setConfig({ align: 'middle' }); + }, + }, + { + label: 'Right', + type: 'radio', + checked: config.align === 'right', + click() { + setConfig({ align: 'right' }); + }, + }, + ], + }, + { + label: 'Force Remove Video Tab', + type: 'checkbox', + checked: config.forceHide, + click(item) { + setConfig({ forceHide: item.checked }); + }, + }, + ]; +}); diff --git a/src/plugins/video-toggle/renderer.ts b/src/plugins/video-toggle/renderer.ts index a8648780..91f877f8 100644 --- a/src/plugins/video-toggle/renderer.ts +++ b/src/plugins/video-toggle/renderer.ts @@ -1,191 +1,210 @@ import buttonTemplate from './templates/button_template.html?raw'; +import builder, { type VideoTogglePluginConfig } from './index'; + import { ElementFromHtml } from '../utils/renderer'; -// import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/renderer'; +import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/renderer'; import { YoutubePlayer } from '../../types/youtube-player'; import { ThumbnailElement } from '../../types/get-player-response'; -import type { ConfigType } from '../../config/dynamic'; -// const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ? preciseVolumeMoveVolumeHud : () => {}; -const moveVolumeHud = () => {}; +export default builder.createRenderer(({ getConfig }) => { + const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ? + preciseVolumeMoveVolumeHud as (_: boolean) => void + : (() => {}); -function $(selector: string): E | null { - return document.querySelector(selector); -} + let config: VideoTogglePluginConfig = builder.config; + let player: HTMLElement & { videoMode_: boolean } | null; + let video: HTMLVideoElement | null; + let api: YoutubePlayer; -let options: ConfigType<'video-toggle'>; -let player: HTMLElement & { videoMode_: boolean } | null; -let video: HTMLVideoElement | null; -let api: YoutubePlayer; + const switchButtonDiv = ElementFromHtml(buttonTemplate); -const switchButtonDiv = ElementFromHtml(buttonTemplate); + function setup(e: CustomEvent) { + api = e.detail; + player = document.querySelector<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player'); + video = document.querySelector('video'); -export default (_options: ConfigType<'video-toggle'>) => { - if (_options.forceHide) { - return; - } + document.querySelector('#player')?.prepend(switchButtonDiv); - switch (_options.mode) { - case 'native': { - $('ytmusic-player-page')?.setAttribute('has-av-switcher', ''); - $('ytmusic-player')?.setAttribute('has-av-switcher', ''); - return; + setVideoState(!config.hideVideo); + forcePlaybackMode(); + // Fix black video + if (video) { + video.style.height = 'auto'; } - case 'disabled': { - $('ytmusic-player-page')?.removeAttribute('has-av-switcher'); - $('ytmusic-player')?.removeAttribute('has-av-switcher'); - return; - } + //Prevents bubbling to the player which causes it to stop or resume + switchButtonDiv.addEventListener('click', (e) => { + e.stopPropagation(); + }); - default: - case 'custom': { - options = _options; - document.addEventListener('apiLoaded', setup, { once: true, passive: true }); + // Button checked = show video + switchButtonDiv.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + + setVideoState(target.checked); + }); + + video?.addEventListener('srcChanged', videoStarted); + + observeThumbnail(); + + switch (config.align) { + case 'right': { + switchButtonDiv.style.left = 'calc(100% - 240px)'; + return; + } + + case 'middle': { + switchButtonDiv.style.left = 'calc(50% - 120px)'; + return; + } + + default: + case 'left': { + switchButtonDiv.style.left = '0px'; + } } } -}; -function setup(e: CustomEvent) { - api = e.detail; - player = $<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player'); - video = $('video'); + function setVideoState(showVideo: boolean) { + config.hideVideo = !showVideo; + window.mainConfig.plugins.setOptions('video-toggle', config); - $('#player')?.prepend(switchButtonDiv); + const checkbox = document.querySelector('.video-switch-button-checkbox'); // custom mode + if (checkbox) checkbox.checked = !config.hideVideo; - setVideoState(!options.hideVideo); - forcePlaybackMode(); - // Fix black video - if (video) { - video.style.height = 'auto'; - } + if (player) { + player.style.margin = showVideo ? '' : 'auto 0px'; + player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED'); - //Prevents bubbling to the player which causes it to stop or resume - switchButtonDiv.addEventListener('click', (e) => { - e.stopPropagation(); - }); + document.querySelector('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none'; + document.querySelector('#song-image')!.style.display = showVideo ? 'none' : 'block'; - // Button checked = show video - switchButtonDiv.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement; + if (showVideo && video && !video.style.top) { + video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`; + } - setVideoState(target.checked); - }); - - video?.addEventListener('srcChanged', videoStarted); - - observeThumbnail(); - - switch (options.align) { - case 'right': { - switchButtonDiv.style.left = 'calc(100% - 240px)'; - return; - } - - case 'middle': { - switchButtonDiv.style.left = 'calc(50% - 120px)'; - return; - } - - default: - case 'left': { - switchButtonDiv.style.left = '0px'; + moveVolumeHud(showVideo); } } -} -function setVideoState(showVideo: boolean) { - options.hideVideo = !showVideo; - window.mainConfig.plugins.setOptions('video-toggle', options); - - const checkbox = $('.video-switch-button-checkbox'); // custom mode - if (checkbox) checkbox.checked = !options.hideVideo; - - if (player) { - player.style.margin = showVideo ? '' : 'auto 0px'; - player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED'); - - $('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none'; - $('#song-image')!.style.display = showVideo ? 'none' : 'block'; - - if (showVideo && video && !video.style.top) { - video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`; - } - - moveVolumeHud(showVideo); - } -} - -function videoStarted() { - if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') { - // Video doesn't exist -> switch to song mode - setVideoState(false); - // Hide toggle button - switchButtonDiv.style.display = 'none'; - } else { - const songImage = $('#song-image img'); - if (!songImage) { - return; - } - // Switch to high-res thumbnail - forceThumbnail(songImage); - // Show toggle button - switchButtonDiv.style.display = 'initial'; - // Change display to video mode if video exist & video is hidden & option.hideVideo = false - if (!options.hideVideo && $('#song-video.ytmusic-player')?.style.display === 'none') { - setVideoState(true); + function videoStarted() { + if (api.getPlayerResponse().videoDetails.musicVideoType === 'MUSIC_VIDEO_TYPE_ATV') { + // Video doesn't exist -> switch to song mode + setVideoState(false); + // Hide toggle button + switchButtonDiv.style.display = 'none'; } else { - moveVolumeHud(!options.hideVideo); + const songImage = document.querySelector('#song-image img'); + if (!songImage) { + return; + } + // Switch to high-res thumbnail + forceThumbnail(songImage); + // Show toggle button + switchButtonDiv.style.display = 'initial'; + // Change display to video mode if video exist & video is hidden & option.hideVideo = false + if (!config.hideVideo && document.querySelector('#song-video.ytmusic-player')?.style.display === 'none') { + setVideoState(true); + } else { + moveVolumeHud(!config.hideVideo); + } } } -} // On load, after a delay, the page overrides the playback-mode to 'OMV_PREFERRED' which causes weird aspect ratio in the image container // this function fix the problem by overriding that override :) -function forcePlaybackMode() { - if (player) { - const playbackModeObserver = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if (mutation.target instanceof HTMLElement) { - const target = mutation.target; - if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') { - playbackModeObserver.disconnect(); - target.setAttribute('playback-mode', 'ATV_PREFERRED'); + function forcePlaybackMode() { + if (player) { + const playbackModeObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.target instanceof HTMLElement) { + const target = mutation.target; + if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') { + playbackModeObserver.disconnect(); + target.setAttribute('playback-mode', 'ATV_PREFERRED'); + } } } + }); + playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] }); + } + } + + function observeThumbnail() { + const playbackModeObserver = new MutationObserver((mutations) => { + if (!player?.videoMode_) { + return; + } + + for (const mutation of mutations) { + if (mutation.target instanceof HTMLImageElement) { + const target = mutation.target; + if (!target.src.startsWith('data:')) { + continue; + } + + forceThumbnail(target); + } } }); - playbackModeObserver.observe(player, { attributeFilter: ['playback-mode'] }); + playbackModeObserver.observe(document.querySelector('#song-image img')!, { attributeFilter: ['src'] }); } -} -function observeThumbnail() { - const playbackModeObserver = new MutationObserver((mutations) => { - if (!player?.videoMode_) { - return; + function forceThumbnail(img: HTMLImageElement) { + const thumbnails: ThumbnailElement[] = (document.querySelector('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? []; + if (thumbnails && thumbnails.length > 0) { + const thumbnail = thumbnails.at(-1)?.url.split('?')[0]; + if (typeof thumbnail === 'string') img.src = thumbnail; } + } - for (const mutation of mutations) { - if (mutation.target instanceof HTMLImageElement) { - const target = mutation.target; - if (!target.src.startsWith('data:')) { - continue; + const applyStyleClass = (config: VideoTogglePluginConfig) => { + if (config.forceHide) { + document.body.classList.add('video-toggle-force-hide'); + document.body.classList.remove('video-toggle-custom-mode'); + } else if (!config.mode || config.mode === 'custom') { + document.body.classList.add('video-toggle-custom-mode'); + document.body.classList.remove('video-toggle-force-hide'); + } + }; + + return { + async onLoad() { + config = await getConfig(); + applyStyleClass(config); + + if (config.forceHide) { + return; + } + + switch (config.mode) { + case 'native': { + document.querySelector('ytmusic-player-page')?.setAttribute('has-av-switcher', ''); + document.querySelector('ytmusic-player')?.setAttribute('has-av-switcher', ''); + return; } - forceThumbnail(target); - } - } - }); - playbackModeObserver.observe($('#song-image img')!, { attributeFilter: ['src'] }); -} + case 'disabled': { + document.querySelector('ytmusic-player-page')?.removeAttribute('has-av-switcher'); + document.querySelector('ytmusic-player')?.removeAttribute('has-av-switcher'); + return; + } -function forceThumbnail(img: HTMLImageElement) { - const thumbnails: ThumbnailElement[] = ($('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? []; - if (thumbnails && thumbnails.length > 0) { - const thumbnail = thumbnails.at(-1)?.url.split('?')[0]; - if (typeof thumbnail === 'string') img.src = thumbnail; - } -} + default: + case 'custom': { + document.addEventListener('apiLoaded', setup, { once: true, passive: true }); + } + } + }, + onConfigChange(newConfig) { + config = newConfig; + + applyStyleClass(newConfig); + } + }; +}); diff --git a/src/plugins/visualizer/index.ts b/src/plugins/visualizer/index.ts new file mode 100644 index 00000000..8d658b95 --- /dev/null +++ b/src/plugins/visualizer/index.ts @@ -0,0 +1,132 @@ +import emptyStyle from './empty-player.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +type WaveColor = { + gradient: string[]; + rotate?: number; +}; + +export type VisualizerPluginConfig = { + enabled: boolean; + type: 'butterchurn' | 'vudio' | 'wave'; + butterchurn: { + preset: string; + renderingFrequencyInMs: number; + blendTimeInSeconds: number; + }, + vudio: { + effect: string; + accuracy: number; + lighting: { + maxHeight: number; + maxSize: number; + lineWidth: number; + color: string; + shadowBlur: number; + shadowColor: string; + fadeSide: boolean; + prettify: boolean; + horizontalAlign: string; + verticalAlign: string; + dottify: boolean; + } + }; + wave: { + animations: { + type: string; + config: { + bottom?: boolean; + top?: boolean; + count?: number; + cubeHeight?: number; + lineWidth?: number; + diameter?: number; + fillColor?: string | WaveColor; + lineColor?: string | WaveColor; + radius?: number; + frequencyBand?: string; + } + }[]; + }; +}; + +const builder = createPluginBuilder('visualizer', { + name: 'Visualizer', + restartNeeded: true, + config: { + enabled: false, + type: 'butterchurn', + // Config per visualizer + butterchurn: { + preset: 'martin [shadow harlequins shape code] - fata morgana', + renderingFrequencyInMs: 500, + blendTimeInSeconds: 2.7, + }, + vudio: { + effect: 'lighting', + accuracy: 128, + lighting: { + maxHeight: 160, + maxSize: 12, + lineWidth: 1, + color: '#49f3f7', + shadowBlur: 2, + shadowColor: 'rgba(244,244,244,.5)', + fadeSide: true, + prettify: false, + horizontalAlign: 'center', + verticalAlign: 'middle', + dottify: true, + }, + }, + wave: { + animations: [ + { + type: 'Cubes', + config: { + bottom: true, + count: 30, + cubeHeight: 5, + fillColor: { gradient: ['#FAD961', '#F76B1C'] }, + lineColor: 'rgba(0,0,0,0)', + radius: 20, + }, + }, + { + type: 'Cubes', + config: { + top: true, + count: 12, + cubeHeight: 5, + fillColor: { gradient: ['#FAD961', '#F76B1C'] }, + lineColor: 'rgba(0,0,0,0)', + radius: 10, + }, + }, + { + type: 'Circles', + config: { + lineColor: { + gradient: ['#FAD961', '#FAD961', '#F76B1C'], + rotate: 90, + }, + lineWidth: 4, + diameter: 20, + count: 10, + frequencyBand: 'base', + }, + }, + ], + }, + } as VisualizerPluginConfig, + styles: [emptyStyle], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/visualizer/main.ts b/src/plugins/visualizer/main.ts deleted file mode 100644 index 84123c6f..00000000 --- a/src/plugins/visualizer/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import emptyPlayerStyle from './empty-player.css'; - -import { injectCSS } from '../utils/main'; - -export default (win: BrowserWindow) => { - injectCSS(win.webContents, emptyPlayerStyle); -}; diff --git a/src/plugins/visualizer/menu.ts b/src/plugins/visualizer/menu.ts index 27b24071..bae7e654 100644 --- a/src/plugins/visualizer/menu.ts +++ b/src/plugins/visualizer/menu.ts @@ -1,23 +1,21 @@ -import { BrowserWindow } from 'electron'; +import builder from './index'; -import { MenuTemplate } from '../../menu'; -import { setMenuOptions } from '../../config/plugins'; +const visualizerTypes = ['butterchurn', 'vudio', 'wave'] as const; // For bundling -import type { ConfigType } from '../../config/dynamic'; +export default builder.createMenu(async ({ getConfig, setConfig }) => { + const config = await getConfig(); -const visualizerTypes = ['butterchurn', 'vudio', 'wave']; // For bundling - -export default (win: BrowserWindow, options: ConfigType<'visualizer'>): MenuTemplate => [ - { - label: 'Type', - submenu: visualizerTypes.map((visualizerType) => ({ - label: visualizerType, - type: 'radio', - checked: options.type === visualizerType, - click() { - options.type = visualizerType; - setMenuOptions('visualizer', options); - }, - })), - }, -]; + return [ + { + label: 'Type', + submenu: visualizerTypes.map((visualizerType) => ({ + label: visualizerType, + type: 'radio', + checked: config.type === visualizerType, + click() { + setConfig({ type: visualizerType }); + }, + })), + }, + ]; +}); diff --git a/src/plugins/visualizer/renderer.ts b/src/plugins/visualizer/renderer.ts index 1fdda444..b892291e 100644 --- a/src/plugins/visualizer/renderer.ts +++ b/src/plugins/visualizer/renderer.ts @@ -1,83 +1,82 @@ import { ButterchurnVisualizer as butterchurn, WaveVisualizer as wave, VudioVisualizer as vudio } from './visualizers'; import { Visualizer } from './visualizers/visualizer'; -import defaultConfig from '../../config/defaults'; +import builder from './index'; -import type { ConfigType } from '../../config/dynamic'; +export default builder.createRenderer(({ getConfig }) => { + return { + async onLoad() { + const config = await getConfig(); -export default (options: ConfigType<'visualizer'>) => { - const optionsWithDefaults = { - ...defaultConfig.plugins.visualizer, - ...options, - }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let visualizerType: { new(...args: any[]): Visualizer } = vudio; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let visualizerType: { new(...args: any[]): Visualizer } = vudio; - - if (optionsWithDefaults.type === 'wave') { - visualizerType = wave; - } else if (optionsWithDefaults.type === 'butterchurn') { - visualizerType = butterchurn; - } - - document.addEventListener( - 'audioCanPlay', - (e) => { - const video = document.querySelector('video'); - if (!video) { - return; + if (config.type === 'wave') { + visualizerType = wave; + } else if (config.type === 'butterchurn') { + visualizerType = butterchurn; } - const visualizerContainer = document.querySelector('#player'); - if (!visualizerContainer) { - return; - } + document.addEventListener( + 'audioCanPlay', + (e) => { + const video = document.querySelector('video'); + if (!video) { + return; + } - let canvas = document.querySelector('#visualizer'); - if (!canvas) { - canvas = document.createElement('canvas'); - canvas.id = 'visualizer'; - visualizerContainer?.prepend(canvas); - } + const visualizerContainer = document.querySelector('#player'); + if (!visualizerContainer) { + return; + } - const resizeCanvas = () => { - if (canvas) { - canvas.width = visualizerContainer.clientWidth; - canvas.height = visualizerContainer.clientHeight; - } - }; + let canvas = document.querySelector('#visualizer'); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.id = 'visualizer'; + visualizerContainer?.prepend(canvas); + } - resizeCanvas(); + const resizeCanvas = () => { + if (canvas) { + canvas.width = visualizerContainer.clientWidth; + canvas.height = visualizerContainer.clientHeight; + } + }; - const gainNode = e.detail.audioContext.createGain(); - gainNode.gain.value = 1.25; - e.detail.audioSource.connect(gainNode); + resizeCanvas(); - const visualizer = new visualizerType( - e.detail.audioContext, - e.detail.audioSource, - visualizerContainer, - canvas, - gainNode, - video.captureStream(), - optionsWithDefaults, + const gainNode = e.detail.audioContext.createGain(); + gainNode.gain.value = 1.25; + e.detail.audioSource.connect(gainNode); + + const visualizer = new visualizerType( + e.detail.audioContext, + e.detail.audioSource, + visualizerContainer, + canvas, + gainNode, + video.captureStream(), + config, + ); + + const resizeVisualizer = (width: number, height: number) => { + resizeCanvas(); + visualizer.resize(width, height); + }; + + resizeVisualizer(canvas.width, canvas.height); + const visualizerContainerObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + resizeVisualizer(entry.contentRect.width, entry.contentRect.height); + } + }); + visualizerContainerObserver.observe(visualizerContainer); + + visualizer.render(); + }, + { passive: true }, ); - - const resizeVisualizer = (width: number, height: number) => { - resizeCanvas(); - visualizer.resize(width, height); - }; - - resizeVisualizer(canvas.width, canvas.height); - const visualizerContainerObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - resizeVisualizer(entry.contentRect.width, entry.contentRect.height); - } - }); - visualizerContainerObserver.observe(visualizerContainer); - - visualizer.render(); }, - { passive: true }, - ); -}; + }; +}); diff --git a/src/plugins/visualizer/visualizers/butterchurn.ts b/src/plugins/visualizer/visualizers/butterchurn.ts index 6d3b070e..12c8fd31 100644 --- a/src/plugins/visualizer/visualizers/butterchurn.ts +++ b/src/plugins/visualizer/visualizers/butterchurn.ts @@ -3,7 +3,7 @@ import ButterchurnPresets from 'butterchurn-presets'; import { Visualizer } from './visualizer'; -import type { ConfigType } from '../../../config/dynamic'; +import type { VisualizerPluginConfig } from '../index'; class ButterchurnVisualizer extends Visualizer { name = 'butterchurn'; @@ -18,7 +18,7 @@ class ButterchurnVisualizer extends Visualizer { canvas: HTMLCanvasElement, audioNode: GainNode, stream: MediaStream, - options: ConfigType<'visualizer'>, + options: VisualizerPluginConfig, ) { super( audioContext, diff --git a/src/plugins/visualizer/visualizers/visualizer.ts b/src/plugins/visualizer/visualizers/visualizer.ts index dd8ea84d..12972dca 100644 --- a/src/plugins/visualizer/visualizers/visualizer.ts +++ b/src/plugins/visualizer/visualizers/visualizer.ts @@ -1,4 +1,4 @@ -import type { ConfigType } from '../../../config/dynamic'; +import type { VisualizerPluginConfig } from '../index'; export abstract class Visualizer { /** @@ -14,7 +14,7 @@ export abstract class Visualizer { _canvas: HTMLCanvasElement, _audioNode: GainNode, _stream: MediaStream, - _options: ConfigType<'visualizer'>, + _options: VisualizerPluginConfig, ) {} abstract resize(width: number, height: number): void; diff --git a/src/plugins/visualizer/visualizers/vudio.ts b/src/plugins/visualizer/visualizers/vudio.ts index 3b296eda..64167813 100644 --- a/src/plugins/visualizer/visualizers/vudio.ts +++ b/src/plugins/visualizer/visualizers/vudio.ts @@ -2,7 +2,7 @@ import Vudio from 'vudio/umd/vudio'; import { Visualizer } from './visualizer'; -import type { ConfigType } from '../../../config/dynamic'; +import type { VisualizerPluginConfig } from '../index'; class VudioVisualizer extends Visualizer { name = 'vudio'; @@ -16,7 +16,7 @@ class VudioVisualizer extends Visualizer { canvas: HTMLCanvasElement, audioNode: GainNode, stream: MediaStream, - options: ConfigType<'visualizer'>, + options: VisualizerPluginConfig, ) { super( audioContext, diff --git a/src/plugins/visualizer/visualizers/wave.ts b/src/plugins/visualizer/visualizers/wave.ts index fa6d2a70..c1f1c7fd 100644 --- a/src/plugins/visualizer/visualizers/wave.ts +++ b/src/plugins/visualizer/visualizers/wave.ts @@ -2,8 +2,7 @@ import { Wave } from '@foobar404/wave'; import { Visualizer } from './visualizer'; -import type { ConfigType } from '../../../config/dynamic'; - +import type { VisualizerPluginConfig } from '../index'; class WaveVisualizer extends Visualizer { name = 'wave'; @@ -16,7 +15,7 @@ class WaveVisualizer extends Visualizer { canvas: HTMLCanvasElement, audioNode: GainNode, stream: MediaStream, - options: ConfigType<'visualizer'>, + options: VisualizerPluginConfig, ) { super( audioContext, diff --git a/src/preload.ts b/src/preload.ts index dd9e6826..0d0faf39 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -5,26 +5,34 @@ import config from './config'; // eslint-disable-next-line import/order import { preloadPlugins } from 'virtual:PreloadPlugins'; - -import type { ConfigType, OneOfDefaultConfigKey } from './config/dynamic'; - -export type PluginMapper = { - [Key in OneOfDefaultConfigKey]?: ( - Type extends 'renderer' ? (options: ConfigType) => (Promise | void) : - Type extends 'preload' ? () => (Promise | void) : - never - ) -}; +import { PluginBaseConfig, PluginContext, PreloadPluginFactory } from './plugins/utils/builder'; const enabledPluginNameAndOptions = config.plugins.getEnabled(); -enabledPluginNameAndOptions.forEach(async ([plugin, options]) => { - if (Object.hasOwn(preloadPlugins, plugin)) { - const handler = preloadPlugins[plugin]; +const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], +>(name: Key): PluginContext => ({ + getConfig: () => config.get(`plugins.${name}`) as unknown as Config, + setConfig: (newConfig) => { + config.setPartial(`plugins.${name}`, newConfig); + }, +}); + + +const preloadedPluginList = []; + +enabledPluginNameAndOptions.forEach(async ([id]) => { + if (Object.hasOwn(preloadPlugins, id)) { + const factory = (preloadPlugins as Record>)[id]; + try { - await handler?.(options); + const context = createContext(id); + const plugin = await factory(context); + plugin.onLoad?.(); + preloadedPluginList.push(plugin); } catch (error) { - console.error(`Error in plugin "${plugin}": ${String(error)}`); + console.error('[YTMusic]', `Cannot load preload plugin "${id}": ${String(error)}`); } } }); diff --git a/vite-plugins/plugin-virtual-module-generator.ts b/vite-plugins/plugin-virtual-module-generator.ts index d9316f4f..c2729d37 100644 --- a/vite-plugins/plugin-virtual-module-generator.ts +++ b/vite-plugins/plugin-virtual-module-generator.ts @@ -27,7 +27,7 @@ export const pluginVirtualModuleGenerator = (mode: PluginType) => { .filter(({ name, path }) => { if (name.startsWith('utils')) return false; - return existsSync(resolve(path, `${mode}.ts`)); + return existsSync(resolve(path, `${mode}.ts`)) || (mode !== 'index' && existsSync(resolve(path, `${mode}`, 'index.ts'))); }); console.log('converted plugin list');