feat: migrate to new plugin api

Co-authored-by: Su-Yong <simssy2205@gmail.com>
This commit is contained in:
JellyBrick
2023-11-11 18:02:22 +09:00
parent 739e7a448b
commit 794d00ce9e
124 changed files with 3363 additions and 2720 deletions

View File

@ -1,4 +1,4 @@
import { blockers } from '../plugins/adblocker/blocker-types'; import { blockers } from '../plugins/adblocker/types';
import { DefaultPresetList } from '../plugins/downloader/types'; import { DefaultPresetList } from '../plugins/downloader/types';

View File

@ -1,182 +0,0 @@
import defaultConfig from './defaults';
import { Entries } from '../utils/type-utils';
import type { OneOfDefaultConfigKey, ConfigType, PluginConfigOptions } from './dynamic';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};
export const getActivePlugins
= async () => await window.ipcRenderer.invoke('get-active-plugins') as Promise<typeof activePlugins>;
export const isActive
= async (plugin: string) => plugin in (await window.ipcRenderer.invoke('get-active-plugins'));
/**
* This class is used to create a dynamic synced config for plugins.
*
* @param {string} name - The name of the plugin.
* @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false.
* @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store.
*
* @example
* const { PluginConfig } = require("../../config/dynamic-renderer");
* const config = new PluginConfig("plugin-name", { enableFront: true });
* module.exports = { ...config };
*
* // or
*
* module.exports = (win, options) => {
* const config = new PluginConfig("plugin-name", {
* enableFront: true,
* initialOptions: options,
* });
* setupMyPlugin(win, config);
* };
*/
type ValueOf<T> = T[keyof T];
export class PluginConfig<T extends OneOfDefaultConfigKey> {
private readonly name: string;
private readonly config: ConfigType<T>;
private readonly defaultConfig: ConfigType<T>;
private readonly enableFront: boolean;
private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {};
private allSubscribers: ((config: ConfigType<T>) => void)[] = [];
constructor(
name: T,
options: PluginConfigOptions = {
enableFront: false,
},
) {
const pluginDefaultConfig = defaultConfig.plugins[name] ?? {};
const pluginConfig = options.initialOptions || window.mainConfig.plugins.getOptions(name) || {};
this.name = name;
this.enableFront = options.enableFront;
this.defaultConfig = pluginDefaultConfig;
this.config = { ...pluginDefaultConfig, ...pluginConfig };
if (this.enableFront) {
this.setupFront();
}
activePlugins[name] = this;
}
get<Key extends keyof ConfigType<T> = keyof ConfigType<T>>(key: Key): ConfigType<T>[Key] {
return this.config?.[key];
}
set(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
this.onChange(key);
this.save();
}
getAll(): ConfigType<T> {
return { ...this.config };
}
setAll(options: Partial<ConfigType<T>>) {
if (!options || typeof options !== 'object') {
throw new Error('Options must be an object.');
}
let changed = false;
for (const [key, value] of Object.entries(options) as Entries<typeof options>) {
if (this.config[key] !== value) {
if (value !== undefined) this.config[key] = value;
this.onChange(key, false);
changed = true;
}
}
if (changed) {
for (const fn of this.allSubscribers) {
fn(this.config);
}
}
this.save();
}
getDefaultConfig() {
return this.defaultConfig;
}
/**
* Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true`
*
* Used for options that require a restart to take effect.
*/
setAndMaybeRestart(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
window.mainConfig.plugins.setMenuOptions(this.name, this.config);
this.onChange(key);
}
subscribe(valueName: keyof ConfigType<T>, fn: (config: ConfigType<T>) => void) {
this.subscribers[valueName] = fn;
}
subscribeAll(fn: (config: ConfigType<T>) => void) {
this.allSubscribers.push(fn);
}
/** Called only from back */
private save() {
window.mainConfig.plugins.setOptions(this.name, this.config);
}
private onChange(valueName: keyof ConfigType<T>, single: boolean = true) {
this.subscribers[valueName]?.(this.config[valueName] as ConfigType<T>);
if (single) {
for (const fn of this.allSubscribers) {
fn(this.config);
}
}
}
private setupFront() {
const ignoredMethods = ['subscribe', 'subscribeAll'];
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-return
this[fnName] = (async (...args: any) => await window.ipcRenderer.invoke(
`${this.name}-config-${String(fnName)}`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
...args,
)) as typeof this[keyof this];
this.subscribe = (valueName, fn: (config: ConfigType<T>) => void) => {
if (valueName in this.subscribers) {
console.error(`Already subscribed to ${String(valueName)}`);
}
this.subscribers[valueName] = fn;
window.ipcRenderer.on(
`${this.name}-config-changed-${String(valueName)}`,
(_, value: ConfigType<T>) => {
fn(value);
},
);
window.ipcRenderer.send(`${this.name}-config-subscribe`, valueName);
};
this.subscribeAll = (fn: (config: ConfigType<T>) => void) => {
window.ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType<T>) => {
fn(value);
});
window.ipcRenderer.send(`${this.name}-config-subscribe-all`);
};
}
}
}

View File

@ -1,183 +0,0 @@
import { ipcMain } from 'electron';
import defaultConfig from './defaults';
import { getOptions, setMenuOptions, setOptions } from './plugins';
import { sendToFront } from '../providers/app-controls';
import { Entries } from '../utils/type-utils';
export type DefaultPluginsConfig = typeof defaultConfig.plugins;
export type OneOfDefaultConfigKey = keyof DefaultPluginsConfig;
export type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};
export const getActivePlugins = () => activePlugins;
if (process.type === 'browser') {
ipcMain.handle('get-active-plugins', getActivePlugins);
}
export const isActive = (plugin: string): boolean => plugin in activePlugins;
export interface PluginConfigOptions {
enableFront: boolean;
initialOptions?: OneOfDefaultConfig;
}
/**
* This class is used to create a dynamic synced config for plugins.
*
* @param {string} name - The name of the plugin.
* @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false.
* @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store.
*
* @example
* const { PluginConfig } = require("../../config/dynamic");
* const config = new PluginConfig("plugin-name", { enableFront: true });
* module.exports = { ...config };
*
* // or
*
* module.exports = (win, options) => {
* const config = new PluginConfig("plugin-name", {
* enableFront: true,
* initialOptions: options,
* });
* setupMyPlugin(win, config);
* };
*/
export type ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T];
type ValueOf<T> = T[keyof T];
export class PluginConfig<T extends OneOfDefaultConfigKey> {
private readonly name: string;
private readonly config: ConfigType<T>;
private readonly defaultConfig: ConfigType<T>;
private readonly enableFront: boolean;
private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {};
private allSubscribers: ((config: ConfigType<T>) => void)[] = [];
constructor(
name: T,
options: PluginConfigOptions = {
enableFront: false,
},
) {
const pluginDefaultConfig = defaultConfig.plugins[name] ?? {};
const pluginConfig = options.initialOptions || getOptions(name) || {};
this.name = name;
this.enableFront = options.enableFront;
this.defaultConfig = pluginDefaultConfig;
this.config = { ...pluginDefaultConfig, ...pluginConfig };
if (this.enableFront) {
this.setupFront();
}
activePlugins[name] = this;
}
get<Key extends keyof ConfigType<T> = keyof ConfigType<T>>(key: Key): ConfigType<T>[Key] {
return this.config?.[key];
}
set(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
this.onChange(key);
this.save();
}
getAll(): ConfigType<T> {
return { ...this.config };
}
setAll(options: Partial<ConfigType<T>>) {
if (!options || typeof options !== 'object') {
throw new Error('Options must be an object.');
}
let changed = false;
for (const [key, value] of Object.entries(options) as Entries<typeof options>) {
if (this.config[key] !== value) {
if (value !== undefined) this.config[key] = value;
this.onChange(key, false);
changed = true;
}
}
if (changed) {
for (const fn of this.allSubscribers) {
fn(this.config);
}
}
this.save();
}
getDefaultConfig() {
return this.defaultConfig;
}
/**
* Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true`
*
* Used for options that require a restart to take effect.
*/
setAndMaybeRestart(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
setMenuOptions(this.name, this.config);
this.onChange(key);
}
subscribe(valueName: keyof ConfigType<T>, fn: (config: ConfigType<T>) => void) {
this.subscribers[valueName] = fn;
}
subscribeAll(fn: (config: ConfigType<T>) => void) {
this.allSubscribers.push(fn);
}
/** Called only from back */
private save() {
setOptions(this.name, this.config);
}
private onChange(valueName: keyof ConfigType<T>, single: boolean = true) {
this.subscribers[valueName]?.(this.config[valueName] as ConfigType<T>);
if (single) {
for (const fn of this.allSubscribers) {
fn(this.config);
}
}
}
private setupFront() {
const ignoredMethods = ['subscribe', 'subscribeAll'];
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-return
ipcMain.handle(`${this.name}-config-${String(fnName)}`, (_, ...args) => fn(...args));
}
ipcMain.on(`${this.name}-config-subscribe`, (_, valueName: keyof ConfigType<T>) => {
this.subscribe(valueName, (value) => {
sendToFront(`${this.name}-config-changed-${String(valueName)}`, value);
});
});
ipcMain.on(`${this.name}-config-subscribe-all`, () => {
this.subscribeAll((value) => {
sendToFront(`${this.name}-config-changed`, value);
});
});
}
}

View File

@ -25,9 +25,7 @@ import { mainPlugins } from 'virtual:MainPlugins';
import { pluginBuilders } from 'virtual:PluginBuilders'; import { pluginBuilders } from 'virtual:PluginBuilders';
/* eslint-enable import/order */ /* eslint-enable import/order */
import { setOptions as pipSetOptions } from './plugins/picture-in-picture/main'; import youtubeMusicCSS from './youtube-music.css?inline';
import youtubeMusicCSS from './youtube-music.css';
import { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder'; import { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder';
// Catch errors and log them // Catch errors and log them
@ -156,6 +154,9 @@ async function loadPlugins(win: BrowserWindow) {
handle: (event: string, listener) => { handle: (event: string, listener) => {
ipcMain.handle(event, async (_, ...args) => listener(...args as never)); ipcMain.handle(event, async (_, ...args) => listener(...args as never));
}, },
on: (event: string, listener) => {
ipcMain.on(event, async (_, ...args) => listener(...args as never));
},
}); });
@ -273,39 +274,22 @@ async function createMainWindow() {
: config.defaultConfig.url; : config.defaultConfig.url;
win.on('closed', onClosed); win.on('closed', onClosed);
type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture'];
const setPiPOptions = config.plugins.isEnabled('picture-in-picture')
? (key: string, value: unknown) => pipSetOptions({ [key]: value })
: () => {};
win.on('move', () => { win.on('move', () => {
if (win.isMaximized()) { if (win.isMaximized()) {
return; return;
} }
const position = win.getPosition(); const [x, y] = win.getPosition();
const isPiPEnabled: boolean lateSave('window-position', { x, y });
= config.plugins.isEnabled('picture-in-picture')
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
if (!isPiPEnabled) {
lateSave('window-position', { x: position[0], y: position[1] });
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').savePosition) {
lateSave('pip-position', position, setPiPOptions);
}
}); });
let winWasMaximized: boolean; let winWasMaximized: boolean;
win.on('resize', () => { win.on('resize', () => {
const windowSize = win.getSize(); const [width, height] = win.getSize();
const isMaximized = win.isMaximized(); const isMaximized = win.isMaximized();
const isPiPEnabled if (winWasMaximized !== isMaximized) {
= config.plugins.isEnabled('picture-in-picture')
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
winWasMaximized = isMaximized; winWasMaximized = isMaximized;
config.set('window-maximized', isMaximized); config.set('window-maximized', isMaximized);
} }
@ -314,14 +298,10 @@ async function createMainWindow() {
return; return;
} }
if (!isPiPEnabled) {
lateSave('window-size', { lateSave('window-size', {
width: windowSize[0], width,
height: windowSize[1], height,
}); });
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').saveSize) {
lateSave('pip-size', windowSize, setPiPOptions);
}
}); });
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {}; const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};

View File

@ -57,6 +57,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
config.setPartial(`plugins.${name}`, newConfig); config.setPartial(`plugins.${name}`, newConfig);
}, },
window: win, window: win,
refresh: () => refreshMenu(win),
}); });
const availablePlugins = getAvailablePluginNames(); const availablePlugins = getAvailablePluginNames();

View File

@ -17,10 +17,12 @@ const SOURCES = [
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt', 'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
]; ];
let blocker: ElectronBlocker | undefined;
export const loadAdBlockerEngine = async ( export const loadAdBlockerEngine = async (
session: Electron.Session | undefined = undefined, session: Electron.Session | undefined = undefined,
cache = true, cache: boolean = true,
additionalBlockLists = [], additionalBlockLists: string[] = [],
disableDefaultLists: boolean | unknown[] = false, disableDefaultLists: boolean | unknown[] = false,
) => { ) => {
// Only use cache if no additional blocklists are passed // Only use cache if no additional blocklists are passed
@ -45,7 +47,7 @@ export const loadAdBlockerEngine = async (
]; ];
try { try {
const blocker = await ElectronBlocker.fromLists( blocker = await ElectronBlocker.fromLists(
(url: string) => net.fetch(url), (url: string) => net.fetch(url),
lists, lists,
{ {
@ -64,4 +66,10 @@ export const loadAdBlockerEngine = async (
} }
}; };
export default { loadAdBlockerEngine }; export const unloadAdBlockerEngine = (session: Electron.Session) => {
if (blocker) {
blocker.disableBlockingInSession(session);
}
};
export const isBlockerEnabled = (session: Electron.Session) => blocker !== undefined && blocker.isBlockingEnabled(session);

View File

@ -1,14 +0,0 @@
/* renderer */
import { blockers } from './blocker-types';
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('adblocker', { enableFront: true });
export const shouldUseBlocklists = () => config.get('blocker') !== blockers.InPlayer;
export default Object.assign(config, {
shouldUseBlocklists,
blockers,
});

View File

@ -0,0 +1,52 @@
import { blockers } from './types';
import { createPluginBuilder } from '../utils/builder';
interface AdblockerConfig {
/**
* Whether to enable the adblocker.
* @default true
*/
enabled: boolean;
/**
* When enabled, the adblocker will cache the blocklists.
* @default true
*/
cache: boolean;
/**
* Which adblocker to use.
* @default blockers.InPlayer
*/
blocker: typeof blockers[keyof typeof blockers];
/**
* Additional list of filters to use.
* @example ["https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"]
* @default []
*/
additionalBlockLists: string[];
/**
* Disable the default blocklists.
* @default false
*/
disableDefaultLists: boolean;
}
const builder = createPluginBuilder('adblocker', {
name: 'Adblocker',
restartNeeded: false,
config: {
enabled: true,
cache: true,
blocker: blockers.InPlayer,
additionalBlockLists: [],
disableDefaultLists: false,
} as AdblockerConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1 +0,0 @@
export const inject: () => void;

View File

@ -0,0 +1,3 @@
export const inject: () => void;
export const isInjected: () => boolean;

View File

@ -7,7 +7,13 @@
Parts of this code is derived from set-constant.js: Parts of this code is derived from set-constant.js:
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704 https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
*/ */
let injected = false;
export const isInjected = () => isInjected;
export const inject = () => { export const inject = () => {
injected = true;
{ {
const pruner = function (o) { const pruner = function (o) {
delete o.playerAds; delete o.playerAds;

View File

@ -1,19 +1,43 @@
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
import { loadAdBlockerEngine } from './blocker'; import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from './blocker';
import { shouldUseBlocklists } from './config';
import type { ConfigType } from '../../config/dynamic'; import builder from './index';
import { blockers } from './types';
type AdBlockOptions = ConfigType<'adblocker'>; export default builder.createMain(({ getConfig }) => {
let mainWindow: BrowserWindow | undefined;
export default async (win: BrowserWindow, options: AdBlockOptions) => { return {
if (shouldUseBlocklists()) { async onLoad(window) {
const config = await getConfig();
mainWindow = window;
if (config.blocker === blockers.WithBlocklists) {
await loadAdBlockerEngine( await loadAdBlockerEngine(
win.webContents.session, window.webContents.session,
options.cache, config.cache,
options.additionalBlockLists, config.additionalBlockLists,
options.disableDefaultLists, config.disableDefaultLists,
); );
} }
},
onUnload(window) {
if (isBlockerEnabled(window.webContents.session)) {
unloadAdBlockerEngine(window.webContents.session);
}
},
async onConfigChange(newConfig) {
if (mainWindow) {
if (newConfig.blocker === blockers.WithBlocklists && !isBlockerEnabled(mainWindow.webContents.session)) {
await loadAdBlockerEngine(
mainWindow.webContents.session,
newConfig.cache,
newConfig.additionalBlockLists,
newConfig.disableDefaultLists,
);
}
}
}
}; };
});

View File

@ -1,21 +1,22 @@
import config from './config'; import { blockers } from './types';
import builder from './index';
import { blockers } from './blocker-types'; import type { MenuTemplate } from '../../menu';
import { MenuTemplate } from '../../menu'; export default builder.createMenu(async ({ getConfig, setConfig }): Promise<MenuTemplate> => {
const config = await getConfig();
export default (): MenuTemplate => {
return [ return [
{ {
label: 'Blocker', label: 'Blocker',
submenu: Object.values(blockers).map((blocker: string) => ({ submenu: Object.values(blockers).map((blocker) => ({
label: blocker, label: blocker,
type: 'radio', type: 'radio',
checked: (config.get('blocker') || blockers.WithBlocklists) === blocker, checked: (config.blocker || blockers.WithBlocklists) === blocker,
click() { click() {
config.set('blocker', blocker); setConfig({ blocker });
}, },
})), })),
}, },
]; ];
}; });

View File

@ -1,15 +1,27 @@
import config, { shouldUseBlocklists } from './config'; import { inject, isInjected } from './injectors/inject';
import { inject } from './inject'; import injectCliqzPreload from './injectors/inject-cliqz-preload';
import injectCliqzPreload from './inject-cliqz-preload';
import { blockers } from './blocker-types'; import { blockers } from './types';
import builder from './index';
export default async () => { export default builder.createPreload(({ getConfig }) => ({
if (shouldUseBlocklists()) { async onLoad() {
const config = await getConfig();
if (config.blocker === blockers.WithBlocklists) {
// Preload adblocker to inject scripts/styles // Preload adblocker to inject scripts/styles
await injectCliqzPreload(); await injectCliqzPreload();
// eslint-disable-next-line @typescript-eslint/await-thenable } else if (config.blocker === blockers.InPlayer) {
} else if ((config.get('blocker')) === blockers.InPlayer) {
inject(); inject();
} }
}; },
async onConfigChange(newConfig) {
if (newConfig.blocker === blockers.WithBlocklists) {
await injectCliqzPreload();
} else if (newConfig.blocker === blockers.InPlayer) {
if (!isInjected()) {
inject();
}
}
}
}));

View File

@ -4,6 +4,7 @@ import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('album-color-theme', { const builder = createPluginBuilder('album-color-theme', {
name: 'Album Color Theme', name: 'Album Color Theme',
restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },

View File

@ -1,6 +1,6 @@
import { FastAverageColor } from 'fast-average-color'; import { FastAverageColor } from 'fast-average-color';
import builder from './'; import builder from './index';
export default builder.createRenderer(() => { export default builder.createRenderer(() => {
function hexToHSL(H: string) { function hexToHSL(H: string) {

View File

@ -14,6 +14,7 @@ export type AmbientModePluginConfig = {
}; };
const builder = createPluginBuilder('ambient-mode', { const builder = createPluginBuilder('ambient-mode', {
name: 'Ambient Mode', name: 'Ambient Mode',
restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
quality: 50, quality: 50,

View File

@ -1,4 +1,4 @@
import builder from './'; import builder from './index';
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];

View File

@ -160,13 +160,13 @@ export default builder.createRenderer(async ({ getConfig }) => {
} }
}, },
onConfigChange(newConfig) { onConfigChange(newConfig) {
if (typeof newConfig.interpolationTime === 'number') interpolationTime = newConfig.interpolationTime; interpolationTime = newConfig.interpolationTime;
if (typeof newConfig.buffer === 'number') buffer = newConfig.buffer; buffer = newConfig.buffer;
if (typeof newConfig.quality === 'number') qualityRatio = newConfig.quality; qualityRatio = newConfig.quality;
if (typeof newConfig.size === 'number') sizeRatio = newConfig.size / 100; sizeRatio = newConfig.size / 100;
if (typeof newConfig.blur === 'number') blur = newConfig.blur; blur = newConfig.blur;
if (typeof newConfig.opacity === 'number') opacity = newConfig.opacity; opacity = newConfig.opacity;
if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen; isFullscreen = newConfig.fullscreen;
update?.(); update?.();
}, },

View File

@ -2,6 +2,7 @@ import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('audio-compressor', { const builder = createPluginBuilder('audio-compressor', {
name: 'Audio Compressor', name: 'Audio Compressor',
restartNeeded: false,
config: { config: {
enabled: false, enabled: false,
}, },

View File

@ -1,4 +1,4 @@
import builder from '.'; import builder from './index';
export default builder.createRenderer(() => { export default builder.createRenderer(() => {
return { return {

View File

@ -4,6 +4,7 @@ import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('blur-nav-bar', { const builder = createPluginBuilder('blur-nav-bar', {
name: 'Blur Navigation Bar', name: 'Blur Navigation Bar',
restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },

View File

@ -2,6 +2,7 @@ import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('bypass-age-restrictions', { const builder = createPluginBuilder('bypass-age-restrictions', {
name: 'Bypass Age Restrictions', name: 'Bypass Age Restrictions',
restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },

View File

@ -1,4 +1,4 @@
import builder from '.'; import builder from './index';
export default builder.createRenderer(() => ({ export default builder.createRenderer(() => ({
async onLoad() { async onLoad() {

View File

@ -1,4 +0,0 @@
import { PluginConfig } from '../../config/dynamic-renderer';
const configRenderer = new PluginConfig('captions-selector', { enableFront: true });
export default configRenderer;

View File

@ -1,4 +0,0 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('captions-selector', { enableFront: true });
export default config;

View File

@ -0,0 +1,20 @@
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('captions-selector', {
name: 'Captions Selector',
restartNeeded: false,
config: {
enabled: false,
disableCaptions: false,
autoload: false,
lastCaptionsCode: '',
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,10 +1,12 @@
import { BrowserWindow, ipcMain } from 'electron';
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import builder from './index';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '../../providers/prompt-options';
export default (win: BrowserWindow) => { export default builder.createMain(({ handle }) => ({
ipcMain.handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt( onLoad(window) {
handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
{ {
title: 'Choose Caption', title: 'Choose Caption',
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`, label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
@ -14,6 +16,7 @@ export default (win: BrowserWindow) => {
resizable: true, resizable: true,
...promptOptions(), ...promptOptions(),
}, },
win, window,
)); ));
}; }
}));

View File

@ -1,22 +1,26 @@
import config from './config'; import builder from './index';
import { MenuTemplate } from '../../menu'; import type { MenuTemplate } from '../../menu';
export default (): MenuTemplate => [ export default builder.createMenu(async ({ getConfig, setConfig }): Promise<MenuTemplate> => {
const config = await getConfig();
return [
{ {
label: 'Automatically select last used caption', label: 'Automatically select last used caption',
type: 'checkbox', type: 'checkbox',
checked: config.get('autoload'), checked: config.autoload,
click(item) { click(item) {
config.set('autoload', item.checked); setConfig({ autoload: item.checked });
}, },
}, },
{ {
label: 'No captions by default', label: 'No captions by default',
type: 'checkbox', type: 'checkbox',
checked: config.get('disableCaptions'), checked: config.disableCaptions,
click(item) { click(item) {
config.set('disableCaptions', item.checked); setConfig({ disableCaptions: item.checked });
}, },
}, },
]; ];
});

View File

@ -1,7 +1,7 @@
import configProvider from './config-renderer';
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw'; import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
import builder from './index';
import { ElementFromHtml } from '../utils/renderer'; import { ElementFromHtml } from '../utils/renderer';
import { YoutubePlayer } from '../../types/youtube-player'; import { YoutubePlayer } from '../../types/youtube-player';
@ -20,28 +20,17 @@ interface LanguageOptions {
vss_id: string; vss_id: string;
} }
let captionsSelectorConfig: ConfigType<'captions-selector'>;
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!; const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML); const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
export default () => { export default builder.createRenderer(({ getConfig, setConfig }) => {
captionsSelectorConfig = configProvider.getAll(); let config: Awaited<ReturnType<typeof getConfig>>;
let captionTrackList: LanguageOptions[] | null = null;
let api: YoutubePlayer;
configProvider.subscribeAll((newConfig) => { const videoChangeListener = () => {
captionsSelectorConfig = newConfig; if (config.disableCaptions) {
});
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
};
function setup(api: YoutubePlayer) {
$('.right-controls-buttons').append(captionsSettingsButton);
let captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
$('video').addEventListener('srcChanged', () => {
if (captionsSelectorConfig.disableCaptions) {
setTimeout(() => api.unloadModule('captions'), 100); setTimeout(() => api.unloadModule('captions'), 100);
captionsSettingsButton.style.display = 'none'; captionsSettingsButton.style.display = 'none';
return; return;
@ -52,9 +41,9 @@ function setup(api: YoutubePlayer) {
setTimeout(() => { setTimeout(() => {
captionTrackList = api.getOption('captions', 'tracklist') ?? []; captionTrackList = api.getOption('captions', 'tracklist') ?? [];
if (captionsSelectorConfig.autoload && captionsSelectorConfig.lastCaptionsCode) { if (config.autoload && config.lastCaptionsCode) {
api.setOption('captions', 'track', { api.setOption('captions', 'track', {
languageCode: captionsSelectorConfig.lastCaptionsCode, languageCode: config.lastCaptionsCode,
}); });
} }
@ -62,9 +51,9 @@ function setup(api: YoutubePlayer) {
? 'inline-block' ? 'inline-block'
: 'none'; : 'none';
}, 250); }, 250);
}); };
captionsSettingsButton.addEventListener('click', async () => { const captionsButtonClickListener = async () => {
if (captionTrackList?.length) { if (captionTrackList?.length) {
const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!; const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
let currentIndex = currentCaptionTrack let currentIndex = currentCaptionTrack
@ -82,7 +71,7 @@ function setup(api: YoutubePlayer) {
} }
const newCaptions = captionTrackList[currentIndex]; const newCaptions = captionTrackList[currentIndex];
configProvider.set('lastCaptionsCode', newCaptions?.languageCode); setConfig({ lastCaptionsCode: newCaptions?.languageCode });
if (newCaptions) { if (newCaptions) {
api.setOption('captions', 'track', { languageCode: newCaptions.languageCode }); api.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
} else { } else {
@ -91,5 +80,38 @@ function setup(api: YoutubePlayer) {
setTimeout(() => api.playVideo()); setTimeout(() => api.playVideo());
} }
}); };
const listener = ({ detail }: {
detail: YoutubePlayer;
}) => {
api = detail;
$('.right-controls-buttons').append(captionsSettingsButton);
captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
$('video').addEventListener('srcChanged', videoChangeListener);
captionsSettingsButton.addEventListener('click', captionsButtonClickListener);
};
const removeListener = () => {
$('.right-controls-buttons').removeChild(captionsSettingsButton);
$<YoutubePlayer & HTMLElement>('#movie_player').unloadModule('captions');
document.removeEventListener('apiLoaded', listener);
};
return {
async onLoad() {
config = await getConfig();
document.addEventListener('apiLoaded', listener, { once: true, passive: true });
},
onUnload() {
removeListener();
},
onConfigChange(newConfig) {
config = newConfig;
} }
};
});

View File

@ -0,0 +1,17 @@
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('compact-sidebar', {
name: 'Compact Sidebar',
restartNeeded: false,
config: {
enabled: false,
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,10 +1,27 @@
export default () => { import builder from './index';
const compactSidebar = document.querySelector('#mini-guide');
const isCompactSidebarDisabled
= compactSidebar === null
|| window.getComputedStyle(compactSidebar).display === 'none';
if (isCompactSidebarDisabled) { export default builder.createRenderer(() => {
const getCompactSidebar = () => document.querySelector('#mini-guide');
const isCompactSidebarDisabled = () => {
const compactSidebar = getCompactSidebar();
return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none';
};
return {
onLoad() {
if (isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click(); document.querySelector<HTMLButtonElement>('#button')?.click();
} }
},
onUnload() {
if (!isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
},
onConfigChange() {
if (isCompactSidebarDisabled()) {
document.querySelector<HTMLButtonElement>('#button')?.click();
}
}
}; };
});

View File

@ -1,4 +0,0 @@
import { PluginConfig } from '../../config/dynamic-renderer';
const config = new PluginConfig('crossfade', { enableFront: true });
export default config;

View File

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

View File

@ -15,8 +15,6 @@
* v0.2.0, 07/2016 * v0.2.0, 07/2016
*/ */
'use strict';
// Internal utility: check if value is a valid volume level and throw if not // Internal utility: check if value is a valid volume level and throw if not
const validateVolumeLevel = (value: number) => { const validateVolumeLevel = (value: number) => {
// Number between 0 and 1? // Number between 0 and 1?

View File

@ -0,0 +1,50 @@
import { createPluginBuilder } from '../utils/builder';
export type CrossfadePluginConfig = {
enabled: boolean;
fadeInDuration: number;
fadeOutDuration: number;
secondsBeforeEnd: number;
fadeScaling: 'linear' | 'logarithmic' | number;
}
const builder = createPluginBuilder('crossfade', {
name: 'Crossfade [beta]',
restartNeeded: true,
config: {
enabled: false,
/**
* The duration of the fade in and fade out in milliseconds.
*
* @default 1500ms
*/
fadeInDuration: 1500,
/**
* The duration of the fade in and fade out in milliseconds.
*
* @default 5000ms
*/
fadeOutDuration: 5000,
/**
* The duration of the fade in and fade out in seconds.
*
* @default 10s
*/
secondsBeforeEnd: 10,
/**
* The scaling algorithm to use for the fade.
* (or a positive number in dB)
*
* @default 'linear'
*/
fadeScaling: 'linear',
} as CrossfadePluginConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,11 +1,18 @@
import { ipcMain } from 'electron';
import { Innertube } from 'youtubei.js'; import { Innertube } from 'youtubei.js';
export default async () => { import builder from './index';
const yt = await Innertube.create();
ipcMain.handle('audio-url', async (_, videoID: string) => { import { getNetFetchAsFetch } from '../utils/main';
export default builder.createMain(({ handle }) => ({
async onLoad() {
const yt = await Innertube.create({
fetch: getNetFetchAsFetch(),
});
handle('audio-url', async (_, videoID: string) => {
const info = await yt.getBasicInfo(videoID); const info = await yt.getBasicInfo(videoID);
return info.streaming_data?.formats[0].decipher(yt.session.player); return info.streaming_data?.formats[0].decipher(yt.session.player);
}); });
}; }
}));

View File

@ -2,30 +2,12 @@ import prompt from 'custom-electron-prompt';
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
import config from './config'; import builder, { CrossfadePluginConfig } from './index';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '../../providers/prompt-options';
import configOptions from '../../config/defaults';
import { MenuTemplate } from '../../menu'; export default builder.createMenu(({ window, getConfig, setConfig }) => {
const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
import type { ConfigType } from '../../config/dynamic';
const defaultOptions = configOptions.plugins.crossfade;
export default (win: BrowserWindow): MenuTemplate => [
{
label: 'Advanced',
async click() {
const newOptions = await promptCrossfadeValues(win, config.getAll());
if (newOptions) {
config.setAll(newOptions);
}
},
},
];
async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'crossfade'>): Promise<Partial<ConfigType<'crossfade'>> | undefined> {
const res = await prompt( const res = await prompt(
{ {
title: 'Crossfade Options', title: 'Crossfade Options',
@ -33,7 +15,7 @@ async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'cr
multiInputOptions: [ multiInputOptions: [
{ {
label: 'Fade in duration (ms)', label: 'Fade in duration (ms)',
value: options.fadeInDuration || defaultOptions.fadeInDuration, value: options.fadeInDuration,
inputAttrs: { inputAttrs: {
type: 'number', type: 'number',
required: true, required: true,
@ -43,7 +25,7 @@ async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'cr
}, },
{ {
label: 'Fade out duration (ms)', label: 'Fade out duration (ms)',
value: options.fadeOutDuration || defaultOptions.fadeOutDuration, value: options.fadeOutDuration,
inputAttrs: { inputAttrs: {
type: 'number', type: 'number',
required: true, required: true,
@ -54,7 +36,7 @@ async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'cr
{ {
label: 'Crossfade x seconds before end', label: 'Crossfade x seconds before end',
value: value:
options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd, options.secondsBeforeEnd,
inputAttrs: { inputAttrs: {
type: 'number', type: 'number',
required: true, required: true,
@ -64,7 +46,7 @@ async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'cr
{ {
label: 'Fade scaling', label: 'Fade scaling',
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' }, selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
value: options.fadeScaling || defaultOptions.fadeScaling, value: options.fadeScaling,
}, },
], ],
resizable: true, resizable: true,
@ -73,14 +55,37 @@ async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'cr
}, },
win, win,
).catch(console.error); ).catch(console.error);
if (!res) { if (!res) {
return undefined; return undefined;
} }
let fadeScaling: 'linear' | 'logarithmic' | number;
if (res[3] === 'linear' || res[3] === 'logarithmic') {
fadeScaling = res[3];
} else if (isFinite(Number(res[3]))) {
fadeScaling = Number(res[3]);
} else {
fadeScaling = options.fadeScaling;
}
return { return {
fadeInDuration: Number(res[0]), fadeInDuration: Number(res[0]),
fadeOutDuration: Number(res[1]), fadeOutDuration: Number(res[1]),
secondsBeforeEnd: Number(res[2]), secondsBeforeEnd: Number(res[2]),
fadeScaling: res[3], fadeScaling,
}; };
};
return [
{
label: 'Advanced',
async click() {
const newOptions = await promptCrossfadeValues(window, await getConfig());
if (newOptions) {
setConfig(newOptions);
} }
},
},
];
});

View File

@ -3,23 +3,16 @@ import { Howl } from 'howler';
// Extracted from https://github.com/bitfasching/VolumeFader // Extracted from https://github.com/bitfasching/VolumeFader
import { VolumeFader } from './fader'; import { VolumeFader } from './fader';
import configProvider from './config-renderer'; import builder, { CrossfadePluginConfig } from './index';
import defaultConfigs from '../../config/defaults'; export default builder.createRenderer(({ getConfig, invoke }) => {
let config: CrossfadePluginConfig;
import type { ConfigType } from '../../config/dynamic';
let transitionAudio: Howl; // Howler audio used to fade out the current music let transitionAudio: Howl; // Howler audio used to fade out the current music
let firstVideo = true; let firstVideo = true;
let waitForTransition: Promise<unknown>; let waitForTransition: Promise<unknown>;
const defaultConfig = defaultConfigs.plugins.crossfade; const getStreamURL = async (videoID: string): Promise<string> => invoke('audio-url', videoID);
let crossfadeConfig: ConfigType<'crossfade'>;
const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(crossfadeConfig[key]) || (defaultConfig[key] as number);
const getStreamURL = async (videoID: string) => window.ipcRenderer.invoke('audio-url', videoID) as Promise<string>;
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v'); const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
@ -66,8 +59,8 @@ const syncVideoWithTransitionAudio = () => {
const video = document.querySelector('video')!; const video = document.querySelector('video')!;
const videoFader = new VolumeFader(video, { const videoFader = new VolumeFader(video, {
fadeScaling: configGetNumber('fadeScaling'), fadeScaling: config.fadeScaling,
fadeDuration: configGetNumber('fadeInDuration'), fadeDuration: config.fadeInDuration,
}); });
transitionAudio.play(); transitionAudio.play();
@ -92,9 +85,9 @@ const syncVideoWithTransitionAudio = () => {
}); });
// Exit just before the end for the transition // Exit just before the end for the transition
const transitionBeforeEnd = () => { const transitionBeforeEnd = async () => {
if ( if (
video.currentTime >= video.duration - configGetNumber('secondsBeforeEnd') video.currentTime >= video.duration - config.secondsBeforeEnd
&& isReadyToCrossfade() && isReadyToCrossfade()
) { ) {
video.removeEventListener('timeupdate', transitionBeforeEnd); video.removeEventListener('timeupdate', transitionBeforeEnd);
@ -134,8 +127,8 @@ const crossfade = (cb: () => void) => {
const fader = new VolumeFader(transitionAudio._sounds[0]._node, { const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
initialVolume: video.volume, initialVolume: video.volume,
fadeScaling: configGetNumber('fadeScaling'), fadeScaling: config.fadeScaling,
fadeDuration: configGetNumber('fadeOutDuration'), fadeDuration: config.fadeOutDuration,
}); });
// Fade out the music // Fade out the music
@ -146,15 +139,18 @@ const crossfade = (cb: () => void) => {
}); });
}; };
export default () => { return {
crossfadeConfig = configProvider.getAll(); onLoad() {
document.addEventListener('apiLoaded', async () => {
configProvider.subscribeAll((newConfig) => { config = await getConfig();
crossfadeConfig = newConfig; onApiLoaded();
}); }, {
document.addEventListener('apiLoaded', onApiLoaded, {
once: true, once: true,
passive: true, passive: true,
}); });
},
onConfigChange(newConfig) {
config = newConfig;
},
}; };
});

View File

@ -0,0 +1,23 @@
import { createPluginBuilder } from '../utils/builder';
export type DisableAutoPlayPluginConfig = {
enabled: boolean;
applyOnce: boolean;
}
const builder = createPluginBuilder('disable-autoplay', {
name: 'Disable Autoplay',
restartNeeded: false,
config: {
enabled: false,
applyOnce: false,
} as DisableAutoPlayPluginConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,20 +1,19 @@
import { BrowserWindow } from 'electron'; import builder from './index';
import { setMenuOptions } from '../../config/plugins'; export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
import { MenuTemplate } from '../../menu'; return [
import type { ConfigType } from '../../config/dynamic';
export default (_: BrowserWindow, options: ConfigType<'disable-autoplay'>): MenuTemplate => [
{ {
label: 'Applies only on startup', label: 'Applies only on startup',
type: 'checkbox', type: 'checkbox',
checked: options.applyOnce, checked: config.applyOnce,
click() { async click() {
setMenuOptions('disable-autoplay', { const nowConfig = await getConfig();
applyOnce: !options.applyOnce, setConfig({
applyOnce: !nowConfig.applyOnce,
}); });
} },
} },
]; ];
});

View File

@ -1,15 +1,20 @@
import type { ConfigType } from '../../config/dynamic'; import builder from './index';
import type { YoutubePlayer } from '../../types/youtube-player';
export default builder.createRenderer(({ getConfig }) => {
let config: Awaited<ReturnType<typeof getConfig>>;
let apiEvent: CustomEvent<YoutubePlayer>;
export default (options: ConfigType<'disable-autoplay'>) => {
const timeUpdateListener = (e: Event) => { const timeUpdateListener = (e: Event) => {
if (e.target instanceof HTMLVideoElement) { if (e.target instanceof HTMLVideoElement) {
e.target.pause(); e.target.pause();
} }
}; };
document.addEventListener('apiLoaded', (apiEvent) => { const eventListener = async (name: string) => {
const eventListener = (name: string) => { if (config.applyOnce) {
if (options.applyOnce) {
apiEvent.detail.removeEventListener('videodatachange', eventListener); apiEvent.detail.removeEventListener('videodatachange', eventListener);
} }
@ -18,6 +23,22 @@ export default (options: ConfigType<'disable-autoplay'>) => {
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true }); document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
} }
}; };
return {
async onLoad() {
config = await getConfig();
document.addEventListener('apiLoaded', (api) => {
apiEvent = api;
apiEvent.detail.addEventListener('videodatachange', eventListener); apiEvent.detail.addEventListener('videodatachange', eventListener);
}, { once: true, passive: true }); }, { once: true, passive: true });
},
onUnload() {
apiEvent.detail.removeEventListener('videodatachange', eventListener);
},
onConfigChange(newConfig) {
config = newConfig;
}
}; };
});

View File

@ -0,0 +1,55 @@
import { createPluginBuilder } from '../utils/builder';
export type DiscordPluginConfig = {
enabled: boolean;
/**
* If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
*
* @default true
*/
autoReconnect: boolean;
/**
* If enabled, the discord rich presence gets cleared when music paused after the time specified below
*/
activityTimoutEnabled: boolean;
/**
* The time in milliseconds after which the discord rich presence gets cleared when music paused
*
* @default 10 * 60 * 1000 (10 minutes)
*/
activityTimoutTime: number;
/**
* Add a "Play on YouTube Music" button to rich presence
*/
playOnYouTubeMusic: boolean;
/**
* Hide the "View App On GitHub" button in the rich presence
*/
hideGitHubButton: boolean;
/**
* Hide the "duration left" in the rich presence
*/
hideDurationLeft: boolean;
}
const builder = createPluginBuilder('discord', {
name: 'Discord Rich Presence',
restartNeeded: false,
config: {
enabled: false,
autoReconnect: true,
activityTimoutEnabled: true,
activityTimoutTime: 10 * 60 * 1000,
playOnYouTubeMusic: true,
hideGitHubButton: false,
hideDurationLeft: false,
} as DiscordPluginConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -4,9 +4,9 @@ import { dev } from 'electron-is';
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser'; import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info'; import builder from './index';
import type { ConfigType } from '../../config/dynamic'; import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
// Application ID registered by @Zo-Bro-23 // Application ID registered by @Zo-Bro-23
const clientId = '1043858434585526382'; const clientId = '1043858434585526382';
@ -51,7 +51,6 @@ const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => {
info.rpc.login().then(resolve).catch(reject); info.rpc.login().then(resolve).catch(reject);
}, 5000)); }, 5000));
const connectRecursive = () => { const connectRecursive = () => {
if (!info.autoReconnect || info.rpc.isConnected) { if (!info.autoReconnect || info.rpc.isConnected) {
return; return;
@ -94,12 +93,11 @@ export const connect = (showError = false) => {
let clearActivity: NodeJS.Timeout | undefined; let clearActivity: NodeJS.Timeout | undefined;
let updateActivity: SongInfoCallback; let updateActivity: SongInfoCallback;
type DiscordOptions = ConfigType<'discord'>; export default builder.createMain(({ getConfig }) => {
return {
async onLoad(win) {
const options = await getConfig();
export default (
win: Electron.BrowserWindow,
options: DiscordOptions,
) => {
info.rpc.on('connected', () => { info.rpc.on('connected', () => {
if (dev()) { if (dev()) {
console.log('discord connected'); console.log('discord connected');
@ -216,7 +214,12 @@ export default (
}); });
}); });
app.on('window-all-closed', clear); app.on('window-all-closed', clear);
},
onUnload() {
resetInfo();
},
}; };
});
export const clear = () => { export const clear = () => {
if (info.rpc) { if (info.rpc) {

View File

@ -2,11 +2,13 @@ import prompt from 'custom-electron-prompt';
import { clear, connect, isConnected, registerRefresh } from './main'; import { clear, connect, isConnected, registerRefresh } from './main';
import builder from './index';
import { setMenuOptions } from '../../config/plugins'; import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '../../providers/prompt-options';
import { singleton } from '../../providers/decorators'; import { singleton } from '../../providers/decorators';
import { MenuTemplate } from '../../menu';
import type { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic'; import type { ConfigType } from '../../config/dynamic';
const registerRefreshOnce = singleton((refreshMenu: () => void) => { const registerRefreshOnce = singleton((refreshMenu: () => void) => {
@ -15,8 +17,9 @@ const registerRefreshOnce = singleton((refreshMenu: () => void) => {
type DiscordOptions = ConfigType<'discord'>; type DiscordOptions = ConfigType<'discord'>;
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => { export default builder.createMenu(async ({ window, getConfig, setConfig, refresh }): Promise<MenuTemplate> => {
registerRefreshOnce(refreshMenu); const config = await getConfig();
registerRefreshOnce(refresh);
return [ return [
{ {
@ -27,10 +30,11 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen
{ {
label: 'Auto reconnect', label: 'Auto reconnect',
type: 'checkbox', type: 'checkbox',
checked: options.autoReconnect, checked: config.autoReconnect,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.autoReconnect = item.checked; setConfig({
setMenuOptions('discord', options); autoReconnect: item.checked,
});
}, },
}, },
{ {
@ -40,45 +44,49 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen
{ {
label: 'Clear activity after timeout', label: 'Clear activity after timeout',
type: 'checkbox', type: 'checkbox',
checked: options.activityTimoutEnabled, checked: config.activityTimoutEnabled,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.activityTimoutEnabled = item.checked; setConfig({
setMenuOptions('discord', options); activityTimoutEnabled: item.checked,
});
}, },
}, },
{ {
label: 'Play on YouTube Music', label: 'Play on YouTube Music',
type: 'checkbox', type: 'checkbox',
checked: options.playOnYouTubeMusic, checked: config.playOnYouTubeMusic,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.playOnYouTubeMusic = item.checked; setConfig({
setMenuOptions('discord', options); playOnYouTubeMusic: item.checked,
});
}, },
}, },
{ {
label: 'Hide GitHub link Button', label: 'Hide GitHub link Button',
type: 'checkbox', type: 'checkbox',
checked: options.hideGitHubButton, checked: config.hideGitHubButton,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.hideGitHubButton = item.checked; setConfig({
setMenuOptions('discord', options); hideGitHubButton: item.checked,
});
}, },
}, },
{ {
label: 'Hide duration left', label: 'Hide duration left',
type: 'checkbox', type: 'checkbox',
checked: options.hideDurationLeft, checked: config.hideDurationLeft,
click(item: Electron.MenuItem) { click(item: Electron.MenuItem) {
options.hideDurationLeft = item.checked; setConfig({
setMenuOptions('discord', options); hideGitHubButton: item.checked,
});
}, },
}, },
{ {
label: 'Set inactivity timeout', label: 'Set inactivity timeout',
click: () => setInactivityTimeout(win, options), click: () => setInactivityTimeout(window, config),
}, },
]; ];
}; });
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) { async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
const output = await prompt({ const output = await prompt({

View File

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

View File

@ -0,0 +1,36 @@
import { DefaultPresetList, Preset } from './types';
import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder';
export type DownloaderPluginConfig = {
enabled: boolean;
downloadFolder?: string;
selectedPreset: string;
customPresetSetting: Preset;
skipExisting: boolean;
playlistMaxItems?: number;
}
const builder = createPluginBuilder('downloader', {
name: 'Downloader',
restartNeeded: true,
config: {
enabled: false,
downloadFolder: undefined,
selectedPreset: 'mp3 (256kbps)', // Selected preset
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
skipExisting: false,
playlistMaxItems: undefined,
} as DownloaderPluginConfig,
styles: [style],
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -7,7 +7,7 @@ import {
import { join } from 'node:path'; import { join } from 'node:path';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron'; import { app, BrowserWindow, dialog } from 'electron';
import { import {
ClientType, ClientType,
Innertube, Innertube,
@ -27,16 +27,16 @@ import {
sendFeedback as sendFeedback_, sendFeedback as sendFeedback_,
setBadge, setBadge,
} from './utils'; } from './utils';
import config from './config';
import { YoutubeFormatList, type Preset, DefaultPresetList } from './types';
import style from './style.css'; import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
import { fetchFromGenius } from '../lyrics-genius/main'; import builder, { DownloaderPluginConfig } from '../index';
import { isEnabled } from '../../config/plugins';
import { cleanupName, getImage, SongInfo } from '../../providers/song-info'; import { fetchFromGenius } from '../../lyrics-genius/main';
import { injectCSS } from '../utils/main'; import { isEnabled } from '../../../config/plugins';
import { cache } from '../../providers/decorators'; import { cleanupName, getImage, SongInfo } from '../../../providers/song-info';
import { getNetFetchAsFetch } from '../../utils/main';
import { cache } from '../../../providers/decorators';
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils'; import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage'; import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
@ -44,7 +44,7 @@ import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic';
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube'; import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo'; import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
import type { GetPlayerResponse } from '../../types/get-player-response'; import type { GetPlayerResponse } from '../../../types/get-player-response';
type CustomSongInfo = SongInfo & { trackId?: string }; type CustomSongInfo = SongInfo & { trackId?: string };
@ -89,42 +89,30 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
.join(';'); .join(';');
}; };
export default async (win_: BrowserWindow) => { let config: DownloaderPluginConfig = builder.config;
win = win_;
injectCSS(win.webContents, style); export default builder.createMain(({ handle, getConfig, on }) => {
return {
async onLoad(win) {
config = await getConfig();
yt = await Innertube.create({ yt = await Innertube.create({
cache: new UniversalCache(false), cache: new UniversalCache(false),
cookie: await getCookieFromWindow(win), cookie: await getCookieFromWindow(win),
generate_session_locally: true, generate_session_locally: true,
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { fetch: getNetFetchAsFetch(),
const url =
typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
if (init?.body && !init.method) {
init.method = 'POST';
}
const request = new Request(
url,
input instanceof Request ? input : undefined,
);
return net.fetch(request, init);
}) as typeof fetch,
}); });
ipcMain.on('download-song', (_, url: string) => downloadSong(url)); handle('download-song', (_, url: string) => downloadSong(url));
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => { on('video-src-changed', (_, data: GetPlayerResponse) => {
playingUrl = data.microformat.microformatDataRenderer.urlCanonical; playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
}); });
ipcMain.on('download-playlist-request', async (_event, url: string) => handle('download-playlist-request', async (_event, url: string) => downloadPlaylist(url));
downloadPlaylist(url), },
); onConfigChange(newConfig) {
config = newConfig;
}
}; };
});
export async function downloadSong( export async function downloadSong(
url: string, url: string,
@ -209,7 +197,7 @@ async function downloadSongUnsafe(
metadata.trackId = trackId; metadata.trackId = trackId;
const dir = const dir =
playlistFolder || config.get('downloadFolder') || app.getPath('downloads'); playlistFolder || config.downloadFolder || app.getPath('downloads');
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${ const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
metadata.title metadata.title
}`; }`;
@ -239,11 +227,11 @@ async function downloadSongUnsafe(
); );
} }
const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)'; const selectedPreset = config.selectedPreset ?? 'mp3 (256kbps)';
let presetSetting: Preset; let presetSetting: Preset;
if (selectedPreset === 'Custom') { if (selectedPreset === 'Custom') {
presetSetting = presetSetting =
config.get('customPresetSetting') ?? DefaultPresetList['Custom']; config.customPresetSetting ?? DefaultPresetList['Custom'];
} else if (selectedPreset === 'Source') { } else if (selectedPreset === 'Source') {
presetSetting = DefaultPresetList['Source']; presetSetting = DefaultPresetList['Source'];
} else { } else {
@ -276,7 +264,7 @@ async function downloadSongUnsafe(
} }
const filePath = join(dir, filename); const filePath = join(dir, filename);
if (config.get('skipExisting') && existsSync(filePath)) { if (config.skipExisting && existsSync(filePath)) {
sendFeedback(null, -1); sendFeedback(null, -1);
return; return;
} }
@ -517,10 +505,10 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
safePlaylistTitle = safePlaylistTitle.normalize('NFC'); safePlaylistTitle = safePlaylistTitle.normalize('NFC');
} }
const folder = getFolder(config.get('downloadFolder') ?? ''); const folder = getFolder(config.downloadFolder ?? '');
const playlistFolder = join(folder, safePlaylistTitle); const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) { if (existsSync(playlistFolder)) {
if (!config.get('skipExisting')) { if (!config.skipExisting) {
sendError(new Error(`The folder ${playlistFolder} already exists`)); sendError(new Error(`The folder ${playlistFolder} already exists`));
return; return;
} }
@ -637,6 +625,7 @@ const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
client_type: ClientType.TV_EMBEDDED, client_type: ClientType.TV_EMBEDDED,
generate_session_locally: true, generate_session_locally: true,
retrieve_player: true, retrieve_player: true,
fetch: getNetFetchAsFetch(),
}); });
// GetInfo 404s with the bypass, so we use getBasicInfo instead // GetInfo 404s with the bypass, so we use getBasicInfo instead
// that's fine as we only need the streaming data // that's fine as we only need the streaming data

View File

@ -1,13 +1,15 @@
import { dialog } from 'electron'; import { dialog } from 'electron';
import { downloadPlaylist } from './main'; import { downloadPlaylist } from './main';
import { defaultMenuDownloadLabel, getFolder } from './utils'; import { defaultMenuDownloadLabel, getFolder } from './main/utils';
import { DefaultPresetList } from './types'; import { DefaultPresetList } from './types';
import config from './config';
import { MenuTemplate } from '../../menu'; import builder from './index';
export default (): MenuTemplate => [ export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
return [
{ {
label: defaultMenuDownloadLabel, label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(), click: () => downloadPlaylist(),
@ -17,10 +19,10 @@ export default (): MenuTemplate => [
click() { click() {
const result = dialog.showOpenDialogSync({ const result = dialog.showOpenDialogSync({
properties: ['openDirectory', 'createDirectory'], properties: ['openDirectory', 'createDirectory'],
defaultPath: getFolder(config.get('downloadFolder') ?? ''), defaultPath: getFolder(config.downloadFolder ?? ''),
}); });
if (result) { if (result) {
config.set('downloadFolder', result[0]); setConfig({ downloadFolder: result[0] });
} // Else = user pressed cancel } // Else = user pressed cancel
}, },
}, },
@ -29,18 +31,19 @@ export default (): MenuTemplate => [
submenu: Object.keys(DefaultPresetList).map((preset) => ({ submenu: Object.keys(DefaultPresetList).map((preset) => ({
label: preset, label: preset,
type: 'radio', type: 'radio',
checked: config.get('selectedPreset') === preset, checked: config.selectedPreset === preset,
click() { click() {
config.set('selectedPreset', preset); setConfig({ selectedPreset: preset });
}, },
})), })),
}, },
{ {
label: 'Skip existing files', label: 'Skip existing files',
type: 'checkbox', type: 'checkbox',
checked: config.get('skipExisting'), checked: config.skipExisting,
click(item) { click(item) {
config.set('skipExisting', item.checked); setConfig({ skipExisting: item.checked });
}, },
}, },
]; ];
});

View File

@ -1,5 +1,7 @@
import downloadHTML from './templates/download.html?raw'; import downloadHTML from './templates/download.html?raw';
import builder from './index';
import defaultConfig from '../../config/defaults'; import defaultConfig from '../../config/defaults';
import { getSongMenu } from '../../providers/dom-elements'; import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils/renderer'; import { ElementFromHtml } from '../utils/renderer';
@ -11,7 +13,9 @@ const downloadButton = ElementFromHtml(downloadHTML);
let doneFirstLoad = false; let doneFirstLoad = false;
export default () => { export default builder.createRenderer(() => {
return {
onLoad() {
const menuObserver = new MutationObserver(() => { const menuObserver = new MutationObserver(() => {
if (!menu) { if (!menu) {
menu = getSongMenu(); menu = getSongMenu();
@ -74,4 +78,6 @@ export default () => {
console.warn('Cannot update progress'); console.warn('Cannot update progress');
} }
}); });
}
}; };
});

View File

@ -0,0 +1,17 @@
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('exponential-volume', {
name: 'Exponential Volume',
restartNeeded: true,
config: {
enabled: false,
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,6 +1,8 @@
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer // "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/ // https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
import builder from './index';
const exponentialVolume = () => { const exponentialVolume = () => {
// Manipulation exponent, higher value = lower volume // Manipulation exponent, higher value = lower volume
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a // 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
@ -38,8 +40,11 @@ const exponentialVolume = () => {
}); });
}; };
export default () => export default builder.createRenderer(() => ({
document.addEventListener('apiLoaded', exponentialVolume, { onLoad() {
return document.addEventListener('apiLoaded', exponentialVolume, {
once: true, once: true,
passive: true, passive: true,
}); });
},
}));

View File

@ -2,8 +2,9 @@ import titlebarStyle from './titlebar.css?inline';
import { createPluginBuilder } from '../utils/builder'; import { createPluginBuilder } from '../utils/builder';
export const builder = createPluginBuilder('in-app-menu', { const builder = createPluginBuilder('in-app-menu', {
name: 'In-App Menu', name: 'In-App Menu',
restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
hideDOMWindowControls: false, hideDOMWindowControls: false,

View File

@ -2,19 +2,19 @@ import { register } from 'electron-localshortcut';
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron'; import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron';
import builder from './'; import builder from './index';
export default builder.createMain(({ handle }) => { export default builder.createMain(({ handle, send }) => {
return { return {
onLoad(win) { onLoad(win) {
win.on('close', () => { win.on('close', () => {
win.webContents.send('close-all-in-app-menu-panel'); send('close-all-in-app-menu-panel');
}); });
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
register(win, '`', () => { register(win, '`', () => {
win.webContents.send('toggle-in-app-menu'); send('toggle-in-app-menu');
}); });
}); });
@ -63,9 +63,9 @@ export default builder.createMain(({ handle }) => {
handle('window-close', () => win.close()); handle('window-close', () => win.close());
handle('window-minimize', () => win.minimize()); handle('window-minimize', () => win.minimize());
handle('window-maximize', () => win.maximize()); handle('window-maximize', () => win.maximize());
win.on('maximize', () => win.webContents.send('window-maximize')); win.on('maximize', () => send('window-maximize'));
handle('window-unmaximize', () => win.unmaximize()); handle('window-unmaximize', () => win.unmaximize());
win.on('unmaximize', () => win.webContents.send('window-unmaximize')); win.on('unmaximize', () => send('window-unmaximize'));
handle('image-path-to-data-url', (_, imagePath: string) => { handle('image-path-to-data-url', (_, imagePath: string) => {
const nativeImageIcon = nativeImage.createFromPath(imagePath); const nativeImageIcon = nativeImage.createFromPath(imagePath);

View File

@ -1,6 +1,6 @@
import is from 'electron-is'; import is from 'electron-is';
import builder from './'; import builder from './index';
import { setMenuOptions } from '../../config/plugins'; import { setMenuOptions } from '../../config/plugins';

View File

@ -0,0 +1,50 @@
import { createPluginBuilder } from '../utils/builder';
export interface LastFmPluginConfig {
enabled: boolean;
/**
* Token used for authentication
*/
token?: string;
/**
* Session key used for scrabbling
*/
session_key?: string;
/**
* Root of the Last.fm API
*
* @default 'http://ws.audioscrobbler.com/2.0/'
*/
api_root: string;
/**
* Last.fm api key registered by @semvis123
*
* @default '04d76faaac8726e60988e14c105d421a'
*/
api_key: string;
/**
* Last.fm api secret registered by @semvis123
*
* @default 'a5d2a36fdf64819290f6982481eaffa2'
*/
secret: string;
}
const builder = createPluginBuilder('last-fm', {
name: 'Last.fm',
restartNeeded: true,
config: {
enabled: false,
api_root: 'http://ws.audioscrobbler.com/2.0/',
api_key: '04d76faaac8726e60988e14c105d421a',
secret: 'a5d2a36fdf64819290f6982481eaffa2',
} as LastFmPluginConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,14 +1,11 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { BrowserWindow, net, shell } from 'electron'; import { net, shell } from 'electron';
import builder, { type LastFmPluginConfig } from './index';
import { setOptions } from '../../config/plugins'; import { setOptions } from '../../config/plugins';
import registerCallback, { SongInfo } from '../../providers/song-info'; import registerCallback, { type SongInfo } from '../../providers/song-info';
import defaultConfig from '../../config/defaults';
import type { ConfigType } from '../../config/dynamic';
type LastFMOptions = ConfigType<'last-fm'>;
interface LastFmData { interface LastFmData {
method: string, method: string,
@ -68,7 +65,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
return sig; return sig;
}; };
const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFMOptions) => { const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFmPluginConfig) => {
// Creates and stores the auth token // Creates and stores the auth token
const data = { const data = {
method: 'auth.gettoken', method: 'auth.gettoken',
@ -81,12 +78,12 @@ const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastF
return json?.token; return json?.token;
}; };
const authenticate = async (config: LastFMOptions) => { const authenticate = async (config: LastFmPluginConfig) => {
// Asks the user for authentication // Asks the user for authentication
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`); await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
}; };
const getAndSetSessionKey = async (config: LastFMOptions) => { const getAndSetSessionKey = async (config: LastFmPluginConfig) => {
// Get and store the session key // Get and store the session key
const data = { const data = {
api_key: config.api_key, api_key: config.api_key,
@ -114,7 +111,7 @@ const getAndSetSessionKey = async (config: LastFMOptions) => {
return config; return config;
}; };
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data: LastFmData) => { const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData) => {
// This sends a post request to the api, and adds the common data // This sends a post request to the api, and adds the common data
if (!config.session_key) { if (!config.session_key) {
await getAndSetSessionKey(config); await getAndSetSessionKey(config);
@ -151,7 +148,7 @@ const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data
}); });
}; };
const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => { const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig) => {
// This adds one scrobbled song to last.fm // This adds one scrobbled song to last.fm
const data = { const data = {
method: 'track.scrobble', method: 'track.scrobble',
@ -160,7 +157,7 @@ const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => {
postSongDataToAPI(songInfo, config, data); postSongDataToAPI(songInfo, config, data);
}; };
const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => { const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig) => {
// This sets the now playing status in last.fm // This sets the now playing status in last.fm
const data = { const data = {
method: 'track.updateNowPlaying', method: 'track.updateNowPlaying',
@ -171,10 +168,11 @@ const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => {
// This will store the timeout that will trigger addScrobble // This will store the timeout that will trigger addScrobble
let scrobbleTimer: NodeJS.Timeout | undefined; let scrobbleTimer: NodeJS.Timeout | undefined;
const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => { export default builder.createMain(({ getConfig, send }) => ({
async onLoad(_win) {
let config = await getConfig();
if (!config.api_root) { if (!config.api_root) {
// Settings are not present, creating them with the default values
config = defaultConfig.plugins['last-fm'];
config.enabled = true; config.enabled = true;
setOptions('last-fm', config); setOptions('last-fm', config);
} }
@ -198,6 +196,5 @@ const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => {
} }
} }
}); });
}; }
}));
export default lastfm;

View File

@ -0,0 +1,17 @@
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('lumiastream', {
name: 'Lumia Stream',
restartNeeded: true,
config: {
enabled: false,
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,4 +1,6 @@
import { BrowserWindow , net } from 'electron'; import { net } from 'electron';
import builder from './index';
import registerCallback from '../../providers/song-info'; import registerCallback from '../../providers/song-info';
@ -36,8 +38,8 @@ const post = (data: LumiaData) => {
'Accept': 'application/json', 'Accept': 'application/json',
'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
}; } as const;
const url = `http://localhost:${port}/api/media`; const url = `http://127.0.0.1:${port}/api/media`;
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers }) net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
.catch((error: { code: number, errno: number }) => { .catch((error: { code: number, errno: number }) => {
@ -49,7 +51,9 @@ const post = (data: LumiaData) => {
}); });
}; };
export default (_: BrowserWindow) => { export default builder.createMain(() => {
return {
onLoad() {
registerCallback((songInfo) => { registerCallback((songInfo) => {
if (!songInfo.title && !songInfo.artist) { if (!songInfo.title && !songInfo.artist) {
return; return;
@ -77,6 +81,8 @@ export default (_: BrowserWindow) => {
data.views = songInfo.views; data.views = songInfo.views;
post(data); post(data);
}); });
}
}; };
});

View File

@ -0,0 +1,26 @@
import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder';
export type LyricsGeniusPluginConfig = {
enabled: boolean;
romanizedLyrics: boolean;
}
const builder = createPluginBuilder('lyrics-genius', {
name: 'Lyrics Genius',
restartNeeded: true,
config: {
enabled: false,
romanizedLyrics: false,
} as LyricsGeniusPluginConfig,
styles: [style],
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,37 +1,35 @@
import { BrowserWindow, ipcMain, net } from 'electron'; import { net } from 'electron';
import is from 'electron-is'; import is from 'electron-is';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import style from './style.css';
import { GetGeniusLyric } from './types'; import { GetGeniusLyric } from './types';
import { cleanupName, SongInfo } from '../../providers/song-info'; import builder from './index';
import { injectCSS } from '../utils/main'; import { cleanupName, type SongInfo } from '../../providers/song-info';
import type { ConfigType } from '../../config/dynamic';
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u; const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false; let revRomanized = false;
export type LyricGeniusType = ConfigType<'lyrics-genius'>; export default builder.createMain(({ handle, getConfig }) =>{
return {
async onLoad() {
const config = await getConfig();
export default (win: BrowserWindow, options: LyricGeniusType) => { if (config.romanizedLyrics) {
if (options.romanizedLyrics) {
revRomanized = true; revRomanized = true;
} }
injectCSS(win.webContents, style); handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => {
ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => {
const metadata = extractedSongInfo; const metadata = extractedSongInfo;
return await fetchFromGenius(metadata); return await fetchFromGenius(metadata);
}); });
},
onConfigChange(newConfig) {
revRomanized = newConfig.romanizedLyrics;
}
}; };
});
export const toggleRomanized = () => {
revRomanized = !revRomanized;
};
export const fetchFromGenius = async (metadata: SongInfo) => { export const fetchFromGenius = async (metadata: SongInfo) => {
const songTitle = `${cleanupName(metadata.title)}`; const songTitle = `${cleanupName(metadata.title)}`;

View File

@ -1,19 +1,18 @@
import { BrowserWindow, MenuItem } from 'electron'; import builder from './index';
import { LyricGeniusType, toggleRomanized } from './main'; export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
import { setOptions } from '../../config/plugins'; return [
import { MenuTemplate } from '../../menu';
export default (_: BrowserWindow, options: LyricGeniusType): MenuTemplate => [
{ {
label: 'Romanized Lyrics', label: 'Romanized Lyrics',
type: 'checkbox', type: 'checkbox',
checked: options.romanizedLyrics, checked: config.romanizedLyrics,
click(item: MenuItem) { click(item) {
options.romanizedLyrics = item.checked; setConfig({
setOptions('lyrics-genius', options); romanizedLyrics: item.checked,
toggleRomanized(); });
}, },
}, },
]; ];
});

View File

@ -1,6 +1,9 @@
import builder from './index';
import type { SongInfo } from '../../providers/song-info'; import type { SongInfo } from '../../providers/song-info';
export default () => { export default builder.createRenderer(({ on, invoke }) => ({
onLoad() {
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => { const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
lyricsContainer.innerHTML = ` lyricsContainer.innerHTML = `
<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics"> <div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
@ -21,7 +24,7 @@ export default () => {
let unregister: (() => void) | null = null; let unregister: (() => void) | null = null;
window.ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => { on('update-song-info', (_, extractedSongInfo: SongInfo) => {
unregister?.(); unregister?.();
setTimeout(async () => { setTimeout(async () => {
@ -35,10 +38,10 @@ export default () => {
// Check if disabled // Check if disabled
if (!tabs.lyrics?.hasAttribute('disabled')) return; if (!tabs.lyrics?.hasAttribute('disabled')) return;
const lyrics = await window.ipcRenderer.invoke( const lyrics = await invoke<string | null>(
'search-genius-lyrics', 'search-genius-lyrics',
extractedSongInfo, extractedSongInfo,
) as string | null; );
if (!lyrics) { if (!lyrics) {
// Delete previous lyrics if tab is open and couldn't get new lyrics // Delete previous lyrics if tab is open and couldn't get new lyrics
@ -102,4 +105,5 @@ export default () => {
}; };
}, 500); }, 500);
}); });
}; }
}));

View File

@ -2,8 +2,9 @@ import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder'; import { createPluginBuilder } from '../utils/builder';
export const builder = createPluginBuilder('navigation', { const builder = createPluginBuilder('navigation', {
name: 'Navigation', name: 'Navigation',
restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
}, },

View File

@ -1,7 +1,7 @@
import forwardHTML from './templates/forward.html?raw'; import forwardHTML from './templates/forward.html?raw';
import backHTML from './templates/back.html?raw'; import backHTML from './templates/back.html?raw';
import builder from '.'; import builder from './index';
import { ElementFromHtml } from '../utils/renderer'; import { ElementFromHtml } from '../utils/renderer';

View File

@ -1,37 +0,0 @@
function removeLoginElements() {
const elementsToRemove = [
'.sign-in-link.ytmusic-nav-bar',
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
];
for (const selector of elementsToRemove) {
const node = document.querySelector(selector);
if (node) {
node.remove();
}
}
// Remove the library button
const libraryIconPath
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
const observer = new MutationObserver(() => {
const menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer',
);
menuEntries.forEach((item) => {
const icon = item.querySelector('path');
if (icon) {
observer.disconnect();
if (icon.getAttribute('d') === libraryIconPath) {
item.remove();
}
}
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
export default removeLoginElements;

View File

@ -0,0 +1,20 @@
import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('no-google-login', {
name: 'Remove Google Login',
restartNeeded: true,
config: {
enabled: false,
},
styles: [style],
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,6 +1,6 @@
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
import style from './style.css'; import style from './style.css?inline';
import { injectCSS } from '../utils/main'; import { injectCSS } from '../utils/main';

View File

@ -0,0 +1,39 @@
import builder from './index';
export default builder.createRenderer(() => ({
onLoad() {
const elementsToRemove = [
'.sign-in-link.ytmusic-nav-bar',
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
];
for (const selector of elementsToRemove) {
const node = document.querySelector(selector);
if (node) {
node.remove();
}
}
// Remove the library button
const libraryIconPath
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
const observer = new MutationObserver(() => {
const menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer',
);
menuEntries.forEach((item) => {
const icon = item.querySelector('path');
if (icon) {
observer.disconnect();
if (icon.getAttribute('d') === libraryIconPath) {
item.remove();
}
}
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
}));

View File

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

View File

@ -0,0 +1,36 @@
import { createPluginBuilder } from '../utils/builder';
export interface NotificationsPluginConfig {
enabled: boolean;
unpauseNotification: boolean;
urgency: 'low' | 'normal' | 'critical';
interactive: boolean;
toastStyle: number;
refreshOnPlayPause: boolean;
trayControls: boolean;
hideButtonText: boolean;
}
const builder = createPluginBuilder('notifications', {
name: 'Notifications',
restartNeeded: true,
config: {
enabled: false,
unpauseNotification: false,
urgency: 'normal', // Has effect only on Linux
// the following has effect only on Windows
interactive: true,
toastStyle: 1, // See plugins/notifications/utils for more info
refreshOnPlayPause: false,
trayControls: true,
hideButtonText: false,
} as NotificationsPluginConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,7 +1,6 @@
import { app, BrowserWindow, ipcMain, Notification } from 'electron'; import { app, BrowserWindow, Notification } from 'electron';
import { notificationImage, secondsToMinutes, ToastStyles } from './utils'; import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
import config from './config';
import getSongControls from '../../providers/song-controls'; import getSongControls from '../../providers/song-controls';
import registerCallback, { SongInfo } from '../../providers/song-info'; import registerCallback, { SongInfo } from '../../providers/song-info';
@ -14,85 +13,22 @@ import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnp
import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack'; import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack';
import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack'; import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack';
import { MainPluginContext } from '../utils/builder';
import type { NotificationsPluginConfig } from './index';
let songControls: ReturnType<typeof getSongControls>; let songControls: ReturnType<typeof getSongControls>;
let savedNotification: Notification | undefined; let savedNotification: Notification | undefined;
export default (win: BrowserWindow) => { type Accessor<T> = () => T;
songControls = getSongControls(win);
let currentSeconds = 0; export default (
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); win: BrowserWindow,
config: Accessor<NotificationsPluginConfig>,
ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t); { on, send }: MainPluginContext<NotificationsPluginConfig>,
) => {
let savedSongInfo: SongInfo; const sendNotification = (songInfo: SongInfo) => {
let lastUrl: string | undefined; const iconSrc = notificationImage(songInfo, config());
// Register songInfoCallback
registerCallback((songInfo) => {
if (!songInfo.artist && !songInfo.title) {
return;
}
savedSongInfo = { ...songInfo };
if (!songInfo.isPaused
&& (songInfo.url !== lastUrl || config.get('unpauseNotification'))
) {
lastUrl = songInfo.url;
sendNotification(songInfo);
}
});
if (config.get('trayControls')) {
setTrayOnClick(() => {
if (savedNotification) {
savedNotification.close();
savedNotification = undefined;
} else if (savedSongInfo) {
sendNotification({
...savedSongInfo,
elapsedSeconds: currentSeconds,
});
}
});
setTrayOnDoubleClick(() => {
if (win.isVisible()) {
win.hide();
} else {
win.show();
}
});
}
app.once('before-quit', () => {
savedNotification?.close();
});
changeProtocolHandler(
(cmd) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd as keyof typeof songControls]();
if (config.get('refreshOnPlayPause') && (
cmd === 'pause'
|| (cmd === 'play' && !config.get('unpauseNotification'))
)
) {
setImmediate(() =>
sendNotification({
...savedSongInfo,
isPaused: cmd === 'pause',
elapsedSeconds: currentSeconds,
}),
);
}
}
},
);
};
function sendNotification(songInfo: SongInfo) {
const iconSrc = notificationImage(songInfo);
savedNotification?.close(); savedNotification?.close();
@ -120,10 +56,10 @@ function sendNotification(songInfo: SongInfo) {
}); });
savedNotification.show(); savedNotification.show();
} };
const getXml = (songInfo: SongInfo, iconSrc: string) => { const getXml = (songInfo: SongInfo, iconSrc: string) => {
switch (config.get('toastStyle')) { switch (config().toastStyle) {
default: default:
case ToastStyles.logo: case ToastStyles.logo:
case ToastStyles.legacy: { case ToastStyles.legacy: {
@ -168,12 +104,12 @@ const selectIcon = (kind: keyof typeof mediaIcons): string => {
}; };
const display = (kind: keyof typeof mediaIcons) => { const display = (kind: keyof typeof mediaIcons) => {
if (config.get('toastStyle') === ToastStyles.legacy) { if (config().toastStyle === ToastStyles.legacy) {
return `content="${mediaIcons[kind]}"`; return `content="${mediaIcons[kind]}"`;
} }
return `\ return `\
content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\ content="${config().toastStyle ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
imageUri="file:///${selectIcon(kind)}" imageUri="file:///${selectIcon(kind)}"
`; `;
}; };
@ -270,3 +206,79 @@ const titleFontPicker = (title: string) => {
return 'Subtitle'; return 'Subtitle';
}; };
songControls = getSongControls(win);
let currentSeconds = 0;
on('apiLoaded', () => send('setupTimeChangedListener'));
on('timeChanged', (t: number) => {
currentSeconds = t;
});
let savedSongInfo: SongInfo;
let lastUrl: string | undefined;
// Register songInfoCallback
registerCallback((songInfo) => {
if (!songInfo.artist && !songInfo.title) {
return;
}
savedSongInfo = { ...songInfo };
if (!songInfo.isPaused
&& (songInfo.url !== lastUrl || config().unpauseNotification)
) {
lastUrl = songInfo.url;
sendNotification(songInfo);
}
});
if (config().trayControls) {
setTrayOnClick(() => {
if (savedNotification) {
savedNotification.close();
savedNotification = undefined;
} else if (savedSongInfo) {
sendNotification({
...savedSongInfo,
elapsedSeconds: currentSeconds,
});
}
});
setTrayOnDoubleClick(() => {
if (win.isVisible()) {
win.hide();
} else {
win.show();
}
});
}
app.once('before-quit', () => {
savedNotification?.close();
});
changeProtocolHandler(
(cmd) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd as keyof typeof songControls]();
if (config().refreshOnPlayPause && (
cmd === 'pause'
|| (cmd === 'play' && !config().unpauseNotification)
)
) {
setImmediate(() =>
sendNotification({
...savedSongInfo,
isPaused: cmd === 'pause',
elapsedSeconds: currentSeconds,
}),
);
}
}
},
);
};

View File

@ -1,25 +1,24 @@
import { BrowserWindow, Notification } from 'electron'; import { Notification } from 'electron';
import is from 'electron-is'; import is from 'electron-is';
import { notificationImage } from './utils'; import { notificationImage } from './utils';
import config from './config';
import interactive from './interactive'; import interactive from './interactive';
import builder, { NotificationsPluginConfig } from './index';
import registerCallback, { SongInfo } from '../../providers/song-info'; import registerCallback, { SongInfo } from '../../providers/song-info';
import type { ConfigType } from '../../config/dynamic'; let config: NotificationsPluginConfig = builder.config;
type NotificationOptions = ConfigType<'notifications'>;
const notify = (info: SongInfo) => { const notify = (info: SongInfo) => {
// Send the notification // Send the notification
const currentNotification = new Notification({ const currentNotification = new Notification({
title: info.title || 'Playing', title: info.title || 'Playing',
body: info.artist, body: info.artist,
icon: notificationImage(info), icon: notificationImage(info, config),
silent: true, silent: true,
urgency: config.get('urgency') as 'normal' | 'critical' | 'low', urgency: config.urgency,
}); });
currentNotification.show(); currentNotification.show();
@ -31,7 +30,7 @@ const setup = () => {
let currentUrl: string | undefined; let currentUrl: string | undefined;
registerCallback((songInfo: SongInfo) => { registerCallback((songInfo: SongInfo) => {
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) { if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.unpauseNotification)) {
// Close the old notification // Close the old notification
oldNotification?.close(); oldNotification?.close();
currentUrl = songInfo.url; currentUrl = songInfo.url;
@ -43,9 +42,17 @@ const setup = () => {
}); });
}; };
export default (win: BrowserWindow, options: NotificationOptions) => { export default builder.createMain((context) => {
return {
async onLoad(win) {
config = await context.getConfig();
// Register the callback for new song information // Register the callback for new song information
is.windows() && options.interactive if (is.windows() && config.interactive) interactive(win, () => config, context);
? interactive(win) else setup();
: setup(); },
onConfigChange(newConfig) {
config = newConfig;
}
}; };
});

View File

@ -1,82 +1,17 @@
import is from 'electron-is'; import is from 'electron-is';
import { BrowserWindow, MenuItem } from 'electron'; import { MenuItem } from 'electron';
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils'; import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
import config from './config'; import builder, { NotificationsPluginConfig } from './index';
import { MenuTemplate } from '../../menu'; import type { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic'; export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
const getMenu = (options: ConfigType<'notifications'>): MenuTemplate => { const getToastStyleMenuItems = (options: NotificationsPluginConfig) => {
if (is.linux()) {
return [
{
label: 'Notification Priority',
submenu: urgencyLevels.map((level) => ({
label: level.name,
type: 'radio',
checked: options.urgency === level.value,
click: () => config.set('urgency', level.value),
})),
}
];
} else if (is.windows()) {
return [
{
label: 'Interactive Notifications',
type: 'checkbox',
checked: options.interactive,
// Doesn't update until restart
click: (item: MenuItem) => config.setAndMaybeRestart('interactive', item.checked),
},
{
// Submenu with settings for interactive notifications (name shouldn't be too long)
label: 'Interactive Settings',
submenu: [
{
label: 'Open/Close on tray click',
type: 'checkbox',
checked: options.trayControls,
click: (item: MenuItem) => config.set('trayControls', item.checked),
},
{
label: 'Hide Button Text',
type: 'checkbox',
checked: options.hideButtonText,
click: (item: MenuItem) => config.set('hideButtonText', item.checked),
},
{
label: 'Refresh on Play/Pause',
type: 'checkbox',
checked: options.refreshOnPlayPause,
click: (item: MenuItem) => config.set('refreshOnPlayPause', item.checked),
},
],
},
{
label: 'Style',
submenu: getToastStyleMenuItems(options),
},
];
} else {
return [];
}
};
export default (_win: BrowserWindow, options: ConfigType<'notifications'>): MenuTemplate => [
...getMenu(options),
{
label: 'Show notification on unpause',
type: 'checkbox',
checked: options.unpauseNotification,
click: (item: MenuItem) => config.set('unpauseNotification', item.checked),
},
];
export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
const array = Array.from({ length: Object.keys(ToastStyles).length }); const array = Array.from({ length: Object.keys(ToastStyles).length });
// ToastStyles index starts from 1 // ToastStyles index starts from 1
@ -85,9 +20,76 @@ export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
label: snakeToCamel(name), label: snakeToCamel(name),
type: 'radio', type: 'radio',
checked: options.toastStyle === index, checked: options.toastStyle === index,
click: () => config.set('toastStyle', index), click: () => setConfig({ toastStyle: index }),
} satisfies Electron.MenuItemConstructorOptions; } satisfies Electron.MenuItemConstructorOptions;
} }
return array as Electron.MenuItemConstructorOptions[]; return array as Electron.MenuItemConstructorOptions[];
} }
const getMenu = (): MenuTemplate => {
if (is.linux()) {
return [
{
label: 'Notification Priority',
submenu: urgencyLevels.map((level) => ({
label: level.name,
type: 'radio',
checked: config.urgency === level.value,
click: () => setConfig({ urgency: level.value }),
})),
}
];
} else if (is.windows()) {
return [
{
label: 'Interactive Notifications',
type: 'checkbox',
checked: config.interactive,
// Doesn't update until restart
click: (item: MenuItem) => setConfig({ interactive: item.checked }),
},
{
// Submenu with settings for interactive notifications (name shouldn't be too long)
label: 'Interactive Settings',
submenu: [
{
label: 'Open/Close on tray click',
type: 'checkbox',
checked: config.trayControls,
click: (item: MenuItem) => setConfig({ trayControls: item.checked }),
},
{
label: 'Hide Button Text',
type: 'checkbox',
checked: config.hideButtonText,
click: (item: MenuItem) => setConfig({ hideButtonText: item.checked }),
},
{
label: 'Refresh on Play/Pause',
type: 'checkbox',
checked: config.refreshOnPlayPause,
click: (item: MenuItem) => setConfig({ refreshOnPlayPause: item.checked }),
},
],
},
{
label: 'Style',
submenu: getToastStyleMenuItems(config),
},
];
} else {
return [];
}
};
return [
...getMenu(),
{
label: 'Show notification on unpause',
type: 'checkbox',
checked: config.unpauseNotification,
click: (item) => setConfig({ unpauseNotification: item.checked }),
},
];
});

View File

@ -3,12 +3,11 @@ import fs from 'node:fs';
import { app, NativeImage } from 'electron'; import { app, NativeImage } from 'electron';
import config from './config';
import { cache } from '../../providers/decorators'; import { cache } from '../../providers/decorators';
import { SongInfo } from '../../providers/song-info'; import { SongInfo } from '../../providers/song-info';
import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack'; import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack';
import {NotificationsPluginConfig} from "./index";
const userData = app.getPath('userData'); const userData = app.getPath('userData');
@ -27,9 +26,9 @@ export const ToastStyles = {
}; };
export const urgencyLevels = [ export const urgencyLevels = [
{ name: 'Low', value: 'low' }, { name: 'Low', value: 'low' } as const,
{ name: 'Normal', value: 'normal' }, { name: 'Normal', value: 'normal' } as const,
{ name: 'High', value: 'critical' }, { name: 'High', value: 'critical' } as const,
]; ];
const nativeImageToLogo = cache((nativeImage: NativeImage) => { const nativeImageToLogo = cache((nativeImage: NativeImage) => {
@ -44,16 +43,16 @@ const nativeImageToLogo = cache((nativeImage: NativeImage) => {
}); });
}); });
export const notificationImage = (songInfo: SongInfo) => { export const notificationImage = (songInfo: SongInfo, config: NotificationsPluginConfig) => {
if (!songInfo.image) { if (!songInfo.image) {
return youtubeMusicIcon; return youtubeMusicIcon;
} }
if (!config.get('interactive')) { if (!config.interactive) {
return nativeImageToLogo(songInfo.image); return nativeImageToLogo(songInfo.image);
} }
switch (config.get('toastStyle')) { switch (config.toastStyle) {
case ToastStyles.logo: case ToastStyles.logo:
case ToastStyles.legacy: { case ToastStyles.legacy: {
return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon); return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon);

View File

@ -0,0 +1,40 @@
import style from './style.css?inline';
import { createPluginBuilder } from '../utils/builder';
export type PictureInPicturePluginConfig = {
'enabled': boolean;
'alwaysOnTop': boolean;
'savePosition': boolean;
'saveSize': boolean;
'hotkey': 'P',
'pip-position': [number, number];
'pip-size': [number, number];
'isInPiP': boolean;
'useNativePiP': boolean;
}
const builder = createPluginBuilder('picture-in-picture', {
name: 'Picture In Picture',
restartNeeded: true,
config: {
'enabled': false,
'alwaysOnTop': true,
'savePosition': true,
'saveSize': false,
'hotkey': 'P',
'pip-position': [10, 10],
'pip-size': [450, 275],
'isInPiP': false,
'useNativePiP': true,
} as PictureInPicturePluginConfig,
styles: [style],
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,12 +1,12 @@
import { app, BrowserWindow, ipcMain } from 'electron'; import { app, BrowserWindow, ipcMain } from 'electron';
import style from './style.css'; import style from './style.css?inline';
import builder, { PictureInPicturePluginConfig } from './index';
import { injectCSS } from '../utils/main'; import { injectCSS } from '../utils/main';
import { setOptions as setPluginOptions } from '../../config/plugins';
import type { ConfigType } from '../../config/dynamic';
export default builder.createMain(({ getConfig, setConfig, send, handle }) => {
let isInPiP = false; let isInPiP = false;
let originalPosition: number[]; let originalPosition: number[];
let originalSize: number[]; let originalSize: number[];
@ -15,21 +15,14 @@ let originalMaximized: boolean;
let win: BrowserWindow; let win: BrowserWindow;
type PiPOptions = ConfigType<'picture-in-picture'>; let config: PictureInPicturePluginConfig;
let options: Partial<PiPOptions>; const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10];
const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275];
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
const setLocalOptions = (_options: Partial<PiPOptions>) => {
options = { ...options, ..._options };
setPluginOptions('picture-in-picture', _options);
};
const togglePiP = () => { const togglePiP = () => {
isInPiP = !isInPiP; isInPiP = !isInPiP;
setLocalOptions({ isInPiP }); setConfig({ isInPiP });
if (isInPiP) { if (isInPiP) {
originalFullScreen = win.isFullScreen(); originalFullScreen = win.isFullScreen();
@ -45,19 +38,19 @@ const togglePiP = () => {
originalPosition = win.getPosition(); originalPosition = win.getPosition();
originalSize = win.getSize(); originalSize = win.getSize();
win.webContents.on('before-input-event', blockShortcutsInPiP); handle('before-input-event', blockShortcutsInPiP);
win.setMaximizable(false); win.setMaximizable(false);
win.setFullScreenable(false); win.setFullScreenable(false);
win.webContents.send('pip-toggle', true); send('pip-toggle', true);
app.dock?.hide(); app.dock?.hide();
win.setVisibleOnAllWorkspaces(true, { win.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true, visibleOnFullScreen: true,
}); });
app.dock?.show(); app.dock?.show();
if (options.alwaysOnTop) { if (config.alwaysOnTop) {
win.setAlwaysOnTop(true, 'screen-saver', 1); win.setAlwaysOnTop(true, 'screen-saver', 1);
} }
} else { } else {
@ -65,7 +58,7 @@ const togglePiP = () => {
win.setMaximizable(true); win.setMaximizable(true);
win.setFullScreenable(true); win.setFullScreenable(true);
win.webContents.send('pip-toggle', false); send('pip-toggle', false);
win.setVisibleOnAllWorkspaces(false); win.setVisibleOnAllWorkspaces(false);
win.setAlwaysOnTop(false); win.setAlwaysOnTop(false);
@ -98,14 +91,31 @@ const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
} }
}; };
export default (_win: BrowserWindow, _options: PiPOptions) => { return ({
options ??= _options; async onLoad(window) {
win ??= _win; config ??= await getConfig();
setLocalOptions({ isInPiP }); win ??= window;
setConfig({ isInPiP });
injectCSS(win.webContents, style); injectCSS(win.webContents, style);
ipcMain.on('picture-in-picture', () => { ipcMain.on('picture-in-picture', () => {
togglePiP(); togglePiP();
}); });
};
export const setOptions = setLocalOptions; window.on('move', () => {
if (config.isInPiP && !config.useNativePiP) {
setConfig({ 'pip-position': window.getPosition() as [number, number] });
}
});
window.on('resize', () => {
if (config.isInPiP && !config.useNativePiP) {
setConfig({ 'pip-size': window.getSize() as [number, number] });
}
});
},
onConfigChange(newConfig) {
config = newConfig;
}
});
});

View File

@ -1,45 +1,43 @@
import prompt from 'custom-electron-prompt'; import prompt from 'custom-electron-prompt';
import { BrowserWindow } from 'electron'; import builder from './index';
import { setOptions } from './main';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '../../providers/prompt-options';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic'; export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
const config = await getConfig();
export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [ return [
{ {
label: 'Always on top', label: 'Always on top',
type: 'checkbox', type: 'checkbox',
checked: options.alwaysOnTop, checked: config.alwaysOnTop,
click(item) { click(item) {
setOptions({ alwaysOnTop: item.checked }); setConfig({ alwaysOnTop: item.checked });
win.setAlwaysOnTop(item.checked); window.setAlwaysOnTop(item.checked);
}, },
}, },
{ {
label: 'Save window position', label: 'Save window position',
type: 'checkbox', type: 'checkbox',
checked: options.savePosition, checked: config.savePosition,
click(item) { click(item) {
setOptions({ savePosition: item.checked }); setConfig({ savePosition: item.checked });
}, },
}, },
{ {
label: 'Save window size', label: 'Save window size',
type: 'checkbox', type: 'checkbox',
checked: options.saveSize, checked: config.saveSize,
click(item) { click(item) {
setOptions({ saveSize: item.checked }); setConfig({ saveSize: item.checked });
}, },
}, },
{ {
label: 'Hotkey', label: 'Hotkey',
type: 'checkbox', type: 'checkbox',
checked: !!options.hotkey, checked: !!config.hotkey,
async click(item) { async click(item) {
const output = await prompt({ const output = await prompt({
title: 'Picture in Picture Hotkey', title: 'Picture in Picture Hotkey',
@ -48,14 +46,14 @@ export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>):
keybindOptions: [{ keybindOptions: [{
value: 'hotkey', value: 'hotkey',
label: 'Hotkey', label: 'Hotkey',
default: options.hotkey, default: config.hotkey,
}], }],
...promptOptions(), ...promptOptions(),
}, win); }, window);
if (output) { if (output) {
const { value, accelerator } = output[0]; const { value, accelerator } = output[0];
setOptions({ [value]: accelerator }); setConfig({ [value]: accelerator });
item.checked = !!accelerator; item.checked = !!accelerator;
} else { } else {
@ -67,9 +65,10 @@ export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>):
{ {
label: 'Use native PiP', label: 'Use native PiP',
type: 'checkbox', type: 'checkbox',
checked: options.useNativePiP, checked: config.useNativePiP,
click(item) { click(item) {
setOptions({ useNativePiP: item.checked }); setConfig({ useNativePiP: item.checked });
}, },
}, },
]; ];
});

View File

@ -3,14 +3,12 @@ import keyEventAreEqual from 'keyboardevents-areequal';
import pipHTML from './templates/picture-in-picture.html?raw'; import pipHTML from './templates/picture-in-picture.html?raw';
import builder, { PictureInPicturePluginConfig } from './index';
import { getSongMenu } from '../../providers/dom-elements'; import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils/renderer'; import { ElementFromHtml } from '../utils/renderer';
import type { ConfigType } from '../../config/dynamic';
type PiPOptions = ConfigType<'picture-in-picture'>;
function $<E extends Element = Element>(selector: string) { function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector); return document.querySelector<E>(selector);
} }
@ -135,7 +133,7 @@ const listenForToggle = () => {
}); });
}; };
function observeMenu(options: PiPOptions) { function observeMenu(options: PictureInPicturePluginConfig) {
useNativePiP = options.useNativePiP; useNativePiP = options.useNativePiP;
document.addEventListener( document.addEventListener(
'apiLoaded', 'apiLoaded',
@ -160,11 +158,15 @@ function observeMenu(options: PiPOptions) {
); );
} }
export default (options: PiPOptions) => { export default builder.createRenderer(({ getConfig }) => {
observeMenu(options); return {
async onLoad() {
const config = await getConfig();
if (options.hotkey) { observeMenu(config);
const hotkeyEvent = toKeyEvent(options.hotkey);
if (config.hotkey) {
const hotkeyEvent = toKeyEvent(config.hotkey);
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if ( if (
keyEventAreEqual(event, hotkeyEvent) keyEventAreEqual(event, hotkeyEvent)
@ -174,4 +176,6 @@ export default (options: PiPOptions) => {
} }
}); });
} }
}
}; };
});

View File

@ -0,0 +1,17 @@
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('playback-speed', {
name: 'Playback Speed',
restartNeeded: false,
config: {
enabled: false,
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,5 +1,7 @@
import sliderHTML from './templates/slider.html?raw'; import sliderHTML from './templates/slider.html?raw';
import builder from './index';
import { getSongMenu } from '../../providers/dom-elements'; import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils/renderer'; import { ElementFromHtml } from '../utils/renderer';
import { singleton } from '../../providers/decorators'; import { singleton } from '../../providers/decorators';
@ -32,15 +34,17 @@ const updatePlayBackSpeed = () => {
let menu: Element | null = null; let menu: Element | null = null;
const setupSliderListener = singleton(() => { const immediateValueChangedListener = (e: Event) => {
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', (e) => {
playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED; playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) { if (isNaN(playbackSpeed)) {
playbackSpeed = 1; playbackSpeed = 1;
} }
updatePlayBackSpeed(); updatePlayBackSpeed();
}); };
const setupSliderListener = singleton(() => {
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener);
}); });
const observePopupContainer = () => { const observePopupContainer = () => {
@ -77,8 +81,7 @@ const observeVideo = () => {
} }
}; };
const setupWheelListener = () => { const wheelEventListener = (e: WheelEvent) => {
slider.addEventListener('wheel', (e) => {
e.preventDefault(); e.preventDefault();
if (isNaN(playbackSpeed)) { if (isNaN(playbackSpeed)) {
playbackSpeed = 1; playbackSpeed = 1;
@ -96,7 +99,10 @@ const setupWheelListener = () => {
if (playbackSpeedSilder) { if (playbackSpeedSilder) {
playbackSpeedSilder.value = playbackSpeed; playbackSpeedSilder.value = playbackSpeed;
} }
}); };
const setupWheelListener = () => {
slider.addEventListener('wheel', wheelEventListener);
}; };
function forcePlaybackRate(e: Event) { function forcePlaybackRate(e: Event) {
@ -108,10 +114,24 @@ function forcePlaybackRate(e: Event) {
} }
} }
export default () => { export default builder.createRenderer(() => {
return {
onLoad() {
document.addEventListener('apiLoaded', () => { document.addEventListener('apiLoaded', () => {
observePopupContainer(); observePopupContainer();
observeVideo(); observeVideo();
setupWheelListener(); setupWheelListener();
}, { once: true, passive: true }); }, { once: true, passive: true });
},
onUnload() {
const video = $<HTMLVideoElement>('video');
if (video) {
video.removeEventListener('ratechange', forcePlaybackRate);
video.removeEventListener('srcChanged', forcePlaybackRate);
}
slider.removeEventListener('wheel', wheelEventListener);
getSongMenu()?.removeChild(slider);
$('#playback-speed-slider')?.removeEventListener('immediate-value-changed', immediateValueChangedListener);
}
}; };
});

View File

@ -15,6 +15,7 @@ export type PreciseVolumePluginConfig = {
const builder = createPluginBuilder('precise-volume', { const builder = createPluginBuilder('precise-volume', {
name: 'Precise Volume', name: 'Precise Volume',
restartNeeded: true,
config: { config: {
enabled: false, enabled: false,
steps: 1, // Percentage of volume to change steps: 1, // Percentage of volume to change

View File

@ -1,9 +1,8 @@
import { globalShortcut } from 'electron'; import { globalShortcut } from 'electron';
import builder from '.'; import builder from './index';
export default builder.createMain(({ getConfig, send }) => { export default builder.createMain(({ getConfig, send }) => ({
return {
async onLoad() { async onLoad() {
const config = await getConfig(); const config = await getConfig();
@ -15,5 +14,4 @@ export default builder.createMain(({ getConfig, send }) => {
globalShortcut.register(config.globalShortcuts.volumeDown, () => send('changeVolume', false)); globalShortcut.register(config.globalShortcuts.volumeDown, () => send('changeVolume', false));
} }
}, },
}; }));
});

View File

@ -2,7 +2,7 @@ import prompt, { KeybindOptions } from 'custom-electron-prompt';
import { BrowserWindow, MenuItem } from 'electron'; import { BrowserWindow, MenuItem } from 'electron';
import builder, { PreciseVolumePluginConfig } from '.'; import builder, { PreciseVolumePluginConfig } from './index';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '../../providers/prompt-options';

View File

@ -1,6 +1,6 @@
import { overrideListener } from './override'; import { overrideListener } from './override';
import builder, { type PreciseVolumePluginConfig } from './'; import builder, { type PreciseVolumePluginConfig } from './index';
import { debounce } from '../../providers/decorators'; import { debounce } from '../../providers/decorators';
@ -12,15 +12,7 @@ function $<E extends Element = Element>(selector: string) {
let api: YoutubePlayer; let api: YoutubePlayer;
export default builder.createRenderer(async ({ on, getConfig, setConfig }) => { export const moveVolumeHud = debounce((showVideo: boolean) => {
let options: PreciseVolumePluginConfig = await getConfig();
// 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 = $<HTMLElement>('#volumeHud'); const volumeHud = $<HTMLElement>('#volumeHud');
if (!volumeHud) { if (!volumeHud) {
return; return;
@ -31,6 +23,14 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
: '0'; : '0';
}, 250); }, 250);
export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
let options: PreciseVolumePluginConfig = await getConfig();
// Without this function it would rewrite config 20 time when volume change by 20
const writeOptions = debounce(() => {
setConfig(options);
}, 1000);
const hideVolumeHud = debounce((volumeHud: HTMLElement) => { const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
volumeHud.style.opacity = '0'; volumeHud.style.opacity = '0';
}, 2000); }, 2000);

View File

@ -1,7 +1,8 @@
import { createPluginBuilder } from '../utils/builder'; import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('quality-changer', { const builder = createPluginBuilder('quality-changer', {
name: 'Quality Changer', name: 'Video Quality Changer',
restartNeeded: false,
config: { config: {
enabled: false, enabled: false,
}, },

View File

@ -1,13 +1,10 @@
import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw'; import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
import builder from './'; import builder from './index';
import { ElementFromHtml } from '../utils/renderer'; import { ElementFromHtml } from '../utils/renderer';
import { YoutubePlayer } from '../../types/youtube-player';
// export default () => { import type { YoutubePlayer } from '../../types/youtube-player';
// document.addEventListener('apiLoaded', setup, { once: true, passive: true });
// };
export default builder.createRenderer(({ invoke }) => { export default builder.createRenderer(({ invoke }) => {
function $(selector: string): HTMLElement | null { function $(selector: string): HTMLElement | null {
@ -16,12 +13,9 @@ export default builder.createRenderer(({ invoke }) => {
const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate); const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate);
function setup(event: CustomEvent<YoutubePlayer>) { let api: YoutubePlayer;
const api = event.detail;
$('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton); const chooseQuality = () => {
qualitySettingsButton.addEventListener('click', function chooseQuality() {
setTimeout(() => $('#player')?.click()); setTimeout(() => $('#player')?.click());
const qualityLevels = api.getAvailableQualityLevels(); const qualityLevels = api.getAvailableQualityLevels();
@ -38,12 +32,23 @@ export default builder.createRenderer(({ invoke }) => {
api.setPlaybackQualityRange(newQuality); api.setPlaybackQualityRange(newQuality);
api.setPlaybackQuality(newQuality); api.setPlaybackQuality(newQuality);
}); });
}); }
function setup(event: CustomEvent<YoutubePlayer>) {
api = event.detail;
$('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton);
qualitySettingsButton.addEventListener('click', chooseQuality);
} }
return { return {
onLoad() { onLoad() {
document.addEventListener('apiLoaded', setup, { once: true, passive: true }); document.addEventListener('apiLoaded', setup, { once: true, passive: true });
},
onUnload() {
$('.top-row-buttons.ytmusic-player')?.removeChild(qualitySettingsButton);
qualitySettingsButton.removeEventListener('click', chooseQuality);
} }
}; };
}); });

View File

@ -0,0 +1,40 @@
import { createPluginBuilder } from '../utils/builder';
export type ShortcutMappingType = {
previous: string;
playPause: string;
next: string;
};
export type ShortcutsPluginConfig = {
enabled: boolean;
overrideMediaKeys: boolean;
global: ShortcutMappingType;
local: ShortcutMappingType;
}
const builder = createPluginBuilder('shortcuts', {
name: 'Shortcuts (& MPRIS)',
restartNeeded: true,
config: {
enabled: false,
overrideMediaKeys: false,
global: {
previous: '',
playPause: '',
next: '',
},
local: {
previous: '',
playPause: '',
next: '',
},
} as ShortcutsPluginConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -4,9 +4,10 @@ import electronLocalshortcut from 'electron-localshortcut';
import registerMPRIS from './mpris'; import registerMPRIS from './mpris';
import builder, { ShortcutMappingType } from './index';
import getSongControls from '../../providers/song-controls'; import getSongControls from '../../providers/song-controls';
import type { ConfigType } from '../../config/dynamic';
function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) { function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) {
globalShortcut.register(shortcut, () => { globalShortcut.register(shortcut, () => {
@ -20,11 +21,15 @@ function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (w
}); });
} }
function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>) { export default builder.createMain(({ getConfig }) => {
return {
async onLoad(win) {
const config = await getConfig();
const songControls = getSongControls(win); const songControls = getSongControls(win);
const { playPause, next, previous, search } = songControls; const { playPause, next, previous, search } = songControls;
if (options.overrideMediaKeys) { if (config.overrideMediaKeys) {
_registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause); _registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause);
_registerGlobalShortcut(win.webContents, 'MediaNextTrack', next); _registerGlobalShortcut(win.webContents, 'MediaNextTrack', next);
_registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous); _registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous);
@ -37,21 +42,24 @@ function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>)
registerMPRIS(win); registerMPRIS(win);
} }
const { global, local } = options; const { global, local } = config;
const shortcutOptions = { global, local }; const shortcutOptions = { global, local };
for (const optionType in shortcutOptions) { for (const optionType in shortcutOptions) {
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType); registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
} }
function registerAllShortcuts(container: Record<string, string>, type: string) { function registerAllShortcuts(container: ShortcutMappingType, type: string) {
for (const action in container) { for (const _action in container) {
// HACK: _action is detected as string, but it's actually a key of ShortcutMappingType
const action = _action as keyof ShortcutMappingType;
if (!container[action]) { if (!container[action]) {
continue; // Action accelerator is empty continue; // Action accelerator is empty
} }
console.debug(`Registering ${type} shortcut`, container[action], ':', action); console.debug(`Registering ${type} shortcut`, container[action], ':', action);
const actionCallback: () => void = songControls[action as keyof typeof songControls]; const actionCallback: () => void = songControls[action];
if (typeof actionCallback !== 'function') { if (typeof actionCallback !== 'function') {
console.warn('Invalid action', action); console.warn('Invalid action', action);
continue; continue;
@ -65,5 +73,5 @@ function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>)
} }
} }
} }
};
export default registerShortcuts; });

View File

@ -1,67 +1,55 @@
import prompt, { KeybindOptions } from 'custom-electron-prompt'; import prompt, { KeybindOptions } from 'custom-electron-prompt';
import { BrowserWindow } from 'electron'; import builder, { ShortcutsPluginConfig } from './index';
import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options'; import promptOptions from '../../providers/prompt-options';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic'; import type { BrowserWindow } from 'electron';
export default (win: BrowserWindow, options: ConfigType<'shortcuts'>): MenuTemplate => [ export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
{ const config = await getConfig();
label: 'Set Global Song Controls',
click: () => promptKeybind(options, win),
},
{
label: 'Override MediaKeys',
type: 'checkbox',
checked: options.overrideMediaKeys,
click: (item) => setOption(options, 'overrideMediaKeys', item.checked),
},
];
function setOption<Key extends keyof ConfigType<'shortcuts'> = keyof ConfigType<'shortcuts'>>( /**
options: ConfigType<'shortcuts'>, * Helper function for keybind prompt
key: Key | null = null, */
newValue: ConfigType<'shortcuts'>[Key] | null = null, const kb = (label_: string, value_: string, default_?: string): KeybindOptions => ({ value: value_, label: label_, default: default_ });
) {
if (key && newValue !== null) {
options[key] = newValue;
}
setMenuOptions('shortcuts', options); async function promptKeybind(config: ShortcutsPluginConfig, win: BrowserWindow) {
}
// Helper function for keybind prompt
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ value: value_, label: label_, default: default_ });
async function promptKeybind(options: ConfigType<'shortcuts'>, win: BrowserWindow) {
const output = await prompt({ const output = await prompt({
title: 'Global Keybinds', title: 'Global Keybinds',
label: 'Choose Global Keybinds for Songs Control:', label: 'Choose Global Keybinds for Songs Control:',
type: 'keybind', type: 'keybind',
keybindOptions: [ // If default=undefined then no default is used keybindOptions: [ // If default=undefined then no default is used
kb('Previous', 'previous', options.global?.previous), kb('Previous', 'previous', config.global?.previous),
kb('Play / Pause', 'playPause', options.global?.playPause), kb('Play / Pause', 'playPause', config.global?.playPause),
kb('Next', 'next', options.global?.next), kb('Next', 'next', config.global?.next),
], ],
height: 270, height: 270,
...promptOptions(), ...promptOptions(),
}, win); }, win);
if (output) { if (output) {
if (!options.global) { const newConfig = { ...config };
options.global = {};
}
for (const { value, accelerator } of output) { for (const { value, accelerator } of output) {
options.global[value] = accelerator; newConfig.global[value as keyof ShortcutsPluginConfig['global']] = accelerator;
} }
setOption(options); setConfig(config);
} }
// Else -> pressed cancel // Else -> pressed cancel
} }
return [
{
label: 'Set Global Song Controls',
click: () => promptKeybind(config, window),
},
{
label: 'Override MediaKeys',
type: 'checkbox',
checked: config.overrideMediaKeys,
click: (item) => setConfig({ overrideMediaKeys: item.checked }),
},
];
});

View File

@ -32,7 +32,7 @@ function registerMPRIS(win: BrowserWindow) {
const player = setupMPRIS(); const player = setupMPRIS();
ipcMain.on('apiLoaded', () => { ipcMain.handle('apiLoaded', () => {
win.webContents.send('setupSeekedListener', 'mpris'); win.webContents.send('setupSeekedListener', 'mpris');
win.webContents.send('setupTimeChangedListener', 'mpris'); win.webContents.send('setupTimeChangedListener', 'mpris');
win.webContents.send('setupRepeatChangedListener', 'mpris'); win.webContents.send('setupRepeatChangedListener', 'mpris');

View File

@ -0,0 +1,23 @@
import { createPluginBuilder } from '../utils/builder';
export type SkipSilencesPluginConfig = {
enabled: boolean;
onlySkipBeginning: boolean;
};
const builder = createPluginBuilder('skip-silences', {
name: 'Skip Silences',
restartNeeded: true,
config: {
enabled: false,
onlySkipBeginning: false,
} as SkipSilencesPluginConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,8 +1,8 @@
import type { ConfigType } from '../../config/dynamic'; import builder, { type SkipSilencesPluginConfig } from './index';
type SkipSilencesOptions = ConfigType<'skip-silences'>; export default builder.createRenderer(({ getConfig }) => {
let config: SkipSilencesPluginConfig;
export default (options: SkipSilencesOptions) => {
let isSilent = false; let isSilent = false;
let hasAudioStarted = false; let hasAudioStarted = false;
@ -12,9 +12,22 @@ export default (options: SkipSilencesOptions) => {
const history = 10; const history = 10;
const speakingHistory = Array.from({ length: history }).fill(0) as number[]; const speakingHistory = Array.from({ length: history }).fill(0) as number[];
document.addEventListener( let playOrSeekHandler: (() => void) | undefined;
'audioCanPlay',
(e) => { const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => {
let maxVolume = Number.NEGATIVE_INFINITY;
analyser.getFloatFrequencyData(fftBins);
for (let i = 4, ii = fftBins.length; i < ii; i++) {
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
maxVolume = fftBins[i];
}
}
return maxVolume;
};
const audioCanPlayListener = (e: CustomEvent<Compressor>) => {
const video = document.querySelector('video'); const video = document.querySelector('video');
const { audioContext } = e.detail; const { audioContext } = e.detail;
const sourceNode = e.detail.audioSource; const sourceNode = e.detail.audioSource;
@ -81,7 +94,7 @@ export default (options: SkipSilencesOptions) => {
looper(); looper();
const skipSilence = () => { const skipSilence = () => {
if (options.onlySkipBeginning && hasAudioStarted) { if (config.onlySkipBeginning && hasAudioStarted) {
return; return;
} }
@ -90,31 +103,38 @@ export default (options: SkipSilencesOptions) => {
} }
}; };
video?.addEventListener('play', () => { playOrSeekHandler = () => {
hasAudioStarted = false; hasAudioStarted = false;
skipSilence(); skipSilence();
}); };
video?.addEventListener('seeked', () => { video?.addEventListener('play', playOrSeekHandler);
hasAudioStarted = false; video?.addEventListener('seeked', playOrSeekHandler);
skipSilence(); };
});
}, return {
async onLoad() {
config = await getConfig();
document.addEventListener(
'audioCanPlay',
audioCanPlayListener,
{ {
passive: true, passive: true,
}, },
); );
},
onUnload() {
document.removeEventListener(
'audioCanPlay',
audioCanPlayListener,
);
if (playOrSeekHandler) {
const video = document.querySelector('video');
video?.removeEventListener('play', playOrSeekHandler);
video?.removeEventListener('seeked', playOrSeekHandler);
}
}
}; };
});
function getMaxVolume(analyser: AnalyserNode, fftBins: Float32Array) {
let maxVolume = Number.NEGATIVE_INFINITY;
analyser.getFloatFrequencyData(fftBins);
for (let i = 4, ii = fftBins.length; i < ii; i++) {
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
maxVolume = fftBins[i];
}
}
return maxVolume;
}

View File

@ -0,0 +1,32 @@
import { createPluginBuilder } from '../utils/builder';
export type SponsorBlockPluginConfig = {
enabled: boolean;
apiURL: string;
categories: ('sponsor' | 'intro' | 'outro' | 'interaction' | 'selfpromo' | 'music_offtopic')[];
};
const builder = createPluginBuilder('sponsorblock', {
name: 'SponsorBlock',
restartNeeded: true,
config: {
enabled: false,
apiURL: 'https://sponsor.ajay.app',
categories: [
'sponsor',
'intro',
'outro',
'interaction',
'selfpromo',
'music_offtopic',
],
} as SponsorBlockPluginConfig,
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

View File

@ -1,26 +1,12 @@
import { BrowserWindow, ipcMain } from 'electron';
import is from 'electron-is'; import is from 'electron-is';
import { sortSegments } from './segments'; import { sortSegments } from './segments';
import { SkipSegment } from './types'; import { SkipSegment } from './types';
import defaultConfig from '../../config/defaults'; import builder from './index';
import type { GetPlayerResponse } from '../../types/get-player-response'; import type { GetPlayerResponse } from '../../types/get-player-response';
import type { ConfigType } from '../../config/dynamic';
export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => {
const { apiURL, categories } = {
...defaultConfig.plugins.sponsorblock,
...options,
};
ipcMain.on('video-src-changed', async (_, data: GetPlayerResponse) => {
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
win.webContents.send('sponsorblock-skip', segments);
});
};
const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => { const fetchSegments = async (apiURL: string, categories: string[], videoId: string) => {
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify( const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
@ -50,3 +36,16 @@ const fetchSegments = async (apiURL: string, categories: string[], videoId: stri
return []; return [];
} }
}; };
export default builder.createMain(({ getConfig, on, send }) => ({
async onLoad() {
const config = await getConfig();
const { apiURL, categories } = config;
on('video-src-changed', async (_, data: GetPlayerResponse) => {
const segments = await fetchSegments(apiURL, categories, data?.videoDetails?.videoId);
send('sponsorblock-skip', segments);
});
}
}));

View File

@ -1,17 +1,10 @@
import { Segment } from './types'; import { Segment } from './types';
import builder from './index';
export default builder.createRenderer(({ on }) => {
let currentSegments: Segment[] = []; let currentSegments: Segment[] = [];
export default () => { const timeUpdateListener = (e: Event) => {
window.ipcRenderer.on('sponsorblock-skip', (_, segments: Segment[]) => {
currentSegments = segments;
});
document.addEventListener('apiLoaded', () => {
const video = document.querySelector<HTMLVideoElement>('video');
if (!video) return;
video.addEventListener('timeupdate', (e) => {
if (e.target instanceof HTMLVideoElement) { if (e.target instanceof HTMLVideoElement) {
const target = e.target; const target = e.target;
@ -27,8 +20,31 @@ export default () => {
} }
} }
} }
});
// Reset segments on song end
video.addEventListener('emptied', () => currentSegments = []);
}, { once: true, passive: true });
}; };
const resetSegments = () => currentSegments = [];
return ({
onLoad() {
on('sponsorblock-skip', (_, segments: Segment[]) => {
currentSegments = segments;
});
document.addEventListener('apiLoaded', () => {
const video = document.querySelector<HTMLVideoElement>('video');
if (!video) return;
video.addEventListener('timeupdate', timeUpdateListener);
// Reset segments on song end
video.addEventListener('emptied', resetSegments);
}, { once: true, passive: true });
},
onUnload() {
const video = document.querySelector<HTMLVideoElement>('video');
if (!video) return;
video.removeEventListener('timeupdate', timeUpdateListener);
video.removeEventListener('emptied', resetSegments);
}
});
});

View File

@ -0,0 +1,17 @@
import { createPluginBuilder } from '../utils/builder';
const builder = createPluginBuilder('taskbar-mediacontrol', {
name: 'Taskbar Media Control',
restartNeeded: true,
config: {
enabled: false,
},
});
export default builder;
declare global {
interface PluginBuilderList {
[builder.id]: typeof builder;
}
}

Some files were not shown because too many files have changed in this diff Show More