refactor(plugin): new plugin system poc

This commit is contained in:
Su-Yong
2023-11-09 22:54:58 +09:00
parent b6e7e75ae8
commit afe6accab8
9 changed files with 279 additions and 192 deletions

View File

@ -1,4 +0,0 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('ambient-mode');
export default config;

View File

@ -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;
}
}

View File

@ -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);
};

View File

@ -1,6 +1,4 @@
import config from './config'; import builder from './';
import { MenuTemplate } from '../../menu';
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000]; const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
const qualityList = [10, 25, 50, 100, 200, 500, 1000]; 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 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]; 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', label: 'Smoothness transition',
submenu: interpolationTimeList.map((interpolationTime) => ({ submenu: interpolationTimeList.map((interpolationTime) => ({
label: `During ${interpolationTime / 1000}s`, label: `During ${interpolationTime / 1000}s`,
type: 'radio', type: 'radio',
checked: config.get('interpolationTime') === interpolationTime, checked: getConfig().interpolationTime === interpolationTime,
click() { click() {
config.set('interpolationTime', interpolationTime); setConfig({ interpolationTime });
}, },
})), })),
}, },
@ -26,9 +24,9 @@ export default (): MenuTemplate => [
submenu: qualityList.map((quality) => ({ submenu: qualityList.map((quality) => ({
label: `${quality} pixels`, label: `${quality} pixels`,
type: 'radio', type: 'radio',
checked: config.get('quality') === quality, checked: getConfig().quality === quality,
click() { click() {
config.set('quality', quality); setConfig({ quality });
}, },
})), })),
}, },
@ -37,9 +35,9 @@ export default (): MenuTemplate => [
submenu: sizeList.map((size) => ({ submenu: sizeList.map((size) => ({
label: `${size}%`, label: `${size}%`,
type: 'radio', type: 'radio',
checked: config.get('size') === size, checked: getConfig().size === size,
click() { click() {
config.set('size', size); setConfig({ size });
}, },
})), })),
}, },
@ -48,9 +46,9 @@ export default (): MenuTemplate => [
submenu: bufferList.map((buffer) => ({ submenu: bufferList.map((buffer) => ({
label: `${buffer}`, label: `${buffer}`,
type: 'radio', type: 'radio',
checked: config.get('buffer') === buffer, checked: getConfig().buffer === buffer,
click() { click() {
config.set('buffer', buffer); setConfig({ buffer });
}, },
})), })),
}, },
@ -59,9 +57,9 @@ export default (): MenuTemplate => [
submenu: opacityList.map((opacity) => ({ submenu: opacityList.map((opacity) => ({
label: `${opacity * 100}%`, label: `${opacity * 100}%`,
type: 'radio', type: 'radio',
checked: config.get('opacity') === opacity, checked: getConfig().opacity === opacity,
click() { click() {
config.set('opacity', opacity); setConfig({ opacity });
}, },
})), })),
}, },
@ -70,18 +68,18 @@ export default (): MenuTemplate => [
submenu: blurAmountList.map((blur) => ({ submenu: blurAmountList.map((blur) => ({
label: `${blur} pixels`, label: `${blur} pixels`,
type: 'radio', type: 'radio',
checked: config.get('blur') === blur, checked: getConfig().blur === blur,
click() { click() {
config.set('blur', blur); setConfig({ blur });
}, },
})), })),
}, },
{ {
label: 'Using fullscreen', label: 'Using fullscreen',
type: 'checkbox', type: 'checkbox',
checked: config.get('fullscreen'), checked: getConfig().fullscreen,
click(item) { click(item) {
config.set('fullscreen', item.checked); setConfig({ fullscreen: item.checked });
}, },
}, },
]; ]));

View File

@ -1,95 +1,163 @@
import type { ConfigType } from '../../config/dynamic'; import builder from './index';
export default (config: ConfigType<'ambient-mode'>) => { export default builder.createRenderer(({ getConfig }) => {
let interpolationTime = config.interpolationTime; // interpolation time (ms) const initConfigData = getConfig();
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)
let unregister: (() => void) | null = null; 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;
const injectBlurVideo = (): (() => void) | null => { let update: (() => void) | null = null;
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
const video = document.querySelector<HTMLVideoElement>('#song-video .html5-video-container > video');
const wrapper = document.querySelector('#song-video > .player-wrapper');
if (!songVideo) return null; return {
if (!video) return null; onLoad() {
if (!wrapper) return null; let unregister: (() => void) | null = null;
const blurCanvas = document.createElement('canvas'); const injectBlurVideo = (): (() => void) | null => {
blurCanvas.classList.add('html5-blur-canvas'); const songVideo = document.querySelector<HTMLDivElement>('#song-video');
const video = document.querySelector<HTMLVideoElement>('#song-video .html5-video-container > video');
const wrapper = document.querySelector('#song-video > .player-wrapper');
const context = blurCanvas.getContext('2d', { willReadFrequently: true }); if (!songVideo) return null;
if (!video) return null;
if (!wrapper) return null;
/* effect */ const blurCanvas = document.createElement('canvas');
let lastEffectWorkId: number | null = null; blurCanvas.classList.add('html5-blur-canvas');
let lastImageData: ImageData | null = null;
const onSync = () => { const context = blurCanvas.getContext('2d', { willReadFrequently: true });
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
lastEffectWorkId = requestAnimationFrame(() => { /* effect */
if (!context) return; let lastEffectWorkId: number | null = null;
let lastImageData: ImageData | null = null;
const width = qualityRatio; const onSync = () => {
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1); if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
if (!Number.isFinite(height)) height = width;
if (!height) return;
context.globalAlpha = 1; lastEffectWorkId = requestAnimationFrame(() => {
if (lastImageData) { if (!context) return;
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 const width = qualityRatio;
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
if (!Number.isFinite(height)) height = width;
if (!height) return;
lastEffectWorkId = null; 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);
const applyVideoAttributes = () => { lastImageData = context.getImageData(0, 0, width, height); // current image data
const rect = video.getBoundingClientRect();
const newWidth = Math.floor(video.width || rect.width); lastEffectWorkId = null;
const newHeight = Math.floor(video.height || rect.height); });
};
if (newWidth === 0 || newHeight === 0) return; const applyVideoAttributes = () => {
const rect = video.getBoundingClientRect();
blurCanvas.width = qualityRatio; const newWidth = Math.floor(video.width || rect.width);
blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio); const newHeight = Math.floor(video.height || rect.height);
blurCanvas.style.width = `${newWidth * sizeRatio}px`;
blurCanvas.style.height = `${newHeight * sizeRatio}px`;
if (isFullscreen) blurCanvas.classList.add('fullscreen'); if (newWidth === 0 || newHeight === 0) return;
else blurCanvas.classList.remove('fullscreen');
const leftOffset = newWidth * (sizeRatio - 1) / 2; blurCanvas.width = qualityRatio;
const topOffset = newHeight * (sizeRatio - 1) / 2; blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio);
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`); blurCanvas.style.width = `${newWidth * sizeRatio}px`;
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`); blurCanvas.style.height = `${newHeight * sizeRatio}px`;
blurCanvas.style.setProperty('--blur', `${blur}px`);
blurCanvas.style.setProperty('--opacity', `${opacity}`);
};
const observer = new MutationObserver((mutations) => { if (isFullscreen) blurCanvas.classList.add('fullscreen');
mutations.forEach((mutation) => { else blurCanvas.classList.remove('fullscreen');
if (mutation.type === 'attributes') {
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(); 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<HTMLElement>('#player-page');
const ytmusicAppLayout = document.querySelector<HTMLElement>('#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(() => { if (playerPage) {
applyVideoAttributes(); observer.observe(playerPage, { attributes: true });
}); }
const onConfigSync = (_: Electron.IpcRendererEvent, newConfig: ConfigType<'ambient-mode'>) => { },
onConfigChange(newConfig) {
if (typeof newConfig.interpolationTime === 'number') interpolationTime = newConfig.interpolationTime; if (typeof newConfig.interpolationTime === 'number') interpolationTime = newConfig.interpolationTime;
if (typeof newConfig.buffer === 'number') buffer = newConfig.buffer; if (typeof newConfig.buffer === 'number') buffer = newConfig.buffer;
if (typeof newConfig.quality === 'number') qualityRatio = newConfig.quality; 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.opacity === 'number') opacity = newConfig.opacity;
if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen; if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen;
applyVideoAttributes(); update?.();
}; },
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);
};
}; };
});
const playerPage = document.querySelector<HTMLElement>('#player-page');
const ytmusicAppLayout = document.querySelector<HTMLElement>('#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 });
}
};

View File

@ -0,0 +1,61 @@
import type {
BrowserWindow,
MenuItemConstructorOptions,
} from 'electron';
export type PluginBaseConfig = {
enabled: boolean;
};
export type BasePlugin<Config extends PluginBaseConfig> = {
onLoad?: () => void;
onConfigChange?: (newConfig: Config) => void;
}
export type RendererPlugin<Config extends PluginBaseConfig> = BasePlugin<Config>;
export type MainPlugin<Config extends PluginBaseConfig> = Omit<BasePlugin<Config>, 'onLoad'> & {
onLoad?: (window: BrowserWindow) => void;
};
export type PreloadPlugin<Config extends PluginBaseConfig> = BasePlugin<Config>;
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export type PluginContext<Config extends PluginBaseConfig> = {
getConfig: () => Config;
setConfig: (config: DeepPartial<Config>) => void;
send: (event: string, ...args: unknown[]) => void;
on: (event: string, listener: (...args: unknown[]) => void) => void;
};
type IF<T> = (args: T) => T;
export type PluginBuilder<ID extends string, Config extends PluginBaseConfig> = {
createRenderer: IF<(context: PluginContext<Config>) => RendererPlugin<Config>>;
createMain: IF<(context: PluginContext<Config>) => MainPlugin<Config>>;
createPreload: IF<(context: PluginContext<Config>) => PreloadPlugin<Config>>;
createMenu: IF<(context: PluginContext<Config>) => MenuItemConstructorOptions[]>;
id: ID;
config: Config;
name?: string;
styles?: string[];
};
export type PluginBuilderOptions<Config extends PluginBaseConfig = PluginBaseConfig> = {
name?: string;
config: Config;
styles?: string[];
}
export const createPluginBuilder = <ID extends string, Config extends PluginBaseConfig>(
id: ID,
options: PluginBuilderOptions<Config>,
): PluginBuilder<ID, Config> => ({
createRenderer: (plugin) => plugin,
createMain: (plugin) => plugin,
createPreload: (plugin) => plugin,
createMenu: (plugin) => plugin,
id,
name: options.name,
config: options.config,
styles: options.styles,
});

View File

@ -26,4 +26,9 @@ export interface MenuPlugin<ConfigType extends Config> extends Plugin<ConfigType
onEnable: (config: ConfigType) => void; onEnable: (config: ConfigType) => void;
} }
export const defineConfig = <ConfigType extends Config>(config: ConfigType) => config; const defaultPluginConfig: Record<string, unknown> = {};
export const definePluginConfig = <T>(id: string, defaultValue: T): T => {
defaultPluginConfig[id] = defaultValue;
return defaultValue;
};

View File

@ -1,3 +0,0 @@
import type { Config, RendererPlugin } from '../common';
export const defineRendererPlugin = <ConfigType extends Config>(plugin: RendererPlugin<ConfigType>) => plugin;

View File

@ -0,0 +1 @@
export type PluginConfig<T extends keyof PluginBuilderList> = PluginBuilderList[T]['config'];