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'];