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 += '};';