mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
feat: migrate to new plugin api
Co-authored-by: Su-Yong <simssy2205@gmail.com>
This commit is contained in:
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
40
src/index.ts
40
src/index.ts
@ -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> = {};
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
|
||||||
});
|
|
||||||
52
src/plugins/adblocker/index.ts
Normal file
52
src/plugins/adblocker/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/plugins/adblocker/inject.d.ts
vendored
1
src/plugins/adblocker/inject.d.ts
vendored
@ -1 +0,0 @@
|
|||||||
export const inject: () => void;
|
|
||||||
3
src/plugins/adblocker/injectors/inject.d.ts
vendored
Normal file
3
src/plugins/adblocker/injectors/inject.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const inject: () => void;
|
||||||
|
|
||||||
|
export const isInjected: () => boolean;
|
||||||
@ -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;
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|||||||
@ -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 });
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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?.();
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import builder from '.';
|
import builder from './index';
|
||||||
|
|
||||||
export default builder.createRenderer(() => {
|
export default builder.createRenderer(() => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import builder from '.';
|
import builder from './index';
|
||||||
|
|
||||||
export default builder.createRenderer(() => ({
|
export default builder.createRenderer(() => ({
|
||||||
async onLoad() {
|
async onLoad() {
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic-renderer';
|
|
||||||
|
|
||||||
const configRenderer = new PluginConfig('captions-selector', { enableFront: true });
|
|
||||||
export default configRenderer;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('captions-selector', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
20
src/plugins/captions-selector/index.ts
Normal file
20
src/plugins/captions-selector/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
));
|
));
|
||||||
};
|
}
|
||||||
|
}));
|
||||||
|
|||||||
@ -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 });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
17
src/plugins/compact-sidebar/index.ts
Normal file
17
src/plugins/compact-sidebar/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic-renderer';
|
|
||||||
|
|
||||||
const config = new PluginConfig('crossfade', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('crossfade', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
@ -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?
|
||||||
|
|||||||
50
src/plugins/crossfade/index.ts
Normal file
50
src/plugins/crossfade/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
}));
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|||||||
23
src/plugins/disable-autoplay/index.ts
Normal file
23
src/plugins/disable-autoplay/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|||||||
55
src/plugins/discord/index.ts
Normal file
55
src/plugins/discord/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('downloader');
|
|
||||||
export default config;
|
|
||||||
36
src/plugins/downloader/index.ts
Normal file
36
src/plugins/downloader/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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 });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|||||||
17
src/plugins/exponential-volume/index.ts
Normal file
17
src/plugins/exponential-volume/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
50
src/plugins/last-fm/index.ts
Normal file
50
src/plugins/last-fm/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
|
|||||||
17
src/plugins/lumiastream/index.ts
Normal file
17
src/plugins/lumiastream/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
src/plugins/lyrics-genius/index.ts
Normal file
26
src/plugins/lyrics-genius/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)}`;
|
||||||
|
|||||||
@ -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();
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
}));
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
20
src/plugins/no-google-login/index.ts
Normal file
20
src/plugins/no-google-login/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
39
src/plugins/no-google-login/renderer.ts
Normal file
39
src/plugins/no-google-login/renderer.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('notifications');
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
36
src/plugins/notifications/index.ts
Normal file
36
src/plugins/notifications/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|||||||
@ -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 }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
40
src/plugins/picture-in-picture/index.ts
Normal file
40
src/plugins/picture-in-picture/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
});
|
||||||
|
|||||||
@ -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) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|||||||
17
src/plugins/playback-speed/index.ts
Normal file
17
src/plugins/playback-speed/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
40
src/plugins/shortcuts/index.ts
Normal file
40
src/plugins/shortcuts/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
});
|
||||||
|
|||||||
@ -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 }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
23
src/plugins/skip-silences/index.ts
Normal file
23
src/plugins/skip-silences/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
32
src/plugins/sponsorblock/index.ts
Normal file
32
src/plugins/sponsorblock/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
17
src/plugins/taskbar-mediacontrol/index.ts
Normal file
17
src/plugins/taskbar-mediacontrol/index.ts
Normal 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
Reference in New Issue
Block a user