From b6e7e75ae80c4a7868356e389e233fc1ab1b079a Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Thu, 9 Nov 2023 12:43:41 +0900 Subject: [PATCH 01/79] plugin system poc --- src/plugins/utils/common/index.ts | 1 + src/plugins/utils/common/types.ts | 29 +++++++++++++++++++++++++++++ src/plugins/utils/main/index.ts | 1 + src/plugins/utils/main/types.ts | 7 +++++++ src/plugins/utils/renderer/types.ts | 3 +++ 5 files changed, 41 insertions(+) create mode 100644 src/plugins/utils/common/index.ts create mode 100644 src/plugins/utils/common/types.ts create mode 100644 src/plugins/utils/main/types.ts create mode 100644 src/plugins/utils/renderer/types.ts diff --git a/src/plugins/utils/common/index.ts b/src/plugins/utils/common/index.ts new file mode 100644 index 00000000..fcb073fe --- /dev/null +++ b/src/plugins/utils/common/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/plugins/utils/common/types.ts b/src/plugins/utils/common/types.ts new file mode 100644 index 00000000..e37ac611 --- /dev/null +++ b/src/plugins/utils/common/types.ts @@ -0,0 +1,29 @@ +import type { BrowserWindow } from 'electron'; + +export interface Config { + enabled: boolean; +} + +export interface Plugin { + name: string; + description: string; + config: ConfigType; +} + +export interface RendererPlugin extends Plugin { + onEnable: (config: ConfigType) => void; +} + +export interface MainPlugin extends Plugin { + onEnable: (window: BrowserWindow, config: ConfigType) => string; +} + +export interface PreloadPlugin extends Plugin { + onEnable: (config: ConfigType) => void; +} + +export interface MenuPlugin extends Plugin { + onEnable: (config: ConfigType) => void; +} + +export const defineConfig = (config: ConfigType) => config; diff --git a/src/plugins/utils/main/index.ts b/src/plugins/utils/main/index.ts index e185a90a..64e2016c 100644 --- a/src/plugins/utils/main/index.ts +++ b/src/plugins/utils/main/index.ts @@ -1,3 +1,4 @@ export * from './css'; export * from './fs'; export * from './plugin'; +export * from './types'; diff --git a/src/plugins/utils/main/types.ts b/src/plugins/utils/main/types.ts new file mode 100644 index 00000000..18e66989 --- /dev/null +++ b/src/plugins/utils/main/types.ts @@ -0,0 +1,7 @@ +import type { Config, MainPlugin, MenuPlugin, PreloadPlugin } from '../common'; + +export const defineMainPlugin = (plugin: MainPlugin) => plugin; + +export const definePreloadPlugin = (plugin: PreloadPlugin) => plugin; + +export const defineMenuPlugin = (plugin: MenuPlugin) => plugin; diff --git a/src/plugins/utils/renderer/types.ts b/src/plugins/utils/renderer/types.ts new file mode 100644 index 00000000..38f6b9ab --- /dev/null +++ b/src/plugins/utils/renderer/types.ts @@ -0,0 +1,3 @@ +import type { Config, RendererPlugin } from '../common'; + +export const defineRendererPlugin = (plugin: RendererPlugin) => plugin; From afe6accab89ad71d23604eb9ab77bf58b2932f27 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Thu, 9 Nov 2023 22:54:58 +0900 Subject: [PATCH 02/79] refactor(plugin): new plugin system poc --- src/plugins/ambient-mode/config.ts | 4 - src/plugins/ambient-mode/index.ts | 36 ++++ src/plugins/ambient-mode/main.ts | 14 -- src/plugins/ambient-mode/menu.ts | 36 ++-- src/plugins/ambient-mode/renderer.ts | 309 ++++++++++++++------------- src/plugins/utils/builder.ts | 61 ++++++ src/plugins/utils/common/types.ts | 7 +- src/plugins/utils/renderer/types.ts | 3 - src/plugins/utils/types.ts | 1 + 9 files changed, 279 insertions(+), 192 deletions(-) delete mode 100644 src/plugins/ambient-mode/config.ts create mode 100644 src/plugins/ambient-mode/index.ts delete mode 100644 src/plugins/ambient-mode/main.ts create mode 100644 src/plugins/utils/builder.ts delete mode 100644 src/plugins/utils/renderer/types.ts create mode 100644 src/plugins/utils/types.ts diff --git a/src/plugins/ambient-mode/config.ts b/src/plugins/ambient-mode/config.ts deleted file mode 100644 index 6ec7cc95..00000000 --- a/src/plugins/ambient-mode/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PluginConfig } from '../../config/dynamic'; - -const config = new PluginConfig('ambient-mode'); -export default config; diff --git a/src/plugins/ambient-mode/index.ts b/src/plugins/ambient-mode/index.ts new file mode 100644 index 00000000..be7c8790 --- /dev/null +++ b/src/plugins/ambient-mode/index.ts @@ -0,0 +1,36 @@ +import style from './style.css'; + +import { createPluginBuilder } from '../utils/builder'; + +export type AmbientModePluginConfig = { + enabled: boolean; + quality: number; + buffer: number; + interpolationTime: number; + blur: number; + size: number; + opacity: number; + fullscreen: boolean; +}; +const builder = createPluginBuilder('ambient-mode', { + name: 'Ambient Mode', + config: { + enabled: false, + quality: 50, + buffer: 30, + interpolationTime: 1500, + blur: 100, + size: 100, + opacity: 1, + fullscreen: false, + } as AmbientModePluginConfig, + styles: [style], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/ambient-mode/main.ts b/src/plugins/ambient-mode/main.ts deleted file mode 100644 index ab1bb380..00000000 --- a/src/plugins/ambient-mode/main.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import config from './config'; -import style from './style.css'; - -import { injectCSS } from '../utils/main'; - -export default (win: BrowserWindow) => { - config.subscribeAll((newConfig) => { - win.webContents.send('ambient-mode:config-change', newConfig); - }); - - injectCSS(win.webContents, style); -}; diff --git a/src/plugins/ambient-mode/menu.ts b/src/plugins/ambient-mode/menu.ts index 18a4e8da..2345f7ac 100644 --- a/src/plugins/ambient-mode/menu.ts +++ b/src/plugins/ambient-mode/menu.ts @@ -1,6 +1,4 @@ -import config from './config'; - -import { MenuTemplate } from '../../menu'; +import builder from './'; const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000]; const qualityList = [10, 25, 50, 100, 200, 500, 1000]; @@ -9,15 +7,15 @@ const bufferList = [1, 5, 10, 20, 30]; const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500]; const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]; -export default (): MenuTemplate => [ +export default builder.createMenu(({ getConfig, setConfig }) => ([ { label: 'Smoothness transition', submenu: interpolationTimeList.map((interpolationTime) => ({ label: `During ${interpolationTime / 1000}s`, type: 'radio', - checked: config.get('interpolationTime') === interpolationTime, + checked: getConfig().interpolationTime === interpolationTime, click() { - config.set('interpolationTime', interpolationTime); + setConfig({ interpolationTime }); }, })), }, @@ -26,9 +24,9 @@ export default (): MenuTemplate => [ submenu: qualityList.map((quality) => ({ label: `${quality} pixels`, type: 'radio', - checked: config.get('quality') === quality, + checked: getConfig().quality === quality, click() { - config.set('quality', quality); + setConfig({ quality }); }, })), }, @@ -37,9 +35,9 @@ export default (): MenuTemplate => [ submenu: sizeList.map((size) => ({ label: `${size}%`, type: 'radio', - checked: config.get('size') === size, + checked: getConfig().size === size, click() { - config.set('size', size); + setConfig({ size }); }, })), }, @@ -48,9 +46,9 @@ export default (): MenuTemplate => [ submenu: bufferList.map((buffer) => ({ label: `${buffer}`, type: 'radio', - checked: config.get('buffer') === buffer, + checked: getConfig().buffer === buffer, click() { - config.set('buffer', buffer); + setConfig({ buffer }); }, })), }, @@ -59,9 +57,9 @@ export default (): MenuTemplate => [ submenu: opacityList.map((opacity) => ({ label: `${opacity * 100}%`, type: 'radio', - checked: config.get('opacity') === opacity, + checked: getConfig().opacity === opacity, click() { - config.set('opacity', opacity); + setConfig({ opacity }); }, })), }, @@ -70,18 +68,18 @@ export default (): MenuTemplate => [ submenu: blurAmountList.map((blur) => ({ label: `${blur} pixels`, type: 'radio', - checked: config.get('blur') === blur, + checked: getConfig().blur === blur, click() { - config.set('blur', blur); + setConfig({ blur }); }, })), }, { label: 'Using fullscreen', type: 'checkbox', - checked: config.get('fullscreen'), + checked: getConfig().fullscreen, click(item) { - config.set('fullscreen', item.checked); + setConfig({ fullscreen: item.checked }); }, }, -]; +])); diff --git a/src/plugins/ambient-mode/renderer.ts b/src/plugins/ambient-mode/renderer.ts index 227686fe..2f2306ba 100644 --- a/src/plugins/ambient-mode/renderer.ts +++ b/src/plugins/ambient-mode/renderer.ts @@ -1,95 +1,163 @@ -import type { ConfigType } from '../../config/dynamic'; +import builder from './index'; -export default (config: ConfigType<'ambient-mode'>) => { - let interpolationTime = config.interpolationTime; // interpolation time (ms) - let buffer = config.buffer; // frame - let qualityRatio = config.quality; // width size (pixel) - let sizeRatio = config.size / 100; // size ratio (percent) - let blur = config.blur; // blur (pixel) - let opacity = config.opacity; // opacity (percent) - let isFullscreen = config.fullscreen; // fullscreen (boolean) +export default builder.createRenderer(({ getConfig }) => { + const initConfigData = getConfig(); - 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; - - 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(() => { - 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); - context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1 - context.putImageData(lastImageData, 0, 0); - 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`); - blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`); - blurCanvas.style.setProperty('--blur', `${blur}px`); - blurCanvas.style.setProperty('--opacity', `${opacity}`); - }; - - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'attributes') { + let interpolationTime = initConfigData.interpolationTime; + let buffer = initConfigData.buffer; + let qualityRatio =initConfigData.quality; + let sizeRatio = initConfigData.size / 100; + 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; + + 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(() => { + 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); + context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1 + context.putImageData(lastImageData, 0, 0); + 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`); + blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`); + blurCanvas.style.setProperty('--blur', `${blur}px`); + blurCanvas.style.setProperty('--opacity', `${opacity}`); + }; + update = applyVideoAttributes; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes') { + applyVideoAttributes(); + } + }); + }); + const resizeObserver = new ResizeObserver(() => { applyVideoAttributes(); + }); + + /* hooking */ + let canvasInterval: NodeJS.Timeout | null = null; + canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer))); + applyVideoAttributes(); + observer.observe(songVideo, { attributes: true }); + resizeObserver.observe(songVideo); + window.addEventListener('resize', applyVideoAttributes); + + const onPause = () => { + if (canvasInterval) clearInterval(canvasInterval); + canvasInterval = null; + }; + const onPlay = () => { + if (canvasInterval) clearInterval(canvasInterval); + canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer))); + }; + 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') { + const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open'); + if (isPageOpen) { + unregister?.(); + unregister = injectBlurVideo() ?? null; + } else { + unregister?.(); + unregister = null; + } + } } }); - }); - const resizeObserver = new ResizeObserver(() => { - applyVideoAttributes(); - }); - const onConfigSync = (_: Electron.IpcRendererEvent, newConfig: ConfigType<'ambient-mode'>) => { + + 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; @@ -98,68 +166,7 @@ export default (config: ConfigType<'ambient-mode'>) => { if (typeof newConfig.opacity === 'number') opacity = newConfig.opacity; if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen; - applyVideoAttributes(); - }; - window.ipcRenderer.on('ambient-mode:config-change', onConfigSync); - - /* hooking */ - let canvasInterval: NodeJS.Timeout | null = null; - canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer))); - applyVideoAttributes(); - observer.observe(songVideo, { attributes: true }); - resizeObserver.observe(songVideo); - window.addEventListener('resize', applyVideoAttributes); - - const onPause = () => { - if (canvasInterval) clearInterval(canvasInterval); - canvasInterval = null; - }; - const onPlay = () => { - if (canvasInterval) clearInterval(canvasInterval); - canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer))); - }; - 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.ipcRenderer.removeListener('ambient-mode:config-change', onConfigSync); - window.removeEventListener('resize', applyVideoAttributes); - - wrapper.removeChild(blurCanvas); - }; + update?.(); + }, }; - - - 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') { - const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open'); - if (isPageOpen) { - unregister?.(); - unregister = injectBlurVideo() ?? null; - } else { - unregister?.(); - unregister = null; - } - } - } - }); - - if (playerPage) { - observer.observe(playerPage, { attributes: true }); - } -}; +}); diff --git a/src/plugins/utils/builder.ts b/src/plugins/utils/builder.ts new file mode 100644 index 00000000..5813002c --- /dev/null +++ b/src/plugins/utils/builder.ts @@ -0,0 +1,61 @@ +import type { + BrowserWindow, + MenuItemConstructorOptions, +} from 'electron'; + +export type PluginBaseConfig = { + enabled: boolean; +}; +export type BasePlugin = { + onLoad?: () => void; + onConfigChange?: (newConfig: Config) => void; +} +export type RendererPlugin = BasePlugin; +export type MainPlugin = Omit, 'onLoad'> & { + onLoad?: (window: BrowserWindow) => void; +}; +export type PreloadPlugin = BasePlugin; + +type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; +export type PluginContext = { + getConfig: () => Config; + setConfig: (config: DeepPartial) => void; + + send: (event: string, ...args: unknown[]) => void; + on: (event: string, listener: (...args: unknown[]) => void) => void; +}; + +type IF = (args: T) => T; +export type PluginBuilder = { + createRenderer: IF<(context: PluginContext) => RendererPlugin>; + createMain: IF<(context: PluginContext) => MainPlugin>; + createPreload: IF<(context: PluginContext) => PreloadPlugin>; + createMenu: IF<(context: PluginContext) => MenuItemConstructorOptions[]>; + + id: ID; + config: Config; + name?: string; + styles?: string[]; +}; +export type PluginBuilderOptions = { + name?: string; + + config: Config; + styles?: string[]; +} +export const createPluginBuilder = ( + id: ID, + options: PluginBuilderOptions, +): PluginBuilder => ({ + createRenderer: (plugin) => plugin, + createMain: (plugin) => plugin, + createPreload: (plugin) => plugin, + createMenu: (plugin) => plugin, + + id, + name: options.name, + config: options.config, + styles: options.styles, +}); diff --git a/src/plugins/utils/common/types.ts b/src/plugins/utils/common/types.ts index e37ac611..1a0ef5a9 100644 --- a/src/plugins/utils/common/types.ts +++ b/src/plugins/utils/common/types.ts @@ -26,4 +26,9 @@ export interface MenuPlugin extends Plugin void; } -export const defineConfig = (config: ConfigType) => config; +const defaultPluginConfig: Record = {}; +export const definePluginConfig = (id: string, defaultValue: T): T => { + defaultPluginConfig[id] = defaultValue; + + return defaultValue; +}; diff --git a/src/plugins/utils/renderer/types.ts b/src/plugins/utils/renderer/types.ts deleted file mode 100644 index 38f6b9ab..00000000 --- a/src/plugins/utils/renderer/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Config, RendererPlugin } from '../common'; - -export const defineRendererPlugin = (plugin: RendererPlugin) => plugin; diff --git a/src/plugins/utils/types.ts b/src/plugins/utils/types.ts new file mode 100644 index 00000000..a51a95a9 --- /dev/null +++ b/src/plugins/utils/types.ts @@ -0,0 +1 @@ +export type PluginConfig = PluginBuilderList[T]['config']; From e0e17cac99920589d31113f34539a7a87e621d08 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Fri, 10 Nov 2023 21:32:05 +0900 Subject: [PATCH 03/79] feat(plugin): migrating plugins to new plugin system (WIP) Co-authored-by: JellyBrick --- src/config/index.ts | 4 + src/index.ts | 84 +++++++ src/menu.ts | 61 ++++- src/plugins/album-color-theme/index.ts | 19 ++ src/plugins/album-color-theme/main.ts | 9 - src/plugins/album-color-theme/renderer.ts | 237 +++++++++--------- src/plugins/ambient-mode/index.ts | 2 +- src/plugins/ambient-mode/menu.ts | 154 ++++++------ src/plugins/ambient-mode/renderer.ts | 6 +- src/plugins/quality-changer/index.ts | 16 ++ src/plugins/quality-changer/main.ts | 28 ++- src/plugins/quality-changer/renderer.ts | 58 +++-- src/plugins/utils/builder.ts | 35 ++- src/plugins/utils/main/css.ts | 1 + src/renderer.ts | 60 ++++- src/virtual-module.d.ts | 22 +- src/youtube-music.d.ts | 6 + .../plugin-virtual-module-generator.ts | 9 +- 18 files changed, 538 insertions(+), 273 deletions(-) create mode 100644 src/plugins/album-color-theme/index.ts delete mode 100644 src/plugins/album-color-theme/main.ts create mode 100644 src/plugins/quality-changer/index.ts diff --git a/src/config/index.ts b/src/config/index.ts index a517186f..cd44b634 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -10,6 +10,9 @@ import { restart } from '../providers/app-controls'; const set = (key: string, value: unknown) => { store.set(key, value); }; +const setPartial = (value: object) => { + store.set(value); +}; function setMenuOption(key: string, value: unknown) { set(key, value); @@ -43,6 +46,7 @@ export default { defaultConfig, get, set, + setPartial, setMenuOption, edit: () => store.openInEditor(), watch(cb: Parameters[1]) { diff --git a/src/index.ts b/src/index.ts index 1d2db004..76e35bd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,10 +22,14 @@ import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/ // eslint-disable-next-line import/order import { mainPlugins } from 'virtual:MainPlugins'; +import ambientModeMainPluginBuilder from './plugins/ambient-mode/index'; +import qualityChangerMainPluginBuilder from './plugins/quality-changer/index'; +import qualityChangerMainPlugin from './plugins/quality-changer/main'; import { setOptions as pipSetOptions } from './plugins/picture-in-picture/main'; import youtubeMusicCSS from './youtube-music.css'; +import { MainPlugin, PluginBaseConfig, MainPluginContext } from './plugins/utils/builder'; // Catch errors and log them unhandled({ @@ -96,6 +100,34 @@ if (is.windows()) { ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); +const pluginBuilderList = [ + ['ambient-mode', ambientModeMainPluginBuilder] as const, + ['quality-changer', qualityChangerMainPluginBuilder] as const, +]; + +const mainPluginList = [ + ['quality-changer', qualityChangerMainPlugin] as const, +]; +const initHook = (win: BrowserWindow) => { + ipcMain.handle('get-config', (_, name: string) => config.get(`plugins.${name}` as never)); + ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial({ + plugins: { + [name]: obj, + } + })); + + config.watch((newValue) => { + const value = newValue as Record; + const target = pluginBuilderList.find(([name]) => name in value); + + if (target) { + win.webContents.send('config-changed', target[0], value[target[0]]); + console.log('config-changed', target[0], value[target[0]]); + } + }); +}; + +const loadedPluginList: [string, MainPlugin][] = []; async function loadPlugins(win: BrowserWindow) { injectCSS(win.webContents, youtubeMusicCSS); // Load user CSS @@ -121,7 +153,58 @@ async function loadPlugins(win: BrowserWindow) { } }); + + const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], + >(name: Key): MainPluginContext => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + getConfig: () => config.get(`plugins.${name}`) as unknown as Config, + setConfig: (newConfig) => { + config.setPartial({ + plugins: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + [name]: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + ...config.get(`plugins.${name}`), + ...newConfig, + }, + }, + }); + + return Promise.resolve(); + }, + + send: (event: string, ...args: unknown[]) => { + win.webContents.send(event, ...args); + }, + handle: (event: string, listener) => { + ipcMain.handle(event, async (_, ...args) => listener(...args as never)); + }, + }); + + for (const [plugin, options] of config.plugins.getEnabled()) { + const builderTarget = pluginBuilderList.find(([name]) => name === plugin); + const mainPluginTarget = mainPluginList.find(([name]) => name === plugin); + + if (mainPluginTarget) { + const mainPlugin = mainPluginTarget[1]; + const context = createContext(mainPluginTarget[0]); + const plugin = await mainPlugin(context); + loadedPluginList.push([mainPluginTarget[0], plugin]); + plugin.onLoad?.(win); + } + + if (builderTarget) { + const builder = builderTarget[1]; + builder.styles?.forEach((style) => { + injectCSS(win.webContents, style); + }); + } + try { if (Object.hasOwn(mainPlugins, plugin)) { console.log('Loaded plugin - ' + plugin); @@ -174,6 +257,7 @@ async function createMainWindow() { : 'default'), autoHideMenuBar: config.get('options.hideMenu'), }); + initHook(win); await loadPlugins(win); if (windowPosition) { diff --git a/src/menu.ts b/src/menu.ts index f302c12d..cdafb662 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,5 +1,5 @@ import is from 'electron-is'; -import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron'; +import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu } from 'electron'; import prompt from 'custom-electron-prompt'; import { restart } from './providers/app-controls'; @@ -10,7 +10,10 @@ import promptOptions from './providers/prompt-options'; // eslint-disable-next-line import/order import { menuPlugins as menuList } from 'virtual:MenuPlugins'; +import ambientModeMenuPlugin from './plugins/ambient-mode/menu'; + import { getAvailablePluginNames } from './plugins/utils/main'; +import { PluginBaseConfig, PluginContext } from './plugins/utils/builder'; export type MenuTemplate = Electron.MenuItemConstructorOptions[]; @@ -43,14 +46,58 @@ export const refreshMenu = (win: BrowserWindow) => { } }; -export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { +export const mainMenuTemplate = async (win: BrowserWindow): Promise => { const innerRefreshMenu = () => refreshMenu(win); + const createContext = (name: string): PluginContext => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + getConfig: () => config.get(`plugins.${name}`) as unknown as Config, + setConfig: (newConfig) => { + config.setPartial({ + plugins: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + [name]: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + ...config.get(`plugins.${name}`), + ...newConfig, + }, + }, + }); + + return Promise.resolve(); + }, + }); + + const pluginMenus = await Promise.all( + [['ambient-mode', ambientModeMenuPlugin] as const].map(async ([id, plugin]) => { + let pluginLabel = id; + if (betaPlugins.includes(pluginLabel)) { + pluginLabel += ' [beta]'; + } + + if (!config.plugins.isEnabled(id)) { + return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu); + } + + const template = await plugin(createContext(id)); + + return { + label: pluginLabel, + submenu: [ + pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu), + { type: 'separator' }, + ...template, + ], + } satisfies Electron.MenuItemConstructorOptions; + }), + ); return [ { label: 'Plugins', - submenu: - getAvailablePluginNames().map((pluginName) => { + submenu: [ + ...getAvailablePluginNames().map((pluginName) => { let pluginLabel = pluginName; if (betaPlugins.includes(pluginLabel)) { pluginLabel += ' [beta]'; @@ -75,6 +122,8 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { return pluginEnabledMenu(pluginName, pluginLabel); }), + ...pluginMenus, + ], }, { label: 'Options', @@ -402,8 +451,8 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { } ]; }; -export const setApplicationMenu = (win: Electron.BrowserWindow) => { - const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)]; +export const setApplicationMenu = async (win: Electron.BrowserWindow) => { + const menuTemplate: MenuTemplate = [...await mainMenuTemplate(win)]; if (process.platform === 'darwin') { const { name } = app; menuTemplate.unshift({ diff --git a/src/plugins/album-color-theme/index.ts b/src/plugins/album-color-theme/index.ts new file mode 100644 index 00000000..d7b474f4 --- /dev/null +++ b/src/plugins/album-color-theme/index.ts @@ -0,0 +1,19 @@ +import style from './style.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('album-color-theme', { + name: 'Album Color Theme', + config: { + enabled: false, + }, + styles: [style], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/album-color-theme/main.ts b/src/plugins/album-color-theme/main.ts deleted file mode 100644 index 1d313bf0..00000000 --- a/src/plugins/album-color-theme/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import style from './style.css'; - -import { injectCSS } from '../utils/main'; - -export default (win: BrowserWindow) => { - injectCSS(win.webContents, style); -}; diff --git a/src/plugins/album-color-theme/renderer.ts b/src/plugins/album-color-theme/renderer.ts index a07d08fd..e217bf81 100644 --- a/src/plugins/album-color-theme/renderer.ts +++ b/src/plugins/album-color-theme/renderer.ts @@ -1,127 +1,130 @@ import { FastAverageColor } from 'fast-average-color'; -import type { ConfigType } from '../../config/dynamic'; +import builder from './'; -function hexToHSL(H: string) { - // Convert hex to RGB first - let r = 0; - let g = 0; - let b = 0; - if (H.length == 4) { - r = Number('0x' + H[1] + H[1]); - g = Number('0x' + H[2] + H[2]); - b = Number('0x' + H[3] + H[3]); - } else if (H.length == 7) { - r = Number('0x' + H[1] + H[2]); - g = Number('0x' + H[3] + H[4]); - b = Number('0x' + H[5] + H[6]); +export default builder.createRenderer(() => { + function hexToHSL(H: string) { + // Convert hex to RGB first + let r = 0; + let g = 0; + let b = 0; + if (H.length == 4) { + r = Number('0x' + H[1] + H[1]); + g = Number('0x' + H[2] + H[2]); + b = Number('0x' + H[3] + H[3]); + } else if (H.length == 7) { + r = Number('0x' + H[1] + H[2]); + g = Number('0x' + H[3] + H[4]); + b = Number('0x' + H[5] + H[6]); + } + // Then to HSL + r /= 255; + g /= 255; + b /= 255; + const cmin = Math.min(r, g, b); + const cmax = Math.max(r, g, b); + const delta = cmax - cmin; + let h: number; + let s: number; + let l: number; + + if (delta == 0) { + h = 0; + } else if (cmax == r) { + h = ((g - b) / delta) % 6; + } else if (cmax == g) { + h = ((b - r) / delta) + 2; + } 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]; } - // Then to HSL - r /= 255; - g /= 255; - b /= 255; - const cmin = Math.min(r, g, b); - const cmax = Math.max(r, g, b); - const delta = cmax - cmin; - let h: number; - let s: number; - let l: number; - - if (delta == 0) { - h = 0; - } else if (cmax == r) { - h = ((g - b) / delta) % 6; - } else if (cmax == g) { - h = ((b - r) / delta) + 2; - } else { - h = ((r - g) / delta) + 4; + + 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'); + const navBarBackground = document.querySelector('#nav-bar-background'); + const ytmusicPlayerBar = document.querySelector('ytmusic-player-bar'); + const playerBarBackground = document.querySelector('#player-bar-background'); + const sidebarBig = document.querySelector('#guide-wrapper'); + const sidebarSmall = document.querySelector('#mini-guide-background'); + const ytmusicAppLayout = document.querySelector('#layout'); - 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}%)`; - } -} - -export default (_: ConfigType<'album-color-theme'>) => { - // updated elements - const playerPage = document.querySelector('#player-page'); - const navBarBackground = document.querySelector('#nav-bar-background'); - const ytmusicPlayerBar = document.querySelector('ytmusic-player-bar'); - const playerBarBackground = document.querySelector('#player-bar-background'); - const sidebarBig = document.querySelector('#guide-wrapper'); - const sidebarSmall = document.querySelector('#mini-guide-background'); - const ytmusicAppLayout = document.querySelector('#layout'); - - const observer = new MutationObserver((mutationsList) => { - for (const mutation of mutationsList) { - if (mutation.type === 'attributes') { - const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open'); - if (isPageOpen) { - changeElementColor(sidebarSmall, hue, saturation, lightness - 30); - } else { - if (sidebarSmall) { - sidebarSmall.style.backgroundColor = 'black'; + const observer = new MutationObserver((mutationsList) => { + for (const mutation of mutationsList) { + if (mutation.type === 'attributes') { + const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open'); + if (isPageOpen) { + changeElementColor(sidebarSmall, hue, saturation, lightness - 30); + } else { + if (sidebarSmall) { + sidebarSmall.style.backgroundColor = 'black'; + } + } } } + }); + + if (playerPage) { + observer.observe(playerPage, { attributes: true }); } + + document.addEventListener('apiLoaded', (apiEvent) => { + const fastAverageColor = new FastAverageColor(); + + apiEvent.detail.addEventListener('videodatachange', (name: string) => { + if (name === 'dataloaded') { + const playerResponse = apiEvent.detail.getPlayerResponse(); + const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0); + if (thumbnail) { + fastAverageColor.getColorAsync(thumbnail.url) + .then((albumColor) => { + if (albumColor) { + [hue, saturation, lightness] = hexToHSL(albumColor.hex); + changeElementColor(playerPage, hue, saturation, lightness - 30); + changeElementColor(navBarBackground, hue, saturation, lightness - 15); + changeElementColor(ytmusicPlayerBar, hue, saturation, lightness - 15); + changeElementColor(playerBarBackground, hue, saturation, lightness - 15); + changeElementColor(sidebarBig, hue, saturation, lightness - 15); + if (ytmusicAppLayout?.hasAttribute('player-page-open')) { + changeElementColor(sidebarSmall, hue, saturation, lightness - 30); + } + const ytRightClickList = document.querySelector('tp-yt-paper-listbox'); + changeElementColor(ytRightClickList, hue, saturation, lightness - 15); + } else { + if (playerPage) { + playerPage.style.backgroundColor = '#000000'; + } + } + }) + .catch((e) => console.error(e)); + } + } + }); + }); } - }); - - if (playerPage) { - observer.observe(playerPage, { attributes: true }); - } - - document.addEventListener('apiLoaded', (apiEvent) => { - const fastAverageColor = new FastAverageColor(); - - apiEvent.detail.addEventListener('videodatachange', (name: string) => { - if (name === 'dataloaded') { - const playerResponse = apiEvent.detail.getPlayerResponse(); - const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0); - if (thumbnail) { - fastAverageColor.getColorAsync(thumbnail.url) - .then((albumColor) => { - if (albumColor) { - [hue, saturation, lightness] = hexToHSL(albumColor.hex); - changeElementColor(playerPage, hue, saturation, lightness - 30); - changeElementColor(navBarBackground, hue, saturation, lightness - 15); - changeElementColor(ytmusicPlayerBar, hue, saturation, lightness - 15); - changeElementColor(playerBarBackground, hue, saturation, lightness - 15); - changeElementColor(sidebarBig, hue, saturation, lightness - 15); - if (ytmusicAppLayout?.hasAttribute('player-page-open')) { - changeElementColor(sidebarSmall, hue, saturation, lightness - 30); - } - const ytRightClickList = document.querySelector('tp-yt-paper-listbox'); - changeElementColor(ytRightClickList, hue, saturation, lightness - 15); - } else { - if (playerPage) { - playerPage.style.backgroundColor = '#000000'; - } - } - }) - .catch((e) => console.error(e)); - } - } - }); - }); -}; + }; +}); diff --git a/src/plugins/ambient-mode/index.ts b/src/plugins/ambient-mode/index.ts index be7c8790..bc2f25c2 100644 --- a/src/plugins/ambient-mode/index.ts +++ b/src/plugins/ambient-mode/index.ts @@ -1,4 +1,4 @@ -import style from './style.css'; +import style from './style.css?inline'; import { createPluginBuilder } from '../utils/builder'; diff --git a/src/plugins/ambient-mode/menu.ts b/src/plugins/ambient-mode/menu.ts index 2345f7ac..9909a7dc 100644 --- a/src/plugins/ambient-mode/menu.ts +++ b/src/plugins/ambient-mode/menu.ts @@ -7,79 +7,83 @@ const bufferList = [1, 5, 10, 20, 30]; const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500]; const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]; -export default builder.createMenu(({ getConfig, setConfig }) => ([ - { - label: 'Smoothness transition', - submenu: interpolationTimeList.map((interpolationTime) => ({ - label: `During ${interpolationTime / 1000}s`, - type: 'radio', - checked: getConfig().interpolationTime === interpolationTime, - click() { - setConfig({ interpolationTime }); - }, - })), - }, - { - label: 'Quality', - submenu: qualityList.map((quality) => ({ - label: `${quality} pixels`, - type: 'radio', - checked: getConfig().quality === quality, - click() { - setConfig({ quality }); - }, - })), - }, - { - label: 'Size', - submenu: sizeList.map((size) => ({ - label: `${size}%`, - type: 'radio', - checked: getConfig().size === size, - click() { - setConfig({ size }); - }, - })), - }, - { - label: 'Buffer', - submenu: bufferList.map((buffer) => ({ - label: `${buffer}`, - type: 'radio', - checked: getConfig().buffer === buffer, - click() { - setConfig({ buffer }); - }, - })), - }, - { - label: 'Opacity', - submenu: opacityList.map((opacity) => ({ - label: `${opacity * 100}%`, - type: 'radio', - checked: getConfig().opacity === opacity, - click() { - setConfig({ opacity }); - }, - })), - }, - { - label: 'Blur amount', - submenu: blurAmountList.map((blur) => ({ - label: `${blur} pixels`, - type: 'radio', - checked: getConfig().blur === blur, - click() { - setConfig({ blur }); - }, - })), - }, - { - label: 'Using fullscreen', - type: 'checkbox', - checked: getConfig().fullscreen, - click(item) { - setConfig({ fullscreen: item.checked }); +export default builder.createMenu(async ({ getConfig, setConfig }) => { + const config = await getConfig(); + + return [ + { + label: 'Smoothness transition', + submenu: interpolationTimeList.map((interpolationTime) => ({ + label: `During ${interpolationTime / 1000}s`, + type: 'radio', + checked: config.interpolationTime === interpolationTime, + click() { + setConfig({ interpolationTime }); + }, + })), }, - }, -])); + { + label: 'Quality', + submenu: qualityList.map((quality) => ({ + label: `${quality} pixels`, + type: 'radio', + checked: config.quality === quality, + click() { + setConfig({ quality }); + }, + })), + }, + { + label: 'Size', + submenu: sizeList.map((size) => ({ + label: `${size}%`, + type: 'radio', + checked: config.size === size, + click() { + setConfig({ size }); + }, + })), + }, + { + label: 'Buffer', + submenu: bufferList.map((buffer) => ({ + label: `${buffer}`, + type: 'radio', + checked: config.buffer === buffer, + click() { + setConfig({ buffer }); + }, + })), + }, + { + label: 'Opacity', + submenu: opacityList.map((opacity) => ({ + label: `${opacity * 100}%`, + type: 'radio', + checked: config.opacity === opacity, + click() { + setConfig({ opacity }); + }, + })), + }, + { + label: 'Blur amount', + submenu: blurAmountList.map((blur) => ({ + label: `${blur} pixels`, + type: 'radio', + checked: config.blur === blur, + click() { + setConfig({ blur }); + }, + })), + }, + { + label: 'Using fullscreen', + type: 'checkbox', + checked: config.fullscreen, + click(item) { + setConfig({ fullscreen: item.checked }); + }, + }, + ]; +}); diff --git a/src/plugins/ambient-mode/renderer.ts b/src/plugins/ambient-mode/renderer.ts index 2f2306ba..ff930ec4 100644 --- a/src/plugins/ambient-mode/renderer.ts +++ b/src/plugins/ambient-mode/renderer.ts @@ -1,7 +1,7 @@ import builder from './index'; -export default builder.createRenderer(({ getConfig }) => { - const initConfigData = getConfig(); +export default builder.createRenderer(async ({ getConfig }) => { + const initConfigData = await getConfig(); let interpolationTime = initConfigData.interpolationTime; let buffer = initConfigData.buffer; @@ -26,6 +26,7 @@ export default builder.createRenderer(({ getConfig }) => { 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'); @@ -39,6 +40,7 @@ export default builder.createRenderer(({ getConfig }) => { if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId); lastEffectWorkId = requestAnimationFrame(() => { + // console.log('context', context); if (!context) return; const width = qualityRatio; diff --git a/src/plugins/quality-changer/index.ts b/src/plugins/quality-changer/index.ts new file mode 100644 index 00000000..6d541920 --- /dev/null +++ b/src/plugins/quality-changer/index.ts @@ -0,0 +1,16 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('quality-changer', { + name: 'Quality Changer', + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/quality-changer/main.ts b/src/plugins/quality-changer/main.ts index aa6d0b36..9286a8d4 100644 --- a/src/plugins/quality-changer/main.ts +++ b/src/plugins/quality-changer/main.ts @@ -1,13 +1,17 @@ -import { ipcMain, dialog, BrowserWindow } from 'electron'; +import { dialog, BrowserWindow } from 'electron'; -export default (win: BrowserWindow) => { - ipcMain.handle('qualityChanger', async (_, qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(win, { - type: 'question', - buttons: qualityLabels, - defaultId: currentIndex, - title: 'Choose Video Quality', - message: 'Choose Video Quality:', - detail: `Current Quality: ${qualityLabels[currentIndex]}`, - cancelId: -1, - })); -}; +import builder from './index'; + +export default builder.createMain(({ handle }) => ({ + onLoad(win: BrowserWindow) { + handle('qualityChanger', async (qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox(win, { + type: 'question', + buttons: qualityLabels, + defaultId: currentIndex, + title: 'Choose Video Quality', + message: 'Choose Video Quality:', + detail: `Current Quality: ${qualityLabels[currentIndex]}`, + cancelId: -1, + })); + } +})); diff --git a/src/plugins/quality-changer/renderer.ts b/src/plugins/quality-changer/renderer.ts index 8f6982e0..79c66c8b 100644 --- a/src/plugins/quality-changer/renderer.ts +++ b/src/plugins/quality-changer/renderer.ts @@ -1,38 +1,50 @@ import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw'; +import builder from './'; + import { ElementFromHtml } from '../utils/renderer'; import { YoutubePlayer } from '../../types/youtube-player'; -function $(selector: string): HTMLElement | null { - return document.querySelector(selector); -} +// export default () => { +// document.addEventListener('apiLoaded', setup, { once: true, passive: true }); +// }; -const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate); +export default builder.createRenderer(({ invoke }) => { + function $(selector: string): HTMLElement | null { + return document.querySelector(selector); + } -function setup(event: CustomEvent) { - const api = event.detail; + const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate); - $('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton); + function setup(event: CustomEvent) { + const api = event.detail; - qualitySettingsButton.addEventListener('click', function chooseQuality() { - setTimeout(() => $('#player')?.click()); + $('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton); - const qualityLevels = api.getAvailableQualityLevels(); + qualitySettingsButton.addEventListener('click', function chooseQuality() { + setTimeout(() => $('#player')?.click()); - const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality()); + const qualityLevels = api.getAvailableQualityLevels(); - window.ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise: { response: number }) => { - if (promise.response === -1) { - return; - } + const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality()); - const newQuality = qualityLevels[promise.response]; - api.setPlaybackQualityRange(newQuality); - api.setPlaybackQuality(newQuality); + 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); + }); }); - }); -} + } -export default () => { - document.addEventListener('apiLoaded', setup, { once: true, passive: true }); -}; + return { + onLoad() { + console.log('qc'); + document.addEventListener('apiLoaded', setup, { once: true, passive: true }); + } + }; +}); diff --git a/src/plugins/utils/builder.ts b/src/plugins/utils/builder.ts index 5813002c..cb032a98 100644 --- a/src/plugins/utils/builder.ts +++ b/src/plugins/utils/builder.ts @@ -19,20 +19,33 @@ export type PreloadPlugin = BasePlugin; type DeepPartial = { [P in keyof T]?: DeepPartial; }; -export type PluginContext = { - getConfig: () => Config; - setConfig: (config: DeepPartial) => void; +type IF = (args: T) => T; +type Promisable = T | Promise; - send: (event: string, ...args: unknown[]) => void; - on: (event: string, listener: (...args: unknown[]) => void) => void; +export type PluginContext = { + getConfig: () => Promise; + setConfig: (config: DeepPartial) => Promise; }; -type IF = (args: T) => T; +export type MainPluginContext = PluginContext & { + send: (event: string, ...args: unknown[]) => void; + handle: (event: string, listener: (...args: Arguments) => Promisable) => void; +}; +export type RendererPluginContext = PluginContext & { + invoke: (event: string, ...args: unknown[]) => Promise; + on: (event: string, listener: (...args: Arguments) => Promisable) => void; +}; + +export type RendererPluginFactory = (context: RendererPluginContext) => Promisable>; +export type MainPluginFactory = (context: MainPluginContext) => Promisable>; +export type PreloadPluginFactory = (context: PluginContext) => Promisable>; +export type MenuPluginFactory = (context: PluginContext) => Promisable; + export type PluginBuilder = { - createRenderer: IF<(context: PluginContext) => RendererPlugin>; - createMain: IF<(context: PluginContext) => MainPlugin>; - createPreload: IF<(context: PluginContext) => PreloadPlugin>; - createMenu: IF<(context: PluginContext) => MenuItemConstructorOptions[]>; + createRenderer: IF>; + createMain: IF>; + createPreload: IF>; + createMenu: IF>; id: ID; config: Config; @@ -48,7 +61,7 @@ export type PluginBuilderOptions( id: ID, options: PluginBuilderOptions, -): PluginBuilder => ({ +): PluginBuilder & PluginBaseConfig> => ({ createRenderer: (plugin) => plugin, createMain: (plugin) => plugin, createPreload: (plugin) => plugin, diff --git a/src/plugins/utils/main/css.ts b/src/plugins/utils/main/css.ts index c7dd40cc..5cd613d7 100644 --- a/src/plugins/utils/main/css.ts +++ b/src/plugins/utils/main/css.ts @@ -7,6 +7,7 @@ export const injectCSS = (webContents: Electron.WebContents, css: string, cb: (( setupCssInjection(webContents); } + console.log('injectCSS', css); cssToInject.set(css, cb); }; diff --git a/src/renderer.ts b/src/renderer.ts index 4397899f..cf2e6a4d 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,10 +1,13 @@ -import setupSongInfo from './providers/song-info-front'; -import { setupSongControls } from './providers/song-controls-front'; -import { startingPages } from './providers/extracted-data'; // eslint-disable-next-line import/order import { rendererPlugins } from 'virtual:RendererPlugins'; +import { PluginBaseConfig, RendererPluginContext } from './plugins/utils/builder'; + +import { startingPages } from './providers/extracted-data'; +import { setupSongControls } from './providers/song-controls-front'; +import setupSongInfo from './providers/song-info-front'; + const enabledPluginNameAndOptions = window.mainConfig.plugins.getEnabled(); let api: Element | null = null; @@ -91,8 +94,41 @@ function onApiLoaded() { } } -(() => { +const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], +>(name: Key): RendererPluginContext => ({ + getConfig: async () => { + return await window.ipcRenderer.invoke('get-config', name) as Config; + }, + setConfig: async (newConfig) => { + await window.ipcRenderer.invoke('set-config', name, newConfig); + }, + + invoke: async (event: string, ...args: unknown[]): Promise => { + return await window.ipcRenderer.invoke(event, ...args) as Return; + }, + on: (event: string, listener) => { + window.ipcRenderer.on(event, async (_, ...args) => listener(...args as never)); + }, +}); + +(async () => { enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => { + if (pluginName === 'ambient-mode') { + const builder = rendererPlugins[pluginName]; + + try { + const context = createContext(pluginName); + const plugin = await builder?.(context); + console.log(plugin); + plugin.onLoad?.(); + } catch (error) { + console.error(`Error in plugin "${pluginName}"`); + console.trace(error); + } + } + if (Object.hasOwn(rendererPlugins, pluginName)) { const handler = rendererPlugins[pluginName]; try { @@ -103,6 +139,22 @@ function onApiLoaded() { } } }); + const rendererPluginList = await Promise.all( + newPluginList.map(async ([id, plugin]) => { + const context = createContext(id); + return [id, await plugin(context)] as const; + }), + ); + + rendererPluginList.forEach(([, plugin]) => plugin.onLoad?.()); + + window.ipcRenderer.on('config-changed', (_event, id: string, newConfig) => { + const plugin = rendererPluginList.find(([pluginId]) => pluginId === id); + + if (plugin) { + plugin[1].onConfigChange?.(newConfig as never); + } + }); // Inject song-info provider setupSongInfo(); diff --git a/src/virtual-module.d.ts b/src/virtual-module.d.ts index 56daf491..f1e6b4d9 100644 --- a/src/virtual-module.d.ts +++ b/src/virtual-module.d.ts @@ -1,26 +1,24 @@ -declare module 'virtual:MainPlugins' { - import type { BrowserWindow } from 'electron'; - import type { ConfigType } from './config/dynamic'; - export const mainPlugins: Record Promise>; +declare module 'virtual:MainPlugins' { + import type { MainPluginFactory } from './plugins/utils/builder'; + + export const mainPlugins: Record; } declare module 'virtual:MenuPlugins' { - import type { BrowserWindow } from 'electron'; - import type { MenuTemplate } from './menu'; - import type { ConfigType } from './config/dynamic'; + import type { MenuPluginFactory } from './plugins/utils/builder'; - export const menuPlugins: Record void) => MenuTemplate>; + export const menuPlugins: Record; } declare module 'virtual:PreloadPlugins' { - import type { ConfigType } from './config/dynamic'; + import type { PreloadPluginFactory } from './plugins/utils/builder'; - export const preloadPlugins: Record Promise>; + export const preloadPlugins: Record; } declare module 'virtual:RendererPlugins' { - import type { ConfigType } from './config/dynamic'; + import type { RendererPluginFactory } from './plugins/utils/builder'; - export const rendererPlugins: Record Promise>; + export const rendererPlugins: Record; } diff --git a/src/youtube-music.d.ts b/src/youtube-music.d.ts index 3eb6e491..d0622920 100644 --- a/src/youtube-music.d.ts +++ b/src/youtube-music.d.ts @@ -29,3 +29,9 @@ declare module '*.css' { export default css; } +declare module '*.css?inline' { + const css: string; + + export default css; +} + diff --git a/vite-plugins/plugin-virtual-module-generator.ts b/vite-plugins/plugin-virtual-module-generator.ts index 4355bff2..320b606a 100644 --- a/vite-plugins/plugin-virtual-module-generator.ts +++ b/vite-plugins/plugin-virtual-module-generator.ts @@ -10,7 +10,14 @@ export const pluginVirtualModuleGenerator = (mode: 'main' | 'preload' | 'rendere const plugins = globSync(`${srcPath}/plugins/*`) .map((path) => ({ name: basename(path), path })) - .filter(({ name, path }) => !name.startsWith('utils') && existsSync(resolve(path, `${mode}.ts`))); + .filter(({ name, path }) => { + if (name.startsWith('utils')) return false; + if (path.includes('ambient-mode')) return false; + if (path.includes('quality')) return false; + + return existsSync(resolve(path, `${mode}.ts`)); + }); + // for test !name.startsWith('ambient-mode') let result = ''; From 5cd1d9abe865f8a1035803ca4e39c95091926593 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sat, 11 Nov 2023 00:03:26 +0900 Subject: [PATCH 04/79] feat(plugin): migrate some plugin (WIP) Co-authored-by: JellyBrick --- electron.vite.config.ts | 1 + src/config/index.ts | 1 + src/index.ts | 76 ++- src/menu.ts | 60 +-- src/plugins/audio-compressor/index.ts | 16 + src/plugins/audio-compressor/renderer.ts | 37 +- src/plugins/blur-nav-bar/index.ts | 19 + src/plugins/blur-nav-bar/main.ts | 9 - src/plugins/bypass-age-restrictions/index.ts | 16 + .../bypass-age-restrictions/renderer.ts | 12 +- src/plugins/in-app-menu/index.ts | 20 + src/plugins/in-app-menu/main.ts | 124 ++--- src/plugins/in-app-menu/menu.ts | 35 +- src/plugins/in-app-menu/renderer.ts | 347 ++++++------ src/plugins/navigation/front.ts | 18 - src/plugins/navigation/index.ts | 19 + src/plugins/navigation/main.ts | 13 - src/plugins/navigation/renderer.ts | 20 + src/plugins/precise-volume/index.ts | 37 ++ src/plugins/precise-volume/main.ts | 41 +- src/plugins/precise-volume/menu.ts | 164 +++--- src/plugins/precise-volume/renderer.ts | 501 +++++++++--------- src/plugins/quality-changer/renderer.ts | 1 - src/plugins/utils/builder.ts | 5 +- src/plugins/utils/main/css.ts | 1 - src/plugins/video-toggle/renderer.ts | 5 +- src/renderer.ts | 85 +-- src/virtual-module.d.ts | 21 +- .../plugin-virtual-module-generator.ts | 30 +- 29 files changed, 938 insertions(+), 796 deletions(-) create mode 100644 src/plugins/audio-compressor/index.ts create mode 100644 src/plugins/blur-nav-bar/index.ts delete mode 100644 src/plugins/blur-nav-bar/main.ts create mode 100644 src/plugins/bypass-age-restrictions/index.ts create mode 100644 src/plugins/in-app-menu/index.ts delete mode 100644 src/plugins/navigation/front.ts create mode 100644 src/plugins/navigation/index.ts delete mode 100644 src/plugins/navigation/main.ts create mode 100644 src/plugins/navigation/renderer.ts create mode 100644 src/plugins/precise-volume/index.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d939b08f..a9ecee02 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ const commonConfig: UserConfig = { plugins: [ viteResolve({ + 'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'), 'virtual:MainPlugins': pluginVirtualModuleGenerator('main'), 'virtual:MenuPlugins': pluginVirtualModuleGenerator('menu'), }), diff --git a/src/config/index.ts b/src/config/index.ts index cd44b634..225efc6a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -11,6 +11,7 @@ const set = (key: string, value: unknown) => { store.set(key, value); }; const setPartial = (value: object) => { + // deepmerge(store.get, value); store.set(value); }; diff --git a/src/index.ts b/src/index.ts index 76e35bd5..e27ed203 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,16 +20,15 @@ import { setupSongInfo } from './providers/song-info'; import { restart, setupAppControls } from './providers/app-controls'; import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler'; -// eslint-disable-next-line import/order +/* eslint-disable import/order */ import { mainPlugins } from 'virtual:MainPlugins'; -import ambientModeMainPluginBuilder from './plugins/ambient-mode/index'; -import qualityChangerMainPluginBuilder from './plugins/quality-changer/index'; -import qualityChangerMainPlugin from './plugins/quality-changer/main'; +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 { MainPlugin, PluginBaseConfig, MainPluginContext } from './plugins/utils/builder'; +import { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder'; // Catch errors and log them unhandled({ @@ -100,16 +99,8 @@ if (is.windows()) { ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); -const pluginBuilderList = [ - ['ambient-mode', ambientModeMainPluginBuilder] as const, - ['quality-changer', qualityChangerMainPluginBuilder] as const, -]; - -const mainPluginList = [ - ['quality-changer', qualityChangerMainPlugin] as const, -]; const initHook = (win: BrowserWindow) => { - ipcMain.handle('get-config', (_, name: string) => config.get(`plugins.${name}` as never)); + ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => config.get(`plugins.${id}` as never) ?? pluginBuilders[id].config); ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial({ plugins: { [name]: obj, @@ -118,11 +109,11 @@ const initHook = (win: BrowserWindow) => { config.watch((newValue) => { const value = newValue as Record; - const target = pluginBuilderList.find(([name]) => name in value); + const id = Object.keys(pluginBuilders).find((id) => id in value); - if (target) { - win.webContents.send('config-changed', target[0], value[target[0]]); - console.log('config-changed', target[0], value[target[0]]); + if (id) { + win.webContents.send('config-changed', id, value[id]); + // console.log('config-changed', id, value[id]); } }); }; @@ -186,36 +177,41 @@ async function loadPlugins(win: BrowserWindow) { }); - for (const [plugin, options] of config.plugins.getEnabled()) { - const builderTarget = pluginBuilderList.find(([name]) => name === plugin); - const mainPluginTarget = mainPluginList.find(([name]) => name === plugin); + for (const [pluginId, options] of config.plugins.getEnabled()) { + const builder = pluginBuilders[pluginId as keyof PluginBuilderList]; + const factory = (mainPlugins as Record>)[pluginId]; - if (mainPluginTarget) { - const mainPlugin = mainPluginTarget[1]; - const context = createContext(mainPluginTarget[0]); - const plugin = await mainPlugin(context); - loadedPluginList.push([mainPluginTarget[0], plugin]); - plugin.onLoad?.(win); - } - - if (builderTarget) { - const builder = builderTarget[1]; + if (builder) { builder.styles?.forEach((style) => { injectCSS(win.webContents, style); + console.log('[YTMusic]', `"${pluginId}" plugin meta data is loaded`); }); } - try { - if (Object.hasOwn(mainPlugins, plugin)) { - console.log('Loaded plugin - ' + plugin); - const handler = mainPlugins[plugin as keyof typeof mainPlugins]; - if (handler) { - await handler(win, options as never); - } + if (factory) { + try { + const context = createContext(pluginId as keyof PluginBuilderList); + const plugin = await factory(context); + loadedPluginList.push([pluginId, plugin]); + plugin.onLoad?.(win); + console.log('[YTMusic]', `"${pluginId}" plugin is loaded`); + } catch (error) { + console.error('[YTMusic]', `Cannot load plugin "${pluginId}"`); + console.trace(error); } - } catch (e) { - console.error(`Failed to load plugin "${plugin}"`, e); } + + // try { + // if (Object.hasOwn(mainPlugins, plugin)) { + // console.log('Loaded plugin - ' + plugin); + // const handler = mainPlugins[plugin as keyof typeof mainPlugins]; + // if (handler) { + // await handler(win, options as never); + // } + // } + // } catch (e) { + // console.error(`Failed to load plugin "${plugin}"`, e); + // } } } diff --git a/src/menu.ts b/src/menu.ts index cdafb662..9d210f3d 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,5 +1,5 @@ import is from 'electron-is'; -import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu } from 'electron'; +import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron'; import prompt from 'custom-electron-prompt'; import { restart } from './providers/app-controls'; @@ -7,13 +7,13 @@ import config from './config'; import { startingPages } from './providers/extracted-data'; import promptOptions from './providers/prompt-options'; -// eslint-disable-next-line import/order +/* eslint-disable import/order */ import { menuPlugins as menuList } from 'virtual:MenuPlugins'; - -import ambientModeMenuPlugin from './plugins/ambient-mode/menu'; +import { pluginBuilders } from 'virtual:PluginBuilders'; +/* eslint-enable import/order */ import { getAvailablePluginNames } from './plugins/utils/main'; -import { PluginBaseConfig, PluginContext } from './plugins/utils/builder'; +import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig, PluginContext } from './plugins/utils/builder'; export type MenuTemplate = Electron.MenuItemConstructorOptions[]; @@ -48,7 +48,7 @@ export const refreshMenu = (win: BrowserWindow) => { export const mainMenuTemplate = async (win: BrowserWindow): Promise => { const innerRefreshMenu = () => refreshMenu(win); - const createContext = (name: string): PluginContext => ({ + const createContext = (name: string): MenuPluginContext => ({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error getConfig: () => config.get(`plugins.${name}`) as unknown as Config, @@ -67,11 +67,13 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise { - let pluginLabel = id; + const availablePlugins = getAvailablePluginNames(); + const menuResult = await Promise.allSettled( + availablePlugins.map(async (id) => { + let pluginLabel = pluginBuilders[id as keyof PluginBuilderList]?.name ?? id; if (betaPlugins.includes(pluginLabel)) { pluginLabel += ' [beta]'; } @@ -80,7 +82,8 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise; + const template = await factory(createContext(id)); return { label: pluginLabel, @@ -93,37 +96,18 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise { + if (it.status === 'fulfilled') return it.value; + const id = availablePlugins[index]; + const pluginLabel = pluginBuilders[id as keyof PluginBuilderList]?.name ?? id; + + return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu); + }); + return [ { label: 'Plugins', - submenu: [ - ...getAvailablePluginNames().map((pluginName) => { - let pluginLabel = pluginName; - if (betaPlugins.includes(pluginLabel)) { - pluginLabel += ' [beta]'; - } - - if (Object.hasOwn(menuList, pluginName)) { - const getPluginMenu = menuList[pluginName]; - - if (!config.plugins.isEnabled(pluginName)) { - return pluginEnabledMenu(pluginName, pluginLabel, true, innerRefreshMenu); - } - - return { - label: pluginLabel, - submenu: [ - pluginEnabledMenu(pluginName, 'Enabled', true, innerRefreshMenu), - { type: 'separator' }, - ...(getPluginMenu(win, config.plugins.getOptions(pluginName), innerRefreshMenu) as MenuTemplate), - ], - } satisfies Electron.MenuItemConstructorOptions; - } - - return pluginEnabledMenu(pluginName, pluginLabel); - }), - ...pluginMenus, - ], + submenu: pluginMenus, }, { label: 'Options', diff --git a/src/plugins/audio-compressor/index.ts b/src/plugins/audio-compressor/index.ts new file mode 100644 index 00000000..880fbc99 --- /dev/null +++ b/src/plugins/audio-compressor/index.ts @@ -0,0 +1,16 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('audio-compressor', { + name: 'Audio Compressor', + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/audio-compressor/renderer.ts b/src/plugins/audio-compressor/renderer.ts index 66fd94dd..5b102840 100644 --- a/src/plugins/audio-compressor/renderer.ts +++ b/src/plugins/audio-compressor/renderer.ts @@ -1,17 +1,24 @@ -export default () => - document.addEventListener('audioCanPlay', (e) => { - const { audioContext } = e.detail; +import builder from '.'; - const compressor = audioContext.createDynamicsCompressor(); - compressor.threshold.value = -50; - compressor.ratio.value = 12; - compressor.knee.value = 40; - compressor.attack.value = 0; - compressor.release.value = 0.25; +export default builder.createRenderer(() => { + return { + onLoad() { + document.addEventListener('audioCanPlay', (e) => { + const { audioContext } = e.detail; - e.detail.audioSource.connect(compressor); - compressor.connect(audioContext.destination); - }, { - once: true, // Only create the audio compressor once, not on each video - passive: true, - }); + const compressor = audioContext.createDynamicsCompressor(); + compressor.threshold.value = -50; + compressor.ratio.value = 12; + compressor.knee.value = 40; + compressor.attack.value = 0; + compressor.release.value = 0.25; + + e.detail.audioSource.connect(compressor); + compressor.connect(audioContext.destination); + }, { + once: true, // Only create the audio compressor once, not on each video + passive: true, + }); + } + }; +}); diff --git a/src/plugins/blur-nav-bar/index.ts b/src/plugins/blur-nav-bar/index.ts new file mode 100644 index 00000000..386bad63 --- /dev/null +++ b/src/plugins/blur-nav-bar/index.ts @@ -0,0 +1,19 @@ +import style from './style.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('blur-nav-bar', { + name: 'Blur Navigation Bar', + config: { + enabled: false, + }, + styles: [style], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/blur-nav-bar/main.ts b/src/plugins/blur-nav-bar/main.ts deleted file mode 100644 index 1d313bf0..00000000 --- a/src/plugins/blur-nav-bar/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import style from './style.css'; - -import { injectCSS } from '../utils/main'; - -export default (win: BrowserWindow) => { - injectCSS(win.webContents, style); -}; diff --git a/src/plugins/bypass-age-restrictions/index.ts b/src/plugins/bypass-age-restrictions/index.ts new file mode 100644 index 00000000..aa1bbc8a --- /dev/null +++ b/src/plugins/bypass-age-restrictions/index.ts @@ -0,0 +1,16 @@ +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('bypass-age-restrictions', { + name: 'Bypass Age Restrictions', + config: { + enabled: false, + }, +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/bypass-age-restrictions/renderer.ts b/src/plugins/bypass-age-restrictions/renderer.ts index 42df78ea..4cd51eac 100644 --- a/src/plugins/bypass-age-restrictions/renderer.ts +++ b/src/plugins/bypass-age-restrictions/renderer.ts @@ -1,4 +1,8 @@ -export default async () => { - // See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript - await import('simple-youtube-age-restriction-bypass'); -}; +import builder from '.'; + +export default builder.createRenderer(() => ({ + async onLoad() { + // See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript + await import('simple-youtube-age-restriction-bypass'); + }, +})); diff --git a/src/plugins/in-app-menu/index.ts b/src/plugins/in-app-menu/index.ts new file mode 100644 index 00000000..ca76cb3c --- /dev/null +++ b/src/plugins/in-app-menu/index.ts @@ -0,0 +1,20 @@ +import titlebarStyle from './titlebar.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +export const builder = createPluginBuilder('in-app-menu', { + name: 'In-App Menu', + config: { + enabled: false, + hideDOMWindowControls: false, + }, + styles: [titlebarStyle], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/in-app-menu/main.ts b/src/plugins/in-app-menu/main.ts index a97177cd..8fc335e7 100644 --- a/src/plugins/in-app-menu/main.ts +++ b/src/plugins/in-app-menu/main.ts @@ -2,75 +2,75 @@ import { register } from 'electron-localshortcut'; import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron'; -import titlebarStyle from './titlebar.css'; +import builder from './'; -import { injectCSS } from '../utils/main'; +export default builder.createMain(({ handle }) => { -// Tracks menu visibility -export default (win: BrowserWindow) => { - injectCSS(win.webContents, titlebarStyle); + return { + onLoad(win) { + win.on('close', () => { + win.webContents.send('close-all-in-app-menu-panel'); + }); - win.on('close', () => { - win.webContents.send('close-all-in-app-menu-panel'); - }); + win.once('ready-to-show', () => { + register(win, '`', () => { + win.webContents.send('toggle-in-app-menu'); + }); + }); - win.once('ready-to-show', () => { - register(win, '`', () => { - win.webContents.send('toggle-in-app-menu'); - }); - }); + handle( + 'get-menu', + () => JSON.parse(JSON.stringify( + Menu.getApplicationMenu(), + (key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined), + ), + ); - ipcMain.handle( - 'get-menu', - () => JSON.parse(JSON.stringify( - Menu.getApplicationMenu(), - (key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined), - ), - ); + const getMenuItemById = (commandId: number): MenuItem | null => { + const menu = Menu.getApplicationMenu(); - const getMenuItemById = (commandId: number): MenuItem | null => { - const menu = Menu.getApplicationMenu(); + let target: MenuItem | null = null; + const stack = [...menu?.items ?? []]; + while (stack.length > 0) { + const now = stack.shift(); + now?.submenu?.items.forEach((item) => stack.push(item)); - let target: MenuItem | null = null; - const stack = [...menu?.items ?? []]; - while (stack.length > 0) { - const now = stack.shift(); - now?.submenu?.items.forEach((item) => stack.push(item)); + if (now?.commandId === commandId) { + target = now; + break; + } + } - if (now?.commandId === commandId) { - target = now; - break; - } - } + return target; + }; - return target; + ipcMain.handle('menu-event', (event, commandId: number) => { + const target = getMenuItemById(commandId); + if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender); + }); + + handle('get-menu-by-id', (_, commandId: number) => { + const result = getMenuItemById(commandId); + + return JSON.parse(JSON.stringify( + result, + (key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined), + ); + }); + + handle('window-is-maximized', () => win.isMaximized()); + + handle('window-close', () => win.close()); + handle('window-minimize', () => win.minimize()); + handle('window-maximize', () => win.maximize()); + win.on('maximize', () => win.webContents.send('window-maximize')); + handle('window-unmaximize', () => win.unmaximize()); + win.on('unmaximize', () => win.webContents.send('window-unmaximize')); + + handle('image-path-to-data-url', (_, imagePath: string) => { + const nativeImageIcon = nativeImage.createFromPath(imagePath); + return nativeImageIcon?.toDataURL(); + }); + }, }; - - ipcMain.handle('menu-event', (event, commandId: number) => { - const target = getMenuItemById(commandId); - if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender); - }); - - ipcMain.handle('get-menu-by-id', (_, commandId: number) => { - const result = getMenuItemById(commandId); - - return JSON.parse(JSON.stringify( - result, - (key: string, value: unknown) => (key !== 'commandsMap' && key !== 'menu') ? value : undefined), - ); - }); - - ipcMain.handle('window-is-maximized', () => win.isMaximized()); - - ipcMain.handle('window-close', () => win.close()); - ipcMain.handle('window-minimize', () => win.minimize()); - ipcMain.handle('window-maximize', () => win.maximize()); - win.on('maximize', () => win.webContents.send('window-maximize')); - ipcMain.handle('window-unmaximize', () => win.unmaximize()); - win.on('unmaximize', () => win.webContents.send('window-unmaximize')); - - ipcMain.handle('image-path-to-data-url', (_, imagePath: string) => { - const nativeImageIcon = nativeImage.createFromPath(imagePath); - return nativeImageIcon?.toDataURL(); - }); -}; +}); diff --git a/src/plugins/in-app-menu/menu.ts b/src/plugins/in-app-menu/menu.ts index 3ed2fed2..cd69780e 100644 --- a/src/plugins/in-app-menu/menu.ts +++ b/src/plugins/in-app-menu/menu.ts @@ -1,22 +1,25 @@ -import { BrowserWindow } from 'electron'; - import is from 'electron-is'; +import builder from './'; + import { setMenuOptions } from '../../config/plugins'; -import type { MenuTemplate } from '../../menu'; -import type { ConfigType } from '../../config/dynamic'; +export default builder.createMenu(async ({ getConfig }) => { + const config = await getConfig(); -export default (_: BrowserWindow, config: ConfigType<'in-app-menu'>): MenuTemplate => [ - ...(is.linux() ? [ - { - label: 'Hide DOM Window Controls', - type: 'checkbox', - checked: config.hideDOMWindowControls, - click(item) { - config.hideDOMWindowControls = item.checked; - setMenuOptions('in-app-menu', config); + if (is.linux()) { + return [ + { + label: 'Hide DOM Window Controls', + type: 'checkbox', + checked: config.hideDOMWindowControls, + click(item) { + config.hideDOMWindowControls = item.checked; + setMenuOptions('in-app-menu', config); + } } - } - ] : []) satisfies Electron.MenuItemConstructorOptions[], -]; + ]; + } + + return []; +}); diff --git a/src/plugins/in-app-menu/renderer.ts b/src/plugins/in-app-menu/renderer.ts index 02a72c67..998fa971 100644 --- a/src/plugins/in-app-menu/renderer.ts +++ b/src/plugins/in-app-menu/renderer.ts @@ -6,186 +6,191 @@ import minimizeRaw from './assets/minimize.svg?inline'; import maximizeRaw from './assets/maximize.svg?inline'; import unmaximizeRaw from './assets/unmaximize.svg?inline'; -import type { Menu } from 'electron'; +import builder from './index'; -function $(selector: string) { - return document.querySelector(selector); -} +import type { Menu } from 'electron'; const isMacOS = navigator.userAgent.includes('Macintosh'); const isNotWindowsOrMacOS = !navigator.userAgent.includes('Windows') && !isMacOS; -export default async () => { - const hideDOMWindowControls = window.mainConfig.get('plugins.in-app-menu.hideDOMWindowControls'); - let hideMenu = window.mainConfig.get('options.hideMenu'); - const titleBar = document.createElement('title-bar'); - const navBar = document.querySelector('#nav-bar-background'); - let maximizeButton: HTMLButtonElement; - let panelClosers: (() => void)[] = []; - if (isMacOS) titleBar.style.setProperty('--offset-left', '70px'); +export default builder.createRenderer(({ getConfig, invoke, on }) => { + return { + async onLoad() { + const config = await getConfig(); + + const hideDOMWindowControls = config.hideDOMWindowControls; - const logo = document.createElement('img'); - const close = document.createElement('img'); - const minimize = document.createElement('img'); - const maximize = document.createElement('img'); - const unmaximize = document.createElement('img'); - - if (window.ELECTRON_RENDERER_URL) { - logo.src = window.ELECTRON_RENDERER_URL + '/' + logoRaw; - close.src = window.ELECTRON_RENDERER_URL + '/' + closeRaw; - minimize.src = window.ELECTRON_RENDERER_URL + '/' + minimizeRaw; - maximize.src = window.ELECTRON_RENDERER_URL + '/' + maximizeRaw; - unmaximize.src = window.ELECTRON_RENDERER_URL + '/' + unmaximizeRaw; - } else { - logo.src = logoRaw; - close.src = closeRaw; - minimize.src = minimizeRaw; - maximize.src = maximizeRaw; - unmaximize.src = unmaximizeRaw; - } - - logo.classList.add('title-bar-icon'); - const logoClick = () => { - hideMenu = !hideMenu; - let visibilityStyle: string; - if (hideMenu) { - visibilityStyle = 'hidden'; - } else { - visibilityStyle = 'visible'; - } - const menus = document.querySelectorAll('menu-button'); - menus.forEach((menu) => { - menu.style.visibility = visibilityStyle; - }); - }; - logo.onclick = logoClick; - - window.ipcRenderer.on('toggle-in-app-menu', logoClick); - - if (!isMacOS) titleBar.appendChild(logo); - document.body.appendChild(titleBar); - - titleBar.appendChild(logo); - - const addWindowControls = async () => { - - // Create window control buttons - const minimizeButton = document.createElement('button'); - minimizeButton.classList.add('window-control'); - minimizeButton.appendChild(minimize); - minimizeButton.onclick = () => window.ipcRenderer.invoke('window-minimize'); - - maximizeButton = document.createElement('button'); - if (await window.ipcRenderer.invoke('window-is-maximized')) { - maximizeButton.classList.add('window-control'); - maximizeButton.appendChild(unmaximize); - } else { - maximizeButton.classList.add('window-control'); - maximizeButton.appendChild(maximize); - } - maximizeButton.onclick = async () => { - if (await window.ipcRenderer.invoke('window-is-maximized')) { - // change icon to maximize - maximizeButton.removeChild(maximizeButton.firstChild!); - maximizeButton.appendChild(maximize); - - // call unmaximize - await window.ipcRenderer.invoke('window-unmaximize'); + let hideMenu = window.mainConfig.get('options.hideMenu'); + const titleBar = document.createElement('title-bar'); + const navBar = document.querySelector('#nav-bar-background'); + let maximizeButton: HTMLButtonElement; + let panelClosers: (() => void)[] = []; + if (isMacOS) titleBar.style.setProperty('--offset-left', '70px'); + + const logo = document.createElement('img'); + const close = document.createElement('img'); + const minimize = document.createElement('img'); + const maximize = document.createElement('img'); + const unmaximize = document.createElement('img'); + + if (window.ELECTRON_RENDERER_URL) { + logo.src = window.ELECTRON_RENDERER_URL + '/' + logoRaw; + close.src = window.ELECTRON_RENDERER_URL + '/' + closeRaw; + minimize.src = window.ELECTRON_RENDERER_URL + '/' + minimizeRaw; + maximize.src = window.ELECTRON_RENDERER_URL + '/' + maximizeRaw; + unmaximize.src = window.ELECTRON_RENDERER_URL + '/' + unmaximizeRaw; } else { - // change icon to unmaximize - maximizeButton.removeChild(maximizeButton.firstChild!); - maximizeButton.appendChild(unmaximize); - - // call maximize - await window.ipcRenderer.invoke('window-maximize'); + logo.src = logoRaw; + close.src = closeRaw; + minimize.src = minimizeRaw; + maximize.src = maximizeRaw; + unmaximize.src = unmaximizeRaw; } - }; + + logo.classList.add('title-bar-icon'); + const logoClick = () => { + hideMenu = !hideMenu; + let visibilityStyle: string; + if (hideMenu) { + visibilityStyle = 'hidden'; + } else { + visibilityStyle = 'visible'; + } + const menus = document.querySelectorAll('menu-button'); + menus.forEach((menu) => { + menu.style.visibility = visibilityStyle; + }); + }; + logo.onclick = logoClick; + + on('toggle-in-app-menu', logoClick); + + if (!isMacOS) titleBar.appendChild(logo); + document.body.appendChild(titleBar); + + titleBar.appendChild(logo); + + const addWindowControls = async () => { + + // Create window control buttons + const minimizeButton = document.createElement('button'); + minimizeButton.classList.add('window-control'); + minimizeButton.appendChild(minimize); + minimizeButton.onclick = () => invoke('window-minimize'); + + maximizeButton = document.createElement('button'); + if (await invoke('window-is-maximized')) { + maximizeButton.classList.add('window-control'); + maximizeButton.appendChild(unmaximize); + } else { + maximizeButton.classList.add('window-control'); + maximizeButton.appendChild(maximize); + } + maximizeButton.onclick = async () => { + if (await invoke('window-is-maximized')) { + // change icon to maximize + maximizeButton.removeChild(maximizeButton.firstChild!); + maximizeButton.appendChild(maximize); + + // call unmaximize + await invoke('window-unmaximize'); + } else { + // change icon to unmaximize + maximizeButton.removeChild(maximizeButton.firstChild!); + maximizeButton.appendChild(unmaximize); + + // call maximize + await invoke('window-maximize'); + } + }; + + const closeButton = document.createElement('button'); + closeButton.classList.add('window-control'); + closeButton.appendChild(close); + closeButton.onclick = () => invoke('window-close'); + + // Create a container div for the window control buttons + const windowControlsContainer = document.createElement('div'); + windowControlsContainer.classList.add('window-controls-container'); + windowControlsContainer.appendChild(minimizeButton); + windowControlsContainer.appendChild(maximizeButton); + windowControlsContainer.appendChild(closeButton); + + // Add window control buttons to the title bar + titleBar.appendChild(windowControlsContainer); + }; + + if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls(); + + if (navBar) { + const observer = new MutationObserver((mutations) => { + mutations.forEach(() => { + titleBar.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor); + document.querySelector('html')!.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor); + }); + }); + + observer.observe(navBar, { attributes : true, attributeFilter : ['style'] }); + } + + const updateMenu = async () => { + const children = [...titleBar.children]; + children.forEach((child) => { + if (child !== logo) child.remove(); + }); + panelClosers = []; + + const menu = await invoke('get-menu'); + if (!menu) return; + + menu.items.forEach((menuItem) => { + const menu = document.createElement('menu-button'); + const [, { close: closer }] = createPanel(titleBar, menu, menuItem.submenu?.items ?? []); + panelClosers.push(closer); + + menu.append(menuItem.label); + titleBar.appendChild(menu); + if (hideMenu) { + menu.style.visibility = 'hidden'; + } + }); + if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls(); + }; + await updateMenu(); - const closeButton = document.createElement('button'); - closeButton.classList.add('window-control'); - closeButton.appendChild(close); - closeButton.onclick = () => window.ipcRenderer.invoke('window-close'); - - // Create a container div for the window control buttons - const windowControlsContainer = document.createElement('div'); - windowControlsContainer.classList.add('window-controls-container'); - windowControlsContainer.appendChild(minimizeButton); - windowControlsContainer.appendChild(maximizeButton); - windowControlsContainer.appendChild(closeButton); - - // Add window control buttons to the title bar - titleBar.appendChild(windowControlsContainer); - }; - - if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls(); - - if (navBar) { - const observer = new MutationObserver((mutations) => { - mutations.forEach(() => { - titleBar.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor); - document.querySelector('html')!.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor); + document.title = 'Youtube Music'; + + on('close-all-in-app-menu-panel', () => { + panelClosers.forEach((closer) => closer()); }); - }); - - observer.observe(navBar, { attributes : true, attributeFilter : ['style'] }); - } - - const updateMenu = async () => { - const children = [...titleBar.children]; - children.forEach((child) => { - if (child !== logo) child.remove(); - }); - panelClosers = []; - - const menu = await window.ipcRenderer.invoke('get-menu') as Menu | null; - if (!menu) return; - - menu.items.forEach((menuItem) => { - const menu = document.createElement('menu-button'); - const [, { close: closer }] = createPanel(titleBar, menu, menuItem.submenu?.items ?? []); - panelClosers.push(closer); - - menu.append(menuItem.label); - titleBar.appendChild(menu); - if (hideMenu) { - menu.style.visibility = 'hidden'; + on('refresh-in-app-menu', () => updateMenu()); + on('window-maximize', () => { + if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) { + maximizeButton.removeChild(maximizeButton.firstChild); + maximizeButton.appendChild(unmaximize); + } + }); + on('window-unmaximize', () => { + if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) { + maximizeButton.removeChild(maximizeButton.firstChild); + maximizeButton.appendChild(unmaximize); + } + }); + + if (window.mainConfig.plugins.isEnabled('picture-in-picture')) { + on('pip-toggle', () => { + updateMenu(); + }); } - }); - if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls(); + + // Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it) + document.addEventListener('apiLoaded', () => { + const htmlHeadStyle = document.querySelector('head > div > style'); + if (htmlHeadStyle) { + // HACK: This is a hack to remove the scrollbar width + htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {'); + } + }, { once: true, passive: true }); + } }; - await updateMenu(); - - document.title = 'Youtube Music'; - - window.ipcRenderer.on('close-all-in-app-menu-panel', () => { - panelClosers.forEach((closer) => closer()); - }); - window.ipcRenderer.on('refresh-in-app-menu', () => updateMenu()); - window.ipcRenderer.on('window-maximize', () => { - if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) { - maximizeButton.removeChild(maximizeButton.firstChild); - maximizeButton.appendChild(unmaximize); - } - }); - window.ipcRenderer.on('window-unmaximize', () => { - if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) { - maximizeButton.removeChild(maximizeButton.firstChild); - maximizeButton.appendChild(unmaximize); - } - }); - - if (window.mainConfig.plugins.isEnabled('picture-in-picture')) { - window.ipcRenderer.on('pip-toggle', () => { - updateMenu(); - }); - } - - // Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it) - document.addEventListener('apiLoaded', () => { - const htmlHeadStyle = $('head > div > style'); - if (htmlHeadStyle) { - // HACK: This is a hack to remove the scrollbar width - htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {'); - } - }, { once: true, passive: true }); -}; +}); diff --git a/src/plugins/navigation/front.ts b/src/plugins/navigation/front.ts deleted file mode 100644 index 8cfcf45f..00000000 --- a/src/plugins/navigation/front.ts +++ /dev/null @@ -1,18 +0,0 @@ -import forwardHTML from './templates/forward.html?raw'; -import backHTML from './templates/back.html?raw'; - -import { ElementFromHtml } from '../utils/renderer'; - -export function run() { - window.ipcRenderer.on('navigation-css-ready', () => { - const forwardButton = ElementFromHtml(forwardHTML); - const backButton = ElementFromHtml(backHTML); - const menu = document.querySelector('#right-content'); - - if (menu) { - menu.prepend(backButton, forwardButton); - } - }); -} - -export default run; diff --git a/src/plugins/navigation/index.ts b/src/plugins/navigation/index.ts new file mode 100644 index 00000000..46ac3ab6 --- /dev/null +++ b/src/plugins/navigation/index.ts @@ -0,0 +1,19 @@ +import style from './style.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +export const builder = createPluginBuilder('navigation', { + name: 'Navigation', + config: { + enabled: false, + }, + styles: [style], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/navigation/main.ts b/src/plugins/navigation/main.ts deleted file mode 100644 index 51dceb4c..00000000 --- a/src/plugins/navigation/main.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BrowserWindow } from 'electron'; - -import style from './style.css'; - -import { injectCSS } from '../utils/main'; - -export function handle(win: BrowserWindow) { - injectCSS(win.webContents, style, () => { - win.webContents.send('navigation-css-ready'); - }); -} - -export default handle; diff --git a/src/plugins/navigation/renderer.ts b/src/plugins/navigation/renderer.ts new file mode 100644 index 00000000..ad67a29c --- /dev/null +++ b/src/plugins/navigation/renderer.ts @@ -0,0 +1,20 @@ +import forwardHTML from './templates/forward.html?raw'; +import backHTML from './templates/back.html?raw'; + +import builder from '.'; + +import { ElementFromHtml } from '../utils/renderer'; + +export default builder.createRenderer(() => { + return { + onLoad() { + const forwardButton = ElementFromHtml(forwardHTML); + const backButton = ElementFromHtml(backHTML); + const menu = document.querySelector('#right-content'); + + if (menu) { + menu.prepend(backButton, forwardButton); + } + } + }; +}); diff --git a/src/plugins/precise-volume/index.ts b/src/plugins/precise-volume/index.ts new file mode 100644 index 00000000..9f735423 --- /dev/null +++ b/src/plugins/precise-volume/index.ts @@ -0,0 +1,37 @@ +import hudStyle from './volume-hud.css?inline'; + +import { createPluginBuilder } from '../utils/builder'; + +export type PreciseVolumePluginConfig = { + enabled: boolean; + steps: number; + arrowsShortcut: boolean; + globalShortcuts: { + volumeUp: string; + volumeDown: string; + }; + savedVolume: number | undefined; +}; + +const builder = createPluginBuilder('precise-volume', { + name: 'Precise Volume', + config: { + enabled: false, + steps: 1, // Percentage of volume to change + arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts + globalShortcuts: { + volumeUp: '', + volumeDown: '', + }, + savedVolume: undefined, // Plugin save volume between session here + } as PreciseVolumePluginConfig, + styles: [hudStyle], +}); + +export default builder; + +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} diff --git a/src/plugins/precise-volume/main.ts b/src/plugins/precise-volume/main.ts index e161f4f2..fc25b081 100644 --- a/src/plugins/precise-volume/main.ts +++ b/src/plugins/precise-volume/main.ts @@ -1,28 +1,19 @@ -import { globalShortcut, BrowserWindow } from 'electron'; +import { globalShortcut } from 'electron'; -import volumeHudStyle from './volume-hud.css'; +import builder from '.'; -import { injectCSS } from '../utils/main'; +export default builder.createMain(({ getConfig, send }) => { + return { + async onLoad() { + const config = await getConfig(); -import type { ConfigType } from '../../config/dynamic'; - -/* -This is used to determine if plugin is actually active -(not if it's only enabled in options) -*/ -let isEnabled = false; - -export const enabled = () => isEnabled; - -export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => { - isEnabled = true; - injectCSS(win.webContents, volumeHudStyle); - - if (options.globalShortcuts?.volumeUp) { - globalShortcut.register((options.globalShortcuts.volumeUp), () => win.webContents.send('changeVolume', true)); - } - - if (options.globalShortcuts?.volumeDown) { - globalShortcut.register((options.globalShortcuts.volumeDown), () => win.webContents.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 fb0ec9c0..e828fffe 100644 --- a/src/plugins/precise-volume/menu.ts +++ b/src/plugins/precise-volume/menu.ts @@ -2,93 +2,89 @@ import prompt, { KeybindOptions } from 'custom-electron-prompt'; import { BrowserWindow, MenuItem } from 'electron'; -import { enabled } from './main'; +import builder, { PreciseVolumePluginConfig } from '.'; -import { setMenuOptions } from '../../config/plugins'; import promptOptions from '../../providers/prompt-options'; -import { MenuTemplate } from '../../menu'; -import type { ConfigType } from '../../config/dynamic'; - -function changeOptions(changedOptions: Partial>, options: ConfigType<'precise-volume'>, win: BrowserWindow) { - for (const option in changedOptions) { - // HACK: Weird TypeScript error - (options as Record)[option] = (changedOptions as Record)[option]; - } - // Dynamically change setting if plugin is enabled - if (enabled()) { - win.webContents.send('setOptions', changedOptions); - } else { // Fallback to usual method if disabled - setMenuOptions('precise-volume', options); - } -} - -export default (win: BrowserWindow, options: ConfigType<'precise-volume'>): MenuTemplate => [ - { - label: 'Local Arrowkeys Controls', - type: 'checkbox', - checked: Boolean(options.arrowsShortcut), - click(item) { - changeOptions({ arrowsShortcut: item.checked }, options, win); - }, - }, - { - label: 'Global Hotkeys', - type: 'checkbox', - checked: Boolean(options.globalShortcuts?.volumeUp ?? options.globalShortcuts?.volumeDown), - click: (item) => promptGlobalShortcuts(win, options, item), - }, - { - label: 'Set Custom Volume Steps', - click: () => promptVolumeSteps(win, options), - }, -]; - -// Helper function for globalShortcuts prompt -const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined }); - -async function promptVolumeSteps(win: BrowserWindow, options: ConfigType<'precise-volume'>) { - const output = await prompt({ - title: 'Volume Steps', - label: 'Choose Volume Increase/Decrease Steps', - value: options.steps || 1, - type: 'counter', - counterOptions: { minimum: 0, maximum: 100, multiFire: true }, - width: 380, - ...promptOptions(), - }, win); - - if (output || output === 0) { // 0 is somewhat valid - changeOptions({ steps: output }, options, win); - } -} - -async function promptGlobalShortcuts(win: BrowserWindow, options: ConfigType<'precise-volume'>, item: MenuItem) { - const output = await prompt({ - title: 'Global Volume Keybinds', - label: 'Choose Global Volume Keybinds:', - type: 'keybind', - keybindOptions: [ - kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp), - kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown), - ], - ...promptOptions(), - }, win); - - if (output) { - const newGlobalShortcuts: { - volumeUp: string; - volumeDown: string; - } = { volumeUp: '', volumeDown: '' }; - for (const { value, accelerator } of output) { - newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator; +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 + (options as Record)[option] = (changedOptions as Record)[option]; } - changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win); - - item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown); - } else { - // Reset checkbox if prompt was canceled - item.checked = !item.checked; + setConfig(options); } -} + + // Helper function for globalShortcuts prompt + const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined }); + + async function promptVolumeSteps(win: BrowserWindow, options: PreciseVolumePluginConfig) { + const output = await prompt({ + title: 'Volume Steps', + label: 'Choose Volume Increase/Decrease Steps', + value: options.steps || 1, + type: 'counter', + counterOptions: { minimum: 0, maximum: 100, multiFire: true }, + width: 380, + ...promptOptions(), + }, win); + + if (output || output === 0) { // 0 is somewhat valid + changeOptions({ steps: output }, options, win); + } + } + + async function promptGlobalShortcuts(win: BrowserWindow, options: PreciseVolumePluginConfig, item: MenuItem) { + const output = await prompt({ + title: 'Global Volume Keybinds', + label: 'Choose Global Volume Keybinds:', + type: 'keybind', + keybindOptions: [ + kb('Increase Volume', 'volumeUp', options.globalShortcuts?.volumeUp), + kb('Decrease Volume', 'volumeDown', options.globalShortcuts?.volumeDown), + ], + ...promptOptions(), + }, win); + + if (output) { + const newGlobalShortcuts: { + volumeUp: string; + volumeDown: string; + } = { volumeUp: '', volumeDown: '' }; + for (const { value, accelerator } of output) { + newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator; + } + + changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win); + + item.checked = Boolean(options.globalShortcuts.volumeUp) || Boolean(options.globalShortcuts.volumeDown); + } else { + // Reset checkbox if prompt was canceled + item.checked = !item.checked; + } + } + + return [ + { + label: 'Local Arrowkeys Controls', + type: 'checkbox', + checked: Boolean(config.arrowsShortcut), + click(item) { + changeOptions({ arrowsShortcut: item.checked }, config, window); + }, + }, + { + label: 'Global Hotkeys', + type: 'checkbox', + checked: Boolean(config.globalShortcuts?.volumeUp ?? config.globalShortcuts?.volumeDown), + click: (item) => promptGlobalShortcuts(window, config, item), + }, + { + label: 'Set Custom Volume Steps', + click: () => promptVolumeSteps(window, config), + }, + ]; +}); diff --git a/src/plugins/precise-volume/renderer.ts b/src/plugins/precise-volume/renderer.ts index 0cf0d13c..774f140d 100644 --- a/src/plugins/precise-volume/renderer.ts +++ b/src/plugins/precise-volume/renderer.ts @@ -1,270 +1,273 @@ import { overrideListener } from './override'; +import builder, { type PreciseVolumePluginConfig } from './'; + import { debounce } from '../../providers/decorators'; import type { YoutubePlayer } from '../../types/youtube-player'; -import type { ConfigType } from '../../config/dynamic'; function $(selector: string) { return document.querySelector(selector); } let api: YoutubePlayer; -let options: ConfigType<'precise-volume'>; -export default (_options: ConfigType<'precise-volume'>) => { - overrideListener(); +export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { + let options: PreciseVolumePluginConfig = await getConfig(); - options = _options; - document.addEventListener('apiLoaded', (e) => { - api = e.detail; - window.ipcRenderer.on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease)); - window.ipcRenderer.on('setVolume', (_, value: number) => setVolume(value)); - firstRun(); - }, { once: true, passive: true }); -}; - -// Without this function it would rewrite config 20 time when volume change by 20 -const writeOptions = debounce(() => { - window.mainConfig.plugins.setOptions('precise-volume', options); -}, 1000); - -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); - -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); + // Without this function it would rewrite config 20 time when volume change by 20 + const writeOptions = debounce(() => { + setConfig(options); + }, 1000); + + const moveVolumeHud = debounce((showVideo: boolean) => { + const volumeHud = $('#volumeHud'); + if (!volumeHud) { + return; } - } - - setupPlaybar(); - - setupLocalArrowShortcuts(); - - // Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue - const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none'; - injectVolumeHud(noVid); - if (!noVid) { - setupVideoPlayerOnwheel(); - if (!window.mainConfig.plugins.isEnabled('video-toggle')) { - // Video-toggle handles hud positioning on its own - const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'; - $('video')?.addEventListener('srcChanged', () => moveVolumeHud(videoMode())); - } - } - - // Change options from renderer to keep sync - window.ipcRenderer.on('setOptions', (_event, newOptions = {}) => { - Object.assign(options, newOptions); - window.mainConfig.plugins.setMenuOptions('precise-volume', options); - }); -} - -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', - ``, - ); - } 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) => { - for (const mutation of mutations) { - if (mutation.target instanceof HTMLInputElement) { - // This checks that volume-slider was manually set - const target = mutation.target; - const targetValueNumeric = Number(target.value); - if (mutation.oldValue !== target.value - && (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - targetValueNumeric) > 4)) { - // Diff>4 means it was manually set - setTooltip(targetValueNumeric); - saveVolume(targetValueNumeric); - } + + 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); } } - }); - - 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 - showVolumeSlider(); - // Show volume HUD - showVolumeHud(value); -} - -/** If (toIncrease = false) then volume decrease */ -function changeVolume(toIncrease: boolean) { - // Apply volume change if valid - const steps = Number(options.steps || 1); - setVolume(toIncrease - ? 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 - for (const slider of ['#volume-slider', '#expand-volume-slider']) { - const silderElement = $(slider); - if (silderElement) { - silderElement.value = String(savedVolume > 0 && savedVolume < 5 ? 5 : savedVolume); - } - } -} - -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', - 'tp-yt-paper-icon-button.volume', - '#expand-volume-slider', - '#expand-volume', -]; - -function setTooltip(volume: number) { - for (const target of tooltipTargets) { - const tooltipTargetElement = $(target); - if (tooltipTargetElement) { - tooltipTargetElement.title = `${volume}%`; - } - } -} - -function setupLocalArrowShortcuts() { - if (options.arrowsShortcut) { - window.addEventListener('keydown', (event) => { - if ($('ytmusic-search-box')?.opened) { - return; + + setupPlaybar(); + + setupLocalArrowShortcuts(); + + // Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue + const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none'; + injectVolumeHud(noVid); + if (!noVid) { + setupVideoPlayerOnwheel(); + if (!window.mainConfig.plugins.isEnabled('video-toggle')) { + // Video-toggle handles hud positioning on its own + const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'; + $('video')?.addEventListener('srcChanged', () => moveVolumeHud(videoMode())); } - - switch (event.code) { - case 'ArrowUp': { - event.preventDefault(); - changeVolume(true); - break; - } - - case 'ArrowDown': { - event.preventDefault(); - changeVolume(false); - break; + } + } + + 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', + ``, + ); + } 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) => { + for (const mutation of mutations) { + if (mutation.target instanceof HTMLInputElement) { + // This checks that volume-slider was manually set + const target = mutation.target; + const targetValueNumeric = Number(target.value); + if (mutation.oldValue !== target.value + && (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - targetValueNumeric) > 4)) { + // Diff>4 means it was manually set + setTooltip(targetValueNumeric); + saveVolume(targetValueNumeric); + } } } }); + + 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 + showVolumeSlider(); + // Show volume HUD + showVolumeHud(value); + } + + /** If (toIncrease = false) then volume decrease */ + function changeVolume(toIncrease: boolean) { + // Apply volume change if valid + const steps = Number(options.steps || 1); + setVolume(toIncrease + ? 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 + for (const slider of ['#volume-slider', '#expand-volume-slider']) { + const silderElement = $(slider); + if (silderElement) { + silderElement.value = String(savedVolume > 0 && savedVolume < 5 ? 5 : savedVolume); + } + } + } + + 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', + 'tp-yt-paper-icon-button.volume', + '#expand-volume-slider', + '#expand-volume', + ]; + + function setTooltip(volume: number) { + for (const target of tooltipTargets) { + const tooltipTargetElement = $(target); + if (tooltipTargetElement) { + tooltipTargetElement.title = `${volume}%`; + } + } + } + + 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); + break; + } + } + }); + } + } + + + return { + onLoad() { + overrideListener(); + + document.addEventListener('apiLoaded', (e) => { + api = e.detail; + on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease)); + on('setVolume', (_, value: number) => setVolume(value)); + firstRun(); + }, { once: true, passive: true }); + }, + onConfigChange(config) { + options = config; + } + }; +}); diff --git a/src/plugins/quality-changer/renderer.ts b/src/plugins/quality-changer/renderer.ts index 79c66c8b..f39ab9cc 100644 --- a/src/plugins/quality-changer/renderer.ts +++ b/src/plugins/quality-changer/renderer.ts @@ -43,7 +43,6 @@ export default builder.createRenderer(({ invoke }) => { return { onLoad() { - console.log('qc'); document.addEventListener('apiLoaded', setup, { once: true, passive: true }); } }; diff --git a/src/plugins/utils/builder.ts b/src/plugins/utils/builder.ts index cb032a98..c4d8ec6a 100644 --- a/src/plugins/utils/builder.ts +++ b/src/plugins/utils/builder.ts @@ -35,11 +35,14 @@ export type RendererPluginContext(event: string, ...args: unknown[]) => Promise; on: (event: string, listener: (...args: Arguments) => Promisable) => void; }; +export type MenuPluginContext = PluginContext & { + window: BrowserWindow; +}; export type RendererPluginFactory = (context: RendererPluginContext) => Promisable>; export type MainPluginFactory = (context: MainPluginContext) => Promisable>; export type PreloadPluginFactory = (context: PluginContext) => Promisable>; -export type MenuPluginFactory = (context: PluginContext) => Promisable; +export type MenuPluginFactory = (context: MenuPluginContext) => Promisable; export type PluginBuilder = { createRenderer: IF>; diff --git a/src/plugins/utils/main/css.ts b/src/plugins/utils/main/css.ts index 5cd613d7..c7dd40cc 100644 --- a/src/plugins/utils/main/css.ts +++ b/src/plugins/utils/main/css.ts @@ -7,7 +7,6 @@ export const injectCSS = (webContents: Electron.WebContents, css: string, cb: (( setupCssInjection(webContents); } - console.log('injectCSS', css); cssToInject.set(css, cb); }; diff --git a/src/plugins/video-toggle/renderer.ts b/src/plugins/video-toggle/renderer.ts index c64be509..a8648780 100644 --- a/src/plugins/video-toggle/renderer.ts +++ b/src/plugins/video-toggle/renderer.ts @@ -2,14 +2,15 @@ import buttonTemplate from './templates/button_template.html?raw'; 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 = window.mainConfig.plugins.isEnabled('precise-volume') ? preciseVolumeMoveVolumeHud : () => {}; +const moveVolumeHud = () => {}; function $(selector: string): E | null { return document.querySelector(selector); diff --git a/src/renderer.ts b/src/renderer.ts index cf2e6a4d..d8aed8ad 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -2,7 +2,7 @@ // eslint-disable-next-line import/order import { rendererPlugins } from 'virtual:RendererPlugins'; -import { PluginBaseConfig, RendererPluginContext } from './plugins/utils/builder'; +import { PluginBaseConfig, RendererPluginContext, RendererPluginFactory } from './plugins/utils/builder'; import { startingPages } from './providers/extracted-data'; import { setupSongControls } from './providers/song-controls-front'; @@ -99,7 +99,9 @@ const createContext = < Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], >(name: Key): RendererPluginContext => ({ getConfig: async () => { - return await window.ipcRenderer.invoke('get-config', name) as Config; + const result = await window.ipcRenderer.invoke('get-config', name) as Config; + + return result; }, setConfig: async (newConfig) => { await window.ipcRenderer.invoke('set-config', name, newConfig); @@ -114,39 +116,62 @@ const createContext = < }); (async () => { - enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => { - if (pluginName === 'ambient-mode') { - const builder = rendererPlugins[pluginName]; + // enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => { + // if (pluginName === 'ambient-mode') { + // const builder = rendererPlugins[pluginName]; - try { - const context = createContext(pluginName); - const plugin = await builder?.(context); - console.log(plugin); - plugin.onLoad?.(); - } catch (error) { - console.error(`Error in plugin "${pluginName}"`); - console.trace(error); - } - } + // try { + // const context = createContext(pluginName); + // const plugin = await builder?.(context); + // console.log(plugin); + // plugin.onLoad?.(); + // } catch (error) { + // console.error(`Error in plugin "${pluginName}"`); + // console.trace(error); + // } + // } - if (Object.hasOwn(rendererPlugins, pluginName)) { - const handler = rendererPlugins[pluginName]; - try { - await handler?.(options as never); - } catch (error) { - console.error(`Error in plugin "${pluginName}"`); - console.trace(error); - } - } - }); - const rendererPluginList = await Promise.all( - newPluginList.map(async ([id, plugin]) => { - const context = createContext(id); - return [id, await plugin(context)] as const; + // if (Object.hasOwn(rendererPlugins, pluginName)) { + // const handler = rendererPlugins[pluginName]; + // try { + // await handler?.(options as never); + // } catch (error) { + // console.error(`Error in plugin "${pluginName}"`); + // console.trace(error); + // } + // } + // }); + + const rendererPluginResult = await Promise.allSettled( + enabledPluginNameAndOptions.map(async ([id]) => { + const builder = (rendererPlugins as Record>)[id]; + + const context = createContext(id as never); + return [id, await builder(context as never)] as const; }), ); - rendererPluginList.forEach(([, plugin]) => plugin.onLoad?.()); + const rendererPluginList = rendererPluginResult + .map((it) => it.status === 'fulfilled' ? it.value : null) + .filter(Boolean); + + rendererPluginResult.forEach((it, index) => { + if (it.status === 'rejected') { + const id = enabledPluginNameAndOptions[index][0]; + console.error('[YTMusic]', `Cannot load plugin "${id}"`); + console.trace(it.reason); + } + }); + + rendererPluginList.forEach(([id, plugin]) => { + try { + plugin.onLoad?.(); + console.log('[YTMusic]', `"${id}" plugin is loaded`); + } catch (error) { + console.error('[YTMusic]', `Cannot load plugin "${id}"`); + console.trace(error); + } + }); window.ipcRenderer.on('config-changed', (_event, id: string, newConfig) => { const plugin = rendererPluginList.find(([pluginId]) => pluginId === id); diff --git a/src/virtual-module.d.ts b/src/virtual-module.d.ts index f1e6b4d9..62e66bbf 100644 --- a/src/virtual-module.d.ts +++ b/src/virtual-module.d.ts @@ -1,24 +1,27 @@ - declare module 'virtual:MainPlugins' { - import type { MainPluginFactory } from './plugins/utils/builder'; + import type { MainPluginFactory, PluginBaseConfig } from './plugins/utils/builder'; - export const mainPlugins: Record; + export const mainPlugins: Record>; } declare module 'virtual:MenuPlugins' { - import type { MenuPluginFactory } from './plugins/utils/builder'; + import type { MenuPluginFactory, PluginBaseConfig } from './plugins/utils/builder'; - export const menuPlugins: Record; + export const menuPlugins: Record>; } declare module 'virtual:PreloadPlugins' { - import type { PreloadPluginFactory } from './plugins/utils/builder'; + import type { PreloadPluginFactory, PluginBaseConfig } from './plugins/utils/builder'; - export const preloadPlugins: Record; + export const preloadPlugins: Record>; } declare module 'virtual:RendererPlugins' { - import type { RendererPluginFactory } from './plugins/utils/builder'; + import type { RendererPluginFactory, PluginBaseConfig } from './plugins/utils/builder'; - export const rendererPlugins: Record; + export const rendererPlugins: Record>; } + +declare module 'virtual:PluginBuilders' { + export const pluginBuilders: PluginBuilderList; +} \ No newline at end of file diff --git a/vite-plugins/plugin-virtual-module-generator.ts b/vite-plugins/plugin-virtual-module-generator.ts index 320b606a..d9316f4f 100644 --- a/vite-plugins/plugin-virtual-module-generator.ts +++ b/vite-plugins/plugin-virtual-module-generator.ts @@ -3,31 +3,45 @@ import { basename, relative, resolve } from 'node:path'; import { globSync } from 'glob'; -const snakeToCamel = (text: string) => text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase()); +type PluginType = 'index' | 'main' | 'preload' | 'renderer' | 'menu'; -export const pluginVirtualModuleGenerator = (mode: 'main' | 'preload' | 'renderer' | 'menu') => { +const snakeToCamel = (text: string) => text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase()); +const getName = (mode: PluginType, name: string) => { + if (mode === 'index') { + return snakeToCamel(name); + } + + return `${snakeToCamel(name)}Plugin`; +}; +const getListName = (mode: PluginType) => { + if (mode === 'index') return 'pluginBuilders'; + + return `${mode}Plugins`; +}; + +export const pluginVirtualModuleGenerator = (mode: PluginType) => { const srcPath = resolve(__dirname, '..', 'src'); const plugins = globSync(`${srcPath}/plugins/*`) .map((path) => ({ name: basename(path), path })) .filter(({ name, path }) => { if (name.startsWith('utils')) return false; - if (path.includes('ambient-mode')) return false; - if (path.includes('quality')) return false; return existsSync(resolve(path, `${mode}.ts`)); }); - // for test !name.startsWith('ambient-mode') + + console.log('converted plugin list'); + console.log(plugins.map((it) => it.name)); let result = ''; for (const { name, path } of plugins) { - result += `import ${snakeToCamel(name)}Plugin from "./${relative(resolve(srcPath, '..'), path).replace(/\\/g, '/')}/${mode}";\n`; + result += `import ${getName(mode, name)} from "./${relative(resolve(srcPath, '..'), path).replace(/\\/g, '/')}/${mode}";\n`; } - result += `export const ${mode}Plugins = {\n`; + result += `export const ${getListName(mode)} = {\n`; for (const { name } of plugins) { - result += ` "${name}": ${snakeToCamel(name)}Plugin,\n`; + result += ` "${name}": ${getName(mode, name)},\n`; } result += '};'; From 7fa8a454b654f06d4d61bed5970f74faac504677 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 11:50:02 +0900 Subject: [PATCH 05/79] fix: fix setPartial --- src/config/index.ts | 7 +++++-- src/index.ts | 26 +++++--------------------- src/menu.ts | 23 +++++++---------------- src/plugins/utils/builder.ts | 4 ++-- src/renderer.ts | 11 ++++++----- 5 files changed, 25 insertions(+), 46 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 225efc6a..fb081f2b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,17 +1,20 @@ import Store from 'electron-store'; +import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; + import defaultConfig from './defaults'; import plugins from './plugins'; import store from './store'; import { restart } from '../providers/app-controls'; +const deepmerge = createDeepmerge(); const set = (key: string, value: unknown) => { store.set(key, value); }; -const setPartial = (value: object) => { - // deepmerge(store.get, value); +const setPartial = (key: string, value: object) => { + deepmerge(store.get(key), value); store.set(value); }; diff --git a/src/index.ts b/src/index.ts index e27ed203..ff99b5cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,11 +101,7 @@ ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); const initHook = (win: BrowserWindow) => { ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => config.get(`plugins.${id}` as never) ?? pluginBuilders[id].config); - ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial({ - plugins: { - [name]: obj, - } - })); + ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj)); config.watch((newValue) => { const value = newValue as Record; @@ -144,30 +140,18 @@ async function loadPlugins(win: BrowserWindow) { } }); - + const createContext = < Key extends keyof PluginBuilderList, Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], >(name: Key): MainPluginContext => ({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error getConfig: () => config.get(`plugins.${name}`) as unknown as Config, setConfig: (newConfig) => { - config.setPartial({ - plugins: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - [name]: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - ...config.get(`plugins.${name}`), - ...newConfig, - }, - }, - }); + config.setPartial(`plugins.${name}`, newConfig); return Promise.resolve(); }, - + send: (event: string, ...args: unknown[]) => { win.webContents.send(event, ...args); }, @@ -175,7 +159,7 @@ async function loadPlugins(win: BrowserWindow) { ipcMain.handle(event, async (_, ...args) => listener(...args as never)); }, }); - + for (const [pluginId, options] of config.plugins.getEnabled()) { const builder = pluginBuilders[pluginId as keyof PluginBuilderList]; diff --git a/src/menu.ts b/src/menu.ts index 9d210f3d..d8199246 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -13,7 +13,7 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; /* eslint-enable import/order */ import { getAvailablePluginNames } from './plugins/utils/main'; -import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig, PluginContext } from './plugins/utils/builder'; +import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig } from './plugins/utils/builder'; export type MenuTemplate = Electron.MenuItemConstructorOptions[]; @@ -48,22 +48,13 @@ export const refreshMenu = (win: BrowserWindow) => { export const mainMenuTemplate = async (win: BrowserWindow): Promise => { const innerRefreshMenu = () => refreshMenu(win); - const createContext = (name: string): MenuPluginContext => ({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error + const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], + >(name: Key): MenuPluginContext => ({ getConfig: () => config.get(`plugins.${name}`) as unknown as Config, setConfig: (newConfig) => { - config.setPartial({ - plugins: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - [name]: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - ...config.get(`plugins.${name}`), - ...newConfig, - }, - }, - }); + config.setPartial(`plugins.${name}`, newConfig); return Promise.resolve(); }, @@ -83,7 +74,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise; - const template = await factory(createContext(id)); + const template = await factory(createContext(id as never)); return { label: pluginLabel, diff --git a/src/plugins/utils/builder.ts b/src/plugins/utils/builder.ts index c4d8ec6a..9416fe64 100644 --- a/src/plugins/utils/builder.ts +++ b/src/plugins/utils/builder.ts @@ -23,8 +23,8 @@ type IF = (args: T) => T; type Promisable = T | Promise; export type PluginContext = { - getConfig: () => Promise; - setConfig: (config: DeepPartial) => Promise; + getConfig: () => Promisable; + setConfig: (config: DeepPartial) => Promisable; }; export type MainPluginContext = PluginContext & { diff --git a/src/renderer.ts b/src/renderer.ts index d8aed8ad..dcb326a0 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -144,10 +144,11 @@ const createContext = < const rendererPluginResult = await Promise.allSettled( enabledPluginNameAndOptions.map(async ([id]) => { + // HACK: eslint has a bug detects the type of rendererPlugins as "any" const builder = (rendererPlugins as Record>)[id]; - const context = createContext(id as never); - return [id, await builder(context as never)] as const; + const context = createContext(id as keyof PluginBuilderList); + return [id, await builder(context)] as const; }), ); @@ -172,12 +173,12 @@ const createContext = < console.trace(error); } }); - - window.ipcRenderer.on('config-changed', (_event, id: string, newConfig) => { + + window.ipcRenderer.on('config-changed', (_event, id: string, newConfig: PluginBaseConfig) => { const plugin = rendererPluginList.find(([pluginId]) => pluginId === id); if (plugin) { - plugin[1].onConfigChange?.(newConfig as never); + plugin[1].onConfigChange?.(newConfig); } }); From 739e7a448b13b505a04cdf24d3923d147f1f46d4 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 11:51:06 +0900 Subject: [PATCH 06/79] remove unused Promise.resolve --- src/index.ts | 2 -- src/menu.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index ff99b5cf..1eeba193 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,8 +148,6 @@ async function loadPlugins(win: BrowserWindow) { getConfig: () => config.get(`plugins.${name}`) as unknown as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); - - return Promise.resolve(); }, send: (event: string, ...args: unknown[]) => { diff --git a/src/menu.ts b/src/menu.ts index d8199246..503aad1f 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -55,8 +55,6 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise config.get(`plugins.${name}`) as unknown as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); - - return Promise.resolve(); }, window: win, }); From 794d00ce9e2a3f31da09e3232f9d01d31217fbb7 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 18:02:22 +0900 Subject: [PATCH 07/79] 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'); From f35d192650d5b2ed285cc003ec4405b4b708a404 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sat, 11 Nov 2023 18:11:24 +0900 Subject: [PATCH 08/79] fix(downloader): fix downloader plugin --- src/plugins/downloader/main/index.ts | 7 ++++--- src/plugins/downloader/renderer.ts | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts index 2e945441..9443489e 100644 --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -93,7 +93,8 @@ let config: DownloaderPluginConfig = builder.config; export default builder.createMain(({ handle, getConfig, on }) => { return { - async onLoad(win) { + async onLoad(_win) { + win = _win; config = await getConfig(); yt = await Innertube.create({ @@ -102,8 +103,8 @@ export default builder.createMain(({ handle, getConfig, on }) => { generate_session_locally: true, fetch: getNetFetchAsFetch(), }); - handle('download-song', (_, url: string) => downloadSong(url)); - on('video-src-changed', (_, data: GetPlayerResponse) => { + 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)); diff --git a/src/plugins/downloader/renderer.ts b/src/plugins/downloader/renderer.ts index 49518e75..94325eb6 100644 --- a/src/plugins/downloader/renderer.ts +++ b/src/plugins/downloader/renderer.ts @@ -13,7 +13,7 @@ const downloadButton = ElementFromHtml(downloadHTML); let doneFirstLoad = false; -export default builder.createRenderer(() => { +export default builder.createRenderer(({ invoke, on }) => { return { onLoad() { const menuObserver = new MutationObserver(() => { @@ -54,14 +54,14 @@ export default builder.createRenderer(() => { } if (videoUrl.includes('?playlist=')) { - window.ipcRenderer.send('download-playlist-request', videoUrl); + invoke('download-playlist-request', videoUrl); return; } } else { videoUrl = getSongInfo().url || window.location.href; } - window.ipcRenderer.send('download-song', videoUrl); + invoke('download-song', videoUrl); }; document.addEventListener('apiLoaded', () => { @@ -71,7 +71,7 @@ export default builder.createRenderer(() => { }); }, { once: true, passive: true }); - window.ipcRenderer.on('downloader-feedback', (_, feedback: string) => { + on('downloader-feedback', (feedback: string) => { if (progress) { progress.innerHTML = feedback || 'Download'; } else { From de0b228ae8e1fac3cfaa1d788e70f8550d46a98c Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 18:12:20 +0900 Subject: [PATCH 09/79] feat: electron-vite hot-reload --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8be51992..b794b196 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "build": "electron-vite build", "start": "electron-vite preview", "start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start", - "dev": "electron-vite dev", + "dev": "electron-vite dev --watch", "dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run dev", "clean": "del-cli dist && del-cli pack", "dist": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win --mac --linux -p never", From 1f96b6b44d8549cfcb03b0393f8e8c84c2bc072a Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 19:23:17 +0900 Subject: [PATCH 10/79] fix: plugin load --- electron.vite.config.ts | 2 + src/config/plugins.ts | 21 +++------ src/index.ts | 59 +++++++++++-------------- src/plugins/ambient-mode/renderer.ts | 1 - src/plugins/in-app-menu/main.ts | 4 +- src/plugins/lyrics-genius/main.ts | 2 +- src/plugins/lyrics-genius/renderer.ts | 2 +- src/plugins/precise-volume/renderer.ts | 4 +- src/preload.ts | 13 ++++-- src/renderer.ts | 60 +++++++++----------------- 10 files changed, 69 insertions(+), 99 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a9ecee02..5d8dafae 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -50,6 +50,7 @@ export default defineConfig({ const commonConfig: UserConfig = { plugins: [ viteResolve({ + 'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'), 'virtual:PreloadPlugins': pluginVirtualModuleGenerator('preload'), }), ], @@ -86,6 +87,7 @@ export default defineConfig({ const commonConfig: UserConfig = { plugins: [ viteResolve({ + 'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'), 'virtual:RendererPlugins': pluginVirtualModuleGenerator('renderer'), }), ], diff --git a/src/config/plugins.ts b/src/config/plugins.ts index 3092b1d2..32b516ae 100644 --- a/src/config/plugins.ts +++ b/src/config/plugins.ts @@ -1,27 +1,16 @@ -import { deepmerge } from '@fastify/deepmerge'; import store from './store'; -import defaultConfig from './defaults'; import { restart } from '../providers/app-controls'; -import { Entries } from '../utils/type-utils'; -interface Plugin { - enabled: boolean; -} +import type { PluginBaseConfig } from '../plugins/utils/builder'; -type DefaultPluginsConfig = typeof defaultConfig.plugins; -const deepmergeFn = deepmerge(); - -export function getEnabled() { - const plugins = deepmergeFn(defaultConfig.plugins, (store.get('plugins') as DefaultPluginsConfig)); - return (Object.entries(plugins) as Entries).filter(([, options]) => - (options as Plugin).enabled, - ); +export function getPlugins() { + return store.get('plugins') as Record; } export function isEnabled(plugin: string) { - const pluginConfig = (store.get('plugins') as Record)[plugin]; + const pluginConfig = (store.get('plugins') as Record)[plugin]; return pluginConfig !== undefined && pluginConfig.enabled; } @@ -69,7 +58,7 @@ export function disable(plugin: string) { export default { isEnabled, - getEnabled, + getPlugins, enable, disable, setOptions, diff --git a/src/index.ts b/src/index.ts index e126d11b..edbc2d10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ import { autoUpdater } from 'electron-updater'; import electronDebug from 'electron-debug'; import { parse } from 'node-html-parser'; +import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; + import config from './config'; import { refreshMenu, setApplicationMenu } from './menu'; @@ -26,7 +28,10 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; /* eslint-enable import/order */ import youtubeMusicCSS from './youtube-music.css?inline'; -import { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder'; + +import type { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder'; + +const deepmerge = createDeepmerge(); // Catch errors and log them unhandled({ @@ -159,42 +164,30 @@ async function loadPlugins(win: BrowserWindow) { }, }); + const pluginConfigs = config.plugins.getPlugins(); + for (const [pluginId, factory] of Object.entries(mainPlugins)) { + if (Object.hasOwn(pluginBuilders, pluginId)) { + const builder = pluginBuilders[pluginId as keyof PluginBuilderList]; + const config = deepmerge(builder.config, pluginConfigs[pluginId as keyof PluginBuilderList]); - for (const [pluginId, options] of config.plugins.getEnabled()) { - const builder = pluginBuilders[pluginId as keyof PluginBuilderList]; - const factory = (mainPlugins as Record>)[pluginId]; + if (config?.enabled) { + builder.styles?.forEach((style) => { + injectCSS(win.webContents, style); + console.log('[YTMusic]', `"${pluginId}" plugin meta data is loaded`); + }); - if (builder) { - builder.styles?.forEach((style) => { - injectCSS(win.webContents, style); - console.log('[YTMusic]', `"${pluginId}" plugin meta data is loaded`); - }); - } - - if (factory) { - try { - const context = createContext(pluginId as keyof PluginBuilderList); - const plugin = await factory(context); - loadedPluginList.push([pluginId, plugin]); - plugin.onLoad?.(win); - console.log('[YTMusic]', `"${pluginId}" plugin is loaded`); - } catch (error) { - console.error('[YTMusic]', `Cannot load plugin "${pluginId}"`); - console.trace(error); + try { + const context = createContext(pluginId as keyof PluginBuilderList); + const plugin = await (factory as MainPluginFactory)(context); + loadedPluginList.push([pluginId, plugin]); + plugin.onLoad?.(win); + console.log('[YTMusic]', `"${pluginId}" plugin is loaded`); + } catch (error) { + console.error('[YTMusic]', `Cannot load plugin "${pluginId}"`); + console.trace(error); + } } } - - // try { - // if (Object.hasOwn(mainPlugins, plugin)) { - // console.log('Loaded plugin - ' + plugin); - // const handler = mainPlugins[plugin as keyof typeof mainPlugins]; - // if (handler) { - // await handler(win, options as never); - // } - // } - // } catch (e) { - // console.error(`Failed to load plugin "${plugin}"`, e); - // } } } diff --git a/src/plugins/ambient-mode/renderer.ts b/src/plugins/ambient-mode/renderer.ts index 91a92fc1..d1c3728a 100644 --- a/src/plugins/ambient-mode/renderer.ts +++ b/src/plugins/ambient-mode/renderer.ts @@ -26,7 +26,6 @@ export default builder.createRenderer(async ({ getConfig }) => { 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'); diff --git a/src/plugins/in-app-menu/main.ts b/src/plugins/in-app-menu/main.ts index d0058ce3..5510b015 100644 --- a/src/plugins/in-app-menu/main.ts +++ b/src/plugins/in-app-menu/main.ts @@ -49,7 +49,7 @@ export default builder.createMain(({ handle, send }) => { if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender); }); - handle('get-menu-by-id', (_, commandId: number) => { + handle('get-menu-by-id', (commandId: number) => { const result = getMenuItemById(commandId); return JSON.parse(JSON.stringify( @@ -67,7 +67,7 @@ export default builder.createMain(({ handle, send }) => { handle('window-unmaximize', () => win.unmaximize()); win.on('unmaximize', () => send('window-unmaximize')); - handle('image-path-to-data-url', (_, imagePath: string) => { + handle('image-path-to-data-url', (imagePath: string) => { const nativeImageIcon = nativeImage.createFromPath(imagePath); return nativeImageIcon?.toDataURL(); }); diff --git a/src/plugins/lyrics-genius/main.ts b/src/plugins/lyrics-genius/main.ts index 3b20b69a..0c43877c 100644 --- a/src/plugins/lyrics-genius/main.ts +++ b/src/plugins/lyrics-genius/main.ts @@ -20,7 +20,7 @@ export default builder.createMain(({ handle, getConfig }) =>{ revRomanized = true; } - handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => { + handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => { const metadata = extractedSongInfo; return await fetchFromGenius(metadata); }); diff --git a/src/plugins/lyrics-genius/renderer.ts b/src/plugins/lyrics-genius/renderer.ts index ea13334c..d9c8f4ce 100644 --- a/src/plugins/lyrics-genius/renderer.ts +++ b/src/plugins/lyrics-genius/renderer.ts @@ -24,7 +24,7 @@ export default builder.createRenderer(({ on, invoke }) => ({ let unregister: (() => void) | null = null; - on('update-song-info', (_, extractedSongInfo: SongInfo) => { + on('update-song-info', (extractedSongInfo: SongInfo) => { unregister?.(); setTimeout(async () => { diff --git a/src/plugins/precise-volume/renderer.ts b/src/plugins/precise-volume/renderer.ts index 9dce69da..c878c1fe 100644 --- a/src/plugins/precise-volume/renderer.ts +++ b/src/plugins/precise-volume/renderer.ts @@ -261,8 +261,8 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { document.addEventListener('apiLoaded', (e) => { api = e.detail; - on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease)); - on('setVolume', (_, value: number) => setVolume(value)); + on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease)); + on('setVolume', (value: number) => setVolume(value)); firstRun(); }, { once: true, passive: true }); }, diff --git a/src/preload.ts b/src/preload.ts index 0d0faf39..861dabe5 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,13 +1,17 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; import is from 'electron-is'; +import { pluginBuilders } from 'virtual:PluginBuilders'; + +import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; + import config from './config'; // eslint-disable-next-line import/order import { preloadPlugins } from 'virtual:PreloadPlugins'; import { PluginBaseConfig, PluginContext, PreloadPluginFactory } from './plugins/utils/builder'; -const enabledPluginNameAndOptions = config.plugins.getEnabled(); +const deepmerge = createDeepmerge(); const createContext = < Key extends keyof PluginBuilderList, @@ -22,12 +26,15 @@ const createContext = < const preloadedPluginList = []; -enabledPluginNameAndOptions.forEach(async ([id]) => { +const pluginConfig = config.plugins.getPlugins(); +Object.entries(preloadPlugins) + .filter(([id]) => deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, pluginConfig[id as keyof PluginBuilderList])?.enabled) + .forEach(async ([id]) => { if (Object.hasOwn(preloadPlugins, id)) { const factory = (preloadPlugins as Record>)[id]; try { - const context = createContext(id); + const context = createContext(id as keyof PluginBuilderList); const plugin = await factory(context); plugin.onLoad?.(); preloadedPluginList.push(plugin); diff --git a/src/renderer.ts b/src/renderer.ts index dcb326a0..64b22f9a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -2,13 +2,17 @@ // eslint-disable-next-line import/order import { rendererPlugins } from 'virtual:RendererPlugins'; +import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; + import { PluginBaseConfig, RendererPluginContext, RendererPluginFactory } from './plugins/utils/builder'; import { startingPages } from './providers/extracted-data'; import { setupSongControls } from './providers/song-controls-front'; import setupSongInfo from './providers/song-info-front'; +import {mainPlugins} from "virtual:MainPlugins"; +import {pluginBuilders} from "virtual:PluginBuilders"; -const enabledPluginNameAndOptions = window.mainConfig.plugins.getEnabled(); +const deepmerge = createDeepmerge(); let api: Element | null = null; @@ -116,55 +120,31 @@ const createContext = < }); (async () => { - // enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => { - // if (pluginName === 'ambient-mode') { - // const builder = rendererPlugins[pluginName]; - - // try { - // const context = createContext(pluginName); - // const plugin = await builder?.(context); - // console.log(plugin); - // plugin.onLoad?.(); - // } catch (error) { - // console.error(`Error in plugin "${pluginName}"`); - // console.trace(error); - // } - // } - - // if (Object.hasOwn(rendererPlugins, pluginName)) { - // const handler = rendererPlugins[pluginName]; - // try { - // await handler?.(options as never); - // } catch (error) { - // console.error(`Error in plugin "${pluginName}"`); - // console.trace(error); - // } - // } - // }); + const pluginConfig = window.mainConfig.plugins.getPlugins(); + const rendererPluginList = Object.entries(rendererPlugins); const rendererPluginResult = await Promise.allSettled( - enabledPluginNameAndOptions.map(async ([id]) => { - // HACK: eslint has a bug detects the type of rendererPlugins as "any" - const builder = (rendererPlugins as Record>)[id]; - - const context = createContext(id as keyof PluginBuilderList); - return [id, await builder(context)] as const; - }), + rendererPluginList + .filter(([id]) => deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, pluginConfig[id as keyof PluginBuilderList])?.enabled) + .map(async ([id, builder]) => { + const context = createContext(id as keyof PluginBuilderList); + return [id, await (builder as RendererPluginFactory)(context)] as const; + }), ); - const rendererPluginList = rendererPluginResult - .map((it) => it.status === 'fulfilled' ? it.value : null) - .filter(Boolean); - rendererPluginResult.forEach((it, index) => { if (it.status === 'rejected') { - const id = enabledPluginNameAndOptions[index][0]; + const id = rendererPluginList[index][0]; console.error('[YTMusic]', `Cannot load plugin "${id}"`); console.trace(it.reason); } }); - rendererPluginList.forEach(([id, plugin]) => { + const loadedRendererPluginList = rendererPluginResult + .map((it) => it.status === 'fulfilled' ? it.value : null) + .filter(Boolean); + + loadedRendererPluginList.forEach(([id, plugin]) => { try { plugin.onLoad?.(); console.log('[YTMusic]', `"${id}" plugin is loaded`); @@ -175,7 +155,7 @@ const createContext = < }); window.ipcRenderer.on('config-changed', (_event, id: string, newConfig: PluginBaseConfig) => { - const plugin = rendererPluginList.find(([pluginId]) => pluginId === id); + const plugin = loadedRendererPluginList.find(([pluginId]) => pluginId === id); if (plugin) { plugin[1].onConfigChange?.(newConfig); From b470dbd6b990cda70fb7d8dcd6758b9356090154 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 19:32:05 +0900 Subject: [PATCH 11/79] fix: add default config --- src/index.ts | 4 ++-- src/menu.ts | 6 +++++- src/preload.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index edbc2d10..03eac02b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,7 +103,7 @@ if (is.windows()) { ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); const initHook = (win: BrowserWindow) => { - ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => config.get(`plugins.${id}` as never) ?? pluginBuilders[id].config); + ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`)) as PluginBuilderList[typeof id]['config']); ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj)); config.watch((newValue) => { @@ -148,7 +148,7 @@ async function loadPlugins(win: BrowserWindow) { Key extends keyof PluginBuilderList, Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], >(name: Key): MainPluginContext => ({ - getConfig: () => config.get(`plugins.${name}`) as unknown as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`)) as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, diff --git a/src/menu.ts b/src/menu.ts index 356ec8d4..82c0b2f8 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -2,6 +2,8 @@ import is from 'electron-is'; import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron'; import prompt from 'custom-electron-prompt'; +import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; + import { restart } from './providers/app-controls'; import config from './config'; import { startingPages } from './providers/extracted-data'; @@ -22,6 +24,8 @@ const inAppMenuActive = config.plugins.isEnabled('in-app-menu'); const betaPlugins = ['crossfade', 'lumiastream']; +const deepmerge = createDeepmerge(); + const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({ label: label || plugin, type: 'checkbox', @@ -52,7 +56,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise(name: Key): MenuPluginContext => ({ - getConfig: () => config.get(`plugins.${name}`) as unknown as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`)) as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, diff --git a/src/preload.ts b/src/preload.ts index 861dabe5..60c800d0 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -17,7 +17,7 @@ const createContext = < Key extends keyof PluginBuilderList, Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], >(name: Key): PluginContext => ({ - getConfig: () => config.get(`plugins.${name}`) as unknown as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`)) as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, From b77f5c9eccc799fc959be1f0d0c9394eefe446a4 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 20:05:38 +0900 Subject: [PATCH 12/79] fix: fix insert --- package.json | 1 + pnpm-lock.yaml | 8 ++++++ src/config/index.ts | 2 +- src/index.ts | 40 ++++++++++++++------------ src/menu.ts | 2 +- src/plugins/picture-in-picture/main.ts | 5 ++-- src/preload.ts | 4 +-- src/renderer.ts | 6 ++-- 8 files changed, 40 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index b794b196..05dcd5d9 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "conf": "10.2.0", "custom-electron-prompt": "1.5.7", "dbus-next": "0.10.2", + "deepmerge-ts": "5.1.0", "electron-debug": "3.2.0", "electron-is": "3.0.0", "electron-localshortcut": "3.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35f385e1..abc95b1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ dependencies: dbus-next: specifier: 0.10.2 version: 0.10.2 + deepmerge-ts: + specifier: 5.1.0 + version: 5.1.0 electron-debug: specifier: 3.2.0 version: 3.2.0 @@ -2265,6 +2268,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge-ts@5.1.0: + resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==} + engines: {node: '>=16.0.0'} + dev: false + /deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} diff --git a/src/config/index.ts b/src/config/index.ts index fb081f2b..d88c23d8 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -14,7 +14,7 @@ const set = (key: string, value: unknown) => { store.set(key, value); }; const setPartial = (key: string, value: object) => { - deepmerge(store.get(key), value); + deepmerge(store.get(key) ?? {}, value); store.set(value); }; diff --git a/src/index.ts b/src/index.ts index 03eac02b..0cb2e6ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,7 +103,7 @@ if (is.windows()) { ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); const initHook = (win: BrowserWindow) => { - ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`)) as PluginBuilderList[typeof id]['config']); + ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as PluginBuilderList[typeof id]['config']); ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj)); config.watch((newValue) => { @@ -148,7 +148,7 @@ async function loadPlugins(win: BrowserWindow) { Key extends keyof PluginBuilderList, Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], >(name: Key): MainPluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`)) as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, @@ -167,25 +167,29 @@ async function loadPlugins(win: BrowserWindow) { const pluginConfigs = config.plugins.getPlugins(); for (const [pluginId, factory] of Object.entries(mainPlugins)) { if (Object.hasOwn(pluginBuilders, pluginId)) { - const builder = pluginBuilders[pluginId as keyof PluginBuilderList]; - const config = deepmerge(builder.config, pluginConfigs[pluginId as keyof PluginBuilderList]); + try { + const builder = pluginBuilders[pluginId as keyof PluginBuilderList]; + const config = deepmerge(builder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); - if (config?.enabled) { - builder.styles?.forEach((style) => { - injectCSS(win.webContents, style); - console.log('[YTMusic]', `"${pluginId}" plugin meta data is loaded`); - }); + if (config.enabled) { + builder.styles?.forEach((style) => { + injectCSS(win.webContents, style); + }); + console.log('[YTMusic]', `"${pluginId}" plugin data is loaded`); - try { - const context = createContext(pluginId as keyof PluginBuilderList); - const plugin = await (factory as MainPluginFactory)(context); - loadedPluginList.push([pluginId, plugin]); - plugin.onLoad?.(win); - console.log('[YTMusic]', `"${pluginId}" plugin is loaded`); - } catch (error) { - console.error('[YTMusic]', `Cannot load plugin "${pluginId}"`); - console.trace(error); + try { + const context = createContext(pluginId as keyof PluginBuilderList); + const plugin = await (factory as MainPluginFactory)(context); + loadedPluginList.push([pluginId, plugin]); + plugin.onLoad?.(win); + console.log('[YTMusic]', `"${pluginId}" plugin is loaded`); + } catch (error) { + console.error('[YTMusic]', `Cannot load plugin "${pluginId}"`); + console.trace(error); + } } + } catch(err) { + console.log('[YTMusic]', `Cannot initialize "${pluginId}" plugin: ${String(err)}`); } } } diff --git a/src/menu.ts b/src/menu.ts index 82c0b2f8..314ca8e0 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -56,7 +56,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise(name: Key): MenuPluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`)) as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, diff --git a/src/plugins/picture-in-picture/main.ts b/src/plugins/picture-in-picture/main.ts index 4b3cb59b..4300ceac 100644 --- a/src/plugins/picture-in-picture/main.ts +++ b/src/plugins/picture-in-picture/main.ts @@ -6,7 +6,7 @@ import builder, { PictureInPicturePluginConfig } from './index'; import { injectCSS } from '../utils/main'; -export default builder.createMain(({ getConfig, setConfig, send, handle }) => { +export default builder.createMain(({ getConfig, setConfig, send, handle, on }) => { let isInPiP = false; let originalPosition: number[]; let originalSize: number[]; @@ -96,8 +96,7 @@ export default builder.createMain(({ getConfig, setConfig, send, handle }) => { config ??= await getConfig(); win ??= window; setConfig({ isInPiP }); - injectCSS(win.webContents, style); - ipcMain.on('picture-in-picture', () => { + on('picture-in-picture', () => { togglePiP(); }); diff --git a/src/preload.ts b/src/preload.ts index 60c800d0..733d697a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -17,7 +17,7 @@ const createContext = < Key extends keyof PluginBuilderList, Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], >(name: Key): PluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`)) as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, @@ -28,7 +28,7 @@ const preloadedPluginList = []; const pluginConfig = config.plugins.getPlugins(); Object.entries(preloadPlugins) - .filter(([id]) => deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, pluginConfig[id as keyof PluginBuilderList])?.enabled) + .filter(([id]) => deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, pluginConfig[id as keyof PluginBuilderList] ?? {}).enabled) .forEach(async ([id]) => { if (Object.hasOwn(preloadPlugins, id)) { const factory = (preloadPlugins as Record>)[id]; diff --git a/src/renderer.ts b/src/renderer.ts index 64b22f9a..5a9d122a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -4,13 +4,13 @@ import { rendererPlugins } from 'virtual:RendererPlugins'; import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; +import { pluginBuilders } from 'virtual:PluginBuilders'; + import { PluginBaseConfig, RendererPluginContext, RendererPluginFactory } from './plugins/utils/builder'; import { startingPages } from './providers/extracted-data'; import { setupSongControls } from './providers/song-controls-front'; import setupSongInfo from './providers/song-info-front'; -import {mainPlugins} from "virtual:MainPlugins"; -import {pluginBuilders} from "virtual:PluginBuilders"; const deepmerge = createDeepmerge(); @@ -125,7 +125,7 @@ const createContext = < const rendererPluginList = Object.entries(rendererPlugins); const rendererPluginResult = await Promise.allSettled( rendererPluginList - .filter(([id]) => deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, pluginConfig[id as keyof PluginBuilderList])?.enabled) + .filter(([id]) => deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, pluginConfig[id as keyof PluginBuilderList] ?? {}).enabled) .map(async ([id, builder]) => { const context = createContext(id as keyof PluginBuilderList); return [id, await (builder as RendererPluginFactory)(context)] as const; From 6ab3cf9ac9d270a5b72856c7fbf0be9a21df518b Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sat, 11 Nov 2023 20:58:16 +0900 Subject: [PATCH 13/79] fix(frontend): fix cannot apply style --- src/index.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0cb2e6ce..db4cdf53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,7 +138,7 @@ async function loadPlugins(win: BrowserWindow) { win.webContents.once('did-finish-load', () => { if (is.dev()) { - console.log('did finish load'); + console.log('[YTMusic]', 'did finish load'); win.webContents.openDevTools(); } }); @@ -165,6 +165,22 @@ async function loadPlugins(win: BrowserWindow) { }); const pluginConfigs = config.plugins.getPlugins(); + for (const [pluginId, builder] of Object.entries(pluginBuilders)) { + const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; + + const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + + if (config.enabled) { + typedBuilder.styles?.forEach((style) => { + injectCSS(win.webContents, style, () => { + console.log('[YTMusic]', `Injected CSS for "${pluginId}" plugin`); + }); + }); + + console.log('[YTMusic]', `"${pluginId}" plugin data is loaded`); + } + } + for (const [pluginId, factory] of Object.entries(mainPlugins)) { if (Object.hasOwn(pluginBuilders, pluginId)) { try { @@ -172,11 +188,6 @@ async function loadPlugins(win: BrowserWindow) { const config = deepmerge(builder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); if (config.enabled) { - builder.styles?.forEach((style) => { - injectCSS(win.webContents, style); - }); - console.log('[YTMusic]', `"${pluginId}" plugin data is loaded`); - try { const context = createContext(pluginId as keyof PluginBuilderList); const plugin = await (factory as MainPluginFactory)(context); From bb2e865880bf1f274d33dfce0f4e13d26cb26158 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sat, 11 Nov 2023 21:48:51 +0900 Subject: [PATCH 14/79] wip: trying to fix `electron-store` issue Co-authored-by: JellyBrick --- package.json | 2 +- pnpm-lock.yaml | 15 ++++++++------- src/config/index.ts | 15 ++++++--------- src/index.ts | 27 +++++++++++++++------------ src/menu.ts | 7 ++----- src/plugins/ambient-mode/renderer.ts | 1 + src/preload.ts | 13 ++++++++----- src/renderer.ts | 13 ++++++++----- 8 files changed, 49 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index 05dcd5d9..30c27bbe 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,6 @@ "dependencies": { "@cliqz/adblocker-electron": "1.26.11", "@cliqz/adblocker-electron-preload": "1.26.11", - "@fastify/deepmerge": "1.3.0", "@ffmpeg.wasm/core-mt": "0.12.0", "@ffmpeg.wasm/main": "0.12.0", "@foobar404/wave": "2.0.4", @@ -157,6 +156,7 @@ "electron-unhandled": "4.0.1", "electron-updater": "6.1.4", "fast-average-color": "9.4.0", + "fast-equals": "^5.0.1", "filenamify": "6.0.0", "howler": "2.2.4", "html-to-text": "9.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abc95b1e..6fe005dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,9 +19,6 @@ dependencies: '@cliqz/adblocker-electron-preload': specifier: 1.26.11 version: 1.26.11(electron@27.0.4) - '@fastify/deepmerge': - specifier: 1.3.0 - version: 1.3.0 '@ffmpeg.wasm/core-mt': specifier: 0.12.0 version: 0.12.0 @@ -82,6 +79,9 @@ dependencies: fast-average-color: specifier: 9.4.0 version: 9.4.0 + fast-equals: + specifier: ^5.0.1 + version: 5.0.1 filenamify: specifier: 6.0.0 version: 6.0.0 @@ -793,10 +793,6 @@ packages: engines: {node: '>=14'} dev: false - /@fastify/deepmerge@1.3.0: - resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==} - dev: false - /@ffmpeg.wasm/core-mt@0.12.0: resolution: {integrity: sha512-M9pjL7JQX4AYl3WI8vGcPGPTz/O7JmhW8ac/fHA3oXTxoRAPwYSY/OsY1N9C0XahIM0+fxa1QSLN9Ekz8sBM/Q==} dev: false @@ -3082,6 +3078,11 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: true + /fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + dev: false + /fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} diff --git a/src/config/index.ts b/src/config/index.ts index d88c23d8..a618d0ae 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,6 +1,5 @@ import Store from 'electron-store'; - -import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; +import { deepmerge } from 'deepmerge-ts'; import defaultConfig from './defaults'; import plugins from './plugins'; @@ -8,14 +7,13 @@ import store from './store'; import { restart } from '../providers/app-controls'; -const deepmerge = createDeepmerge(); - const set = (key: string, value: unknown) => { store.set(key, value); }; const setPartial = (key: string, value: object) => { - deepmerge(store.get(key) ?? {}, value); - store.set(value); + const newValue = deepmerge(store.get(key) ?? {}, value); + console.log('sival', key, value, newValue); + store.set(newValue); }; function setMenuOption(key: string, value: unknown) { @@ -53,9 +51,8 @@ export default { setPartial, setMenuOption, edit: () => store.openInEditor(), - watch(cb: Parameters[1]) { - store.onDidChange('options', cb); - store.onDidChange('plugins', cb); + watch(cb: Parameters[0]) { + store.onDidAnyChange(cb); }, plugins, }; diff --git a/src/index.ts b/src/index.ts index db4cdf53..a109d396 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,8 @@ import unhandled from 'electron-unhandled'; import { autoUpdater } from 'electron-updater'; import electronDebug from 'electron-debug'; import { parse } from 'node-html-parser'; - -import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; +import { deepmerge } from 'deepmerge-ts'; +import { deepEqual } from 'fast-equals'; import config from './config'; @@ -31,8 +31,6 @@ import youtubeMusicCSS from './youtube-music.css?inline'; import type { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder'; -const deepmerge = createDeepmerge(); - // Catch errors and log them unhandled({ logger: console.error, @@ -106,14 +104,19 @@ const initHook = (win: BrowserWindow) => { ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as PluginBuilderList[typeof id]['config']); ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj)); - config.watch((newValue) => { - const value = newValue as Record; - const id = Object.keys(pluginBuilders).find((id) => id in value); + config.watch((newValue, oldValue) => { + const newPluginConfigList = (newValue?.plugins ?? {}) as Record; + const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record; - if (id) { - win.webContents.send('config-changed', id, value[id]); - // console.log('config-changed', id, value[id]); - } + Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => { + const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig); + + console.log('check', id, isEqual, ';', oldPluginConfigList[id], newPluginConfig); + if (!isEqual) { + win.webContents.send('config-changed', id, newPluginConfig); + console.log('config-changed', id, newPluginConfig); + } + }); }); }; @@ -148,7 +151,7 @@ async function loadPlugins(win: BrowserWindow) { Key extends keyof PluginBuilderList, Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], >(name: Key): MainPluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as unknown as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, diff --git a/src/menu.ts b/src/menu.ts index 314ca8e0..d270d9d9 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,8 +1,7 @@ import is from 'electron-is'; import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron'; import prompt from 'custom-electron-prompt'; - -import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; +import { deepmerge } from 'deepmerge-ts'; import { restart } from './providers/app-controls'; import config from './config'; @@ -24,8 +23,6 @@ const inAppMenuActive = config.plugins.isEnabled('in-app-menu'); const betaPlugins = ['crossfade', 'lumiastream']; -const deepmerge = createDeepmerge(); - const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({ label: label || plugin, type: 'checkbox', @@ -56,7 +53,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise(name: Key): MenuPluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as unknown as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, diff --git a/src/plugins/ambient-mode/renderer.ts b/src/plugins/ambient-mode/renderer.ts index d1c3728a..105b9a24 100644 --- a/src/plugins/ambient-mode/renderer.ts +++ b/src/plugins/ambient-mode/renderer.ts @@ -84,6 +84,7 @@ export default builder.createRenderer(async ({ getConfig }) => { blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`); blurCanvas.style.setProperty('--blur', `${blur}px`); blurCanvas.style.setProperty('--opacity', `${opacity}`); + console.log('updated!!!'); }; update = applyVideoAttributes; diff --git a/src/preload.ts b/src/preload.ts index 733d697a..5f6ac5d0 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -3,7 +3,7 @@ import is from 'electron-is'; import { pluginBuilders } from 'virtual:PluginBuilders'; -import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; +import { deepmerge } from 'deepmerge-ts'; import config from './config'; @@ -11,13 +11,11 @@ import config from './config'; import { preloadPlugins } from 'virtual:PreloadPlugins'; import { PluginBaseConfig, PluginContext, PreloadPluginFactory } from './plugins/utils/builder'; -const deepmerge = createDeepmerge(); - const createContext = < Key extends keyof PluginBuilderList, Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], >(name: Key): PluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as Config, + getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as unknown as Config, setConfig: (newConfig) => { config.setPartial(`plugins.${name}`, newConfig); }, @@ -28,7 +26,12 @@ const preloadedPluginList = []; const pluginConfig = config.plugins.getPlugins(); Object.entries(preloadPlugins) - .filter(([id]) => deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, pluginConfig[id as keyof PluginBuilderList] ?? {}).enabled) + .filter(([id]) => { + const typedId = id as keyof PluginBuilderList; + const config = deepmerge(pluginBuilders[typedId].config, pluginConfig[typedId] ?? {}); + + return config.enabled; + }) .forEach(async ([id]) => { if (Object.hasOwn(preloadPlugins, id)) { const factory = (preloadPlugins as Record>)[id]; diff --git a/src/renderer.ts b/src/renderer.ts index 5a9d122a..603dd21b 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -2,18 +2,16 @@ // eslint-disable-next-line import/order import { rendererPlugins } from 'virtual:RendererPlugins'; -import { deepmerge as createDeepmerge } from '@fastify/deepmerge'; - import { pluginBuilders } from 'virtual:PluginBuilders'; +import { deepmerge } from 'deepmerge-ts'; + import { PluginBaseConfig, RendererPluginContext, RendererPluginFactory } from './plugins/utils/builder'; import { startingPages } from './providers/extracted-data'; import { setupSongControls } from './providers/song-controls-front'; import setupSongInfo from './providers/song-info-front'; -const deepmerge = createDeepmerge(); - let api: Element | null = null; function listenForApiLoad() { @@ -125,7 +123,12 @@ const createContext = < const rendererPluginList = Object.entries(rendererPlugins); const rendererPluginResult = await Promise.allSettled( rendererPluginList - .filter(([id]) => deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, pluginConfig[id as keyof PluginBuilderList] ?? {}).enabled) + .filter(([id]) => { + const typedId = id as keyof PluginBuilderList; + const config = deepmerge(pluginBuilders[typedId].config, pluginConfig[typedId] ?? {}); + + return config.enabled; + }) .map(async ([id, builder]) => { const context = createContext(id as keyof PluginBuilderList); return [id, await (builder as RendererPluginFactory)(context)] as const; From 7242f9bfd0f6fc129b4f50c9462f460c506c2cb3 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sat, 11 Nov 2023 22:08:44 +0900 Subject: [PATCH 15/79] fix(plugin): fix `onChangeConfig` hook --- src/config/index.ts | 5 ++--- src/index.ts | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index a618d0ae..f8bdff14 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -12,11 +12,10 @@ const set = (key: string, value: unknown) => { }; const setPartial = (key: string, value: object) => { const newValue = deepmerge(store.get(key) ?? {}, value); - console.log('sival', key, value, newValue); - store.set(newValue); + store.set(key, newValue); }; -function setMenuOption(key: string, value: unknown) { +function setMenuOption(key: string, value: unknown) { set(key, value); if (store.get('options.restartOnConfigChanges')) { restart(); diff --git a/src/index.ts b/src/index.ts index a109d396..837bc4d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,10 +111,10 @@ const initHook = (win: BrowserWindow) => { Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => { const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig); - console.log('check', id, isEqual, ';', oldPluginConfigList[id], newPluginConfig); if (!isEqual) { - win.webContents.send('config-changed', id, newPluginConfig); - console.log('config-changed', id, newPluginConfig); + const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig); + + win.webContents.send('config-changed', id, config); } }); }); From c7ff0dcbf6c9ea9712d668cd33d5c91777936f91 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sat, 11 Nov 2023 22:43:51 +0900 Subject: [PATCH 16/79] fix: remove defaults Co-authored-by: Su-Yong --- src/config/defaults.ts | 248 ++----------------------------- src/config/store.ts | 26 ++-- src/plugins/utils/main/plugin.ts | 23 +-- 3 files changed, 38 insertions(+), 259 deletions(-) diff --git a/src/config/defaults.ts b/src/config/defaults.ts index ff49b9cb..f6f0e646 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,22 +1,17 @@ -import { blockers } from '../plugins/adblocker/types'; - -import { DefaultPresetList } from '../plugins/downloader/types'; - export interface WindowSizeConfig { width: number; height: number; } +export interface WindowPositionConfig { + x: number; + y: number; +} + export interface DefaultConfig { - 'window-size': { - width: number; - height: number; - } + 'window-size': WindowSizeConfig; 'window-maximized': boolean; - 'window-position': { - x: number; - y: number; - } + 'window-position': WindowPositionConfig; url: string; options: { tray: boolean; @@ -37,10 +32,11 @@ export interface DefaultConfig { startingPage: string; overrideUserAgent: boolean; themes: string[]; - } + }, + plugins: Record, } -const defaultConfig = { +const defaultConfig: DefaultConfig = { 'window-size': { width: 1100, height: 550, @@ -69,229 +65,9 @@ const defaultConfig = { proxy: '', startingPage: '', overrideUserAgent: false, - themes: [] as string[], - }, - /** please order alphabetically */ - 'plugins': { - 'adblocker': { - enabled: true, - cache: true, - blocker: blockers.InPlayer as string, - additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt" - disableDefaultLists: false, - }, - 'album-color-theme': {}, - 'ambient-mode': { - enabled: false, - quality: 50, - buffer: 30, - interpolationTime: 1500, - blur: 100, - size: 100, - opacity: 1, - fullscreen: false, - }, - 'audio-compressor': {}, - 'blur-nav-bar': {}, - 'bypass-age-restrictions': {}, - 'captions-selector': { - enabled: false, - disableCaptions: false, - autoload: false, - lastCaptionsCode: '', - }, - 'compact-sidebar': {}, - 'crossfade': { - enabled: false, - fadeInDuration: 1500, // Ms - fadeOutDuration: 5000, // Ms - secondsBeforeEnd: 10, // S - fadeScaling: 'linear', // 'linear', 'logarithmic' or a positive number in dB - }, - 'disable-autoplay': { - applyOnce: false, - }, - 'discord': { - enabled: false, - autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect - activityTimoutEnabled: true, // If enabled, the discord rich presence gets cleared when music paused after the time specified below - activityTimoutTime: 10 * 60 * 1000, // 10 minutes - playOnYouTubeMusic: true, // Add a "Play on YouTube Music" button to rich presence - hideGitHubButton: false, // Disable the "View App On GitHub" button - hideDurationLeft: false, // Hides the start and end time of the song to rich presence - }, - 'downloader': { - enabled: false, - downloadFolder: undefined as string | undefined, // Custom download folder (absolute path) - selectedPreset: 'mp3 (256kbps)', // Selected preset - customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets - skipExisting: false, - playlistMaxItems: undefined as number | undefined, - }, - 'exponential-volume': {}, - 'in-app-menu': { - /** - * true in Windows, false in Linux and macOS (see youtube-music/config/store.ts) - */ - enabled: false, - hideDOMWindowControls: false, - }, - 'last-fm': { - enabled: false, - token: undefined as string | undefined, // Token used for authentication - session_key: undefined as string | undefined, // Session key used for scrobbling - api_root: 'http://ws.audioscrobbler.com/2.0/', - api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123 - secret: 'a5d2a36fdf64819290f6982481eaffa2', - }, - 'lumiastream': {}, - 'lyrics-genius': { - romanizedLyrics: false, - }, - 'navigation': { - enabled: true, - }, - 'no-google-login': {}, - 'notifications': { - 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, - }, - 'picture-in-picture': { - 'enabled': false, - 'alwaysOnTop': true, - 'savePosition': true, - 'saveSize': false, - 'hotkey': 'P', - 'pip-position': [10, 10], - 'pip-size': [450, 275], - 'isInPiP': false, - 'useNativePiP': true, - }, - 'playback-speed': {}, - 'precise-volume': { - enabled: false, - steps: 1, // Percentage of volume to change - arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts - globalShortcuts: { - volumeUp: '', - volumeDown: '', - }, - savedVolume: undefined as number | undefined, // Plugin save volume between session here - }, - 'quality-changer': {}, - 'shortcuts': { - enabled: false, - overrideMediaKeys: false, - global: { - previous: '', - playPause: '', - next: '', - } as Record, - local: { - previous: '', - playPause: '', - next: '', - } as Record, - }, - 'skip-silences': { - onlySkipBeginning: false, - }, - 'sponsorblock': { - enabled: false, - apiURL: 'https://sponsor.ajay.app', - categories: [ - 'sponsor', - 'intro', - 'outro', - 'interaction', - 'selfpromo', - 'music_offtopic', - ], - }, - 'taskbar-mediacontrol': {}, - 'touchbar': {}, - 'tuna-obs': {}, - 'video-toggle': { - enabled: false, - hideVideo: false, - mode: 'custom', - forceHide: false, - align: '', - }, - 'visualizer': { - 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', - }, - }, - ], - }, - }, + themes: [], }, + 'plugins': {}, }; export default defaultConfig; diff --git a/src/config/store.ts b/src/config/store.ts index e9271d64..80b6c8ff 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,21 +1,15 @@ import Store from 'electron-store'; import Conf from 'conf'; -import is from 'electron-is'; + +import { pluginBuilders } from 'virtual:PluginBuilders'; import defaults from './defaults'; import { DefaultPresetList, type Preset } from '../plugins/downloader/types'; -const getDefaults = () => { - if (is.windows()) { - defaults.plugins['in-app-menu'].enabled = true; - } - return defaults; -}; - -const setDefaultPluginOptions = (store: Conf>, plugin: keyof typeof defaults.plugins) => { +const setDefaultPluginOptions = (store: Conf>, plugin: keyof typeof pluginBuilders) => { if (!store.get(`plugins.${plugin}`)) { - store.set(`plugins.${plugin}`, defaults.plugins[plugin]); + store.set(`plugins.${plugin}`, pluginBuilders[plugin].config); } }; @@ -53,7 +47,7 @@ const migrations = { if (store.get('plugins.notifications.toastStyle') === undefined) { const pluginOptions = store.get('plugins.notifications') || {}; store.set('plugins.notifications', { - ...defaults.plugins.notifications, + ...pluginBuilders.notifications.config, ...pluginOptions, }); } @@ -155,7 +149,15 @@ const migrations = { }; export default new Store({ - defaults: getDefaults(), + defaults: { + ...defaults, + plugins: Object + .entries(pluginBuilders) + .reduce((prev, [id, builder]) => ({ + ...prev, + [id]: (builder as PluginBuilderList[keyof PluginBuilderList]).config, + }), {}), + }, clearInvalidConfig: false, migrations, }); diff --git a/src/plugins/utils/main/plugin.ts b/src/plugins/utils/main/plugin.ts index 4505e7ff..da9aab19 100644 --- a/src/plugins/utils/main/plugin.ts +++ b/src/plugins/utils/main/plugin.ts @@ -1,16 +1,17 @@ import is from 'electron-is'; -import defaultConfig from '../../../config/defaults'; +import { pluginBuilders } from 'virtual:PluginBuilders'; export const getAvailablePluginNames = () => { - return Object.keys(defaultConfig.plugins).filter((name) => { - if (is.windows() && name === 'touchbar') { - return false; - } else if (is.macOS() && name === 'taskbar-mediacontrol') { - return false; - } else if (is.linux() && (name === 'taskbar-mediacontrol' || name === 'touchbar')) { - return false; - } - return true; - }); + return Object.keys(pluginBuilders) + .filter((id) => { + if (is.windows() && id === 'touchbar') { + return false; + } else if (is.macOS() && id === 'taskbar-mediacontrol') { + return false; + } else if (is.linux() && (id === 'taskbar-mediacontrol' || id === 'touchbar')) { + return false; + } + return true; + }); }; From bc916f3a6e2812e76f32d71fc39f616550b44470 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 00:09:56 +0900 Subject: [PATCH 17/79] refactor(plugin): refactor plugin loader and add dynamic loading --- src/index.ts | 92 ++++-------------------- src/loader/main.ts | 128 ++++++++++++++++++++++++++++++++++ src/plugins/utils/main/css.ts | 41 ++++++----- 3 files changed, 168 insertions(+), 93 deletions(-) create mode 100644 src/loader/main.ts diff --git a/src/index.ts b/src/index.ts index 837bc4d1..ee0a8062 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,8 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; import youtubeMusicCSS from './youtube-music.css?inline'; -import type { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder'; +import { getLoadedAllPlugins, loadAllPlugins, registerMainPlugin } from './loader/main'; +import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder'; // Catch errors and log them unhandled({ @@ -87,17 +88,6 @@ function onClosed() { mainWindow = null; } -export const mainPluginNames = Object.keys(mainPlugins); - -if (is.windows()) { - delete mainPlugins['touchbar']; -} else if (is.macOS()) { - delete mainPlugins['taskbar-mediacontrol']; -} else { - delete mainPlugins['touchbar']; - delete mainPlugins['taskbar-mediacontrol']; -} - ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); const initHook = (win: BrowserWindow) => { @@ -114,14 +104,16 @@ const initHook = (win: BrowserWindow) => { if (!isEqual) { const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig); + const mainPlugin = getLoadedAllPlugins()[id]; + if (mainPlugin) mainPlugin.onConfigChange?.(config as PluginBaseConfig); + win.webContents.send('config-changed', id, config); } }); }); }; -const loadedPluginList: [string, MainPlugin][] = []; -async function loadPlugins(win: BrowserWindow) { +function initTheme(win: BrowserWindow) { injectCSS(win.webContents, youtubeMusicCSS); // Load user CSS const themes: string[] = config.get('options.themes'); @@ -145,68 +137,6 @@ async function loadPlugins(win: BrowserWindow) { win.webContents.openDevTools(); } }); - - - const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], - >(name: Key): MainPluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as unknown as Config, - setConfig: (newConfig) => { - config.setPartial(`plugins.${name}`, newConfig); - }, - - send: (event: string, ...args: unknown[]) => { - win.webContents.send(event, ...args); - }, - 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)); - }, - }); - - const pluginConfigs = config.plugins.getPlugins(); - for (const [pluginId, builder] of Object.entries(pluginBuilders)) { - const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; - - const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); - - if (config.enabled) { - typedBuilder.styles?.forEach((style) => { - injectCSS(win.webContents, style, () => { - console.log('[YTMusic]', `Injected CSS for "${pluginId}" plugin`); - }); - }); - - console.log('[YTMusic]', `"${pluginId}" plugin data is loaded`); - } - } - - for (const [pluginId, factory] of Object.entries(mainPlugins)) { - if (Object.hasOwn(pluginBuilders, pluginId)) { - try { - const builder = pluginBuilders[pluginId as keyof PluginBuilderList]; - const config = deepmerge(builder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); - - if (config.enabled) { - try { - const context = createContext(pluginId as keyof PluginBuilderList); - const plugin = await (factory as MainPluginFactory)(context); - loadedPluginList.push([pluginId, plugin]); - plugin.onLoad?.(win); - console.log('[YTMusic]', `"${pluginId}" plugin is loaded`); - } catch (error) { - console.error('[YTMusic]', `Cannot load plugin "${pluginId}"`); - console.trace(error); - } - } - } catch(err) { - console.log('[YTMusic]', `Cannot initialize "${pluginId}" plugin: ${String(err)}`); - } - } - } } async function createMainWindow() { @@ -248,7 +178,15 @@ async function createMainWindow() { autoHideMenuBar: config.get('options.hideMenu'), }); initHook(win); - await loadPlugins(win); + initTheme(win); + + Object.entries(pluginBuilders).forEach(([id, builder]) => { + const typedBuilder = builder as PluginBuilder; + const plugin = mainPlugins[id] as MainPluginFactory | undefined; + + registerMainPlugin(id, typedBuilder, plugin); + }); + await loadAllPlugins(win); if (windowPosition) { const { x: windowX, y: windowY } = windowPosition; diff --git a/src/loader/main.ts b/src/loader/main.ts new file mode 100644 index 00000000..79424623 --- /dev/null +++ b/src/loader/main.ts @@ -0,0 +1,128 @@ +import { BrowserWindow, ipcMain } from 'electron'; + +import { deepmerge } from 'deepmerge-ts'; + +import config from '../config'; +import { injectCSS } from '../plugins/utils/main'; +import { + MainPlugin, + MainPluginContext, + MainPluginFactory, + PluginBaseConfig, + PluginBuilder +} from '../plugins/utils/builder'; + +const allPluginFactoryList: Record> = {}; +const allPluginBuilders: Record> = {}; +const unregisterStyleMap: Record void)[]> = {}; +const loadedPluginMap: Record> = {}; + +const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], +>(id: Key, win: BrowserWindow): MainPluginContext => ({ + getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config, + setConfig: (newConfig) => { + config.setPartial(`plugins.${id}`, newConfig); + }, + + send: (event: string, ...args: unknown[]) => { + win.webContents.send(event, ...args); + }, + 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)); + }, +}); + +const forceUnloadPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => { + unregisterStyleMap[id]?.forEach((unregister) => unregister()); + delete unregisterStyleMap[id]; + + loadedPluginMap[id]?.onUnload?.(win); + delete loadedPluginMap[id]; + + console.log('[YTMusic]', `"${id}" plugin is unloaded`); +}; + +export const forceLoadPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => { + const builder = allPluginBuilders[id]; + + Promise.allSettled( + builder.styles?.map(async (style) => { + const unregister = await injectCSS(win.webContents, style); + console.log('[YTMusic]', `Injected CSS for "${id}" plugin`); + + return unregister; + }) ?? [], + ).then((result) => { + unregisterStyleMap[id] = result + .map((it) => it.status === 'fulfilled' && it.value) + .filter(Boolean); + + let isInjectSuccess = true; + result.forEach((it) => { + if (it.status === 'rejected') { + isInjectSuccess = false; + + console.log('[YTMusic]', `Cannot inject "${id}" plugin style: ${String(it.reason)}`); + } + }); + if (isInjectSuccess) console.log('[YTMusic]', `"${id}" plugin data is loaded`); + }); + + try { + const factory = allPluginFactoryList[id]; + if (!factory) return; + + const context = createContext(id, win); + const plugin = await factory(context); + loadedPluginMap[id] = plugin; + plugin.onLoad?.(win); + + console.log('[YTMusic]', `"${id}" plugin is loaded`); + } catch (err) { + console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); + } +}; + +export const loadAllPlugins = async (win: BrowserWindow) => { + const pluginConfigs = config.plugins.getPlugins(); + + for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { + const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; + + const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + + if (config.enabled) { + await forceLoadPlugin(pluginId as keyof PluginBuilderList, win); + } else { + if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { + forceUnloadPlugin(pluginId as keyof PluginBuilderList, win); + } + } + } +}; + +export const unloadAllPlugins = (win: BrowserWindow) => { + for (const id of Object.keys(loadedPluginMap)) { + forceUnloadPlugin(id as keyof PluginBuilderList, win); + } +}; + +export const getLoadedPlugin = (id: Key): MainPlugin | undefined => { + return loadedPluginMap[id]; +}; +export const getLoadedAllPlugins = () => { + return loadedPluginMap; +}; +export const registerMainPlugin = ( + id: string, + builder: PluginBuilder, + factory?: MainPluginFactory, +) => { + if (factory) allPluginFactoryList[id] = factory; + allPluginBuilders[id] = builder; +}; diff --git a/src/plugins/utils/main/css.ts b/src/plugins/utils/main/css.ts index c7dd40cc..11981988 100644 --- a/src/plugins/utils/main/css.ts +++ b/src/plugins/utils/main/css.ts @@ -1,33 +1,42 @@ import fs from 'node:fs'; -const cssToInject = new Map void) | undefined>(); -const cssToInjectFile = new Map void) | undefined>(); -export const injectCSS = (webContents: Electron.WebContents, css: string, cb: (() => void) | undefined = undefined) => { - if (cssToInject.size === 0 && cssToInjectFile.size === 0) { - setupCssInjection(webContents); - } +type Unregister = () => void; - cssToInject.set(css, cb); +const cssToInject = new Map void) | undefined>(); +const cssToInjectFile = new Map void) | undefined>(); +export const injectCSS = (webContents: Electron.WebContents, css: string): Promise => { + return new Promise((resolve) => { + if (cssToInject.size === 0 && cssToInjectFile.size === 0) { + setupCssInjection(webContents); + } + cssToInject.set(css, resolve); + }); }; -export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: string, cb: (() => void) | undefined = undefined) => { - if (cssToInject.size === 0 && cssToInjectFile.size === 0) { - setupCssInjection(webContents); - } +export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: string): Promise => { + return new Promise((resolve) => { + if (cssToInject.size === 0 && cssToInjectFile.size === 0) { + setupCssInjection(webContents); + } - cssToInjectFile.set(filepath, cb); + cssToInjectFile.set(filepath, resolve); + }); }; const setupCssInjection = (webContents: Electron.WebContents) => { webContents.on('did-finish-load', () => { cssToInject.forEach(async (callback, css) => { - await webContents.insertCSS(css); - callback?.(); + const key = await webContents.insertCSS(css); + const remove = async () => await webContents.removeInsertedCSS(key); + + callback?.(remove); }); cssToInjectFile.forEach(async (callback, filepath) => { - await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8')); - callback?.(); + const key = await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8')); + const remove = async () => await webContents.removeInsertedCSS(key); + + callback?.(remove); }); }); }; From ef71abfff1a830617e117f3d15e6f2d65fb7afdb Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 00:21:34 +0900 Subject: [PATCH 18/79] refactor(plugin): add renderer-plugin-loader --- src/index.ts | 8 ++-- src/loader/main.ts | 18 ++++---- src/loader/renderer.ts | 97 ++++++++++++++++++++++++++++++++++++++++++ src/renderer.ts | 74 ++++---------------------------- 4 files changed, 119 insertions(+), 78 deletions(-) create mode 100644 src/loader/renderer.ts diff --git a/src/index.ts b/src/index.ts index ee0a8062..d9f9fa82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,7 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; import youtubeMusicCSS from './youtube-music.css?inline'; -import { getLoadedAllPlugins, loadAllPlugins, registerMainPlugin } from './loader/main'; +import { getAllLoadedMainPlugins, loadAllMainPlugins, registerMainPlugin } from './loader/main'; import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder'; // Catch errors and log them @@ -104,7 +104,7 @@ const initHook = (win: BrowserWindow) => { if (!isEqual) { const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig); - const mainPlugin = getLoadedAllPlugins()[id]; + const mainPlugin = getAllLoadedMainPlugins()[id]; if (mainPlugin) mainPlugin.onConfigChange?.(config as PluginBaseConfig); win.webContents.send('config-changed', id, config); @@ -125,7 +125,7 @@ function initTheme(win: BrowserWindow) { injectCSSAsFile(win.webContents, cssFile); }, () => { - console.warn(`CSS file "${cssFile}" does not exist, ignoring`); + console.warn('[YTMusic]', `CSS file "${cssFile}" does not exist, ignoring`); }, ); } @@ -186,7 +186,7 @@ async function createMainWindow() { registerMainPlugin(id, typedBuilder, plugin); }); - await loadAllPlugins(win); + await loadAllMainPlugins(win); if (windowPosition) { const { x: windowX, y: windowY } = windowPosition; diff --git a/src/loader/main.ts b/src/loader/main.ts index 79424623..7897f9e2 100644 --- a/src/loader/main.ts +++ b/src/loader/main.ts @@ -37,7 +37,7 @@ const createContext = < }, }); -const forceUnloadPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => { +const forceUnloadMainPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => { unregisterStyleMap[id]?.forEach((unregister) => unregister()); delete unregisterStyleMap[id]; @@ -47,7 +47,7 @@ const forceUnloadPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => { console.log('[YTMusic]', `"${id}" plugin is unloaded`); }; -export const forceLoadPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => { +export const forceLoadMainPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => { const builder = allPluginBuilders[id]; Promise.allSettled( @@ -88,7 +88,7 @@ export const forceLoadPlugin = async (id: keyof PluginBuilderList, win: BrowserW } }; -export const loadAllPlugins = async (win: BrowserWindow) => { +export const loadAllMainPlugins = async (win: BrowserWindow) => { const pluginConfigs = config.plugins.getPlugins(); for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { @@ -97,25 +97,25 @@ export const loadAllPlugins = async (win: BrowserWindow) => { const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); if (config.enabled) { - await forceLoadPlugin(pluginId as keyof PluginBuilderList, win); + await forceLoadMainPlugin(pluginId as keyof PluginBuilderList, win); } else { if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { - forceUnloadPlugin(pluginId as keyof PluginBuilderList, win); + forceUnloadMainPlugin(pluginId as keyof PluginBuilderList, win); } } } }; -export const unloadAllPlugins = (win: BrowserWindow) => { +export const unloadAllMainPlugins = (win: BrowserWindow) => { for (const id of Object.keys(loadedPluginMap)) { - forceUnloadPlugin(id as keyof PluginBuilderList, win); + forceUnloadMainPlugin(id as keyof PluginBuilderList, win); } }; -export const getLoadedPlugin = (id: Key): MainPlugin | undefined => { +export const getLoadedMainPlugin = (id: Key): MainPlugin | undefined => { return loadedPluginMap[id]; }; -export const getLoadedAllPlugins = () => { +export const getAllLoadedMainPlugins = () => { return loadedPluginMap; }; export const registerMainPlugin = ( diff --git a/src/loader/renderer.ts b/src/loader/renderer.ts new file mode 100644 index 00000000..5c11e778 --- /dev/null +++ b/src/loader/renderer.ts @@ -0,0 +1,97 @@ +import { deepmerge } from 'deepmerge-ts'; + +import { + PluginBaseConfig, PluginBuilder, + RendererPlugin, + RendererPluginContext, + RendererPluginFactory +} from '../plugins/utils/builder'; + +const allPluginFactoryList: Record> = {}; +const allPluginBuilders: Record> = {}; +const unregisterStyleMap: Record void)[]> = {}; +const loadedPluginMap: Record> = {}; + +const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], +>(id: Key): RendererPluginContext => ({ + getConfig: async () => { + return await window.ipcRenderer.invoke('get-config', id) as Config; + }, + setConfig: async (newConfig) => { + await window.ipcRenderer.invoke('set-config', id, newConfig); + }, + + invoke: async (event: string, ...args: unknown[]): Promise => { + return await window.ipcRenderer.invoke(event, ...args) as Return; + }, + on: (event: string, listener) => { + window.ipcRenderer.on(event, async (_, ...args) => listener(...args as never)); + }, +}); + +const forceUnloadRendererPlugin = (id: keyof PluginBuilderList) => { + unregisterStyleMap[id]?.forEach((unregister) => unregister()); + delete unregisterStyleMap[id]; + + loadedPluginMap[id]?.onUnload?.(); + delete loadedPluginMap[id]; + + console.log('[YTMusic]', `"${id}" plugin is unloaded`); +}; + +export const forceLoadRendererPlugin = async (id: keyof PluginBuilderList) => { + try { + const factory = allPluginFactoryList[id]; + if (!factory) return; + + const context = createContext(id); + const plugin = await factory(context); + loadedPluginMap[id] = plugin; + plugin.onLoad?.(); + + console.log('[YTMusic]', `"${id}" plugin is loaded`); + } catch (err) { + console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); + } +}; + +export const loadAllRendererPlugins = async () => { + const pluginConfigs = window.mainConfig.plugins.getPlugins(); + + for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { + const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; + + const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + + if (config.enabled) { + await forceLoadRendererPlugin(pluginId as keyof PluginBuilderList); + } else { + if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { + forceUnloadRendererPlugin(pluginId as keyof PluginBuilderList); + } + } + } +}; + +export const unloadAllRendererPlugins = () => { + for (const id of Object.keys(loadedPluginMap)) { + forceUnloadRendererPlugin(id as keyof PluginBuilderList); + } +}; + +export const getLoadedRendererPlugin = (id: Key): RendererPlugin | undefined => { + return loadedPluginMap[id]; +}; +export const getAllLoadedRendererPlugins = () => { + return loadedPluginMap; +}; +export const registerRendererPlugin = ( + id: string, + builder: PluginBuilder, + factory?: RendererPluginFactory, +) => { + if (factory) allPluginFactoryList[id] = factory; + allPluginBuilders[id] = builder; +}; diff --git a/src/renderer.ts b/src/renderer.ts index 603dd21b..9478d841 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,16 +1,14 @@ // eslint-disable-next-line import/order import { rendererPlugins } from 'virtual:RendererPlugins'; - import { pluginBuilders } from 'virtual:PluginBuilders'; -import { deepmerge } from 'deepmerge-ts'; - -import { PluginBaseConfig, RendererPluginContext, RendererPluginFactory } from './plugins/utils/builder'; +import { PluginBaseConfig, PluginBuilder, RendererPluginFactory } from './plugins/utils/builder'; import { startingPages } from './providers/extracted-data'; import { setupSongControls } from './providers/song-controls-front'; import setupSongInfo from './providers/song-info-front'; +import { getAllLoadedRendererPlugins, loadAllRendererPlugins, registerRendererPlugin } from './loader/renderer'; let api: Element | null = null; @@ -96,73 +94,19 @@ function onApiLoaded() { } } -const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], ->(name: Key): RendererPluginContext => ({ - getConfig: async () => { - const result = await window.ipcRenderer.invoke('get-config', name) as Config; - - return result; - }, - setConfig: async (newConfig) => { - await window.ipcRenderer.invoke('set-config', name, newConfig); - }, - - invoke: async (event: string, ...args: unknown[]): Promise => { - return await window.ipcRenderer.invoke(event, ...args) as Return; - }, - on: (event: string, listener) => { - window.ipcRenderer.on(event, async (_, ...args) => listener(...args as never)); - }, -}); - (async () => { - const pluginConfig = window.mainConfig.plugins.getPlugins(); + Object.entries(pluginBuilders).forEach(([id, builder]) => { + const typedBuilder = builder as PluginBuilder; + const plugin = rendererPlugins[id] as RendererPluginFactory | undefined; - const rendererPluginList = Object.entries(rendererPlugins); - const rendererPluginResult = await Promise.allSettled( - rendererPluginList - .filter(([id]) => { - const typedId = id as keyof PluginBuilderList; - const config = deepmerge(pluginBuilders[typedId].config, pluginConfig[typedId] ?? {}); - - return config.enabled; - }) - .map(async ([id, builder]) => { - const context = createContext(id as keyof PluginBuilderList); - return [id, await (builder as RendererPluginFactory)(context)] as const; - }), - ); - - rendererPluginResult.forEach((it, index) => { - if (it.status === 'rejected') { - const id = rendererPluginList[index][0]; - console.error('[YTMusic]', `Cannot load plugin "${id}"`); - console.trace(it.reason); - } - }); - - const loadedRendererPluginList = rendererPluginResult - .map((it) => it.status === 'fulfilled' ? it.value : null) - .filter(Boolean); - - loadedRendererPluginList.forEach(([id, plugin]) => { - try { - plugin.onLoad?.(); - console.log('[YTMusic]', `"${id}" plugin is loaded`); - } catch (error) { - console.error('[YTMusic]', `Cannot load plugin "${id}"`); - console.trace(error); - } + registerRendererPlugin(id, typedBuilder, plugin); }); + await loadAllRendererPlugins(); window.ipcRenderer.on('config-changed', (_event, id: string, newConfig: PluginBaseConfig) => { - const plugin = loadedRendererPluginList.find(([pluginId]) => pluginId === id); + const plugin = getAllLoadedRendererPlugins()[id]; - if (plugin) { - plugin[1].onConfigChange?.(newConfig); - } + if (plugin) plugin.onConfigChange?.(newConfig); }); // Inject song-info provider From dfcc4107b78ed5ccff6afd2f2526f52c933a6fdc Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sun, 12 Nov 2023 00:31:48 +0900 Subject: [PATCH 19/79] feat(menu): sort plugin name --- src/plugins/utils/main/plugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/utils/main/plugin.ts b/src/plugins/utils/main/plugin.ts index da9aab19..7b455423 100644 --- a/src/plugins/utils/main/plugin.ts +++ b/src/plugins/utils/main/plugin.ts @@ -4,6 +4,7 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; export const getAvailablePluginNames = () => { return Object.keys(pluginBuilders) + .sort() .filter((id) => { if (is.windows() && id === 'touchbar') { return false; From 9c59f56aac226f1050e353769c09738849e021cf Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 00:50:09 +0900 Subject: [PATCH 20/79] refactor(plugin): apply new plugin loader at all type of plugin --- src/loader/menu.ts | 72 +++++++++++++++++++++++++ src/loader/preload.ts | 90 ++++++++++++++++++++++++++++++++ src/menu.ts | 67 ++++++++++++------------ src/plugins/lumiastream/index.ts | 2 +- src/preload.ts | 47 +++++------------ 5 files changed, 208 insertions(+), 70 deletions(-) create mode 100644 src/loader/menu.ts create mode 100644 src/loader/preload.ts diff --git a/src/loader/menu.ts b/src/loader/menu.ts new file mode 100644 index 00000000..901cfc85 --- /dev/null +++ b/src/loader/menu.ts @@ -0,0 +1,72 @@ +import { deepmerge } from 'deepmerge-ts'; + +import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig, PluginBuilder } from '../plugins/utils/builder'; +import config from '../config'; +import { setApplicationMenu } from '../menu'; + +import type { BrowserWindow, MenuItemConstructorOptions } from 'electron'; + +const allPluginFactoryList: Record> = {}; +const allPluginBuilders: Record> = {}; +const menuTemplateMap: Record = {}; + +const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], +>(id: Key, win: BrowserWindow): MenuPluginContext => ({ + getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config, + setConfig: (newConfig) => { + config.setPartial(`plugins.${id}`, newConfig); + }, + window: win, + refresh: async () => { + await setApplicationMenu(win); + + if (config.plugins.isEnabled('in-app-menu')) { + win.webContents.send('refresh-in-app-menu'); + } + }, +}); + +export const forceLoadMenuPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => { + try { + const factory = allPluginFactoryList[id]; + if (!factory) return; + + const context = createContext(id, win); + menuTemplateMap[id] = await factory(context); + + console.log('[YTMusic]', `"${id}" plugin is loaded`); + } catch (err) { + console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); + } +}; + +export const loadAllMenuPlugins = async (win: BrowserWindow) => { + const pluginConfigs = config.plugins.getPlugins(); + + for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { + const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; + + const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + + if (config.enabled) { + await forceLoadMenuPlugin(pluginId as keyof PluginBuilderList, win); + } + } +}; + +export const getMenuTemplate = (id: Key): MenuItemConstructorOptions[] | undefined => { + return menuTemplateMap[id]; +}; +export const getAllMenuTemplate = () => { + return menuTemplateMap; +}; +export const registerMenuPlugin = ( + id: string, + builder: PluginBuilder, + factory?: MenuPluginFactory, +) => { + if (factory) allPluginFactoryList[id] = factory; + allPluginBuilders[id] = builder; +}; diff --git a/src/loader/preload.ts b/src/loader/preload.ts new file mode 100644 index 00000000..7d55942c --- /dev/null +++ b/src/loader/preload.ts @@ -0,0 +1,90 @@ +import { deepmerge } from 'deepmerge-ts'; + +import { + PluginBaseConfig, + PluginBuilder, + PreloadPlugin, + PluginContext, + PreloadPluginFactory +} from '../plugins/utils/builder'; +import config from '../config'; + +const allPluginFactoryList: Record> = {}; +const allPluginBuilders: Record> = {}; +const unregisterStyleMap: Record void)[]> = {}; +const loadedPluginMap: Record> = {}; + +const createContext = < + Key extends keyof PluginBuilderList, + Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], +>(id: Key): PluginContext => ({ + getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config, + setConfig: (newConfig) => { + config.setPartial(`plugins.${id}`, newConfig); + }, +}); + +const forceUnloadPreloadPlugin = (id: keyof PluginBuilderList) => { + unregisterStyleMap[id]?.forEach((unregister) => unregister()); + delete unregisterStyleMap[id]; + + loadedPluginMap[id]?.onUnload?.(); + delete loadedPluginMap[id]; + + console.log('[YTMusic]', `"${id}" plugin is unloaded`); +}; + +export const forceLoadPreloadPlugin = async (id: keyof PluginBuilderList) => { + try { + const factory = allPluginFactoryList[id]; + if (!factory) return; + + const context = createContext(id); + const plugin = await factory(context); + loadedPluginMap[id] = plugin; + plugin.onLoad?.(); + + console.log('[YTMusic]', `"${id}" plugin is loaded`); + } catch (err) { + console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); + } +}; + +export const loadAllPreloadPlugins = async () => { + const pluginConfigs = config.plugins.getPlugins(); + + for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { + const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; + + const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + + if (config.enabled) { + await forceLoadPreloadPlugin(pluginId as keyof PluginBuilderList); + } else { + if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { + forceUnloadPreloadPlugin(pluginId as keyof PluginBuilderList); + } + } + } +}; + +export const unloadAllPreloadPlugins = () => { + for (const id of Object.keys(loadedPluginMap)) { + forceUnloadPreloadPlugin(id as keyof PluginBuilderList); + } +}; + +export const getLoadedPreloadPlugin = (id: Key): PreloadPlugin | undefined => { + return loadedPluginMap[id]; +}; +export const getAllLoadedPreloadPlugins = () => { + return loadedPluginMap; +}; +export const registerPreloadPlugin = ( + id: string, + builder: PluginBuilder, + factory?: PreloadPluginFactory, +) => { + if (factory) allPluginFactoryList[id] = factory; + allPluginBuilders[id] = builder; +}; diff --git a/src/menu.ts b/src/menu.ts index d270d9d9..d8ddb75e 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,7 +1,6 @@ import is from 'electron-is'; import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron'; import prompt from 'custom-electron-prompt'; -import { deepmerge } from 'deepmerge-ts'; import { restart } from './providers/app-controls'; import config from './config'; @@ -14,15 +13,18 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; /* eslint-enable import/order */ import { getAvailablePluginNames } from './plugins/utils/main'; -import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig } from './plugins/utils/builder'; +import { + MenuPluginFactory, + PluginBaseConfig, + PluginBuilder +} from './plugins/utils/builder'; +import { getAllMenuTemplate, loadAllMenuPlugins, registerMenuPlugin } from './loader/menu'; export type MenuTemplate = Electron.MenuItemConstructorOptions[]; // True only if in-app-menu was loaded on launch const inAppMenuActive = config.plugins.isEnabled('in-app-menu'); -const betaPlugins = ['crossfade', 'lumiastream']; - const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({ label: label || plugin, type: 'checkbox', @@ -47,49 +49,46 @@ export const refreshMenu = (win: BrowserWindow) => { } }; +Object.entries(pluginBuilders).forEach(([id, builder]) => { + const typedBuilder = builder as PluginBuilder; + const plugin = menuList[id] as MenuPluginFactory | undefined; + + registerMenuPlugin(id, typedBuilder, plugin); +}); + export const mainMenuTemplate = async (win: BrowserWindow): Promise => { const innerRefreshMenu = () => refreshMenu(win); - const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], - >(name: Key): MenuPluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as unknown as Config, - setConfig: (newConfig) => { - config.setPartial(`plugins.${name}`, newConfig); - }, - window: win, - refresh: () => refreshMenu(win), - }); - const availablePlugins = getAvailablePluginNames(); - const menuResult = await Promise.allSettled( - availablePlugins.map(async (id) => { - let pluginLabel = pluginBuilders[id as keyof PluginBuilderList]?.name ?? id; - if (betaPlugins.includes(pluginLabel)) { - pluginLabel += ' [beta]'; - } + await loadAllMenuPlugins(win); - if (!config.plugins.isEnabled(id)) { - return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu); - } + const menuResult = Object.entries(getAllMenuTemplate()).map(([id, template]) => { + const pluginLabel = (pluginBuilders[id as keyof PluginBuilderList])?.name ?? id; - const factory = menuList[id] as MenuPluginFactory; - const template = await factory(createContext(id as never)); + if (!config.plugins.isEnabled(id)) { + return [ + id, + pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu), + ] as const; + } - return { + return [ + id, + { label: pluginLabel, submenu: [ pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu), { type: 'separator' }, ...template, ], - } satisfies Electron.MenuItemConstructorOptions; - }), - ); + } satisfies Electron.MenuItemConstructorOptions + ] as const; + }); + + const availablePlugins = getAvailablePluginNames(); + const pluginMenus = availablePlugins.map((id) => { + const predefinedTemplate = menuResult.find((it) => it[0] === id); + if (predefinedTemplate) return predefinedTemplate[1]; - const pluginMenus = menuResult.map((it, index) => { - if (it.status === 'fulfilled') return it.value; - const id = availablePlugins[index]; const pluginLabel = pluginBuilders[id as keyof PluginBuilderList]?.name ?? id; return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu); diff --git a/src/plugins/lumiastream/index.ts b/src/plugins/lumiastream/index.ts index b3af3375..e8a2db55 100644 --- a/src/plugins/lumiastream/index.ts +++ b/src/plugins/lumiastream/index.ts @@ -1,7 +1,7 @@ import { createPluginBuilder } from '../utils/builder'; const builder = createPluginBuilder('lumiastream', { - name: 'Lumia Stream', + name: 'Lumia Stream [beta]', restartNeeded: true, config: { enabled: false, diff --git a/src/preload.ts b/src/preload.ts index 5f6ac5d0..ea8f138a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -9,43 +9,20 @@ import config from './config'; // eslint-disable-next-line import/order import { preloadPlugins } from 'virtual:PreloadPlugins'; -import { PluginBaseConfig, PluginContext, PreloadPluginFactory } from './plugins/utils/builder'; +import { + PluginBaseConfig, + PluginBuilder, + PreloadPluginFactory +} from './plugins/utils/builder'; +import { loadAllPreloadPlugins, registerPreloadPlugin } from './loader/preload'; -const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], ->(name: Key): PluginContext => ({ - getConfig: () => deepmerge(pluginBuilders[name].config, config.get(`plugins.${name}`) ?? {}) as unknown as Config, - setConfig: (newConfig) => { - config.setPartial(`plugins.${name}`, newConfig); - }, -}); - - -const preloadedPluginList = []; - -const pluginConfig = config.plugins.getPlugins(); -Object.entries(preloadPlugins) - .filter(([id]) => { - const typedId = id as keyof PluginBuilderList; - const config = deepmerge(pluginBuilders[typedId].config, pluginConfig[typedId] ?? {}); - - return config.enabled; - }) - .forEach(async ([id]) => { - if (Object.hasOwn(preloadPlugins, id)) { - const factory = (preloadPlugins as Record>)[id]; - - try { - const context = createContext(id as keyof PluginBuilderList); - const plugin = await factory(context); - plugin.onLoad?.(); - preloadedPluginList.push(plugin); - } catch (error) { - console.error('[YTMusic]', `Cannot load preload plugin "${id}": ${String(error)}`); - } - } +Object.entries(pluginBuilders).forEach(([id, builder]) => { + const typedBuilder = builder as PluginBuilder; + const plugin = preloadPlugins[id] as PreloadPluginFactory | undefined; + + registerPreloadPlugin(id, typedBuilder, plugin); }); +loadAllPreloadPlugins(); contextBridge.exposeInMainWorld('mainConfig', config); contextBridge.exposeInMainWorld('electronIs', is); From 2097f42efb2cdc9a67adf8907e0aa48ef1b4f79f Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 01:16:34 +0900 Subject: [PATCH 21/79] feat(plugin): support dynamic plugin load / unload --- src/index.ts | 30 +++++++++++++++++++++++++--- src/loader/main.ts | 2 +- src/loader/preload.ts | 2 +- src/loader/renderer.ts | 2 +- src/plugins/ambient-mode/renderer.ts | 16 ++++++++++++--- src/plugins/utils/main/css.ts | 18 +++++++++++++++-- src/preload.ts | 17 +++++++++++++--- src/renderer.ts | 15 +++++++++++++- 8 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/index.ts b/src/index.ts index d9f9fa82..440bb63e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,13 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; import youtubeMusicCSS from './youtube-music.css?inline'; -import { getAllLoadedMainPlugins, loadAllMainPlugins, registerMainPlugin } from './loader/main'; +import { + forceLoadMainPlugin, + forceUnloadMainPlugin, + getAllLoadedMainPlugins, + loadAllMainPlugins, + registerMainPlugin +} from './loader/main'; import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder'; // Catch errors and log them @@ -90,6 +96,7 @@ function onClosed() { ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); + const initHook = (win: BrowserWindow) => { ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as PluginBuilderList[typeof id]['config']); ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj)); @@ -102,10 +109,27 @@ const initHook = (win: BrowserWindow) => { const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig); if (!isEqual) { - const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig); + const oldConfig = oldPluginConfigList[id] as PluginBaseConfig; + const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig) as PluginBaseConfig; + + if (config.enabled !== oldConfig.enabled) { + if (config.enabled) { + win.webContents.send('plugin:enable', id); + ipcMain.emit('plugin:enable', id); + forceLoadMainPlugin(id as keyof PluginBuilderList, win); + } else { + win.webContents.send('plugin:unload', id); + ipcMain.emit('plugin:unload', id); + forceUnloadMainPlugin(id as keyof PluginBuilderList, win); + } + } const mainPlugin = getAllLoadedMainPlugins()[id]; - if (mainPlugin) mainPlugin.onConfigChange?.(config as PluginBaseConfig); + if (mainPlugin) { + if (config.enabled) { + mainPlugin.onConfigChange?.(config); + } + } win.webContents.send('config-changed', id, config); } diff --git a/src/loader/main.ts b/src/loader/main.ts index 7897f9e2..883feead 100644 --- a/src/loader/main.ts +++ b/src/loader/main.ts @@ -37,7 +37,7 @@ const createContext = < }, }); -const forceUnloadMainPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => { +export const forceUnloadMainPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => { unregisterStyleMap[id]?.forEach((unregister) => unregister()); delete unregisterStyleMap[id]; diff --git a/src/loader/preload.ts b/src/loader/preload.ts index 7d55942c..dc598a1e 100644 --- a/src/loader/preload.ts +++ b/src/loader/preload.ts @@ -24,7 +24,7 @@ const createContext = < }, }); -const forceUnloadPreloadPlugin = (id: keyof PluginBuilderList) => { +export const forceUnloadPreloadPlugin = (id: keyof PluginBuilderList) => { unregisterStyleMap[id]?.forEach((unregister) => unregister()); delete unregisterStyleMap[id]; diff --git a/src/loader/renderer.ts b/src/loader/renderer.ts index 5c11e778..d19b3a88 100644 --- a/src/loader/renderer.ts +++ b/src/loader/renderer.ts @@ -31,7 +31,7 @@ const createContext = < }, }); -const forceUnloadRendererPlugin = (id: keyof PluginBuilderList) => { +export const forceUnloadRendererPlugin = (id: keyof PluginBuilderList) => { unregisterStyleMap[id]?.forEach((unregister) => unregister()); delete unregisterStyleMap[id]; diff --git a/src/plugins/ambient-mode/renderer.ts b/src/plugins/ambient-mode/renderer.ts index 105b9a24..11e462b8 100644 --- a/src/plugins/ambient-mode/renderer.ts +++ b/src/plugins/ambient-mode/renderer.ts @@ -11,12 +11,12 @@ export default builder.createRenderer(async ({ getConfig }) => { let opacity = initConfigData.opacity; let isFullscreen = initConfigData.fullscreen; + let unregister: (() => void) | null = null; let update: (() => void) | null = null; + let observer: MutationObserver; 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'); @@ -84,7 +84,6 @@ export default builder.createRenderer(async ({ getConfig }) => { blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`); blurCanvas.style.setProperty('--blur', `${blur}px`); blurCanvas.style.setProperty('--opacity', `${opacity}`); - console.log('updated!!!'); }; update = applyVideoAttributes; @@ -140,6 +139,12 @@ export default builder.createRenderer(async ({ getConfig }) => { const playerPage = document.querySelector('#player-page'); const ytmusicAppLayout = document.querySelector('#layout'); + const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open'); + if (isPageOpen) { + unregister?.(); + unregister = injectBlurVideo() ?? null; + } + const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'attributes') { @@ -170,5 +175,10 @@ export default builder.createRenderer(async ({ getConfig }) => { update?.(); }, + onUnload() { + observer?.disconnect(); + update = null; + unregister?.(); + } }; }); diff --git a/src/plugins/utils/main/css.ts b/src/plugins/utils/main/css.ts index 11981988..a69f5f85 100644 --- a/src/plugins/utils/main/css.ts +++ b/src/plugins/utils/main/css.ts @@ -2,9 +2,16 @@ import fs from 'node:fs'; type Unregister = () => void; +let isLoaded = false; + const cssToInject = new Map void) | undefined>(); const cssToInjectFile = new Map void) | undefined>(); -export const injectCSS = (webContents: Electron.WebContents, css: string): Promise => { +export const injectCSS = async (webContents: Electron.WebContents, css: string): Promise => { + if (isLoaded) { + const key = await webContents.insertCSS(css); + return async () => await webContents.removeInsertedCSS(key); + } + return new Promise((resolve) => { if (cssToInject.size === 0 && cssToInjectFile.size === 0) { setupCssInjection(webContents); @@ -13,7 +20,12 @@ export const injectCSS = (webContents: Electron.WebContents, css: string): Promi }); }; -export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: string): Promise => { +export const injectCSSAsFile = async (webContents: Electron.WebContents, filepath: string): Promise => { + if (isLoaded) { + const key = await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8')); + return async () => await webContents.removeInsertedCSS(key); + } + return new Promise((resolve) => { if (cssToInject.size === 0 && cssToInjectFile.size === 0) { setupCssInjection(webContents); @@ -25,6 +37,8 @@ export const injectCSSAsFile = (webContents: Electron.WebContents, filepath: str const setupCssInjection = (webContents: Electron.WebContents) => { webContents.on('did-finish-load', () => { + isLoaded = true; + cssToInject.forEach(async (callback, css) => { const key = await webContents.insertCSS(css); const remove = async () => await webContents.removeInsertedCSS(key); diff --git a/src/preload.ts b/src/preload.ts index ea8f138a..95f2d89b 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -3,8 +3,6 @@ import is from 'electron-is'; import { pluginBuilders } from 'virtual:PluginBuilders'; -import { deepmerge } from 'deepmerge-ts'; - import config from './config'; // eslint-disable-next-line import/order @@ -14,7 +12,12 @@ import { PluginBuilder, PreloadPluginFactory } from './plugins/utils/builder'; -import { loadAllPreloadPlugins, registerPreloadPlugin } from './loader/preload'; +import { + forceLoadPreloadPlugin, + forceUnloadPreloadPlugin, + loadAllPreloadPlugins, + registerPreloadPlugin +} from './loader/preload'; Object.entries(pluginBuilders).forEach(([id, builder]) => { const typedBuilder = builder as PluginBuilder; @@ -24,6 +27,14 @@ Object.entries(pluginBuilders).forEach(([id, builder]) => { }); loadAllPreloadPlugins(); +ipcRenderer.on('plugin:unload', (_, id: keyof PluginBuilderList) => { + forceUnloadPreloadPlugin(id); +}); +ipcRenderer.on('plugin:enable', (_, id: keyof PluginBuilderList) => { + forceLoadPreloadPlugin(id); +}); + + contextBridge.exposeInMainWorld('mainConfig', config); contextBridge.exposeInMainWorld('electronIs', is); contextBridge.exposeInMainWorld('ipcRenderer', { diff --git a/src/renderer.ts b/src/renderer.ts index 9478d841..ec9f1743 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -8,7 +8,13 @@ import { PluginBaseConfig, PluginBuilder, RendererPluginFactory } from './plugin import { startingPages } from './providers/extracted-data'; import { setupSongControls } from './providers/song-controls-front'; import setupSongInfo from './providers/song-info-front'; -import { getAllLoadedRendererPlugins, loadAllRendererPlugins, registerRendererPlugin } from './loader/renderer'; +import { + forceLoadRendererPlugin, + forceUnloadRendererPlugin, + getAllLoadedRendererPlugins, + loadAllRendererPlugins, + registerRendererPlugin +} from './loader/renderer'; let api: Element | null = null; @@ -103,6 +109,13 @@ function onApiLoaded() { }); await loadAllRendererPlugins(); + window.ipcRenderer.on('plugin:unload', (_event, id: keyof PluginBuilderList) => { + forceUnloadRendererPlugin(id); + }); + window.ipcRenderer.on('plugin:enable', (_event, id: keyof PluginBuilderList) => { + forceLoadRendererPlugin(id); + }); + window.ipcRenderer.on('config-changed', (_event, id: string, newConfig: PluginBaseConfig) => { const plugin = getAllLoadedRendererPlugins()[id]; From a4f4ecb5697dad407720ab3223f03d12db6f84c8 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 01:51:26 +0900 Subject: [PATCH 22/79] feat(plugin): add onPlayerApiReady hook Co-authored-by: JellyBrick --- src/index.ts | 2 +- src/plugins/album-color-theme/renderer.ts | 81 ++++++++------- src/plugins/captions-selector/renderer.ts | 26 ++--- src/plugins/crossfade/renderer.ts | 15 ++- src/plugins/disable-autoplay/renderer.ts | 16 ++- src/plugins/downloader/renderer.ts | 69 ++++++------- src/plugins/exponential-volume/renderer.ts | 7 +- src/plugins/in-app-menu/renderer.ts | 67 ++++++------ src/plugins/notifications/interactive.ts | 2 +- src/plugins/picture-in-picture/renderer.ts | 49 ++++----- src/plugins/playback-speed/renderer.ts | 10 +- src/plugins/precise-volume/renderer.ts | 12 +-- src/plugins/quality-changer/renderer.ts | 12 +-- src/plugins/shortcuts/mpris.ts | 2 +- src/plugins/sponsorblock/renderer.ts | 15 ++- src/plugins/tuna-obs/main.ts | 2 +- src/plugins/utils/builder.ts | 5 +- src/plugins/video-toggle/renderer.ts | 12 +-- src/providers/song-controls-front.ts | 6 -- src/providers/song-info-front.ts | 114 ++++++++++----------- src/renderer.ts | 39 ++++--- src/reset.d.ts | 1 - 22 files changed, 273 insertions(+), 291 deletions(-) delete mode 100644 src/providers/song-controls-front.ts diff --git a/src/index.ts b/src/index.ts index 440bb63e..8a03c2f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,7 +112,7 @@ const initHook = (win: BrowserWindow) => { const oldConfig = oldPluginConfigList[id] as PluginBaseConfig; const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig) as PluginBaseConfig; - if (config.enabled !== oldConfig.enabled) { + if (config.enabled !== oldConfig?.enabled) { if (config.enabled) { win.webContents.send('plugin:enable', id); ipcMain.emit('plugin:enable', id); diff --git a/src/plugins/album-color-theme/renderer.ts b/src/plugins/album-color-theme/renderer.ts index 2d79bf85..0e70c26f 100644 --- a/src/plugins/album-color-theme/renderer.ts +++ b/src/plugins/album-color-theme/renderer.ts @@ -63,15 +63,23 @@ export default builder.createRenderer(() => { } } + let playerPage: HTMLElement | null = null; + let navBarBackground: HTMLElement | null = null; + let ytmusicPlayerBar: HTMLElement | null = null; + let playerBarBackground: HTMLElement | null = null; + let sidebarBig: HTMLElement | null = null; + let sidebarSmall: HTMLElement | null = null; + let ytmusicAppLayout: HTMLElement | null = null; + return { onLoad() { - const playerPage = document.querySelector('#player-page'); - const navBarBackground = document.querySelector('#nav-bar-background'); - const ytmusicPlayerBar = document.querySelector('ytmusic-player-bar'); - const playerBarBackground = document.querySelector('#player-bar-background'); - const sidebarBig = document.querySelector('#guide-wrapper'); - const sidebarSmall = document.querySelector('#mini-guide-background'); - const ytmusicAppLayout = document.querySelector('#layout'); + playerPage = document.querySelector('#player-page'); + navBarBackground = document.querySelector('#nav-bar-background'); + ytmusicPlayerBar = document.querySelector('ytmusic-player-bar'); + playerBarBackground = document.querySelector('#player-bar-background'); + sidebarBig = document.querySelector('#guide-wrapper'); + sidebarSmall = document.querySelector('#mini-guide-background'); + ytmusicAppLayout = document.querySelector('#layout'); const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { @@ -91,39 +99,38 @@ export default builder.createRenderer(() => { if (playerPage) { observer.observe(playerPage, { attributes: true }); } + }, + onPlayerApiReady(playerApi) { + const fastAverageColor = new FastAverageColor(); - document.addEventListener('apiLoaded', (apiEvent) => { - const fastAverageColor = new FastAverageColor(); - - apiEvent.detail.addEventListener('videodatachange', (name: string) => { - if (name === 'dataloaded') { - const playerResponse = apiEvent.detail.getPlayerResponse(); - const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0); - if (thumbnail) { - fastAverageColor.getColorAsync(thumbnail.url) - .then((albumColor) => { - if (albumColor) { - [hue, saturation, lightness] = hexToHSL(albumColor.hex); - changeElementColor(playerPage, hue, saturation, lightness - 30); - changeElementColor(navBarBackground, hue, saturation, lightness - 15); - changeElementColor(ytmusicPlayerBar, hue, saturation, lightness - 15); - changeElementColor(playerBarBackground, hue, saturation, lightness - 15); - changeElementColor(sidebarBig, hue, saturation, lightness - 15); - if (ytmusicAppLayout?.hasAttribute('player-page-open')) { - changeElementColor(sidebarSmall, hue, saturation, lightness - 30); - } - const ytRightClickList = document.querySelector('tp-yt-paper-listbox'); - changeElementColor(ytRightClickList, hue, saturation, lightness - 15); - } else { - if (playerPage) { - playerPage.style.backgroundColor = '#000000'; - } + playerApi.addEventListener('videodatachange', (name: string) => { + if (name === 'dataloaded') { + const playerResponse = playerApi.getPlayerResponse(); + const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0); + if (thumbnail) { + fastAverageColor.getColorAsync(thumbnail.url) + .then((albumColor) => { + if (albumColor) { + [hue, saturation, lightness] = hexToHSL(albumColor.hex); + changeElementColor(playerPage, hue, saturation, lightness - 30); + changeElementColor(navBarBackground, hue, saturation, lightness - 15); + changeElementColor(ytmusicPlayerBar, hue, saturation, lightness - 15); + changeElementColor(playerBarBackground, hue, saturation, lightness - 15); + changeElementColor(sidebarBig, hue, saturation, lightness - 15); + if (ytmusicAppLayout?.hasAttribute('player-page-open')) { + changeElementColor(sidebarSmall, hue, saturation, lightness - 30); } - }) - .catch((e) => console.error(e)); - } + const ytRightClickList = document.querySelector('tp-yt-paper-listbox'); + changeElementColor(ytRightClickList, hue, saturation, lightness - 15); + } else { + if (playerPage) { + playerPage.style.backgroundColor = '#000000'; + } + } + }) + .catch((e) => console.error(e)); } - }); + } }); } }; diff --git a/src/plugins/captions-selector/renderer.ts b/src/plugins/captions-selector/renderer.ts index 26aa9d59..633bfa53 100644 --- a/src/plugins/captions-selector/renderer.ts +++ b/src/plugins/captions-selector/renderer.ts @@ -5,8 +5,6 @@ import builder from './index'; import { ElementFromHtml } from '../utils/renderer'; import { YoutubePlayer } from '../../types/youtube-player'; -import type { ConfigType } from '../../config/dynamic'; - interface LanguageOptions { displayName: string; id: string | null; @@ -82,30 +80,24 @@ export default builder.createRenderer(({ getConfig, setConfig }) => { } }; - 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(); + }, + onPlayerApiReady(playerApi) { + api = playerApi; - document.addEventListener('apiLoaded', listener, { once: true, passive: true }); + $('.right-controls-buttons').append(captionsSettingsButton); + + captionTrackList = api.getOption('captions', 'tracklist') ?? []; + + $('video').addEventListener('srcChanged', videoChangeListener); + captionsSettingsButton.addEventListener('click', captionsButtonClickListener); }, onUnload() { removeListener(); diff --git a/src/plugins/crossfade/renderer.ts b/src/plugins/crossfade/renderer.ts index d84aa45b..520e365e 100644 --- a/src/plugins/crossfade/renderer.ts +++ b/src/plugins/crossfade/renderer.ts @@ -85,7 +85,7 @@ export default builder.createRenderer(({ getConfig, invoke }) => { }); // Exit just before the end for the transition - const transitionBeforeEnd = async () => { + const transitionBeforeEnd = () => { if ( video.currentTime >= video.duration - config.secondsBeforeEnd && isReadyToCrossfade() @@ -140,14 +140,11 @@ export default builder.createRenderer(({ getConfig, invoke }) => { }; return { - onLoad() { - document.addEventListener('apiLoaded', async () => { - config = await getConfig(); - onApiLoaded(); - }, { - once: true, - passive: true, - }); + async onLoad() { + config = await getConfig(); + }, + onPlayerApiReady() { + onApiLoaded(); }, onConfigChange(newConfig) { config = newConfig; diff --git a/src/plugins/disable-autoplay/renderer.ts b/src/plugins/disable-autoplay/renderer.ts index 71f974b2..c9557dc3 100644 --- a/src/plugins/disable-autoplay/renderer.ts +++ b/src/plugins/disable-autoplay/renderer.ts @@ -5,7 +5,7 @@ import type { YoutubePlayer } from '../../types/youtube-player'; export default builder.createRenderer(({ getConfig }) => { let config: Awaited>; - let apiEvent: CustomEvent; + let apiEvent: YoutubePlayer; const timeUpdateListener = (e: Event) => { if (e.target instanceof HTMLVideoElement) { @@ -15,27 +15,25 @@ export default builder.createRenderer(({ getConfig }) => { const eventListener = async (name: string) => { if (config.applyOnce) { - apiEvent.detail.removeEventListener('videodatachange', eventListener); + apiEvent.removeEventListener('videodatachange', eventListener); } if (name === 'dataloaded') { - apiEvent.detail.pauseVideo(); + apiEvent.pauseVideo(); document.querySelector('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true }); } }; return { - async onLoad() { + async onPlayerApiReady(api) { config = await getConfig(); - document.addEventListener('apiLoaded', (api) => { - apiEvent = api; + apiEvent = api; - apiEvent.detail.addEventListener('videodatachange', eventListener); - }, { once: true, passive: true }); + apiEvent.addEventListener('videodatachange', eventListener); }, onUnload() { - apiEvent.detail.removeEventListener('videodatachange', eventListener); + apiEvent.removeEventListener('videodatachange', eventListener); }, onConfigChange(newConfig) { config = newConfig; diff --git a/src/plugins/downloader/renderer.ts b/src/plugins/downloader/renderer.ts index 94325eb6..6bab7b9a 100644 --- a/src/plugins/downloader/renderer.ts +++ b/src/plugins/downloader/renderer.ts @@ -14,35 +14,35 @@ const downloadButton = ElementFromHtml(downloadHTML); let doneFirstLoad = false; export default builder.createRenderer(({ invoke, on }) => { + 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); + }); + 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" @@ -64,13 +64,6 @@ export default builder.createRenderer(({ invoke, on }) => { invoke('download-song', videoUrl); }; - document.addEventListener('apiLoaded', () => { - menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { - childList: true, - subtree: true, - }); - }, { once: true, passive: true }); - on('downloader-feedback', (feedback: string) => { if (progress) { progress.innerHTML = feedback || 'Download'; @@ -78,6 +71,12 @@ export default builder.createRenderer(({ invoke, on }) => { console.warn('Cannot update progress'); } }); - } + }, + onPlayerApiReady() { + menuObserver.observe(document.querySelector('ytmusic-popup-container')!, { + childList: true, + subtree: true, + }); + }, }; }); diff --git a/src/plugins/exponential-volume/renderer.ts b/src/plugins/exponential-volume/renderer.ts index 05abbfc4..ff2b3153 100644 --- a/src/plugins/exponential-volume/renderer.ts +++ b/src/plugins/exponential-volume/renderer.ts @@ -41,10 +41,7 @@ const exponentialVolume = () => { }; export default builder.createRenderer(() => ({ - onLoad() { - return document.addEventListener('apiLoaded', exponentialVolume, { - once: true, - passive: true, - }); + onPlayerApiReady() { + exponentialVolume(); }, })); diff --git a/src/plugins/in-app-menu/renderer.ts b/src/plugins/in-app-menu/renderer.ts index 998fa971..a30f2144 100644 --- a/src/plugins/in-app-menu/renderer.ts +++ b/src/plugins/in-app-menu/renderer.ts @@ -17,7 +17,7 @@ export default builder.createRenderer(({ getConfig, invoke, on }) => { return { async onLoad() { const config = await getConfig(); - + const hideDOMWindowControls = config.hideDOMWindowControls; let hideMenu = window.mainConfig.get('options.hideMenu'); @@ -26,13 +26,13 @@ export default builder.createRenderer(({ getConfig, invoke, on }) => { let maximizeButton: HTMLButtonElement; let panelClosers: (() => void)[] = []; if (isMacOS) titleBar.style.setProperty('--offset-left', '70px'); - + const logo = document.createElement('img'); const close = document.createElement('img'); const minimize = document.createElement('img'); const maximize = document.createElement('img'); const unmaximize = document.createElement('img'); - + if (window.ELECTRON_RENDERER_URL) { logo.src = window.ELECTRON_RENDERER_URL + '/' + logoRaw; close.src = window.ELECTRON_RENDERER_URL + '/' + closeRaw; @@ -46,7 +46,7 @@ export default builder.createRenderer(({ getConfig, invoke, on }) => { maximize.src = maximizeRaw; unmaximize.src = unmaximizeRaw; } - + logo.classList.add('title-bar-icon'); const logoClick = () => { hideMenu = !hideMenu; @@ -62,22 +62,22 @@ export default builder.createRenderer(({ getConfig, invoke, on }) => { }); }; logo.onclick = logoClick; - + on('toggle-in-app-menu', logoClick); - + if (!isMacOS) titleBar.appendChild(logo); document.body.appendChild(titleBar); - + titleBar.appendChild(logo); - + const addWindowControls = async () => { - + // Create window control buttons const minimizeButton = document.createElement('button'); minimizeButton.classList.add('window-control'); minimizeButton.appendChild(minimize); minimizeButton.onclick = () => invoke('window-minimize'); - + maximizeButton = document.createElement('button'); if (await invoke('window-is-maximized')) { maximizeButton.classList.add('window-control'); @@ -91,37 +91,37 @@ export default builder.createRenderer(({ getConfig, invoke, on }) => { // change icon to maximize maximizeButton.removeChild(maximizeButton.firstChild!); maximizeButton.appendChild(maximize); - + // call unmaximize await invoke('window-unmaximize'); } else { // change icon to unmaximize maximizeButton.removeChild(maximizeButton.firstChild!); maximizeButton.appendChild(unmaximize); - + // call maximize await invoke('window-maximize'); } }; - + const closeButton = document.createElement('button'); closeButton.classList.add('window-control'); closeButton.appendChild(close); closeButton.onclick = () => invoke('window-close'); - + // Create a container div for the window control buttons const windowControlsContainer = document.createElement('div'); windowControlsContainer.classList.add('window-controls-container'); windowControlsContainer.appendChild(minimizeButton); windowControlsContainer.appendChild(maximizeButton); windowControlsContainer.appendChild(closeButton); - + // Add window control buttons to the title bar titleBar.appendChild(windowControlsContainer); }; - + if (isNotWindowsOrMacOS && !hideDOMWindowControls) await addWindowControls(); - + if (navBar) { const observer = new MutationObserver((mutations) => { mutations.forEach(() => { @@ -129,25 +129,25 @@ export default builder.createRenderer(({ getConfig, invoke, on }) => { document.querySelector('html')!.style.setProperty('--titlebar-background-color', navBar.style.backgroundColor); }); }); - + observer.observe(navBar, { attributes : true, attributeFilter : ['style'] }); } - + const updateMenu = async () => { const children = [...titleBar.children]; children.forEach((child) => { if (child !== logo) child.remove(); }); panelClosers = []; - + const menu = await invoke('get-menu'); if (!menu) return; - + menu.items.forEach((menuItem) => { const menu = document.createElement('menu-button'); const [, { close: closer }] = createPanel(titleBar, menu, menuItem.submenu?.items ?? []); panelClosers.push(closer); - + menu.append(menuItem.label); titleBar.appendChild(menu); if (hideMenu) { @@ -159,7 +159,7 @@ export default builder.createRenderer(({ getConfig, invoke, on }) => { await updateMenu(); document.title = 'Youtube Music'; - + on('close-all-in-app-menu-panel', () => { panelClosers.forEach((closer) => closer()); }); @@ -176,21 +176,20 @@ export default builder.createRenderer(({ getConfig, invoke, on }) => { maximizeButton.appendChild(unmaximize); } }); - + if (window.mainConfig.plugins.isEnabled('picture-in-picture')) { on('pip-toggle', () => { updateMenu(); }); } - - // Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it) - document.addEventListener('apiLoaded', () => { - const htmlHeadStyle = document.querySelector('head > div > style'); - if (htmlHeadStyle) { - // HACK: This is a hack to remove the scrollbar width - htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {'); - } - }, { once: true, passive: true }); - } + }, + // Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it) + onPlayerApiReady() { + const htmlHeadStyle = document.querySelector('head > div > style'); + if (htmlHeadStyle) { + // HACK: This is a hack to remove the scrollbar width + htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {'); + } + }, }; }); diff --git a/src/plugins/notifications/interactive.ts b/src/plugins/notifications/interactive.ts index 90c09067..a672fceb 100644 --- a/src/plugins/notifications/interactive.ts +++ b/src/plugins/notifications/interactive.ts @@ -211,7 +211,7 @@ export default ( songControls = getSongControls(win); let currentSeconds = 0; - on('apiLoaded', () => send('setupTimeChangedListener')); + on('ytmd:player-api-loaded', () => send('setupTimeChangedListener')); on('timeChanged', (t: number) => { currentSeconds = t; diff --git a/src/plugins/picture-in-picture/renderer.ts b/src/plugins/picture-in-picture/renderer.ts index 8a697237..2f6b7a02 100644 --- a/src/plugins/picture-in-picture/renderer.ts +++ b/src/plugins/picture-in-picture/renderer.ts @@ -133,11 +133,27 @@ const listenForToggle = () => { }); }; -function observeMenu(options: PictureInPicturePluginConfig) { - useNativePiP = options.useNativePiP; - document.addEventListener( - 'apiLoaded', - () => { + +export default builder.createRenderer(({ getConfig }) => { + return { + async onLoad() { + const config = await getConfig(); + + useNativePiP = config.useNativePiP; + + if (config.hotkey) { + const hotkeyEvent = toKeyEvent(config.hotkey); + window.addEventListener('keydown', (event) => { + if ( + keyEventAreEqual(event, hotkeyEvent) + && !$('ytmusic-search-box')?.opened + ) { + togglePictureInPicture(); + } + }); + } + }, + onPlayerApiReady() { listenForToggle(); cloneButton('.player-minimize-button')?.addEventListener('click', async () => { @@ -154,28 +170,5 @@ function observeMenu(options: PictureInPicturePluginConfig) { subtree: true, }); }, - { once: true, passive: true }, - ); -} - -export default builder.createRenderer(({ getConfig }) => { - return { - async onLoad() { - const config = await getConfig(); - - 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/renderer.ts b/src/plugins/playback-speed/renderer.ts index e2cef0fc..6725432b 100644 --- a/src/plugins/playback-speed/renderer.ts +++ b/src/plugins/playback-speed/renderer.ts @@ -116,12 +116,10 @@ function forcePlaybackRate(e: Event) { export default builder.createRenderer(() => { return { - onLoad() { - document.addEventListener('apiLoaded', () => { - observePopupContainer(); - observeVideo(); - setupWheelListener(); - }, { once: true, passive: true }); + onPlayerApiReady() { + observePopupContainer(); + observeVideo(); + setupWheelListener(); }, onUnload() { const video = $('video'); diff --git a/src/plugins/precise-volume/renderer.ts b/src/plugins/precise-volume/renderer.ts index c878c1fe..bd9d2905 100644 --- a/src/plugins/precise-volume/renderer.ts +++ b/src/plugins/precise-volume/renderer.ts @@ -258,13 +258,13 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { return { onLoad() { overrideListener(); + }, + onPlayerApiReady(playerApi) { + api = playerApi; - document.addEventListener('apiLoaded', (e) => { - api = e.detail; - on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease)); - on('setVolume', (value: number) => setVolume(value)); - firstRun(); - }, { once: true, passive: true }); + on('changeVolume', (toIncrease: boolean) => changeVolume(toIncrease)); + on('setVolume', (value: number) => setVolume(value)); + firstRun(); }, onConfigChange(config) { options = config; diff --git a/src/plugins/quality-changer/renderer.ts b/src/plugins/quality-changer/renderer.ts index 1da2d7e7..896ed88e 100644 --- a/src/plugins/quality-changer/renderer.ts +++ b/src/plugins/quality-changer/renderer.ts @@ -32,19 +32,19 @@ export default builder.createRenderer(({ invoke }) => { api.setPlaybackQualityRange(newQuality); api.setPlaybackQuality(newQuality); }); - } - - function setup(event: CustomEvent) { - api = event.detail; + }; + function setup() { $('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton); qualitySettingsButton.addEventListener('click', chooseQuality); } return { - onLoad() { - document.addEventListener('apiLoaded', setup, { once: true, passive: true }); + onPlayerApiReady(playerApi) { + api = playerApi; + + setup(); }, onUnload() { $('.top-row-buttons.ytmusic-player')?.removeChild(qualitySettingsButton); diff --git a/src/plugins/shortcuts/mpris.ts b/src/plugins/shortcuts/mpris.ts index 4365ae56..4b68d720 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.handle('apiLoaded', () => { + ipcMain.on('ytmd:player-api-loaded', () => { win.webContents.send('setupSeekedListener', 'mpris'); win.webContents.send('setupTimeChangedListener', 'mpris'); win.webContents.send('setupRepeatChangedListener', 'mpris'); diff --git a/src/plugins/sponsorblock/renderer.ts b/src/plugins/sponsorblock/renderer.ts index e1772183..5378597b 100644 --- a/src/plugins/sponsorblock/renderer.ts +++ b/src/plugins/sponsorblock/renderer.ts @@ -29,15 +29,14 @@ export default builder.createRenderer(({ on }) => { on('sponsorblock-skip', (_, segments: Segment[]) => { currentSegments = segments; }); + }, + onPlayerApiReady() { + const video = document.querySelector('video'); + if (!video) return; - 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 }); + video.addEventListener('timeupdate', timeUpdateListener); + // Reset segments on song end + video.addEventListener('emptied', resetSegments); }, onUnload() { const video = document.querySelector('video'); diff --git a/src/plugins/tuna-obs/main.ts b/src/plugins/tuna-obs/main.ts index 4c1cc4aa..542cde9a 100644 --- a/src/plugins/tuna-obs/main.ts +++ b/src/plugins/tuna-obs/main.ts @@ -54,7 +54,7 @@ const post = (data: Data) => { export default builder.createMain(({ send, handle, on }) => { return { onLoad() { - on('apiLoaded', () => send('setupTimeChangedListener')); + on('ytmd:player-api-loaded', () => send('setupTimeChangedListener')); on('timeChanged', (t: number) => { if (!data.title) { return; diff --git a/src/plugins/utils/builder.ts b/src/plugins/utils/builder.ts index 65340d44..965edf55 100644 --- a/src/plugins/utils/builder.ts +++ b/src/plugins/utils/builder.ts @@ -2,6 +2,7 @@ import type { BrowserWindow, MenuItemConstructorOptions, } from 'electron'; +import type { YoutubePlayer } from '../../types/youtube-player'; export type PluginBaseConfig = { enabled: boolean; @@ -11,7 +12,9 @@ export type BasePlugin = { onUnload?: () => void; onConfigChange?: (newConfig: Config) => void; } -export type RendererPlugin = BasePlugin; +export type RendererPlugin = BasePlugin & { + onPlayerApiReady?: (api: YoutubePlayer) => void; +}; export type MainPlugin = Omit, 'onLoad' | 'onUnload'> & { onLoad?: (window: BrowserWindow) => void; onUnload?: (window: BrowserWindow) => void; diff --git a/src/plugins/video-toggle/renderer.ts b/src/plugins/video-toggle/renderer.ts index 91f877f8..4cf43e7c 100644 --- a/src/plugins/video-toggle/renderer.ts +++ b/src/plugins/video-toggle/renderer.ts @@ -22,8 +22,8 @@ export default builder.createRenderer(({ getConfig }) => { const switchButtonDiv = ElementFromHtml(buttonTemplate); - function setup(e: CustomEvent) { - api = e.detail; + function setup(playerApi: YoutubePlayer) { + api = playerApi; player = document.querySelector<(HTMLElement & { videoMode_: boolean; })>('ytmusic-player'); video = document.querySelector('video'); @@ -194,13 +194,11 @@ export default builder.createRenderer(({ getConfig }) => { document.querySelector('ytmusic-player')?.removeAttribute('has-av-switcher'); return; } - - default: - case 'custom': { - document.addEventListener('apiLoaded', setup, { once: true, passive: true }); - } } }, + onPlayerApiReady(playerApi) { + if (config.mode !== 'native' && config.mode != 'disabled') setup(playerApi); + }, onConfigChange(newConfig) { config = newConfig; diff --git a/src/providers/song-controls-front.ts b/src/providers/song-controls-front.ts deleted file mode 100644 index be080c81..00000000 --- a/src/providers/song-controls-front.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const setupSongControls = () => { - document.addEventListener('apiLoaded', (event) => { - window.ipcRenderer.on('seekTo', (_, t: number) => event.detail.seekTo(t)); - window.ipcRenderer.on('seekBy', (_, t: number) => event.detail.seekBy(t)); - }, { once: true, passive: true }); -}; diff --git a/src/providers/song-info-front.ts b/src/providers/song-info-front.ts index 05ae3f2a..57b91a91 100644 --- a/src/providers/song-info-front.ts +++ b/src/providers/song-info-front.ts @@ -73,74 +73,72 @@ export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => { window.ipcRenderer.send('volumeChanged', api.getVolume()); }); -export default () => { - document.addEventListener('apiLoaded', (apiEvent) => { - window.ipcRenderer.on('setupTimeChangedListener', () => { - setupTimeChangedListener(); - }); +export default (api: YoutubePlayer) => { + window.ipcRenderer.on('setupTimeChangedListener', () => { + setupTimeChangedListener(); + }); - window.ipcRenderer.on('setupRepeatChangedListener', () => { - setupRepeatChangedListener(); - }); + window.ipcRenderer.on('setupRepeatChangedListener', () => { + setupRepeatChangedListener(); + }); - window.ipcRenderer.on('setupVolumeChangedListener', () => { - setupVolumeChangedListener(apiEvent.detail); - }); + window.ipcRenderer.on('setupVolumeChangedListener', () => { + setupVolumeChangedListener(api); + }); - window.ipcRenderer.on('setupSeekedListener', () => { - setupSeekedListener(); - }); + window.ipcRenderer.on('setupSeekedListener', () => { + setupSeekedListener(); + }); - const playPausedHandler = (e: Event, status: string) => { - if (e.target instanceof HTMLVideoElement && Math.round(e.target.currentTime) > 0) { - window.ipcRenderer.send('playPaused', { - isPaused: status === 'pause', - elapsedSeconds: Math.floor(e.target.currentTime), - }); + const playPausedHandler = (e: Event, status: string) => { + if (e.target instanceof HTMLVideoElement && Math.round(e.target.currentTime) > 0) { + window.ipcRenderer.send('playPaused', { + isPaused: status === 'pause', + elapsedSeconds: Math.floor(e.target.currentTime), + }); + } + }; + + const playPausedHandlers = { + playing: (e: Event) => playPausedHandler(e, 'playing'), + pause: (e: Event) => playPausedHandler(e, 'pause'), + }; + + const waitingEvent = new Set(); + // Name = "dataloaded" and abit later "dataupdated" + api.addEventListener('videodatachange', (name: string, videoData) => { + if (name === 'dataupdated' && waitingEvent.has(videoData.videoId)) { + waitingEvent.delete(videoData.videoId); + sendSongInfo(videoData); + } else if (name === 'dataloaded') { + const video = $('video'); + video?.dispatchEvent(srcChangedEvent); + + for (const status of ['playing', 'pause'] as const) { // for fix issue that pause event not fired + video?.addEventListener(status, playPausedHandlers[status]); } - }; - const playPausedHandlers = { - playing: (e: Event) => playPausedHandler(e, 'playing'), - pause: (e: Event) => playPausedHandler(e, 'pause'), - }; + waitingEvent.add(videoData.videoId); + } + }); - const waitingEvent = new Set(); - // Name = "dataloaded" and abit later "dataupdated" - apiEvent.detail.addEventListener('videodatachange', (name: string, videoData) => { - if (name === 'dataupdated' && waitingEvent.has(videoData.videoId)) { - waitingEvent.delete(videoData.videoId); - sendSongInfo(videoData); - } else if (name === 'dataloaded') { - const video = $('video'); - video?.dispatchEvent(srcChangedEvent); + const video = $('video')!; + for (const status of ['playing', 'pause'] as const) { + video.addEventListener(status, playPausedHandlers[status]); + } - for (const status of ['playing', 'pause'] as const) { // for fix issue that pause event not fired - video?.addEventListener(status, playPausedHandlers[status]); - } + function sendSongInfo(videoData: VideoDataChangeValue) { + const data = api.getPlayerResponse(); - waitingEvent.add(videoData.videoId); - } - }); + data.videoDetails.album = videoData?.Hd?.playerOverlays?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album.runs?.at(0)?.text; + data.videoDetails.elapsedSeconds = 0; + data.videoDetails.isPaused = false; - const video = $('video')!; - for (const status of ['playing', 'pause'] as const) { - video.addEventListener(status, playPausedHandlers[status]); + // HACK: This is a workaround for "podcast" type video. GREAT JOB GOOGLE. + if (data.playabilityStatus.transportControlsConfig) { + data.videoDetails.author = data.microformat.microformatDataRenderer.pageOwnerDetails.name; } - function sendSongInfo(videoData: VideoDataChangeValue) { - const data = apiEvent.detail.getPlayerResponse(); - - data.videoDetails.album = videoData?.Hd?.playerOverlays?.playerOverlayRenderer?.browserMediaSession?.browserMediaSessionRenderer?.album.runs?.at(0)?.text; - data.videoDetails.elapsedSeconds = 0; - data.videoDetails.isPaused = false; - - // HACK: This is a workaround for "podcast" type video. GREAT JOB GOOGLE. - if (data.playabilityStatus.transportControlsConfig) { - data.videoDetails.author = data.microformat.microformatDataRenderer.pageOwnerDetails.name; - } - - window.ipcRenderer.send('video-src-changed', data); - } - }, { once: true, passive: true }); + window.ipcRenderer.send('video-src-changed', data); + } }; diff --git a/src/renderer.ts b/src/renderer.ts index ec9f1743..bab99200 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -6,22 +6,23 @@ import { pluginBuilders } from 'virtual:PluginBuilders'; import { PluginBaseConfig, PluginBuilder, RendererPluginFactory } from './plugins/utils/builder'; import { startingPages } from './providers/extracted-data'; -import { setupSongControls } from './providers/song-controls-front'; import setupSongInfo from './providers/song-info-front'; import { forceLoadRendererPlugin, forceUnloadRendererPlugin, - getAllLoadedRendererPlugins, + getAllLoadedRendererPlugins, getLoadedRendererPlugin, loadAllRendererPlugins, registerRendererPlugin } from './loader/renderer'; +import { YoutubePlayer } from './types/youtube-player'; -let api: Element | null = null; +let api: (Element & YoutubePlayer) | null = null; function listenForApiLoad() { api = document.querySelector('#movie_player'); if (api) { onApiLoaded(); + return; } @@ -29,6 +30,7 @@ function listenForApiLoad() { api = document.querySelector('#movie_player'); if (api) { observer.disconnect(); + onApiLoaded(); } }); @@ -41,6 +43,12 @@ interface YouTubeMusicAppElement extends HTMLElement { } function onApiLoaded() { + window.ipcRenderer.on('seekTo', (_, t: number) => api!.seekTo(t)); + window.ipcRenderer.on('seekBy', (_, t: number) => api!.seekBy(t)); + + // Inject song-info provider + setupSongInfo(api!); + const video = document.querySelector('video')!; const audioContext = new AudioContext(); const audioSource = audioContext.createMediaElementSource(video); @@ -66,10 +74,14 @@ function onApiLoaded() { ); }, { passive: true }, - );! + ); - document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api })); - window.ipcRenderer.send('apiLoaded'); + Object.values(getAllLoadedRendererPlugins()) + .forEach((plugin) => { + plugin.onPlayerApiReady?.(api!); + }); + + window.ipcRenderer.send('ytmd:player-api-loaded'); // Navigate to "Starting page" const startingPage: string = window.mainConfig.get('options.startingPage'); @@ -112,8 +124,13 @@ function onApiLoaded() { window.ipcRenderer.on('plugin:unload', (_event, id: keyof PluginBuilderList) => { forceUnloadRendererPlugin(id); }); - window.ipcRenderer.on('plugin:enable', (_event, id: keyof PluginBuilderList) => { - forceLoadRendererPlugin(id); + window.ipcRenderer.on('plugin:enable', async (_event, id: keyof PluginBuilderList) => { + await forceLoadRendererPlugin(id); + if (api) { + const plugin = getLoadedRendererPlugin(id); + + if (plugin) plugin.onPlayerApiReady?.(api); + } }); window.ipcRenderer.on('config-changed', (_event, id: string, newConfig: PluginBaseConfig) => { @@ -122,12 +139,6 @@ function onApiLoaded() { if (plugin) plugin.onConfigChange?.(newConfig); }); - // Inject song-info provider - setupSongInfo(); - - // Inject song-controls - setupSongControls(); - // Wait for complete load of YouTube api listenForApiLoad(); diff --git a/src/reset.d.ts b/src/reset.d.ts index a6af3678..6cf2792d 100644 --- a/src/reset.d.ts +++ b/src/reset.d.ts @@ -13,7 +13,6 @@ declare global { } interface DocumentEventMap { - 'apiLoaded': CustomEvent; 'audioCanPlay': CustomEvent; } From ffe53d5596001f187e0fbf5cfd59c1744a452a0c Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 02:02:54 +0900 Subject: [PATCH 23/79] feat(plugin): show dialog need to restart --- src/index.ts | 38 ++++++++++++++++++++++++++++++++++++ src/plugins/utils/builder.ts | 2 ++ 2 files changed, 40 insertions(+) diff --git a/src/index.ts b/src/index.ts index 8a03c2f8..7cecc620 100644 --- a/src/index.ts +++ b/src/index.ts @@ -131,12 +131,50 @@ const initHook = (win: BrowserWindow) => { } } + if (pluginBuilders[id as keyof PluginBuilderList].restartNeeded) { + showNeedToRestartDialog(id as keyof PluginBuilderList); + } + win.webContents.send('config-changed', id, config); } }); }); }; +const showNeedToRestartDialog = (id: keyof PluginBuilderList) => { + const builder = pluginBuilders[id]; + const dialogOptions: Electron.MessageBoxOptions = { + type: 'info', + buttons: ['Restart Now', 'Later'], + title: 'Restart Required', + message: `"${builder.name ?? builder.id}" needs to restart`, + detail: `"${builder.name ?? builder.id}" plugin requires a restart to take effect`, + defaultId: 0, + cancelId: 1, + }; + + let dialogPromise: Promise; + if (mainWindow) { + dialogPromise = dialog.showMessageBox(mainWindow, dialogOptions); + } else { + dialogPromise = dialog.showMessageBox(dialogOptions); + } + + dialogPromise.then((dialogOutput) => { + switch (dialogOutput.response) { + case 0: { + restart(); + break; + } + + // Ignore + default: { + break; + } + } + }); +}; + function initTheme(win: BrowserWindow) { injectCSS(win.webContents, youtubeMusicCSS); // Load user CSS diff --git a/src/plugins/utils/builder.ts b/src/plugins/utils/builder.ts index 965edf55..0c7bd655 100644 --- a/src/plugins/utils/builder.ts +++ b/src/plugins/utils/builder.ts @@ -62,6 +62,7 @@ export type PluginBuilder = config: Config; name?: string; styles?: string[]; + restartNeeded: boolean; }; export type PluginBuilderOptions = { name?: string; @@ -83,4 +84,5 @@ export const createPluginBuilder = Date: Sun, 12 Nov 2023 02:05:12 +0900 Subject: [PATCH 24/79] fix(ambient-plugin): fix plugin definition --- src/plugins/ambient-mode/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/ambient-mode/index.ts b/src/plugins/ambient-mode/index.ts index 38114249..7e7ad93e 100644 --- a/src/plugins/ambient-mode/index.ts +++ b/src/plugins/ambient-mode/index.ts @@ -14,7 +14,7 @@ export type AmbientModePluginConfig = { }; const builder = createPluginBuilder('ambient-mode', { name: 'Ambient Mode', - restartNeeded: true, + restartNeeded: false, config: { enabled: false, quality: 50, From deceae8354eaaece55c1e5490d4f913af59b3753 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 02:10:11 +0900 Subject: [PATCH 25/79] chore(plugin): clean import --- src/index.ts | 7 +++---- src/preload.ts | 3 +-- src/renderer.ts | 2 -- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7cecc620..1499b77f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,9 @@ import { parse } from 'node-html-parser'; import { deepmerge } from 'deepmerge-ts'; import { deepEqual } from 'fast-equals'; +import { mainPlugins } from 'virtual:MainPlugins'; +import { pluginBuilders } from 'virtual:PluginBuilders'; + import config from './config'; import { refreshMenu, setApplicationMenu } from './menu'; @@ -22,10 +25,6 @@ import { setupSongInfo } from './providers/song-info'; import { restart, setupAppControls } from './providers/app-controls'; import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler'; -/* eslint-disable import/order */ -import { mainPlugins } from 'virtual:MainPlugins'; -import { pluginBuilders } from 'virtual:PluginBuilders'; -/* eslint-enable import/order */ import youtubeMusicCSS from './youtube-music.css?inline'; diff --git a/src/preload.ts b/src/preload.ts index 95f2d89b..b4e2efd4 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -2,11 +2,10 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; import is from 'electron-is'; import { pluginBuilders } from 'virtual:PluginBuilders'; +import { preloadPlugins } from 'virtual:PreloadPlugins'; import config from './config'; -// eslint-disable-next-line import/order -import { preloadPlugins } from 'virtual:PreloadPlugins'; import { PluginBaseConfig, PluginBuilder, diff --git a/src/renderer.ts b/src/renderer.ts index bab99200..9bf208cf 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,5 +1,3 @@ - -// eslint-disable-next-line import/order import { rendererPlugins } from 'virtual:RendererPlugins'; import { pluginBuilders } from 'virtual:PluginBuilders'; From 3a431841b737499444108bd091b83ef8b9f98f01 Mon Sep 17 00:00:00 2001 From: Su-Yong Date: Sun, 12 Nov 2023 02:10:50 +0900 Subject: [PATCH 26/79] fix(plugin): fix always show restart dialog --- src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1499b77f..e665c975 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,6 +121,10 @@ const initHook = (win: BrowserWindow) => { ipcMain.emit('plugin:unload', id); forceUnloadMainPlugin(id as keyof PluginBuilderList, win); } + + if (pluginBuilders[id as keyof PluginBuilderList].restartNeeded) { + showNeedToRestartDialog(id as keyof PluginBuilderList); + } } const mainPlugin = getAllLoadedMainPlugins()[id]; @@ -130,10 +134,6 @@ const initHook = (win: BrowserWindow) => { } } - if (pluginBuilders[id as keyof PluginBuilderList].restartNeeded) { - showNeedToRestartDialog(id as keyof PluginBuilderList); - } - win.webContents.send('config-changed', id, config); } }); From ccd029c0402286b1a56e5bd49640ca475e1968db Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sun, 12 Nov 2023 02:55:44 +0900 Subject: [PATCH 27/79] fix: update types --- src/plugins/captions-selector/renderer.ts | 3 ++- src/plugins/video-toggle/renderer.ts | 5 ++--- src/renderer.ts | 3 ++- src/reset.d.ts | 1 - 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plugins/captions-selector/renderer.ts b/src/plugins/captions-selector/renderer.ts index 633bfa53..fe709a0d 100644 --- a/src/plugins/captions-selector/renderer.ts +++ b/src/plugins/captions-selector/renderer.ts @@ -3,7 +3,8 @@ import CaptionsSettingsButtonHTML from './templates/captions-settings-template.h import builder from './index'; import { ElementFromHtml } from '../utils/renderer'; -import { YoutubePlayer } from '../../types/youtube-player'; + +import type { YoutubePlayer } from '../../types/youtube-player'; interface LanguageOptions { displayName: string; diff --git a/src/plugins/video-toggle/renderer.ts b/src/plugins/video-toggle/renderer.ts index 4cf43e7c..f5aa4c0d 100644 --- a/src/plugins/video-toggle/renderer.ts +++ b/src/plugins/video-toggle/renderer.ts @@ -6,9 +6,8 @@ import { ElementFromHtml } from '../utils/renderer'; import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/renderer'; -import { YoutubePlayer } from '../../types/youtube-player'; -import { ThumbnailElement } from '../../types/get-player-response'; - +import type { ThumbnailElement } from '../../types/get-player-response'; +import type { YoutubePlayer } from '../../types/youtube-player'; export default builder.createRenderer(({ getConfig }) => { const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ? diff --git a/src/renderer.ts b/src/renderer.ts index 9bf208cf..b4bf79c8 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -12,7 +12,8 @@ import { loadAllRendererPlugins, registerRendererPlugin } from './loader/renderer'; -import { YoutubePlayer } from './types/youtube-player'; + +import type { YoutubePlayer } from './types/youtube-player'; let api: (Element & YoutubePlayer) | null = null; diff --git a/src/reset.d.ts b/src/reset.d.ts index 6cf2792d..b19fea09 100644 --- a/src/reset.d.ts +++ b/src/reset.d.ts @@ -4,7 +4,6 @@ import { ipcRenderer as electronIpcRenderer } from 'electron'; import is from 'electron-is'; import config from './config'; -import { YoutubePlayer } from './types/youtube-player'; declare global { interface Compressor { From 10a54b9de0326bac87079e630ee524d9c89eb473 Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sun, 12 Nov 2023 02:56:02 +0900 Subject: [PATCH 28/79] feat: update README --- readme.md | 182 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 122 insertions(+), 60 deletions(-) diff --git a/readme.md b/readme.md index 85b4acbd..90697607 100644 --- a/readme.md +++ b/readme.md @@ -190,7 +190,7 @@ Some predefined themes are available in https://github.com/kerichdev/themes-for- git clone https://github.com/th-ch/youtube-music cd youtube-music pnpm install --frozen-lockfile -pnpm start +pnpm dev ``` ## Build your own plugins @@ -204,61 +204,105 @@ Using plugins, you can: Create a folder in `plugins/YOUR-PLUGIN-NAME`: -- if you need to manipulate the BrowserWindow, create a file with the following template: - +- `index.ts`: the main file of the plugin ```typescript -// file: main.ts -export default (win: Electron.BrowserWindow, config: ConfigType<'YOUR-PLUGIN-NAME'>) => { - // something -}; +import style from './style.css?inline'; // import style as inline + +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('plugin-id', { + name: 'Plugin Label', + restartNeeded: true, // if value is true, ytmusic show restart dialog + config: { + enabled: false, + }, // your custom config + styles: [style], // your custom style +}); + +export default builder; + +// below code must be included in `index.ts` for type checking +declare global { + interface PluginBuilderList { + [builder.id]: typeof builder; + } +} ``` -then, register the plugin in `src/index.ts`: - +- `main.ts` (optional): executed in the main process ```typescript -import yourPlugin from './plugins/YOUR-PLUGIN-NAME/back'; +import builder from './index'; -// ... +export default builder.createMain((context) => { + return { + onLoad(win: BrowserWindow) { + win.maximize(); -const mainPlugins = { - // ... - 'YOUR-PLUGIN-NAME': yourPlugin, -}; -``` - -- if you need to change the front, create a file with the following template: - -```typescript -// file: renderer.ts -export default (config: ConfigType<'YOUR-PLUGIN-NAME'>) => { - // This function will be called as a preload script - // So you can use front features like `document.querySelector` -}; -``` - -then, register the plugin in `src/renderer.ts`: - -```typescript -import yourPlugin from './plugins/YOUR-PLUGIN-NAME/front'; - -const rendererPlugins: PluginMapper<'renderer'> = { - // ... - 'YOUR-PLUGIN-NAME': yourPlugin, -}; -``` - -Finally, add the plugin to the default config file `src/config/default.ts`: - -```typescript -export default { - // ... - 'plugins': { - // ... - 'YOUR-PLUGIN-NAME': { - // ... + context.handle('some-event', () => { + return 'hello'; + }); }, + onConfigChange(newConfig) { /* ... */ }, + onUnload(win) { /* ... */ }, + }; +}); +``` + +- `renderer.ts` (optional): executed in the renderer process +```typescript +import builder from './index'; + +import type { YoutubePlayer } from '../../types/youtube-player'; + +export default builder.createRenderer((context) => { + return { + async onLoad() { + console.log(await context.invoke('some-event')); + }, + onPlayerApiReady(api: YoutubePlayer) { + context.setConfig({ myConfig: api.getVolume() }); + }, + onConfigChange(newConfig) { /* ... */ }, + onUnload() { /* ... */ }, + }; +}); +``` + +- `preload.ts` (optional): executed in the renderer process before `renderer.ts` +```typescript +import builder from './index'; + +export default builder.createPreload(({ getConfig }) => ({ + async onLoad() { + const config = await getConfig(); + + // some logic... }, -}; + async onConfigChange(newConfig) {} +})); +``` + +- `menu.ts` (optional): executed in the main process for registering menu +```typescript +import builder from './index'; + +export default builder.createMenu(async ({ getConfig, setConfig }) => { + const config = await getConfig(); + + return [ + { + label: 'menu', + submenu: [1, 2, 3].map((value) => ({ + label: `value ${value}`, + type: 'radio', + checked: config.value === value, + click() { + setConfig({ value }); + }, + })) + }, + ] satisfies Electron.MenuItemConstructorOptions[]; +}); ``` ### Common use cases @@ -266,27 +310,39 @@ export default { - injecting custom CSS: create a `style.css` file in the same folder then: ```typescript -import path from 'node:path'; -import style from './style.css'; +// index.ts +import style from './style.css?inline'; // import style as inline -// main.ts -export default (win: Electron.BrowserWindow) => { - injectCSS(win.webContents, style); -}; +import { createPluginBuilder } from '../utils/builder'; + +const builder = createPluginBuilder('plugin-id', { + name: 'Plugin Label', + restartNeeded: true, // if value is true, ytmusic show restart dialog + config: { + enabled: false, + }, // your custom config + styles: [style], // your custom style +}); ``` -- changing the HTML: +- If you want to change the HTML: ```typescript // renderer.ts -export default () => { - // Remove the login button - document.querySelector(".sign-in-link.ytmusic-nav-bar").remove(); -}; +import builder from './index'; + +export default builder.createRenderer((context) => { + return { + async onLoad() { + // Remove the login button + document.querySelector(".sign-in-link.ytmusic-nav-bar").remove(); + }, + }; +}); ``` - communicating between the front and back: can be done using the ipcMain module from electron. See `utils.js` file and - example in `navigation` plugin. + example in `sponsorblock` plugin. ## Build @@ -302,6 +358,12 @@ export default () => { Builds the app for macOS, Linux, and Windows, using [electron-builder](https://github.com/electron-userland/electron-builder). +## Production Preview + +```bash +pnpm start +``` + ## Tests ```bash From 3ab4cd5d05132e5913c46b2ead0ab3f10048ecb0 Mon Sep 17 00:00:00 2001 From: Angelos Bouklis <53124886+ArjixWasTaken@users.noreply.github.com> Date: Sun, 26 Nov 2023 01:17:24 +0200 Subject: [PATCH 29/79] change plugin system --- .eslintrc.js | 66 +- .gitignore | 1 + electron.vite.config.ts | 48 +- package.json | 8 +- pnpm-lock.yaml | 608 +++++++++++++----- src/config/index.ts | 66 +- src/config/store.ts | 45 +- src/index.ts | 345 ++++++---- src/loader/main.ts | 166 ++--- src/loader/menu.ts | 61 +- src/loader/preload.ts | 87 ++- src/loader/renderer.ts | 101 ++- src/menu.ts | 273 ++++---- src/plugins/audio-compressor.ts | 25 + src/plugins/audio-compressor/index.ts | 17 - src/plugins/audio-compressor/renderer.ts | 24 - src/plugins/blur-nav-bar/index.ts | 19 +- src/plugins/bypass-age-restrictions/index.ts | 18 +- .../bypass-age-restrictions/renderer.ts | 8 - src/plugins/captions-selector/index.ts | 58 +- src/plugins/captions-selector/main.ts | 22 - src/plugins/captions-selector/menu.ts | 26 - src/plugins/utils/main/index.ts | 1 - src/plugins/utils/main/plugin.ts | 18 - src/preload.ts | 52 +- src/renderer.ts | 96 +-- src/types/contexts.ts | 33 + src/types/plugins.ts | 38 ++ src/utils/index.ts | 64 ++ src/virtual-module.d.ts | 36 +- tsconfig.json | 13 +- vite-plugins/plugin-importer.ts | 63 ++ vite-plugins/plugin-loader.ts | 105 +++ .../plugin-virtual-module-generator.ts | 49 -- 34 files changed, 1670 insertions(+), 990 deletions(-) create mode 100644 src/plugins/audio-compressor.ts delete mode 100644 src/plugins/audio-compressor/index.ts delete mode 100644 src/plugins/audio-compressor/renderer.ts delete mode 100644 src/plugins/bypass-age-restrictions/renderer.ts delete mode 100644 src/plugins/captions-selector/main.ts delete mode 100644 src/plugins/captions-selector/menu.ts delete mode 100644 src/plugins/utils/main/plugin.ts create mode 100644 src/types/contexts.ts create mode 100644 src/types/plugins.ts create mode 100644 src/utils/index.ts create mode 100644 vite-plugins/plugin-importer.ts create mode 100644 vite-plugins/plugin-loader.ts delete mode 100644 vite-plugins/plugin-virtual-module-generator.ts diff --git a/.eslintrc.js b/.eslintrc.js index 59f3401f..a64e42d2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,8 +1,8 @@ module.exports = { extends: [ + 'plugin:import/typescript', 'eslint:recommended', 'plugin:import/recommended', - 'plugin:import/typescript', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended-requiring-type-checking', @@ -13,30 +13,51 @@ module.exports = { project: './tsconfig.json', tsconfigRootDir: __dirname, sourceType: 'module', - ecmaVersion: 'latest' + ecmaVersion: 'latest', }, rules: { 'arrow-parens': ['error', 'always'], 'object-curly-spacing': ['error', 'always'], '@typescript-eslint/no-floating-promises': 'off', - '@typescript-eslint/no-misused-promises': ['off', { checksVoidReturn: false }], + '@typescript-eslint/no-misused-promises': [ + 'off', + { checksVoidReturn: false }, + ], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], - "@typescript-eslint/no-non-null-assertion": "off", + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', 'import/first': 'error', 'import/newline-after-import': 'error', 'import/no-default-export': 'off', 'import/no-duplicates': 'error', - 'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack', '^youtubei.js$'] }], + 'import/no-unresolved': [ + 'error', + { + ignore: [ + '^virtual:', + '\\?inline$', + '\\?raw$', + '\\?asset&asarUnpack', + '^youtubei.js$', + ], + }, + ], 'import/order': [ 'error', { - 'groups': ['builtin', 'external', ['internal', 'index', 'sibling'], 'parent', 'type'], + groups: [ + 'builtin', + 'external', + ['internal', 'index', 'sibling'], + 'parent', + 'type', + ], 'newlines-between': 'always-and-inside-groups', - 'alphabetize': {order: 'ignore', caseInsensitive: false} - } + alphabetize: { order: 'ignore', caseInsensitive: false }, + }, ], 'import/prefer-default-export': 'off', - 'camelcase': ['error', {properties: 'never'}], + camelcase: ['error', { properties: 'never' }], 'class-methods-use-this': 'off', 'lines-around-comment': [ 'error', @@ -49,17 +70,21 @@ module.exports = { ], 'max-len': 'off', 'no-mixed-operators': 'error', - 'no-multi-spaces': ['error', {ignoreEOLComments: true}], + 'no-multi-spaces': ['error', { ignoreEOLComments: true }], 'no-tabs': 'error', 'no-void': 'error', 'no-empty': 'off', 'prefer-promise-reject-errors': 'off', - 'quotes': ['error', 'single', { - avoidEscape: true, - allowTemplateLiterals: false, - }], + quotes: [ + 'error', + 'single', + { + avoidEscape: true, + allowTemplateLiterals: false, + }, + ], 'quote-props': ['error', 'consistent'], - 'semi': ['error', 'always'], + semi: ['error', 'always'], }, env: { browser: true, @@ -67,4 +92,15 @@ module.exports = { es6: true, }, ignorePatterns: ['dist', 'node_modules'], + root: true, + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + }, + }, + }, }; diff --git a/.gitignore b/.gitignore index ec2a06da..6cb8c059 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ electron-builder.yml !.yarn/releases !.yarn/sdks !.yarn/versions +.vite-inspect diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 5d8dafae..1dd2b941 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,8 +1,12 @@ +import { fileURLToPath, URL } from 'node:url'; + import { defineConfig, defineViteConfig } from 'electron-vite'; import builtinModules from 'builtin-modules'; import viteResolve from 'vite-plugin-resolve'; +import Inspect from 'vite-plugin-inspect'; -import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-virtual-module-generator'; +import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer'; +import pluginLoader from './vite-plugins/plugin-loader'; import type { UserConfig } from 'vite'; @@ -10,10 +14,9 @@ export default defineConfig({ main: defineViteConfig(({ mode }) => { const commonConfig: UserConfig = { plugins: [ + pluginLoader('backend'), viteResolve({ - 'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'), - 'virtual:MainPlugins': pluginVirtualModuleGenerator('main'), - 'virtual:MenuPlugins': pluginVirtualModuleGenerator('menu'), + 'virtual:plugins': pluginVirtualModuleGenerator('main'), }), ], publicDir: 'assets', @@ -31,9 +34,18 @@ export default defineConfig({ input: './src/index.ts', }, }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@assets': fileURLToPath(new URL('./assets', import.meta.url)), + }, + }, }; if (mode === 'development') { + commonConfig.plugins?.push( + Inspect({ build: true, outputDir: '.vite-inspect/backend' }), + ); return commonConfig; } @@ -49,9 +61,9 @@ export default defineConfig({ preload: defineViteConfig(({ mode }) => { const commonConfig: UserConfig = { plugins: [ + pluginLoader('preload'), viteResolve({ - 'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'), - 'virtual:PreloadPlugins': pluginVirtualModuleGenerator('preload'), + 'virtual:plugins': pluginVirtualModuleGenerator('preload'), }), ], build: { @@ -66,11 +78,20 @@ export default defineConfig({ rollupOptions: { external: ['electron', 'custom-electron-prompt', ...builtinModules], input: './src/preload.ts', - } + }, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@assets': fileURLToPath(new URL('./assets', import.meta.url)), + }, }, }; if (mode === 'development') { + commonConfig.plugins?.push( + Inspect({ build: true, outputDir: '.vite-inspect/preload' }), + ); return commonConfig; } @@ -86,9 +107,9 @@ export default defineConfig({ renderer: defineViteConfig(({ mode }) => { const commonConfig: UserConfig = { plugins: [ + pluginLoader('renderer'), viteResolve({ - 'virtual:PluginBuilders': pluginVirtualModuleGenerator('index'), - 'virtual:RendererPlugins': pluginVirtualModuleGenerator('renderer'), + 'virtual:plugins': pluginVirtualModuleGenerator('renderer'), }), ], root: './src/', @@ -107,9 +128,18 @@ export default defineConfig({ input: './src/index.html', }, }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@assets': fileURLToPath(new URL('./assets', import.meta.url)), + }, + }, }; if (mode === 'development') { + commonConfig.plugins?.push( + Inspect({ build: true, outputDir: '.vite-inspect/renderer' }), + ); return commonConfig; } diff --git a/package.json b/package.json index 30c27bbe..ef00b764 100644 --- a/package.json +++ b/package.json @@ -94,11 +94,12 @@ "test": "playwright test", "test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test", "build": "electron-vite build", + "vite:inspect": "yarpm-pnpm run clean && electron-vite build --mode development && yarpm-pnpm exec serve .vite-inspect", "start": "electron-vite preview", "start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start", "dev": "electron-vite dev --watch", "dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run dev", - "clean": "del-cli dist && del-cli pack", + "clean": "del-cli dist && del-cli pack && del-cli .vite-inspect", "dist": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win --mac --linux -p never", "dist:linux": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --linux -p never", "dist:mac": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac dmg:x64 -p never", @@ -136,6 +137,7 @@ "dependencies": { "@cliqz/adblocker-electron": "1.26.11", "@cliqz/adblocker-electron-preload": "1.26.11", + "@electron-toolkit/tsconfig": "^1.0.1", "@ffmpeg.wasm/core-mt": "0.12.0", "@ffmpeg.wasm/main": "0.12.0", "@foobar404/wave": "2.0.4", @@ -155,6 +157,7 @@ "electron-store": "8.1.0", "electron-unhandled": "4.0.1", "electron-updater": "6.1.4", + "eslint-import-resolver-typescript": "^3.6.1", "fast-average-color": "9.4.0", "fast-equals": "^5.0.1", "filenamify": "6.0.0", @@ -164,7 +167,9 @@ "keyboardevents-areequal": "0.2.2", "node-html-parser": "6.1.11", "node-id3": "0.2.6", + "serve": "^14.2.1", "simple-youtube-age-restriction-bypass": "git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8", + "ts-morph": "^20.0.0", "vudio": "2.1.1", "x11": "2.3.0", "youtubei.js": "7.0.0" @@ -194,6 +199,7 @@ "typescript": "5.2.2", "utf-8-validate": "6.0.3", "vite": "4.5.0", + "vite-plugin-inspect": "^0.7.42", "vite-plugin-resolve": "2.5.1", "ws": "8.14.2", "yarpm": "1.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fe005dc..c1ac585f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ dependencies: '@cliqz/adblocker-electron-preload': specifier: 1.26.11 version: 1.26.11(electron@27.0.4) + '@electron-toolkit/tsconfig': + specifier: ^1.0.1 + version: 1.0.1(@types/node@20.8.6) '@ffmpeg.wasm/core-mt': specifier: 0.12.0 version: 0.12.0 @@ -76,6 +79,9 @@ dependencies: electron-updater: specifier: 6.1.4 version: 6.1.4 + eslint-import-resolver-typescript: + specifier: ^3.6.1 + version: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-plugin-import@2.29.0)(eslint@8.53.0) fast-average-color: specifier: 9.4.0 version: 9.4.0 @@ -103,9 +109,15 @@ dependencies: node-id3: specifier: 0.2.6 version: 0.2.6 + serve: + specifier: ^14.2.1 + version: 14.2.1 simple-youtube-age-restriction-bypass: specifier: git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8 version: github.com/organization/Simple-YouTube-Age-Restriction-Bypass/816a882c68fcfe6cdd9410a6877b88093ed15b28 + ts-morph: + specifier: ^20.0.0 + version: 20.0.0 vudio: specifier: 2.1.1 version: 2.1.1 @@ -164,7 +176,7 @@ devDependencies: version: 8.53.0 eslint-plugin-import: specifier: 2.29.0 - version: 2.29.0(@typescript-eslint/parser@6.7.5)(eslint@8.53.0) + version: 2.29.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.53.0) eslint-plugin-prettier: specifier: 5.0.1 version: 5.0.1(eslint@8.53.0)(prettier@3.0.3) @@ -188,7 +200,10 @@ devDependencies: version: 6.0.3 vite: specifier: 4.5.0 - version: 4.5.0 + version: 4.5.0(@types/node@20.8.6) + vite-plugin-inspect: + specifier: ^0.7.42 + version: 0.7.42(rollup@4.3.0)(vite@4.5.0) vite-plugin-resolve: specifier: 2.5.1 version: 2.5.1 @@ -208,7 +223,6 @@ packages: /@aashutoshrathi/word-wrap@1.2.6: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} - dev: true /@ampproject/remapping@2.2.1: resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} @@ -218,6 +232,10 @@ packages: '@jridgewell/trace-mapping': 0.3.19 dev: true + /@antfu/utils@0.7.6: + resolution: {integrity: sha512-pvFiLP2BeOKA/ZOS6jxx4XhKzdVLHDhGlFEaZ2flWWYf2xOqVniqpk38I04DFRyz+L0ASggl7SkItTc+ZLju4w==} + dev: true + /@assemblyscript/loader@0.17.14: resolution: {integrity: sha512-+PVTOfla/0XMLRTQLJFPg4u40XcdTfon6GGea70hBGi8Pd7ZymIXyVUR+vK8wt5Jb4MVKTKPIz43Myyebw5mZA==} dev: false @@ -486,6 +504,14 @@ packages: ajv-keywords: 3.5.2(ajv@6.12.6) dev: true + /@electron-toolkit/tsconfig@1.0.1(@types/node@20.8.6): + resolution: {integrity: sha512-M0Mol3odspvtCuheyujLNAW7bXq7KFNYVMRtpjFa4ZfES4MuklXBC7Nli/omvc+PRKlrklgAGx3l4VakjNo8jg==} + peerDependencies: + '@types/node': '*' + dependencies: + '@types/node': 20.8.6 + dev: false + /@electron/asar@3.2.7: resolution: {integrity: sha512-8FaSCAIiZGYFWyjeevPQt+0e9xCK9YmJ2Rjg5SXgdsXon6cRnU0Yxnbe6CvJbQn26baifur2Y2G5EBayRIsjyg==} engines: {node: '>=10.12.0'} @@ -759,12 +785,10 @@ packages: dependencies: eslint: 8.53.0 eslint-visitor-keys: 3.4.3 - dev: true /@eslint-community/regexpp@4.9.1: resolution: {integrity: sha512-Y27x+MBLjXa+0JWDhykM3+JE+il3kHKAEqabfEWq3SDhZjLYb6/BHL/JKFnH3fe207JaXkyDo685Oc2Glt6ifA==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - dev: true /@eslint/eslintrc@2.1.3: resolution: {integrity: sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==} @@ -781,12 +805,10 @@ packages: strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - dev: true /@eslint/js@8.53.0: resolution: {integrity: sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true /@fastify/busboy@2.0.0: resolution: {integrity: sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==} @@ -819,16 +841,13 @@ packages: minimatch: 3.1.2 transitivePeerDependencies: - supports-color - dev: true /@humanwhocodes/module-importer@1.0.1: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - dev: true /@humanwhocodes/object-schema@2.0.1: resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==} - dev: true /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -913,12 +932,10 @@ packages: dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - dev: true /@nodelib/fs.stat@2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true /@nodelib/fs.walk@1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} @@ -926,7 +943,6 @@ packages: dependencies: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - dev: true /@nornagon/put@0.0.8: resolution: {integrity: sha512-ugvXJjwF5ldtUpa7D95kruNJ41yFQDEKyF5CW4TgKJnh+W/zmlBzXXeKTyqIgwMFrkePN2JqOBqcF0M0oOunow==} @@ -977,6 +993,10 @@ packages: playwright: 1.39.0 dev: true + /@polka/url@1.0.0-next.23: + resolution: {integrity: sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==} + dev: true + /@remusao/guess-url-type@1.2.1: resolution: {integrity: sha512-rbOqre2jW8STjheOsOaQHLgYBaBZ9Owbdt8NO7WvNZftJlaG3y/K9oOkl8ZUpuFBisIhmBuMEW6c+YrQl5inRA==} dev: false @@ -1006,6 +1026,21 @@ packages: resolution: {integrity: sha512-yvwa+aCyYI/UjeD39BnpMypG8N06l86wIDW1/PAc6ihBRnodIfZDwccxQN3n1t74wduzaz74m4ZMHZnB06567Q==} dev: false + /@rollup/pluginutils@5.0.5(rollup@4.3.0): + resolution: {integrity: sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + rollup: 4.3.0 + dev: true + /@rollup/rollup-android-arm-eabi@4.3.0: resolution: {integrity: sha512-/4pns6BYi8MXdwnXM44yoGAcFYVHL/BYlB2q1HXZ6AzH++LaiEVWFpBWQ/glXhbMbv3E3o09igrHFbP/snhAvA==} cpu: [arm] @@ -1128,6 +1163,15 @@ packages: resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==} dev: true + /@ts-morph/common@0.21.0: + resolution: {integrity: sha512-ES110Mmne5Vi4ypUKrtVQfXFDtCsDXiUiGxF6ILVlE90dDD4fdpC1LSjydl/ml7xJWKSDZwUYD2zkOePMSrPBA==} + dependencies: + fast-glob: 3.3.1 + minimatch: 7.4.6 + mkdirp: 2.1.6 + path-browserify: 1.0.1 + dev: false + /@types/cacheable-request@6.0.3: resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} dependencies: @@ -1157,6 +1201,10 @@ packages: - supports-color dev: true + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + dev: true + /@types/filesystem@0.0.33: resolution: {integrity: sha512-2KedRPzwu2K528vFkoXnnWdsG0MtUwPjuA7pRy4vKxlxHEe8qUDZibYHXJKZZr2Cl/ELdCWYqyb/MKwsUuzBWw==} dependencies: @@ -1198,7 +1246,6 @@ packages: /@types/json5@0.0.29: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - dev: true /@types/keyv@3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -1304,7 +1351,6 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: true /@typescript-eslint/scope-manager@6.10.0: resolution: {integrity: sha512-TN/plV7dzqqC2iPNf1KrxozDgZs53Gfgg5ZHyw8erd6jd5Ta/JIEcdCheXFt9b1NYb93a1wmIIVW/2gLkombDg==} @@ -1320,7 +1366,6 @@ packages: dependencies: '@typescript-eslint/types': 6.7.5 '@typescript-eslint/visitor-keys': 6.7.5 - dev: true /@typescript-eslint/type-utils@6.10.0(eslint@8.53.0)(typescript@5.2.2): resolution: {integrity: sha512-wYpPs3hgTFblMYwbYWPT3eZtaDOjbLyIYuqpwuLBBqhLiuvJ+9sEp2gNRJEtR5N/c9G1uTtQQL5AhV0fEPJYcg==} @@ -1350,7 +1395,6 @@ packages: /@typescript-eslint/types@6.7.5: resolution: {integrity: sha512-WboQBlOXtdj1tDFPyIthpKrUb+kZf2VroLZhxKa/VlwLlLyqv/PwUNgL30BlTVZV1Wu4Asu2mMYPqarSO4L5ZQ==} engines: {node: ^16.0.0 || >=18.0.0} - dev: true /@typescript-eslint/typescript-estree@6.10.0(typescript@5.2.2): resolution: {integrity: sha512-ek0Eyuy6P15LJVeghbWhSrBCj/vJpPXXR+EpaRZqou7achUWL8IdYnMSC5WHAeTWswYQuP2hAZgij/bC9fanBg==} @@ -1392,7 +1436,6 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: true /@typescript-eslint/utils@6.10.0(eslint@8.53.0)(typescript@5.2.2): resolution: {integrity: sha512-v+pJ1/RcVyRc0o4wAGux9x42RHmAjIGzPRo538Z8M1tVx6HOnoQBCX/NoadHQlZeC+QO2yr4nNSFWOoraZCAyg==} @@ -1427,11 +1470,9 @@ packages: dependencies: '@typescript-eslint/types': 6.7.5 eslint-visitor-keys: 3.4.3 - dev: true /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - dev: true /@xhayper/discord-rpc@1.0.24(bufferutil@4.0.8)(utf-8-validate@6.0.3): resolution: {integrity: sha512-gzC8OaOSz7cGALSHyyq6nANQvBfyfntbSq+Qh+cNanoKX8ybOj+jWKmDP6PbLVDWoBftTU3JYsWXrLml2df2Hw==} @@ -1451,17 +1492,28 @@ packages: requiresBuild: true dev: true + /@zeit/schemas@2.29.0: + resolution: {integrity: sha512-g5QiLIfbg3pLuYUJPlisNKY+epQJTcMDsOnVNkscrDP1oi7vmJnzOANYJI/1pZcVJ6umUkBv3aFtlg1UvUHGzA==} + dev: false + /abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + /accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + dev: false + /acorn-jsx@5.3.2(acorn@8.10.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: acorn: 8.10.0 - dev: true /acorn@8.10.0: resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} @@ -1526,7 +1578,15 @@ packages: fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - dev: true + + /ajv@8.11.0: + resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: false /ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} @@ -1537,6 +1597,12 @@ packages: uri-js: 4.4.1 dev: false + /ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + dependencies: + string-width: 4.2.3 + dev: false + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1602,6 +1668,14 @@ packages: - supports-color dev: true + /arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + dev: false + + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: false + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1620,12 +1694,10 @@ packages: es-abstract: 1.22.2 get-intrinsic: 1.2.1 is-string: 1.0.7 - dev: true /array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - dev: true /array.prototype.findlastindex@1.2.3: resolution: {integrity: sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==} @@ -1636,7 +1708,6 @@ packages: es-abstract: 1.22.2 es-shim-unscopables: 1.0.0 get-intrinsic: 1.2.1 - dev: true /array.prototype.flat@1.3.2: resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} @@ -1646,7 +1717,6 @@ packages: define-properties: 1.2.1 es-abstract: 1.22.2 es-shim-unscopables: 1.0.0 - dev: true /array.prototype.flatmap@1.3.2: resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} @@ -1656,7 +1726,6 @@ packages: define-properties: 1.2.1 es-abstract: 1.22.2 es-shim-unscopables: 1.0.0 - dev: true /arraybuffer.prototype.slice@1.0.2: resolution: {integrity: sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==} @@ -1669,7 +1738,6 @@ packages: get-intrinsic: 1.2.1 is-array-buffer: 3.0.2 is-shared-array-buffer: 1.0.2 - dev: true /arrify@1.0.1: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} @@ -1772,6 +1840,20 @@ packages: requiresBuild: true optional: true + /boxen@7.0.0: + resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} + engines: {node: '>=14.16'} + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.0.1 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + dev: false + /bplist-parser@0.2.0: resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} engines: {node: '>= 5.10.0'} @@ -1784,7 +1866,6 @@ packages: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - dev: true /brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} @@ -1796,7 +1877,6 @@ packages: engines: {node: '>=8'} dependencies: fill-range: 7.0.1 - dev: true /browser-extension-url-match@1.0.0: resolution: {integrity: sha512-LfIs9SYgPjYksjxkgOVYZhxMIroR56isQB3YHTAmzunWuT9qrH6Fxt7TD9/s9MoKo7GP37JZbLlZhL9vwQAk3w==} @@ -1901,6 +1981,11 @@ packages: eel-wasm: 0.0.15 dev: false + /bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + dev: false + /cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1948,7 +2033,6 @@ packages: /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - dev: true /camelcase-keys@7.0.2: resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==} @@ -1965,10 +2049,22 @@ packages: engines: {node: '>=10'} dev: true + /camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + dev: false + /caniuse-lite@1.0.30001561: resolution: {integrity: sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==} dev: true + /chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + dependencies: + chalk: 4.1.2 + dev: false + /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -1984,7 +2080,11 @@ packages: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - dev: true + + /chalk@5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: false /chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} @@ -2010,6 +2110,11 @@ packages: escape-string-regexp: 5.0.0 dev: true + /cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + dev: false + /cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -2020,6 +2125,15 @@ packages: dev: true optional: true + /clipboardy@3.0.0: + resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + arch: 2.2.0 + execa: 5.1.1 + is-wsl: 2.2.0 + dev: false + /cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2034,6 +2148,10 @@ packages: dependencies: mimic-response: 1.0.1 + /code-block-writer@12.0.0: + resolution: {integrity: sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -2075,9 +2193,30 @@ packages: engines: {node: '>=0.10.0'} dev: true + /compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /compression@1.7.4: + resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==} + engines: {node: '>= 0.8.0'} + dependencies: + accepts: 1.3.8 + bytes: 3.0.0 + compressible: 2.0.18 + debug: 2.6.9 + on-headers: 1.0.2 + safe-buffer: 5.1.2 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: false + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: true /conf@10.2.0: resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} @@ -2102,6 +2241,11 @@ packages: typescript: 4.9.5 dev: true + /content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + dev: false + /convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true @@ -2191,6 +2335,17 @@ packages: mimic-fn: 3.1.0 dev: false + /debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.0.0 + dev: false + /debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2200,7 +2355,6 @@ packages: optional: true dependencies: ms: 2.1.3 - dev: true /debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} @@ -2260,9 +2414,13 @@ packages: which-typed-array: 1.1.11 dev: false + /deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dev: true /deepmerge-ts@5.1.0: resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==} @@ -2361,7 +2519,6 @@ packages: engines: {node: '>=8'} dependencies: path-type: 4.0.0 - dev: true /dmg-builder@24.6.4: resolution: {integrity: sha512-BNcHRc9CWEuI9qt0E655bUBU/j/3wUCYBVKGu1kVpbN5lcUdEJJJeiO0NHK3dgKmra6LUUZlo+mWqc+OCbi0zw==} @@ -2401,14 +2558,12 @@ packages: engines: {node: '>=0.10.0'} dependencies: esutils: 2.0.3 - dev: true /doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} dependencies: esutils: 2.0.3 - dev: true /dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2615,7 +2770,7 @@ packages: esbuild: 0.18.20 magic-string: 0.30.5 picocolors: 1.0.0 - vite: 4.5.0 + vite: 4.5.0(@types/node@20.8.6) transitivePeerDependencies: - supports-color dev: true @@ -2650,6 +2805,13 @@ packages: dependencies: once: 1.4.0 + /enhanced-resolve@5.15.0: + resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + /ensure-error@2.1.0: resolution: {integrity: sha512-+BMSJHw9gxiJAAp2ZR1E0TNcL09dD3lOvkl7WVm4+Y6xnes/pMetP/TzCHiDduh8ihNDjbGfuYxl7l4PA1xZ8A==} engines: {node: '>=8'} @@ -2673,6 +2835,10 @@ packages: is-arrayish: 0.2.1 dev: true + /error-stack-parser-es@0.1.1: + resolution: {integrity: sha512-g/9rfnvnagiNf+DRMHEVGuGuIBlCIMDFoTA616HaP2l9PlCjGjVhD98PNbVSJvmK4TttqT5mV5tInMhoFgi+aA==} + dev: true + /es-abstract@1.22.2: resolution: {integrity: sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==} engines: {node: '>= 0.4'} @@ -2716,7 +2882,6 @@ packages: typed-array-length: 1.0.4 unbox-primitive: 1.0.2 which-typed-array: 1.1.11 - dev: true /es-get-iterator@1.1.3: resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} @@ -2739,13 +2904,11 @@ packages: get-intrinsic: 1.2.1 has: 1.0.4 has-tostringtag: 1.0.0 - dev: true /es-shim-unscopables@1.0.0: resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} dependencies: has: 1.0.4 - dev: true /es-to-primitive@1.2.1: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} @@ -2754,7 +2917,6 @@ packages: is-callable: 1.2.7 is-date-object: 1.0.5 is-symbol: 1.0.4 - dev: true /es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -2818,9 +2980,30 @@ packages: resolve: 1.22.8 transitivePeerDependencies: - supports-color - dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint@8.53.0): + /eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.7.5)(eslint-plugin-import@2.29.0)(eslint@8.53.0): + resolution: {integrity: sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + dependencies: + debug: 4.3.4 + enhanced-resolve: 5.15.0 + eslint: 8.53.0 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.53.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.53.0) + fast-glob: 3.3.1 + get-tsconfig: 4.7.2 + is-core-module: 2.13.1 + is-glob: 4.0.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - supports-color + + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.53.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} peerDependencies: @@ -2845,11 +3028,11 @@ packages: debug: 3.2.7 eslint: 8.53.0 eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.7.5)(eslint-plugin-import@2.29.0)(eslint@8.53.0) transitivePeerDependencies: - supports-color - dev: true - /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.7.5)(eslint@8.53.0): + /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-typescript@3.6.1)(eslint@8.53.0): resolution: {integrity: sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==} engines: {node: '>=4'} peerDependencies: @@ -2868,7 +3051,7 @@ packages: doctrine: 2.1.0 eslint: 8.53.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint@8.53.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.7.5)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.53.0) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -2882,7 +3065,6 @@ packages: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - dev: true /eslint-plugin-prettier@5.0.1(eslint@8.53.0)(prettier@3.0.3): resolution: {integrity: sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==} @@ -2910,12 +3092,10 @@ packages: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 - dev: true /eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true /eslint@8.53.0: resolution: {integrity: sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==} @@ -2962,7 +3142,6 @@ packages: text-table: 0.2.0 transitivePeerDependencies: - supports-color - dev: true /espree@9.6.1: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} @@ -2971,31 +3150,30 @@ packages: acorn: 8.10.0 acorn-jsx: 5.3.2(acorn@8.10.0) eslint-visitor-keys: 3.4.3 - dev: true /esquery@1.5.0: resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} engines: {node: '>=0.10'} dependencies: estraverse: 5.3.0 - dev: true /esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} dependencies: estraverse: 5.3.0 - dev: true /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} dev: true /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - dev: true /event-stream@3.3.4: resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} @@ -3022,7 +3200,6 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 strip-final-newline: 2.0.0 - dev: true /execa@7.2.0: resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} @@ -3092,21 +3269,23 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: true /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - dev: true /fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - dev: true + + /fast-url-parser@1.1.3: + resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} + dependencies: + punycode: 1.4.1 + dev: false /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 - dev: true /fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -3126,7 +3305,6 @@ packages: engines: {node: ^10.12.0 || >=12.0.0} dependencies: flat-cache: 3.1.1 - dev: true /file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -3157,7 +3335,6 @@ packages: engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - dev: true /find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} @@ -3172,7 +3349,6 @@ packages: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 - dev: true /flat-cache@3.1.1: resolution: {integrity: sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==} @@ -3181,11 +3357,9 @@ packages: flatted: 3.2.9 keyv: 4.5.4 rimraf: 3.0.2 - dev: true /flatted@3.2.9: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} - dev: true /follow-redirects@1.15.3: resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} @@ -3236,6 +3410,15 @@ packages: jsonfile: 6.1.0 universalify: 2.0.0 + /fs-extra@11.1.1: + resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + /fs-extra@8.1.0: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} @@ -3268,7 +3451,6 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true /fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} @@ -3297,7 +3479,6 @@ packages: define-properties: 1.2.1 es-abstract: 1.22.2 functions-have-names: 1.2.3 - dev: true /functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} @@ -3329,7 +3510,6 @@ packages: /get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - dev: true /get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} @@ -3337,21 +3517,23 @@ packages: dependencies: call-bind: 1.0.2 get-intrinsic: 1.2.1 - dev: true + + /get-tsconfig@4.7.2: + resolution: {integrity: sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==} + dependencies: + resolve-pkg-maps: 1.0.0 /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} dependencies: is-glob: 4.0.3 - dev: true /glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} dependencies: is-glob: 4.0.3 - dev: true /glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} @@ -3373,7 +3555,6 @@ packages: minimatch: 3.1.2 once: 1.4.0 path-is-absolute: 1.0.1 - dev: true /global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -3398,7 +3579,6 @@ packages: engines: {node: '>=8'} dependencies: type-fest: 0.20.2 - dev: true /globalthis@1.0.3: resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} @@ -3416,7 +3596,6 @@ packages: ignore: 5.2.4 merge2: 1.4.1 slash: 3.0.0 - dev: true /globby@13.2.2: resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} @@ -3455,7 +3634,6 @@ packages: /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - dev: true /hard-rejection@2.1.0: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} @@ -3473,7 +3651,6 @@ packages: /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - dev: true /has-property-descriptors@1.0.0: resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} @@ -3503,7 +3680,6 @@ packages: engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 - dev: true /he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} @@ -3598,7 +3774,6 @@ packages: /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - dev: true /human-signals@4.3.1: resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} @@ -3638,7 +3813,6 @@ packages: /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} - dev: true /immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} @@ -3650,7 +3824,6 @@ packages: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 - dev: true /imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -3670,11 +3843,13 @@ packages: dependencies: once: 1.4.0 wrappy: 1.0.2 - dev: true /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true + + /ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + dev: false /internal-slot@1.0.5: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} @@ -3739,7 +3914,6 @@ packages: resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} dependencies: hasown: 2.0.0 - dev: true /is-date-object@1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} @@ -3751,7 +3925,6 @@ packages: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} hasBin: true - dev: true /is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} @@ -3762,7 +3935,6 @@ packages: /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} @@ -3773,7 +3945,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 - dev: true /is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} @@ -3793,7 +3964,6 @@ packages: /is-negative-zero@2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} - dev: true /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} @@ -3804,7 +3974,6 @@ packages: /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - dev: true /is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} @@ -3819,7 +3988,6 @@ packages: /is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} - dev: true /is-path-inside@4.0.0: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} @@ -3831,6 +3999,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-port-reachable@4.0.0: + resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + /is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -3850,7 +4023,6 @@ packages: /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - dev: true /is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} @@ -3887,7 +4059,6 @@ packages: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} dependencies: call-bind: 1.0.2 - dev: true /is-weakset@2.0.2: resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} @@ -3901,7 +4072,6 @@ packages: engines: {node: '>=8'} dependencies: is-docker: 2.2.1 - dev: true /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -3981,7 +4151,6 @@ packages: /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - dev: true /json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -3993,7 +4162,6 @@ packages: /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - dev: true /json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -4005,7 +4173,6 @@ packages: hasBin: true dependencies: minimist: 1.2.8 - dev: true /json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} @@ -4065,7 +4232,6 @@ packages: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - dev: true /lib-esm@0.4.1: resolution: {integrity: sha512-tdSqfyryhnl5k09357x2iWmw3WeU84SaoP/vMGw/nw8z8RPTrfu9sxwRApn6p6GyStuBNyASgwXIV8ctZWlG1A==} @@ -4094,7 +4260,6 @@ packages: engines: {node: '>=10'} dependencies: p-locate: 5.0.0 - dev: true /lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -4110,7 +4275,6 @@ packages: /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: true /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4207,12 +4371,10 @@ packages: /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - dev: true /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - dev: true /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} @@ -4220,12 +4382,23 @@ packages: dependencies: braces: 3.0.2 picomatch: 2.3.1 - dev: true + + /mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + dev: false /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + /mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.33.0 + dev: false + /mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} @@ -4269,7 +4442,6 @@ packages: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 - dev: true /minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} @@ -4278,6 +4450,13 @@ packages: brace-expansion: 2.0.1 dev: true + /minimatch@7.4.6: + resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: false + /minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -4295,7 +4474,6 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true /minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} @@ -4364,12 +4542,26 @@ packages: engines: {node: '>=10'} hasBin: true + /mkdirp@2.1.6: + resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} + engines: {node: '>=10'} + hasBin: true + dev: false + + /mrmime@1.0.1: + resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} + engines: {node: '>=10'} + dev: true + + /ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + dev: false + /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: true /nan@2.18.0: resolution: {integrity: sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==} @@ -4385,7 +4577,6 @@ packages: /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - dev: true /negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} @@ -4476,7 +4667,6 @@ packages: engines: {node: '>=8'} dependencies: path-key: 3.1.1 - dev: true /npm-run-path@5.1.0: resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} @@ -4522,7 +4712,6 @@ packages: call-bind: 1.0.2 define-properties: 1.2.1 es-abstract: 1.22.2 - dev: true /object.groupby@1.0.1: resolution: {integrity: sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==} @@ -4531,7 +4720,6 @@ packages: define-properties: 1.2.1 es-abstract: 1.22.2 get-intrinsic: 1.2.1 - dev: true /object.values@1.1.7: resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==} @@ -4540,7 +4728,11 @@ packages: call-bind: 1.0.2 define-properties: 1.2.1 es-abstract: 1.22.2 - dev: true + + /on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + dev: false /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4580,7 +4772,6 @@ packages: levn: 0.4.1 prelude-ls: 1.2.1 type-check: 0.4.0 - dev: true /os-homedir@1.0.2: resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==} @@ -4603,7 +4794,6 @@ packages: engines: {node: '>=10'} dependencies: yocto-queue: 0.1.0 - dev: true /p-locate@3.0.0: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} @@ -4617,7 +4807,6 @@ packages: engines: {node: '>=10'} dependencies: p-limit: 3.1.0 - dev: true /p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} @@ -4646,7 +4835,6 @@ packages: engines: {node: '>=6'} dependencies: callsites: 3.1.0 - dev: true /parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} @@ -4665,6 +4853,10 @@ packages: peberminta: 0.9.0 dev: false + /path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + dev: false + /path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -4673,12 +4865,14 @@ packages: /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - dev: true /path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} - dev: true + + /path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + dev: false /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} @@ -4691,7 +4885,6 @@ packages: /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - dev: true /path-scurry@1.10.1: resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} @@ -4700,10 +4893,13 @@ packages: lru-cache: 10.0.1 minipass: 7.0.4 + /path-to-regexp@2.2.1: + resolution: {integrity: sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==} + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - dev: true /pause-stream@0.0.11: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} @@ -4725,7 +4921,6 @@ packages: /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - dev: true /pkg-up@3.1.0: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} @@ -4771,7 +4966,6 @@ packages: /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - dev: true /prettier-linter-helpers@1.0.0: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} @@ -4815,18 +5009,36 @@ packages: end-of-stream: 1.4.4 once: 1.4.0 + /punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + dev: false + /punycode@2.3.0: resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} engines: {node: '>=6'} /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true /quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + /range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + dev: false + + /rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + dev: false + /read-config-file@6.3.2: resolution: {integrity: sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==} engines: {node: '>=12.0.0'} @@ -4894,6 +5106,20 @@ packages: define-properties: 1.2.1 set-function-name: 2.0.1 + /registry-auth-token@3.3.2: + resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} + dependencies: + rc: 1.2.8 + safe-buffer: 5.2.1 + dev: false + + /registry-url@3.1.0: + resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} + engines: {node: '>=0.10.0'} + dependencies: + rc: 1.2.8 + dev: false + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4910,7 +5136,9 @@ packages: /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - dev: true + + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} @@ -4919,7 +5147,6 @@ packages: is-core-module: 2.13.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: true /responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} @@ -4933,14 +5160,12 @@ packages: /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} hasBin: true dependencies: glob: 7.2.3 - dev: true /roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} @@ -4986,7 +5211,6 @@ packages: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 - dev: true /safe-array-concat@1.0.1: resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} @@ -4996,11 +5220,9 @@ packages: get-intrinsic: 1.2.1 has-symbols: 1.0.3 isarray: 2.0.5 - dev: true /safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - dev: true /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -5012,7 +5234,6 @@ packages: call-bind: 1.0.2 get-intrinsic: 1.2.1 is-regex: 1.1.4 - dev: true /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -5068,6 +5289,39 @@ packages: type-fest: 0.20.2 dev: false + /serve-handler@6.1.5: + resolution: {integrity: sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg==} + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + fast-url-parser: 1.1.3 + mime-types: 2.1.18 + minimatch: 3.1.2 + path-is-inside: 1.0.2 + path-to-regexp: 2.2.1 + range-parser: 1.2.0 + dev: false + + /serve@14.2.1: + resolution: {integrity: sha512-48er5fzHh7GCShLnNyPBRPEjs2I6QBozeGr02gaacROiyS/8ARADlj595j39iZXAqBbJHH/ivJJyPRWY9sQWZA==} + engines: {node: '>= 14'} + hasBin: true + dependencies: + '@zeit/schemas': 2.29.0 + ajv: 8.11.0 + arg: 5.0.2 + boxen: 7.0.0 + chalk: 5.0.1 + chalk-template: 0.4.0 + clipboardy: 3.0.0 + compression: 1.7.4 + is-port-reachable: 4.0.0 + serve-handler: 6.1.5 + update-check: 1.5.4 + transitivePeerDependencies: + - supports-color + dev: false + /set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} engines: {node: '>= 0.4'} @@ -5099,7 +5353,6 @@ packages: /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - dev: true /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} @@ -5112,10 +5365,18 @@ packages: semver: 7.5.4 dev: true + /sirv@2.0.3: + resolution: {integrity: sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.23 + mrmime: 1.0.1 + totalist: 3.0.1 + dev: true + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - dev: true /slash@4.0.0: resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} @@ -5249,7 +5510,6 @@ packages: call-bind: 1.0.2 define-properties: 1.2.1 es-abstract: 1.22.2 - dev: true /string.prototype.trimend@1.0.7: resolution: {integrity: sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==} @@ -5257,7 +5517,6 @@ packages: call-bind: 1.0.2 define-properties: 1.2.1 es-abstract: 1.22.2 - dev: true /string.prototype.trimstart@1.0.7: resolution: {integrity: sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==} @@ -5265,7 +5524,6 @@ packages: call-bind: 1.0.2 define-properties: 1.2.1 es-abstract: 1.22.2 - dev: true /string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -5288,12 +5546,10 @@ packages: /strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - dev: true /strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - dev: true /strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} @@ -5307,10 +5563,14 @@ packages: min-indent: 1.0.1 dev: true + /strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + dev: false + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - dev: true /sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} @@ -5332,12 +5592,10 @@ packages: engines: {node: '>=8'} dependencies: has-flag: 4.0.0 - dev: true /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - dev: true /synckit@0.8.5: resolution: {integrity: sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==} @@ -5347,6 +5605,10 @@ packages: tslib: 2.6.2 dev: true + /tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + /tar@6.2.0: resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==} engines: {node: '>=10'} @@ -5367,7 +5629,6 @@ packages: /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - dev: true /through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -5415,6 +5676,10 @@ packages: engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 + + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} dev: true /trim-newlines@4.1.1: @@ -5435,7 +5700,13 @@ packages: typescript: '>=4.2.0' dependencies: typescript: 5.2.2 - dev: true + + /ts-morph@20.0.0: + resolution: {integrity: sha512-JVmEJy2Wow5n/84I3igthL9sudQ8qzjh/6i4tmYCm6IqYyKFlNbJZi7oBdjyqcWSWYRu3CtL0xbT6fS03ESZIg==} + dependencies: + '@ts-morph/common': 0.21.0 + code-block-writer: 12.0.0 + dev: false /tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} @@ -5444,7 +5715,6 @@ packages: json5: 1.0.2 minimist: 1.2.8 strip-bom: 3.0.0 - dev: true /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} @@ -5454,7 +5724,6 @@ packages: engines: {node: '>= 0.8.0'} dependencies: prelude-ls: 1.2.1 - dev: true /type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} @@ -5483,7 +5752,6 @@ packages: call-bind: 1.0.2 get-intrinsic: 1.2.1 is-typed-array: 1.1.12 - dev: true /typed-array-byte-length@1.0.0: resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==} @@ -5493,7 +5761,6 @@ packages: for-each: 0.3.3 has-proto: 1.0.1 is-typed-array: 1.1.12 - dev: true /typed-array-byte-offset@1.0.0: resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==} @@ -5504,7 +5771,6 @@ packages: for-each: 0.3.3 has-proto: 1.0.1 is-typed-array: 1.1.12 - dev: true /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} @@ -5512,7 +5778,6 @@ packages: call-bind: 1.0.2 for-each: 0.3.3 is-typed-array: 1.1.12 - dev: true /typescript@4.9.5: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} @@ -5524,7 +5789,6 @@ packages: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} hasBin: true - dev: true /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -5533,7 +5797,6 @@ packages: has-bigints: 1.0.2 has-symbols: 1.0.3 which-boxed-primitive: 1.0.2 - dev: true /undici-types@5.25.3: resolution: {integrity: sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==} @@ -5590,6 +5853,13 @@ packages: picocolors: 1.0.0 dev: true + /update-check@1.5.4: + resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + dependencies: + registry-auth-token: 3.3.2 + registry-url: 3.1.0 + dev: false + /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: @@ -5634,6 +5904,11 @@ packages: spdx-expression-parse: 3.0.1 dev: true + /vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + dev: false + /verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -5645,13 +5920,37 @@ packages: dev: true optional: true + /vite-plugin-inspect@0.7.42(rollup@4.3.0)(vite@4.5.0): + resolution: {integrity: sha512-JCyX86wr3siQc+p9Kd0t8VkFHAJag0RaQVIpdFGSv5FEaePEVB6+V/RGtz2dQkkGSXQzRWrPs4cU3dRKg32bXw==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^3.1.0 || ^4.0.0 || ^5.0.0-0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + dependencies: + '@antfu/utils': 0.7.6 + '@rollup/pluginutils': 5.0.5(rollup@4.3.0) + debug: 4.3.4 + error-stack-parser-es: 0.1.1 + fs-extra: 11.1.1 + open: 9.1.0 + picocolors: 1.0.0 + sirv: 2.0.3 + vite: 4.5.0(@types/node@20.8.6) + transitivePeerDependencies: + - rollup + - supports-color + dev: true + /vite-plugin-resolve@2.5.1: resolution: {integrity: sha512-9dD0Yq5JT1RxHQGZOyhC7e/JlhyhMCftCpQ8TPzQa7KEB/3ERnoCPinH3VJk/0C8qHsA+l41bIcHh5BcHBTmAw==} dependencies: lib-esm: 0.4.1 dev: true - /vite@4.5.0: + /vite@4.5.0(@types/node@20.8.6): resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -5679,6 +5978,7 @@ packages: terser: optional: true dependencies: + '@types/node': 20.8.6 esbuild: 0.18.20 postcss: 8.4.31 rollup: 4.3.0 @@ -5737,6 +6037,13 @@ packages: dependencies: isexe: 3.1.1 + /widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + dev: false + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5852,7 +6159,6 @@ packages: /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - dev: true /youtubei.js@7.0.0: resolution: {integrity: sha512-z87cv6AAjj0c98BkD0qTJvBDTF2DdT+FntJUjmi+vHY2EV+CepeYQAE/eLsdhGvCb6LrNBgGVwVUzXpHYi8NoA==} diff --git a/src/config/index.ts b/src/config/index.ts index f8bdff14..d9e03365 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -2,8 +2,9 @@ import Store from 'electron-store'; import { deepmerge } from 'deepmerge-ts'; import defaultConfig from './defaults'; -import plugins from './plugins'; + import store from './store'; +import plugins from './plugins'; import { restart } from '../providers/app-controls'; @@ -15,7 +16,7 @@ const setPartial = (key: string, value: object) => { store.set(key, newValue); }; -function setMenuOption(key: string, value: unknown) { +function setMenuOption(key: string, value: unknown) { set(key, value); if (store.get('options.restartOnConfigChanges')) { restart(); @@ -24,24 +25,55 @@ function setMenuOption(key: string, value: unknown) { // MAGIC OF TYPESCRIPT -type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, - 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]] -type Join = K extends string | number ? - P extends string | number ? - `${K}${'' extends P ? '' : '.'}${P}` - : never : never; -type Paths = [D] extends [never] ? never : T extends object ? - { [K in keyof T]-?: K extends string | number ? - `${K}` | Join> +type Prev = [ + never, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + ...0[], +]; +type Join = K extends string | number + ? P extends string | number + ? `${K}${'' extends P ? '' : '.'}${P}` : never - }[keyof T] : '' + : never; +type Paths = [D] extends [never] + ? never + : T extends object + ? { + [K in keyof T]-?: K extends string | number + ? `${K}` | Join> + : never; + }[keyof T] + : ''; type SplitKey = K extends `${infer A}.${infer B}` ? [A, B] : [K, string]; -type PathValue = - SplitKey extends [infer A extends keyof T, infer B extends string] - ? PathValue - : T; -const get = >(key: Key) => store.get(key) as PathValue; +type PathValue = SplitKey extends [ + infer A extends keyof T, + infer B extends string, +] + ? PathValue + : T; +const get = >(key: Key) => + store.get(key) as PathValue; export default { defaultConfig, diff --git a/src/config/store.ts b/src/config/store.ts index 80b6c8ff..22f1061b 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,15 +1,18 @@ import Store from 'electron-store'; import Conf from 'conf'; -import { pluginBuilders } from 'virtual:PluginBuilders'; +import { allPlugins } from 'virtual:plugins'; import defaults from './defaults'; import { DefaultPresetList, type Preset } from '../plugins/downloader/types'; -const setDefaultPluginOptions = (store: Conf>, plugin: keyof typeof pluginBuilders) => { +const setDefaultPluginOptions = ( + store: Conf>, + plugin: string, +) => { if (!store.get(`plugins.${plugin}`)) { - store.set(`plugins.${plugin}`, pluginBuilders[plugin].config); + store.set(`plugins.${plugin}`, allPlugins[plugin].config); } }; @@ -22,19 +25,24 @@ const migrations = { } }, '>=2.1.0'(store: Conf>) { - const originalPreset = store.get('plugins.downloader.preset') as string | undefined; + const originalPreset = store.get('plugins.downloader.preset') as + | string + | undefined; if (originalPreset) { if (originalPreset !== 'opus') { store.set('plugins.downloader.selectedPreset', 'Custom'); store.set('plugins.downloader.customPresetSetting', { extension: 'mp3', - ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? DefaultPresetList['mp3 (256kbps)'].ffmpegArgs, + ffmpegArgs: + (store.get('plugins.downloader.ffmpegArgs') as string[]) ?? + DefaultPresetList['mp3 (256kbps)'].ffmpegArgs, } satisfies Preset); } else { store.set('plugins.downloader.selectedPreset', 'Source'); store.set('plugins.downloader.customPresetSetting', { extension: null, - ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [], + ffmpegArgs: + (store.get('plugins.downloader.ffmpegArgs') as string[]) ?? [], } satisfies Preset); } store.delete('plugins.downloader.preset'); @@ -47,7 +55,7 @@ const migrations = { if (store.get('plugins.notifications.toastStyle') === undefined) { const pluginOptions = store.get('plugins.notifications') || {}; store.set('plugins.notifications', { - ...pluginBuilders.notifications.config, + ...allPlugins.notifications.config, ...pluginOptions, }); } @@ -82,10 +90,14 @@ const migrations = { } }, '>=1.12.0'(store: Conf>) { - const options = store.get('plugins.shortcuts') as Record>; + const options = store.get('plugins.shortcuts') as Record< + string, + | { + action: string; + shortcut: unknown; + }[] + | Record + >; let updated = false; for (const optionType of ['global', 'local']) { if (Array.isArray(options[optionType])) { @@ -151,12 +163,13 @@ const migrations = { export default new Store({ defaults: { ...defaults, - plugins: Object - .entries(pluginBuilders) - .reduce((prev, [id, builder]) => ({ + plugins: Object.entries(allPlugins).reduce( + (prev, [id, plugin]) => ({ ...prev, - [id]: (builder as PluginBuilderList[keyof PluginBuilderList]).config, - }), {}), + [id]: plugin.config, + }), + {}, + ), }, clearInvalidConfig: false, migrations, diff --git a/src/index.ts b/src/index.ts index e665c975..f4afc0eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,19 @@ import path from 'node:path'; import url from 'node:url'; import fs from 'node:fs'; -import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron'; -import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request'; +import { + BrowserWindow, + app, + screen, + globalShortcut, + session, + shell, + dialog, + ipcMain, +} from 'electron'; +import enhanceWebRequest, { + BetterSession, +} from '@jellybrick/electron-better-web-request'; import is from 'electron-is'; import unhandled from 'electron-unhandled'; import { autoUpdater } from 'electron-updater'; @@ -12,30 +23,31 @@ import { parse } from 'node-html-parser'; import { deepmerge } from 'deepmerge-ts'; import { deepEqual } from 'fast-equals'; -import { mainPlugins } from 'virtual:MainPlugins'; -import { pluginBuilders } from 'virtual:PluginBuilders'; +import { mainPlugins } from 'virtual:plugins'; -import config from './config'; +import config from '@/config'; -import { refreshMenu, setApplicationMenu } from './menu'; -import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils/main'; -import { isTesting } from './utils/testing'; -import { setUpTray } from './tray'; -import { setupSongInfo } from './providers/song-info'; -import { restart, setupAppControls } from './providers/app-controls'; -import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler'; +import { refreshMenu, setApplicationMenu } from '@/menu'; +import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main'; +import { isTesting } from '@/utils/testing'; +import { setUpTray } from '@/tray'; +import { setupSongInfo } from '@/providers/song-info'; +import { restart, setupAppControls } from '@/providers/app-controls'; +import { + APP_PROTOCOL, + handleProtocol, + setupProtocolHandler, +} from '@/providers/protocol-handler'; - -import youtubeMusicCSS from './youtube-music.css?inline'; +import youtubeMusicCSS from '@/youtube-music.css?inline'; import { forceLoadMainPlugin, forceUnloadMainPlugin, getAllLoadedMainPlugins, loadAllMainPlugins, - registerMainPlugin -} from './loader/main'; -import { MainPluginFactory, PluginBaseConfig, PluginBuilder } from './plugins/utils/builder'; +} from '@/loader/main'; +import { PluginBaseConfig } from '@/plugins/utils/builder'; // Catch errors and log them unhandled({ @@ -57,7 +69,10 @@ if (!gotTheLock) { // SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt) // OverlayScrollbar: Required for overlay scrollbars -app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer'); +app.commandLine.appendSwitch( + 'enable-features', + 'OverlayScrollbar,SharedArrayBuffer', +); if (config.get('options.disableHardwareAcceleration')) { if (is.dev()) { console.log('Disabling hardware acceleration'); @@ -95,42 +110,58 @@ function onClosed() { ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins)); - const initHook = (win: BrowserWindow) => { - ipcMain.handle('get-config', (_, id: keyof PluginBuilderList) => deepmerge(pluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as PluginBuilderList[typeof id]['config']); - ipcMain.handle('set-config', (_, name: string, obj: object) => config.setPartial(`plugins.${name}`, obj)); + ipcMain.handle( + 'get-config', + (_, id: keyof PluginBuilderList) => + deepmerge( + mainPlugins[id].config, + config.get(`plugins.${id}`) ?? {}, + ) as PluginBuilderList[typeof id]['config'], + ); + ipcMain.handle('set-config', (_, name: string, obj: object) => + config.setPartial(`plugins.${name}`, obj), + ); config.watch((newValue, oldValue) => { - const newPluginConfigList = (newValue?.plugins ?? {}) as Record; - const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record; + const newPluginConfigList = (newValue?.plugins ?? {}) as Record< + string, + unknown + >; + const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record< + string, + unknown + >; Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => { const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig); if (!isEqual) { const oldConfig = oldPluginConfigList[id] as PluginBaseConfig; - const config = deepmerge(pluginBuilders[id as keyof PluginBuilderList].config, newPluginConfig) as PluginBaseConfig; + const config = deepmerge( + mainPlugins[id].config, + newPluginConfig, + ) as PluginBaseConfig; if (config.enabled !== oldConfig?.enabled) { if (config.enabled) { win.webContents.send('plugin:enable', id); ipcMain.emit('plugin:enable', id); - forceLoadMainPlugin(id as keyof PluginBuilderList, win); + forceLoadMainPlugin(id, win); } else { win.webContents.send('plugin:unload', id); ipcMain.emit('plugin:unload', id); - forceUnloadMainPlugin(id as keyof PluginBuilderList, win); + forceUnloadMainPlugin(id, win); } - if (pluginBuilders[id as keyof PluginBuilderList].restartNeeded) { - showNeedToRestartDialog(id as keyof PluginBuilderList); + if (mainPlugins[id].restartNeeded) { + showNeedToRestartDialog(id); } } const mainPlugin = getAllLoadedMainPlugins()[id]; if (mainPlugin) { if (config.enabled) { - mainPlugin.onConfigChange?.(config); } } @@ -140,14 +171,15 @@ const initHook = (win: BrowserWindow) => { }); }; -const showNeedToRestartDialog = (id: keyof PluginBuilderList) => { - const builder = pluginBuilders[id]; +const showNeedToRestartDialog = (id: string) => { + const plugin = mainPlugins[id]; + const dialogOptions: Electron.MessageBoxOptions = { type: 'info', buttons: ['Restart Now', 'Later'], title: 'Restart Required', - message: `"${builder.name ?? builder.id}" needs to restart`, - detail: `"${builder.name ?? builder.id}" plugin requires a restart to take effect`, + message: `"${plugin.name ?? id}" needs to restart`, + detail: `"${plugin.name ?? id}" plugin requires a restart to take effect`, defaultId: 0, cancelId: 1, }; @@ -186,7 +218,10 @@ function initTheme(win: BrowserWindow) { injectCSSAsFile(win.webContents, cssFile); }, () => { - console.warn('[YTMusic]', `CSS file "${cssFile}" does not exist, ignoring`); + console.warn( + '[YTMusic]', + `CSS file "${cssFile}" does not exist, ignoring`, + ); }, ); } @@ -224,46 +259,43 @@ async function createMainWindow() { ...(isTesting() ? undefined : { - // Sandbox is only enabled in tests for now - // See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts - sandbox: false, - }), + // Sandbox is only enabled in tests for now + // See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts + sandbox: false, + }), }, frame: !is.macOS() && !useInlineMenu, titleBarOverlay: defaultTitleBarOverlayOptions, titleBarStyle: useInlineMenu ? 'hidden' - : (is.macOS() - ? 'hiddenInset' - : 'default'), + : is.macOS() + ? 'hiddenInset' + : 'default', autoHideMenuBar: config.get('options.hideMenu'), }); initHook(win); initTheme(win); - Object.entries(pluginBuilders).forEach(([id, builder]) => { - const typedBuilder = builder as PluginBuilder; - const plugin = mainPlugins[id] as MainPluginFactory | undefined; - - registerMainPlugin(id, typedBuilder, plugin); - }); await loadAllMainPlugins(win); if (windowPosition) { const { x: windowX, y: windowY } = windowPosition; const winSize = win.getSize(); - const displaySize - = screen.getDisplayNearestPoint(windowPosition).bounds; + const displaySize = screen.getDisplayNearestPoint(windowPosition).bounds; if ( - windowX + winSize[0] < displaySize.x - 8 - || windowX - winSize[0] > displaySize.x + displaySize.width - || windowY < displaySize.y - 8 - || windowY > displaySize.y + displaySize.height + windowX + winSize[0] < displaySize.x - 8 || + windowX - winSize[0] > displaySize.x + displaySize.width || + windowY < displaySize.y - 8 || + windowY > displaySize.y + displaySize.height ) { // Window is offscreen if (is.dev()) { console.log( - `Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`, + `Window tried to render offscreen, windowSize=${String( + winSize, + )}, displaySize=${String(displaySize)}, position=${String( + windowPosition, + )}`, ); } } else { @@ -316,7 +348,11 @@ async function createMainWindow() { const savedTimeouts: Record = {}; - function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) { + function lateSave( + key: string, + value: unknown, + fn: (key: string, value: unknown) => void = config.set, + ) { if (savedTimeouts[key]) { clearTimeout(savedTimeouts[key]); } @@ -343,7 +379,10 @@ async function createMainWindow() { if (useInlineMenu) { win.setTitleBarOverlay({ ...defaultTitleBarOverlayOptions, - height: Math.floor(defaultTitleBarOverlayOptions.height! * win.webContents.getZoomFactor()), + height: Math.floor( + defaultTitleBarOverlayOptions.height! * + win.webContents.getZoomFactor(), + ), }); } @@ -365,14 +404,25 @@ async function createMainWindow() { `); } else { const rendererPath = path.join(__dirname, '..', 'renderer'); - const indexHTML = parse(fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8')); + const indexHTML = parse( + fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'), + ); const scriptSrc = indexHTML.querySelector('script')!; - const scriptPath = path.join(rendererPath, scriptSrc.getAttribute('src')!); + const scriptPath = path.join( + rendererPath, + scriptSrc.getAttribute('src')!, + ); const scriptString = fs.readFileSync(scriptPath, 'utf-8'); - await win.webContents.executeJavaScriptInIsolatedWorld(0, [{ - code: scriptString + ';0', - url: url.pathToFileURL(scriptPath).toString(), - }], true); + await win.webContents.executeJavaScriptInIsolatedWorld( + 0, + [ + { + code: scriptString + ';0', + url: url.pathToFileURL(scriptPath).toString(), + }, + ], + true, + ); } }); @@ -387,21 +437,26 @@ app.once('browser-window-created', (event, win) => { const originalUserAgent = win.webContents.userAgent; const userAgents = { mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0', - windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0', + windows: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0', linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0', }; - const updatedUserAgent - = is.macOS() ? userAgents.mac - : (is.windows() ? userAgents.windows - : userAgents.linux); + const updatedUserAgent = is.macOS() + ? userAgents.mac + : is.windows() + ? userAgents.windows + : userAgents.linux; win.webContents.userAgent = updatedUserAgent; app.userAgentFallback = updatedUserAgent; win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => { // This will only happen if login failed, and "retry" was pressed - if (win.webContents.getURL().startsWith('https://accounts.google.com') && details.url.startsWith('https://accounts.google.com')) { + if ( + win.webContents.getURL().startsWith('https://accounts.google.com') && + details.url.startsWith('https://accounts.google.com') + ) { details.requestHeaders['User-Agent'] = originalUserAgent; } @@ -412,33 +467,41 @@ app.once('browser-window-created', (event, win) => { setupSongInfo(win); setupAppControls(); - win.webContents.on('did-fail-load', ( - _event, - errorCode, - errorDescription, - validatedURL, - isMainFrame, - frameProcessId, - frameRoutingId, - ) => { - const log = JSON.stringify({ - error: 'did-fail-load', + win.webContents.on( + 'did-fail-load', + ( + _event, errorCode, errorDescription, validatedURL, isMainFrame, frameProcessId, frameRoutingId, - }, null, '\t'); - if (is.dev()) { - console.log(log); - } + ) => { + const log = JSON.stringify( + { + error: 'did-fail-load', + errorCode, + errorDescription, + validatedURL, + isMainFrame, + frameProcessId, + frameRoutingId, + }, + null, + '\t', + ); + if (is.dev()) { + console.log(log); + } - if (errorCode !== -3) { // -3 is a false positive - win.webContents.send('log', log); - win.webContents.loadFile(path.join(__dirname, 'error.html')); - } - }); + if (errorCode !== -3) { + // -3 is a false positive + win.webContents.send('log', log); + win.webContents.loadFile(path.join(__dirname, 'error.html')); + } + }, + ); win.webContents.on('will-prevent-unload', (event) => { event.preventDefault(); @@ -484,17 +547,29 @@ app.on('ready', async () => { const appLocation = process.execPath; const appData = app.getPath('appData'); // Check shortcut validity if not in dev mode / running portable app - if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) { - const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk'); - try { // Check if shortcut is registered and valid + if ( + !is.dev() && + !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp')) + ) { + const shortcutPath = path.join( + appData, + 'Microsoft', + 'Windows', + 'Start Menu', + 'Programs', + 'YouTube Music.lnk', + ); + try { + // Check if shortcut is registered and valid const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet if ( - shortcutDetails.target !== appLocation - || shortcutDetails.appUserModelId !== appID + shortcutDetails.target !== appLocation || + shortcutDetails.appUserModelId !== appID ) { throw 'needUpdate'; } - } catch (error) { // If not valid -> Register shortcut + } catch (error) { + // If not valid -> Register shortcut shell.writeShortcutLink( shortcutPath, error === 'needUpdate' ? 'update' : 'create', @@ -556,8 +631,8 @@ app.on('ready', async () => { clearTimeout(updateTimeout); }, 2000); autoUpdater.on('update-available', () => { - const downloadLink - = 'https://github.com/th-ch/youtube-music/releases/latest'; + const downloadLink = + 'https://github.com/th-ch/youtube-music/releases/latest'; const dialogOptions: Electron.MessageBoxOptions = { type: 'info', buttons: ['OK', 'Download', 'Disable updates'], @@ -597,8 +672,10 @@ app.on('ready', async () => { if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) { dialog.showMessageBox(mainWindow, { - type: 'info', title: 'Hide Menu Enabled', - message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)", + type: 'info', + title: 'Hide Menu Enabled', + message: + "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)", }); config.set('options.hideMenuWarned', true); } @@ -624,31 +701,36 @@ app.on('ready', async () => { } }); -function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) { +function showUnresponsiveDialog( + win: BrowserWindow, + details: Electron.RenderProcessGoneDetails, +) { if (details) { console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t')); } - dialog.showMessageBox(win, { - type: 'error', - title: 'Window Unresponsive', - message: 'The Application is Unresponsive', - detail: 'We are sorry for the inconvenience! please choose what to do:', - buttons: ['Wait', 'Relaunch', 'Quit'], - cancelId: 0, - }).then((result) => { - switch (result.response) { - case 1: { - restart(); - break; - } + dialog + .showMessageBox(win, { + type: 'error', + title: 'Window Unresponsive', + message: 'The Application is Unresponsive', + detail: 'We are sorry for the inconvenience! please choose what to do:', + buttons: ['Wait', 'Relaunch', 'Quit'], + cancelId: 0, + }) + .then((result) => { + switch (result.response) { + case 1: { + restart(); + break; + } - case 2: { - app.quit(); - break; + case 2: { + app.quit(); + break; + } } - } - }); + }); } function removeContentSecurityPolicy( @@ -671,18 +753,21 @@ function removeContentSecurityPolicy( }); // When multiple listeners are defined, apply them all - betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => { - return listeners.reduce( - async (accumulator, listener) => { - const acc = await accumulator; - if (acc.cancel) { - return acc; - } + betterSession.webRequest.setResolver( + 'onHeadersReceived', + async (listeners) => { + return listeners.reduce( + async (accumulator, listener) => { + const acc = await accumulator; + if (acc.cancel) { + return acc; + } - const result = await listener.apply(); - return { ...accumulator, ...result }; - }, - Promise.resolve({ cancel: false }), - ); - }); + const result = await listener.apply(); + return { ...accumulator, ...result }; + }, + Promise.resolve({ cancel: false }), + ); + }, + ); } diff --git a/src/loader/main.ts b/src/loader/main.ts index 883feead..9064a98e 100644 --- a/src/loader/main.ts +++ b/src/loader/main.ts @@ -1,27 +1,24 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ import { BrowserWindow, ipcMain } from 'electron'; import { deepmerge } from 'deepmerge-ts'; +import { mainPlugins } from 'virtual:plugins'; -import config from '../config'; -import { injectCSS } from '../plugins/utils/main'; -import { - MainPlugin, - MainPluginContext, - MainPluginFactory, - PluginBaseConfig, - PluginBuilder -} from '../plugins/utils/builder'; +import { PluginDef } from '@/types/plugins'; +import { BackendContext } from '@/types/contexts'; +import config from '@/config'; +import { startPlugin, stopPlugin } from '@/utils'; -const allPluginFactoryList: Record> = {}; -const allPluginBuilders: Record> = {}; -const unregisterStyleMap: Record void)[]> = {}; -const loadedPluginMap: Record> = {}; +const loadedPluginMap: Record = {}; -const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], ->(id: Key, win: BrowserWindow): MainPluginContext => ({ - getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config, +const createContext = (id: string, win: BrowserWindow): BackendContext => ({ + getConfig: () => + // @ts-expect-error ts dum dum + deepmerge( + mainPlugins[id].config, + config.get(`plugins.${id}`) ?? { enabled: false }, + ), setConfig: (newConfig) => { config.setPartial(`plugins.${id}`, newConfig); }, @@ -29,81 +26,95 @@ const createContext = < send: (event: string, ...args: unknown[]) => { win.webContents.send(event, ...args); }, + // @ts-expect-error ts dum dum handle: (event: string, listener) => { - ipcMain.handle(event, async (_, ...args) => listener(...args as never)); + // @ts-expect-error ts dum dum + ipcMain.handle(event, (_, ...args) => listener(...(args as never))); }, + // @ts-expect-error ts dum dum on: (event: string, listener) => { - ipcMain.on(event, async (_, ...args) => listener(...args as never)); + // @ts-expect-error ts dum dum + ipcMain.on(event, (_, ...args) => listener(...(args as never))); }, }); -export const forceUnloadMainPlugin = (id: keyof PluginBuilderList, win: BrowserWindow) => { - unregisterStyleMap[id]?.forEach((unregister) => unregister()); - delete unregisterStyleMap[id]; +export const forceUnloadMainPlugin = async ( + id: string, + win: BrowserWindow, +): Promise => { + const plugin = loadedPluginMap[id]!; + if (!plugin) return; - loadedPluginMap[id]?.onUnload?.(win); - delete loadedPluginMap[id]; + return new Promise((resolve, reject) => { + try { + const hasStopped = stopPlugin(id, plugin, { + ctx: 'backend', + context: createContext(id, win), + }); + if (!hasStopped) { + console.log( + '[YTMusic]', + `Cannot unload "${id}" plugin: no stop function`, + ); + reject(); + return; + } - console.log('[YTMusic]', `"${id}" plugin is unloaded`); + delete loadedPluginMap[id]; + console.log('[YTMusic]', `"${id}" plugin is unloaded`); + resolve(); + } catch (err) { + console.log('[YTMusic]', `Cannot unload "${id}" plugin: ${String(err)}`); + reject(err); + } + }); }; -export const forceLoadMainPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => { - const builder = allPluginBuilders[id]; +export const forceLoadMainPlugin = async ( + id: string, + win: BrowserWindow, +): Promise => { + const plugin = mainPlugins[id]; + if (!plugin.backend) return; - Promise.allSettled( - builder.styles?.map(async (style) => { - const unregister = await injectCSS(win.webContents, style); - console.log('[YTMusic]', `Injected CSS for "${id}" plugin`); - - return unregister; - }) ?? [], - ).then((result) => { - unregisterStyleMap[id] = result - .map((it) => it.status === 'fulfilled' && it.value) - .filter(Boolean); - - let isInjectSuccess = true; - result.forEach((it) => { - if (it.status === 'rejected') { - isInjectSuccess = false; - - console.log('[YTMusic]', `Cannot inject "${id}" plugin style: ${String(it.reason)}`); + return new Promise((resolve, reject) => { + try { + const hasStarted = startPlugin(id, plugin, { + ctx: 'backend', + context: createContext(id, win), + }); + if (!hasStarted) { + console.log('[YTMusic]', `Cannot load "${id}" plugin`); + reject(); + return; } - }); - if (isInjectSuccess) console.log('[YTMusic]', `"${id}" plugin data is loaded`); + + loadedPluginMap[id] = plugin; + resolve(); + } catch (err) { + console.log( + '[YTMusic]', + `Cannot initialize "${id}" plugin: ${String(err)}`, + ); + reject(err); + } }); - - try { - const factory = allPluginFactoryList[id]; - if (!factory) return; - - const context = createContext(id, win); - const plugin = await factory(context); - loadedPluginMap[id] = plugin; - plugin.onLoad?.(win); - - console.log('[YTMusic]', `"${id}" plugin is loaded`); - } catch (err) { - console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); - } }; export const loadAllMainPlugins = async (win: BrowserWindow) => { const pluginConfigs = config.plugins.getPlugins(); + const queue: Promise[] = []; - for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { - const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; - - const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); - + for (const [plugin, pluginDef] of Object.entries(mainPlugins)) { + const config = deepmerge(pluginDef.config, pluginConfigs[plugin] ?? {}); if (config.enabled) { - await forceLoadMainPlugin(pluginId as keyof PluginBuilderList, win); - } else { - if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { - forceUnloadMainPlugin(pluginId as keyof PluginBuilderList, win); - } + queue.push(forceLoadMainPlugin(plugin, win)); + } else if (loadedPluginMap[plugin]) { + queue.push(forceUnloadMainPlugin(plugin, win)); } } + + await Promise.all(queue); }; export const unloadAllMainPlugins = (win: BrowserWindow) => { @@ -112,17 +123,10 @@ export const unloadAllMainPlugins = (win: BrowserWindow) => { } }; -export const getLoadedMainPlugin = (id: Key): MainPlugin | undefined => { +export const getLoadedMainPlugin = (id: string): PluginDef | undefined => { return loadedPluginMap[id]; }; + export const getAllLoadedMainPlugins = () => { return loadedPluginMap; }; -export const registerMainPlugin = ( - id: string, - builder: PluginBuilder, - factory?: MainPluginFactory, -) => { - if (factory) allPluginFactoryList[id] = factory; - allPluginBuilders[id] = builder; -}; diff --git a/src/loader/menu.ts b/src/loader/menu.ts index 901cfc85..c5717787 100644 --- a/src/loader/menu.ts +++ b/src/loader/menu.ts @@ -1,26 +1,21 @@ import { deepmerge } from 'deepmerge-ts'; +import { allPlugins } from 'virtual:plugins'; +import { BrowserWindow, MenuItemConstructorOptions } from 'electron'; -import { MenuPluginContext, MenuPluginFactory, PluginBaseConfig, PluginBuilder } from '../plugins/utils/builder'; -import config from '../config'; -import { setApplicationMenu } from '../menu'; +import { MenuContext } from '@/types/contexts'; -import type { BrowserWindow, MenuItemConstructorOptions } from 'electron'; +import config from '@/config'; +import { setApplicationMenu } from '@/menu'; -const allPluginFactoryList: Record> = {}; -const allPluginBuilders: Record> = {}; const menuTemplateMap: Record = {}; - -const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], ->(id: Key, win: BrowserWindow): MenuPluginContext => ({ - getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config, +const createContext = (id: string, win: BrowserWindow): MenuContext => ({ + getConfig: () => config.plugins.getOptions(id), setConfig: (newConfig) => { config.setPartial(`plugins.${id}`, newConfig); }, window: win, - refresh: async () => { - await setApplicationMenu(win); + refresh: () => { + setApplicationMenu(win); if (config.plugins.isEnabled('in-app-menu')) { win.webContents.send('refresh-in-app-menu'); @@ -28,45 +23,39 @@ const createContext = < }, }); -export const forceLoadMenuPlugin = async (id: keyof PluginBuilderList, win: BrowserWindow) => { +export const forceLoadMenuPlugin = (id: string, win: BrowserWindow) => { try { - const factory = allPluginFactoryList[id]; - if (!factory) return; + const plugin = allPlugins[id]; + if (!plugin) return; - const context = createContext(id, win); - menuTemplateMap[id] = await factory(context); + const menu = plugin.menu?.(createContext(id, win)); + if (menu) menuTemplateMap[id] = menu; + else return; - console.log('[YTMusic]', `"${id}" plugin is loaded`); + console.log('[YTMusic]', `Successfully loaded '${id}::menu'`); } catch (err) { - console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); + console.log('[YTMusic]', `Cannot initialize '${id}::menu': ${String(err)}`); } }; -export const loadAllMenuPlugins = async (win: BrowserWindow) => { +export const loadAllMenuPlugins = (win: BrowserWindow) => { const pluginConfigs = config.plugins.getPlugins(); - for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { - const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; - - const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + for (const [pluginId, pluginDef] of Object.entries(allPlugins)) { + const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}); if (config.enabled) { - await forceLoadMenuPlugin(pluginId as keyof PluginBuilderList, win); + forceLoadMenuPlugin(pluginId, win); } } }; -export const getMenuTemplate = (id: Key): MenuItemConstructorOptions[] | undefined => { +export const getMenuTemplate = ( + id: string, +): MenuItemConstructorOptions[] | undefined => { return menuTemplateMap[id]; }; + export const getAllMenuTemplate = () => { return menuTemplateMap; }; -export const registerMenuPlugin = ( - id: string, - builder: PluginBuilder, - factory?: MenuPluginFactory, -) => { - if (factory) allPluginFactoryList[id] = factory; - allPluginBuilders[id] = builder; -}; diff --git a/src/loader/preload.ts b/src/loader/preload.ts index dc598a1e..3fb2287a 100644 --- a/src/loader/preload.ts +++ b/src/loader/preload.ts @@ -1,68 +1,64 @@ import { deepmerge } from 'deepmerge-ts'; +import { preloadPlugins } from 'virtual:plugins'; -import { - PluginBaseConfig, - PluginBuilder, - PreloadPlugin, - PluginContext, - PreloadPluginFactory -} from '../plugins/utils/builder'; -import config from '../config'; +import { type PluginDef } from '@/types/plugins'; +import { type PreloadContext } from '@/types/contexts'; +import { startPlugin, stopPlugin } from '@/utils'; -const allPluginFactoryList: Record> = {}; -const allPluginBuilders: Record> = {}; -const unregisterStyleMap: Record void)[]> = {}; -const loadedPluginMap: Record> = {}; +import config from '@/config'; -const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], ->(id: Key): PluginContext => ({ - getConfig: () => deepmerge(allPluginBuilders[id].config, config.get(`plugins.${id}`) ?? {}) as Config, +const loadedPluginMap: Record = {}; +const createContext = (id: string): PreloadContext => ({ + getConfig: () => config.plugins.getOptions(id), setConfig: (newConfig) => { config.setPartial(`plugins.${id}`, newConfig); }, }); -export const forceUnloadPreloadPlugin = (id: keyof PluginBuilderList) => { - unregisterStyleMap[id]?.forEach((unregister) => unregister()); - delete unregisterStyleMap[id]; - - loadedPluginMap[id]?.onUnload?.(); - delete loadedPluginMap[id]; - +export const forceUnloadPreloadPlugin = (id: string) => { + const hasStopped = stopPlugin(id, loadedPluginMap[id], { + ctx: 'preload', + context: createContext(id), + }); + if (!hasStopped) { + console.log('[YTMusic]', `Cannot stop "${id}" plugin`); + return; + } console.log('[YTMusic]', `"${id}" plugin is unloaded`); }; -export const forceLoadPreloadPlugin = async (id: keyof PluginBuilderList) => { +export const forceLoadPreloadPlugin = (id: string) => { try { - const factory = allPluginFactoryList[id]; - if (!factory) return; + const plugin = preloadPlugins[id]; + if (!plugin) return; - const context = createContext(id); - const plugin = await factory(context); - loadedPluginMap[id] = plugin; - plugin.onLoad?.(); + const hasStarted = startPlugin(id, plugin, { + ctx: 'preload', + context: createContext(id), + }); + + if (hasStarted) loadedPluginMap[id] = plugin; console.log('[YTMusic]', `"${id}" plugin is loaded`); } catch (err) { - console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); + console.log( + '[YTMusic]', + `Cannot initialize "${id}" plugin: ${String(err)}`, + ); } }; -export const loadAllPreloadPlugins = async () => { +export const loadAllPreloadPlugins = () => { const pluginConfigs = config.plugins.getPlugins(); - for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { - const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; - - const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + for (const [pluginId, pluginDef] of Object.entries(preloadPlugins)) { + const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}); if (config.enabled) { - await forceLoadPreloadPlugin(pluginId as keyof PluginBuilderList); + forceLoadPreloadPlugin(pluginId); } else { - if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { - forceUnloadPreloadPlugin(pluginId as keyof PluginBuilderList); + if (loadedPluginMap[pluginId]) { + forceUnloadPreloadPlugin(pluginId); } } } @@ -74,17 +70,10 @@ export const unloadAllPreloadPlugins = () => { } }; -export const getLoadedPreloadPlugin = (id: Key): PreloadPlugin | undefined => { +export const getLoadedPreloadPlugin = (id: string): PluginDef | undefined => { return loadedPluginMap[id]; }; + export const getAllLoadedPreloadPlugins = () => { return loadedPluginMap; }; -export const registerPreloadPlugin = ( - id: string, - builder: PluginBuilder, - factory?: PreloadPluginFactory, -) => { - if (factory) allPluginFactoryList[id] = factory; - allPluginBuilders[id] = builder; -}; diff --git a/src/loader/renderer.ts b/src/loader/renderer.ts index d19b3a88..285527ca 100644 --- a/src/loader/renderer.ts +++ b/src/loader/renderer.ts @@ -1,75 +1,75 @@ import { deepmerge } from 'deepmerge-ts'; -import { - PluginBaseConfig, PluginBuilder, - RendererPlugin, - RendererPluginContext, - RendererPluginFactory -} from '../plugins/utils/builder'; +import { rendererPlugins } from 'virtual:plugins'; + +import { RendererContext } from '@/types/contexts'; + +import { PluginDef } from '@/types/plugins'; +import { startPlugin, stopPlugin } from '@/utils'; -const allPluginFactoryList: Record> = {}; -const allPluginBuilders: Record> = {}; const unregisterStyleMap: Record void)[]> = {}; -const loadedPluginMap: Record> = {}; +const loadedPluginMap: Record = {}; -const createContext = < - Key extends keyof PluginBuilderList, - Config extends PluginBaseConfig = PluginBuilderList[Key]['config'], ->(id: Key): RendererPluginContext => ({ - getConfig: async () => { - return await window.ipcRenderer.invoke('get-config', id) as Config; - }, +const createContext = (id: string): RendererContext => ({ + getConfig: () => window.mainConfig.plugins.getOptions(id), setConfig: async (newConfig) => { await window.ipcRenderer.invoke('set-config', id, newConfig); }, - - invoke: async (event: string, ...args: unknown[]): Promise => { - return await window.ipcRenderer.invoke(event, ...args) as Return; - }, - on: (event: string, listener) => { - window.ipcRenderer.on(event, async (_, ...args) => listener(...args as never)); - }, }); -export const forceUnloadRendererPlugin = (id: keyof PluginBuilderList) => { +export const forceUnloadRendererPlugin = (id: string) => { unregisterStyleMap[id]?.forEach((unregister) => unregister()); - delete unregisterStyleMap[id]; - loadedPluginMap[id]?.onUnload?.(); + delete unregisterStyleMap[id]; delete loadedPluginMap[id]; + const plugin = rendererPlugins[id]; + if (!plugin) return; + + stopPlugin(id, plugin, { ctx: 'renderer', context: createContext(id) }); + if (plugin.renderer?.stylesheet) + document.querySelector(`style#plugin-${id}`)?.remove(); + console.log('[YTMusic]', `"${id}" plugin is unloaded`); }; -export const forceLoadRendererPlugin = async (id: keyof PluginBuilderList) => { - try { - const factory = allPluginFactoryList[id]; - if (!factory) return; +export const forceLoadRendererPlugin = (id: string) => { + const plugin = rendererPlugins[id]; + if (!plugin) return; - const context = createContext(id); - const plugin = await factory(context); + const hasEvaled = startPlugin(id, plugin, { + ctx: 'renderer', + context: createContext(id), + }); + + if (hasEvaled || plugin.renderer?.stylesheet) { loadedPluginMap[id] = plugin; - plugin.onLoad?.(); - console.log('[YTMusic]', `"${id}" plugin is loaded`); - } catch (err) { - console.log('[YTMusic]', `Cannot initialize "${id}" plugin: ${String(err)}`); + if (plugin.renderer?.stylesheet) + document.head.appendChild( + Object.assign(document.createElement('style'), { + id: `plugin-${id}`, + innerHTML: plugin.renderer?.stylesheet ?? '', + }), + ); + + if (!hasEvaled) console.log('[YTMusic]', `"${id}" plugin is loaded`); + } else { + console.log('[YTMusic]', `Cannot initialize "${id}" plugin`); } }; -export const loadAllRendererPlugins = async () => { +export const loadAllRendererPlugins = () => { const pluginConfigs = window.mainConfig.plugins.getPlugins(); - for (const [pluginId, builder] of Object.entries(allPluginBuilders)) { - const typedBuilder = builder as PluginBuilderList[keyof PluginBuilderList]; - - const config = deepmerge(typedBuilder.config, pluginConfigs[pluginId as keyof PluginBuilderList] ?? {}); + for (const [pluginId, pluginDef] of Object.entries(rendererPlugins)) { + const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}); if (config.enabled) { - await forceLoadRendererPlugin(pluginId as keyof PluginBuilderList); + forceLoadRendererPlugin(pluginId); } else { - if (loadedPluginMap[pluginId as keyof PluginBuilderList]) { - forceUnloadRendererPlugin(pluginId as keyof PluginBuilderList); + if (loadedPluginMap[pluginId]) { + forceUnloadRendererPlugin(pluginId); } } } @@ -77,21 +77,14 @@ export const loadAllRendererPlugins = async () => { export const unloadAllRendererPlugins = () => { for (const id of Object.keys(loadedPluginMap)) { - forceUnloadRendererPlugin(id as keyof PluginBuilderList); + forceUnloadRendererPlugin(id); } }; -export const getLoadedRendererPlugin = (id: Key): RendererPlugin | undefined => { +export const getLoadedRendererPlugin = (id: string): PluginDef | undefined => { return loadedPluginMap[id]; }; + export const getAllLoadedRendererPlugins = () => { return loadedPluginMap; }; -export const registerRendererPlugin = ( - id: string, - builder: PluginBuilder, - factory?: RendererPluginFactory, -) => { - if (factory) allPluginFactoryList[id] = factory; - allPluginBuilders[id] = builder; -}; diff --git a/src/menu.ts b/src/menu.ts index d8ddb75e..4173052e 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,5 +1,12 @@ import is from 'electron-is'; -import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron'; +import { + app, + BrowserWindow, + clipboard, + dialog, + Menu, + MenuItem, +} from 'electron'; import prompt from 'custom-electron-prompt'; import { restart } from './providers/app-controls'; @@ -8,24 +15,22 @@ import { startingPages } from './providers/extracted-data'; import promptOptions from './providers/prompt-options'; /* eslint-disable import/order */ -import { menuPlugins as menuList } from 'virtual:MenuPlugins'; -import { pluginBuilders } from 'virtual:PluginBuilders'; +import { allPlugins } from 'virtual:plugins'; /* eslint-enable import/order */ -import { getAvailablePluginNames } from './plugins/utils/main'; -import { - MenuPluginFactory, - PluginBaseConfig, - PluginBuilder -} from './plugins/utils/builder'; -import { getAllMenuTemplate, loadAllMenuPlugins, registerMenuPlugin } from './loader/menu'; +import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu'; export type MenuTemplate = Electron.MenuItemConstructorOptions[]; // True only if in-app-menu was loaded on launch const inAppMenuActive = config.plugins.isEnabled('in-app-menu'); -const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({ +const pluginEnabledMenu = ( + plugin: string, + label = '', + hasSubmenu = false, + refreshMenu: (() => void) | undefined = undefined, +): Electron.MenuItemConstructorOptions => ({ label: label || plugin, type: 'checkbox', checked: config.plugins.isEnabled(plugin), @@ -49,47 +54,42 @@ export const refreshMenu = (win: BrowserWindow) => { } }; -Object.entries(pluginBuilders).forEach(([id, builder]) => { - const typedBuilder = builder as PluginBuilder; - const plugin = menuList[id] as MenuPluginFactory | undefined; - - registerMenuPlugin(id, typedBuilder, plugin); -}); - -export const mainMenuTemplate = async (win: BrowserWindow): Promise => { +export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => { const innerRefreshMenu = () => refreshMenu(win); - await loadAllMenuPlugins(win); + loadAllMenuPlugins(win); - const menuResult = Object.entries(getAllMenuTemplate()).map(([id, template]) => { - const pluginLabel = (pluginBuilders[id as keyof PluginBuilderList])?.name ?? id; + const menuResult = Object.entries(getAllMenuTemplate()).map( + ([id, template]) => { + const pluginLabel = allPlugins[id]?.name ?? id; + + if (!config.plugins.isEnabled(id)) { + return [ + id, + pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu), + ] as const; + } - if (!config.plugins.isEnabled(id)) { return [ id, - pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu), + { + label: pluginLabel, + submenu: [ + pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu), + { type: 'separator' }, + ...template, + ], + } satisfies Electron.MenuItemConstructorOptions, ] as const; - } + }, + ); - return [ - id, - { - label: pluginLabel, - submenu: [ - pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu), - { type: 'separator' }, - ...template, - ], - } satisfies Electron.MenuItemConstructorOptions - ] as const; - }); - - const availablePlugins = getAvailablePluginNames(); + const availablePlugins = Object.keys(allPlugins); const pluginMenus = availablePlugins.map((id) => { const predefinedTemplate = menuResult.find((it) => it[0] === id); if (predefinedTemplate) return predefinedTemplate[1]; - const pluginLabel = pluginBuilders[id as keyof PluginBuilderList]?.name ?? id; + const pluginLabel = allPlugins[id]?.name ?? id; return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu); }); @@ -106,7 +106,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise { - const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({ - label: name, - type: 'radio', - checked: config.get('options.startingPage') === name, - click() { - config.set('options.startingPage', name); - }, - })); + const subMenuArray: Electron.MenuItemConstructorOptions[] = + Object.keys(startingPages).map((name) => ({ + label: name, + type: 'radio', + checked: config.get('options.startingPage') === name, + click() { + config.set('options.startingPage', name); + }, + })); subMenuArray.unshift({ label: 'Unset', type: 'radio', @@ -147,8 +148,11 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise