mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-09 17:51:46 +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';
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
44
src/index.ts
44
src/index.ts
@ -25,9 +25,7 @@ import { mainPlugins } from 'virtual:MainPlugins';
|
||||
import { pluginBuilders } from 'virtual:PluginBuilders';
|
||||
/* eslint-enable import/order */
|
||||
|
||||
import { setOptions as pipSetOptions } from './plugins/picture-in-picture/main';
|
||||
|
||||
import youtubeMusicCSS from './youtube-music.css';
|
||||
import youtubeMusicCSS from './youtube-music.css?inline';
|
||||
import { MainPlugin, PluginBaseConfig, MainPluginContext, MainPluginFactory } from './plugins/utils/builder';
|
||||
|
||||
// Catch errors and log them
|
||||
@ -156,6 +154,9 @@ async function loadPlugins(win: BrowserWindow) {
|
||||
handle: (event: string, listener) => {
|
||||
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;
|
||||
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', () => {
|
||||
if (win.isMaximized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = win.getPosition();
|
||||
const isPiPEnabled: boolean
|
||||
= 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);
|
||||
}
|
||||
const [x, y] = win.getPosition();
|
||||
lateSave('window-position', { x, y });
|
||||
});
|
||||
|
||||
let winWasMaximized: boolean;
|
||||
|
||||
win.on('resize', () => {
|
||||
const windowSize = win.getSize();
|
||||
const [width, height] = win.getSize();
|
||||
const isMaximized = win.isMaximized();
|
||||
|
||||
const isPiPEnabled
|
||||
= config.plugins.isEnabled('picture-in-picture')
|
||||
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
|
||||
|
||||
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
|
||||
if (winWasMaximized !== isMaximized) {
|
||||
winWasMaximized = isMaximized;
|
||||
config.set('window-maximized', isMaximized);
|
||||
}
|
||||
@ -314,14 +298,10 @@ async function createMainWindow() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPiPEnabled) {
|
||||
lateSave('window-size', {
|
||||
width: windowSize[0],
|
||||
height: windowSize[1],
|
||||
});
|
||||
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').saveSize) {
|
||||
lateSave('pip-size', windowSize, setPiPOptions);
|
||||
}
|
||||
lateSave('window-size', {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
});
|
||||
|
||||
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
|
||||
|
||||
@ -57,6 +57,7 @@ export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate
|
||||
config.setPartial(`plugins.${name}`, newConfig);
|
||||
},
|
||||
window: win,
|
||||
refresh: () => refreshMenu(win),
|
||||
});
|
||||
|
||||
const availablePlugins = getAvailablePluginNames();
|
||||
|
||||
@ -17,10 +17,12 @@ const SOURCES = [
|
||||
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
|
||||
];
|
||||
|
||||
let blocker: ElectronBlocker | undefined;
|
||||
|
||||
export const loadAdBlockerEngine = async (
|
||||
session: Electron.Session | undefined = undefined,
|
||||
cache = true,
|
||||
additionalBlockLists = [],
|
||||
cache: boolean = true,
|
||||
additionalBlockLists: string[] = [],
|
||||
disableDefaultLists: boolean | unknown[] = false,
|
||||
) => {
|
||||
// Only use cache if no additional blocklists are passed
|
||||
@ -45,7 +47,7 @@ export const loadAdBlockerEngine = async (
|
||||
];
|
||||
|
||||
try {
|
||||
const blocker = await ElectronBlocker.fromLists(
|
||||
blocker = await ElectronBlocker.fromLists(
|
||||
(url: string) => net.fetch(url),
|
||||
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:
|
||||
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
|
||||
*/
|
||||
|
||||
let injected = false;
|
||||
|
||||
export const isInjected = () => isInjected;
|
||||
|
||||
export const inject = () => {
|
||||
injected = true;
|
||||
{
|
||||
const pruner = function (o) {
|
||||
delete o.playerAds;
|
||||
@ -1,19 +1,43 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { loadAdBlockerEngine } from './blocker';
|
||||
import { shouldUseBlocklists } from './config';
|
||||
import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from './blocker';
|
||||
|
||||
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) => {
|
||||
if (shouldUseBlocklists()) {
|
||||
await loadAdBlockerEngine(
|
||||
win.webContents.session,
|
||||
options.cache,
|
||||
options.additionalBlockLists,
|
||||
options.disableDefaultLists,
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
async onLoad(window) {
|
||||
const config = await getConfig();
|
||||
mainWindow = window;
|
||||
|
||||
if (config.blocker === blockers.WithBlocklists) {
|
||||
await loadAdBlockerEngine(
|
||||
window.webContents.session,
|
||||
config.cache,
|
||||
config.additionalBlockLists,
|
||||
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 [
|
||||
{
|
||||
label: 'Blocker',
|
||||
submenu: Object.values(blockers).map((blocker: string) => ({
|
||||
submenu: Object.values(blockers).map((blocker) => ({
|
||||
label: blocker,
|
||||
type: 'radio',
|
||||
checked: (config.get('blocker') || blockers.WithBlocklists) === blocker,
|
||||
checked: (config.blocker || blockers.WithBlocklists) === blocker,
|
||||
click() {
|
||||
config.set('blocker', blocker);
|
||||
setConfig({ blocker });
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,15 +1,27 @@
|
||||
import config, { shouldUseBlocklists } from './config';
|
||||
import { inject } from './inject';
|
||||
import injectCliqzPreload from './inject-cliqz-preload';
|
||||
import { inject, isInjected } from './injectors/inject';
|
||||
import injectCliqzPreload from './injectors/inject-cliqz-preload';
|
||||
|
||||
import { blockers } from './blocker-types';
|
||||
import { blockers } from './types';
|
||||
import builder from './index';
|
||||
|
||||
export default async () => {
|
||||
if (shouldUseBlocklists()) {
|
||||
// Preload adblocker to inject scripts/styles
|
||||
await injectCliqzPreload();
|
||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||
} else if ((config.get('blocker')) === blockers.InPlayer) {
|
||||
inject();
|
||||
export default builder.createPreload(({ getConfig }) => ({
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
|
||||
if (config.blocker === blockers.WithBlocklists) {
|
||||
// Preload adblocker to inject scripts/styles
|
||||
await injectCliqzPreload();
|
||||
} else if (config.blocker === blockers.InPlayer) {
|
||||
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', {
|
||||
name: 'Album Color Theme',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
import builder from './';
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => {
|
||||
function hexToHSL(H: string) {
|
||||
@ -27,7 +27,7 @@ export default builder.createRenderer(() => {
|
||||
let h: number;
|
||||
let s: number;
|
||||
let l: number;
|
||||
|
||||
|
||||
if (delta == 0) {
|
||||
h = 0;
|
||||
} else if (cmax == r) {
|
||||
@ -37,32 +37,32 @@ export default builder.createRenderer(() => {
|
||||
} else {
|
||||
h = ((r - g) / delta) + 4;
|
||||
}
|
||||
|
||||
|
||||
h = Math.round(h * 60);
|
||||
|
||||
|
||||
if (h < 0) {
|
||||
h += 360;
|
||||
}
|
||||
|
||||
|
||||
l = (cmax + cmin) / 2;
|
||||
s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1));
|
||||
s = +(s * 100).toFixed(1);
|
||||
l = +(l * 100).toFixed(1);
|
||||
|
||||
|
||||
//return "hsl(" + h + "," + s + "%," + l + "%)";
|
||||
return [h,s,l];
|
||||
}
|
||||
|
||||
|
||||
let hue = 0;
|
||||
let saturation = 0;
|
||||
let lightness = 0;
|
||||
|
||||
|
||||
function changeElementColor(element: HTMLElement | null, hue: number, saturation: number, lightness: number){
|
||||
if (element) {
|
||||
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
const playerPage = document.querySelector<HTMLElement>('#player-page');
|
||||
|
||||
@ -14,6 +14,7 @@ export type AmbientModePluginConfig = {
|
||||
};
|
||||
const builder = createPluginBuilder('ambient-mode', {
|
||||
name: 'Ambient Mode',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
quality: 50,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import builder from './';
|
||||
import builder from './index';
|
||||
|
||||
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
|
||||
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
|
||||
|
||||
@ -10,44 +10,44 @@ export default builder.createRenderer(async ({ getConfig }) => {
|
||||
let blur = initConfigData.blur;
|
||||
let opacity = initConfigData.opacity;
|
||||
let isFullscreen = initConfigData.fullscreen;
|
||||
|
||||
|
||||
let update: (() => void) | null = null;
|
||||
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
let unregister: (() => void) | null = null;
|
||||
|
||||
|
||||
const injectBlurVideo = (): (() => void) | null => {
|
||||
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
|
||||
const video = document.querySelector<HTMLVideoElement>('#song-video .html5-video-container > video');
|
||||
const wrapper = document.querySelector('#song-video > .player-wrapper');
|
||||
|
||||
|
||||
if (!songVideo) return null;
|
||||
if (!video) return null;
|
||||
if (!wrapper) return null;
|
||||
|
||||
|
||||
console.log('injectBlurVideo', songVideo, video, wrapper);
|
||||
const blurCanvas = document.createElement('canvas');
|
||||
blurCanvas.classList.add('html5-blur-canvas');
|
||||
|
||||
|
||||
const context = blurCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
|
||||
/* effect */
|
||||
let lastEffectWorkId: number | null = null;
|
||||
let lastImageData: ImageData | null = null;
|
||||
|
||||
|
||||
const onSync = () => {
|
||||
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
|
||||
|
||||
|
||||
lastEffectWorkId = requestAnimationFrame(() => {
|
||||
// console.log('context', context);
|
||||
if (!context) return;
|
||||
|
||||
|
||||
const width = qualityRatio;
|
||||
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
|
||||
if (!Number.isFinite(height)) height = width;
|
||||
if (!height) return;
|
||||
|
||||
|
||||
context.globalAlpha = 1;
|
||||
if (lastImageData) {
|
||||
const frameOffset = (1 / buffer) * (1000 / interpolationTime);
|
||||
@ -56,29 +56,29 @@ export default builder.createRenderer(async ({ getConfig }) => {
|
||||
context.globalAlpha = frameOffset;
|
||||
}
|
||||
context.drawImage(video, 0, 0, width, height);
|
||||
|
||||
|
||||
lastImageData = context.getImageData(0, 0, width, height); // current image data
|
||||
|
||||
|
||||
lastEffectWorkId = null;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const applyVideoAttributes = () => {
|
||||
const rect = video.getBoundingClientRect();
|
||||
|
||||
|
||||
const newWidth = Math.floor(video.width || rect.width);
|
||||
const newHeight = Math.floor(video.height || rect.height);
|
||||
|
||||
|
||||
if (newWidth === 0 || newHeight === 0) return;
|
||||
|
||||
|
||||
blurCanvas.width = qualityRatio;
|
||||
blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio);
|
||||
blurCanvas.style.width = `${newWidth * sizeRatio}px`;
|
||||
blurCanvas.style.height = `${newHeight * sizeRatio}px`;
|
||||
|
||||
|
||||
if (isFullscreen) blurCanvas.classList.add('fullscreen');
|
||||
else blurCanvas.classList.remove('fullscreen');
|
||||
|
||||
|
||||
const leftOffset = newWidth * (sizeRatio - 1) / 2;
|
||||
const topOffset = newHeight * (sizeRatio - 1) / 2;
|
||||
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
|
||||
@ -87,7 +87,7 @@ export default builder.createRenderer(async ({ getConfig }) => {
|
||||
blurCanvas.style.setProperty('--opacity', `${opacity}`);
|
||||
};
|
||||
update = applyVideoAttributes;
|
||||
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes') {
|
||||
@ -98,7 +98,7 @@ export default builder.createRenderer(async ({ getConfig }) => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
applyVideoAttributes();
|
||||
});
|
||||
|
||||
|
||||
/* hooking */
|
||||
let canvasInterval: NodeJS.Timeout | null = null;
|
||||
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer)));
|
||||
@ -106,7 +106,7 @@ export default builder.createRenderer(async ({ getConfig }) => {
|
||||
observer.observe(songVideo, { attributes: true });
|
||||
resizeObserver.observe(songVideo);
|
||||
window.addEventListener('resize', applyVideoAttributes);
|
||||
|
||||
|
||||
const onPause = () => {
|
||||
if (canvasInterval) clearInterval(canvasInterval);
|
||||
canvasInterval = null;
|
||||
@ -117,29 +117,29 @@ export default builder.createRenderer(async ({ getConfig }) => {
|
||||
};
|
||||
songVideo.addEventListener('pause', onPause);
|
||||
songVideo.addEventListener('play', onPlay);
|
||||
|
||||
|
||||
/* injecting */
|
||||
wrapper.prepend(blurCanvas);
|
||||
|
||||
|
||||
/* cleanup */
|
||||
return () => {
|
||||
if (canvasInterval) clearInterval(canvasInterval);
|
||||
|
||||
|
||||
songVideo.removeEventListener('pause', onPause);
|
||||
songVideo.removeEventListener('play', onPlay);
|
||||
|
||||
|
||||
observer.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', applyVideoAttributes);
|
||||
|
||||
|
||||
wrapper.removeChild(blurCanvas);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
const playerPage = document.querySelector<HTMLElement>('#player-page');
|
||||
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
||||
|
||||
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'attributes') {
|
||||
@ -154,19 +154,19 @@ export default builder.createRenderer(async ({ getConfig }) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (playerPage) {
|
||||
observer.observe(playerPage, { attributes: true });
|
||||
}
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
if (typeof newConfig.interpolationTime === 'number') interpolationTime = newConfig.interpolationTime;
|
||||
if (typeof newConfig.buffer === 'number') buffer = newConfig.buffer;
|
||||
if (typeof newConfig.quality === 'number') qualityRatio = newConfig.quality;
|
||||
if (typeof newConfig.size === 'number') sizeRatio = newConfig.size / 100;
|
||||
if (typeof newConfig.blur === 'number') blur = newConfig.blur;
|
||||
if (typeof newConfig.opacity === 'number') opacity = newConfig.opacity;
|
||||
if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen;
|
||||
interpolationTime = newConfig.interpolationTime;
|
||||
buffer = newConfig.buffer;
|
||||
qualityRatio = newConfig.quality;
|
||||
sizeRatio = newConfig.size / 100;
|
||||
blur = newConfig.blur;
|
||||
opacity = newConfig.opacity;
|
||||
isFullscreen = newConfig.fullscreen;
|
||||
|
||||
update?.();
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
const builder = createPluginBuilder('audio-compressor', {
|
||||
name: 'Audio Compressor',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import builder from '.';
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => {
|
||||
return {
|
||||
|
||||
@ -4,6 +4,7 @@ import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
const builder = createPluginBuilder('blur-nav-bar', {
|
||||
name: 'Blur Navigation Bar',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
const builder = createPluginBuilder('bypass-age-restrictions', {
|
||||
name: 'Bypass Age Restrictions',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import builder from '.';
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createRenderer(() => ({
|
||||
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,19 +1,22 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
|
||||
export default (win: BrowserWindow) => {
|
||||
ipcMain.handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
|
||||
{
|
||||
title: 'Choose Caption',
|
||||
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
|
||||
type: 'select',
|
||||
value: currentIndex,
|
||||
selectOptions: captionLabels,
|
||||
resizable: true,
|
||||
...promptOptions(),
|
||||
},
|
||||
win,
|
||||
));
|
||||
};
|
||||
export default builder.createMain(({ handle }) => ({
|
||||
onLoad(window) {
|
||||
handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
|
||||
{
|
||||
title: 'Choose Caption',
|
||||
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
|
||||
type: 'select',
|
||||
value: currentIndex,
|
||||
selectOptions: captionLabels,
|
||||
resizable: true,
|
||||
...promptOptions(),
|
||||
},
|
||||
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 => [
|
||||
{
|
||||
label: 'Automatically select last used caption',
|
||||
type: 'checkbox',
|
||||
checked: config.get('autoload'),
|
||||
click(item) {
|
||||
config.set('autoload', item.checked);
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Automatically select last used caption',
|
||||
type: 'checkbox',
|
||||
checked: config.autoload,
|
||||
click(item) {
|
||||
setConfig({ autoload: item.checked });
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'No captions by default',
|
||||
type: 'checkbox',
|
||||
checked: config.get('disableCaptions'),
|
||||
click(item) {
|
||||
config.set('disableCaptions', item.checked);
|
||||
{
|
||||
label: 'No captions by default',
|
||||
type: 'checkbox',
|
||||
checked: config.disableCaptions,
|
||||
click(item) {
|
||||
setConfig({ disableCaptions: item.checked });
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import configProvider from './config-renderer';
|
||||
|
||||
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
import { YoutubePlayer } from '../../types/youtube-player';
|
||||
|
||||
@ -20,28 +20,17 @@ interface LanguageOptions {
|
||||
vss_id: string;
|
||||
}
|
||||
|
||||
let captionsSelectorConfig: ConfigType<'captions-selector'>;
|
||||
|
||||
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
|
||||
|
||||
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
|
||||
|
||||
export default () => {
|
||||
captionsSelectorConfig = configProvider.getAll();
|
||||
export default builder.createRenderer(({ getConfig, setConfig }) => {
|
||||
let config: Awaited<ReturnType<typeof getConfig>>;
|
||||
let captionTrackList: LanguageOptions[] | null = null;
|
||||
let api: YoutubePlayer;
|
||||
|
||||
configProvider.subscribeAll((newConfig) => {
|
||||
captionsSelectorConfig = newConfig;
|
||||
});
|
||||
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) {
|
||||
const videoChangeListener = () => {
|
||||
if (config.disableCaptions) {
|
||||
setTimeout(() => api.unloadModule('captions'), 100);
|
||||
captionsSettingsButton.style.display = 'none';
|
||||
return;
|
||||
@ -52,9 +41,9 @@ function setup(api: YoutubePlayer) {
|
||||
setTimeout(() => {
|
||||
captionTrackList = api.getOption('captions', 'tracklist') ?? [];
|
||||
|
||||
if (captionsSelectorConfig.autoload && captionsSelectorConfig.lastCaptionsCode) {
|
||||
if (config.autoload && config.lastCaptionsCode) {
|
||||
api.setOption('captions', 'track', {
|
||||
languageCode: captionsSelectorConfig.lastCaptionsCode,
|
||||
languageCode: config.lastCaptionsCode,
|
||||
});
|
||||
}
|
||||
|
||||
@ -62,9 +51,9 @@ function setup(api: YoutubePlayer) {
|
||||
? 'inline-block'
|
||||
: 'none';
|
||||
}, 250);
|
||||
});
|
||||
};
|
||||
|
||||
captionsSettingsButton.addEventListener('click', async () => {
|
||||
const captionsButtonClickListener = async () => {
|
||||
if (captionTrackList?.length) {
|
||||
const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
|
||||
let currentIndex = currentCaptionTrack
|
||||
@ -82,7 +71,7 @@ function setup(api: YoutubePlayer) {
|
||||
}
|
||||
|
||||
const newCaptions = captionTrackList[currentIndex];
|
||||
configProvider.set('lastCaptionsCode', newCaptions?.languageCode);
|
||||
setConfig({ lastCaptionsCode: newCaptions?.languageCode });
|
||||
if (newCaptions) {
|
||||
api.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
|
||||
} else {
|
||||
@ -91,5 +80,38 @@ function setup(api: YoutubePlayer) {
|
||||
|
||||
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 () => {
|
||||
const compactSidebar = document.querySelector('#mini-guide');
|
||||
const isCompactSidebarDisabled
|
||||
= compactSidebar === null
|
||||
|| window.getComputedStyle(compactSidebar).display === 'none';
|
||||
import builder from './index';
|
||||
|
||||
if (isCompactSidebarDisabled) {
|
||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
},
|
||||
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
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Internal utility: check if value is a valid volume level and throw if not
|
||||
const validateVolumeLevel = (value: number) => {
|
||||
// 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';
|
||||
|
||||
export default async () => {
|
||||
const yt = await Innertube.create();
|
||||
import builder from './index';
|
||||
|
||||
ipcMain.handle('audio-url', async (_, videoID: string) => {
|
||||
const info = await yt.getBasicInfo(videoID);
|
||||
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||
});
|
||||
};
|
||||
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);
|
||||
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
@ -2,85 +2,90 @@ import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import config from './config';
|
||||
import builder, { CrossfadePluginConfig } from './index';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
import configOptions from '../../config/defaults';
|
||||
|
||||
import { MenuTemplate } from '../../menu';
|
||||
|
||||
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(
|
||||
{
|
||||
title: 'Crossfade Options',
|
||||
type: 'multiInput',
|
||||
multiInputOptions: [
|
||||
{
|
||||
label: 'Fade in duration (ms)',
|
||||
value: options.fadeInDuration || defaultOptions.fadeInDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
export default builder.createMenu(({ window, getConfig, setConfig }) => {
|
||||
const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
|
||||
const res = await prompt(
|
||||
{
|
||||
title: 'Crossfade Options',
|
||||
type: 'multiInput',
|
||||
multiInputOptions: [
|
||||
{
|
||||
label: 'Fade in duration (ms)',
|
||||
value: options.fadeInDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Fade out duration (ms)',
|
||||
value: options.fadeOutDuration || defaultOptions.fadeOutDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
{
|
||||
label: 'Fade out duration (ms)',
|
||||
value: options.fadeOutDuration,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
step: '100',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Crossfade x seconds before end',
|
||||
value:
|
||||
options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
{
|
||||
label: 'Crossfade x seconds before end',
|
||||
value:
|
||||
options.secondsBeforeEnd,
|
||||
inputAttrs: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
min: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Fade scaling',
|
||||
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
|
||||
value: options.fadeScaling || defaultOptions.fadeScaling,
|
||||
},
|
||||
],
|
||||
resizable: true,
|
||||
height: 360,
|
||||
...promptOptions(),
|
||||
},
|
||||
win,
|
||||
).catch(console.error);
|
||||
if (!res) {
|
||||
return undefined;
|
||||
}
|
||||
{
|
||||
label: 'Fade scaling',
|
||||
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
|
||||
value: options.fadeScaling,
|
||||
},
|
||||
],
|
||||
resizable: true,
|
||||
height: 360,
|
||||
...promptOptions(),
|
||||
},
|
||||
win,
|
||||
).catch(console.error);
|
||||
|
||||
return {
|
||||
fadeInDuration: Number(res[0]),
|
||||
fadeOutDuration: Number(res[1]),
|
||||
secondsBeforeEnd: Number(res[2]),
|
||||
fadeScaling: res[3],
|
||||
if (!res) {
|
||||
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 {
|
||||
fadeInDuration: Number(res[0]),
|
||||
fadeOutDuration: Number(res[1]),
|
||||
secondsBeforeEnd: Number(res[2]),
|
||||
fadeScaling,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Advanced',
|
||||
async click() {
|
||||
const newOptions = await promptCrossfadeValues(window, await getConfig());
|
||||
if (newOptions) {
|
||||
setConfig(newOptions);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@ -3,158 +3,154 @@ import { Howl } from 'howler';
|
||||
// Extracted from https://github.com/bitfasching/VolumeFader
|
||||
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 firstVideo = true;
|
||||
let waitForTransition: Promise<unknown>;
|
||||
|
||||
let transitionAudio: Howl; // Howler audio used to fade out the current music
|
||||
let firstVideo = true;
|
||||
let waitForTransition: Promise<unknown>;
|
||||
const getStreamURL = async (videoID: string): Promise<string> => invoke('audio-url', videoID);
|
||||
|
||||
const defaultConfig = defaultConfigs.plugins.crossfade;
|
||||
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
|
||||
|
||||
let crossfadeConfig: ConfigType<'crossfade'>;
|
||||
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
|
||||
|
||||
const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(crossfadeConfig[key]) || (defaultConfig[key] as number);
|
||||
const watchVideoIDChanges = (cb: (id: string) => void) => {
|
||||
window.navigation.addEventListener('navigate', (event) => {
|
||||
const currentVideoID = getVideoIDFromURL(
|
||||
(event.currentTarget as Navigation).currentEntry?.url ?? '',
|
||||
);
|
||||
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
|
||||
|
||||
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 isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
|
||||
|
||||
const watchVideoIDChanges = (cb: (id: string) => void) => {
|
||||
window.navigation.addEventListener('navigate', (event) => {
|
||||
const currentVideoID = getVideoIDFromURL(
|
||||
(event.currentTarget as Navigation).currentEntry?.url ?? '',
|
||||
);
|
||||
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
|
||||
|
||||
if (
|
||||
nextVideoID
|
||||
&& currentVideoID
|
||||
&& (firstVideo || nextVideoID !== currentVideoID)
|
||||
) {
|
||||
if (isReadyToCrossfade()) {
|
||||
crossfade(() => {
|
||||
if (
|
||||
nextVideoID
|
||||
&& currentVideoID
|
||||
&& (firstVideo || nextVideoID !== currentVideoID)
|
||||
) {
|
||||
if (isReadyToCrossfade()) {
|
||||
crossfade(() => {
|
||||
cb(nextVideoID);
|
||||
});
|
||||
} else {
|
||||
cb(nextVideoID);
|
||||
});
|
||||
} else {
|
||||
cb(nextVideoID);
|
||||
firstVideo = false;
|
||||
firstVideo = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createAudioForCrossfade = (url: string) => {
|
||||
if (transitionAudio) {
|
||||
transitionAudio.unload();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createAudioForCrossfade = (url: string) => {
|
||||
if (transitionAudio) {
|
||||
transitionAudio.unload();
|
||||
}
|
||||
transitionAudio = new Howl({
|
||||
src: url,
|
||||
html5: true,
|
||||
volume: 0,
|
||||
});
|
||||
syncVideoWithTransitionAudio();
|
||||
};
|
||||
|
||||
transitionAudio = new Howl({
|
||||
src: url,
|
||||
html5: true,
|
||||
volume: 0,
|
||||
});
|
||||
syncVideoWithTransitionAudio();
|
||||
};
|
||||
const syncVideoWithTransitionAudio = () => {
|
||||
const video = document.querySelector('video')!;
|
||||
|
||||
const syncVideoWithTransitionAudio = () => {
|
||||
const video = document.querySelector('video')!;
|
||||
const videoFader = new VolumeFader(video, {
|
||||
fadeScaling: config.fadeScaling,
|
||||
fadeDuration: config.fadeInDuration,
|
||||
});
|
||||
|
||||
const videoFader = new VolumeFader(video, {
|
||||
fadeScaling: configGetNumber('fadeScaling'),
|
||||
fadeDuration: configGetNumber('fadeInDuration'),
|
||||
});
|
||||
|
||||
transitionAudio.play();
|
||||
transitionAudio.seek(video.currentTime);
|
||||
|
||||
video.addEventListener('seeking', () => {
|
||||
transitionAudio.seek(video.currentTime);
|
||||
});
|
||||
|
||||
video.addEventListener('pause', () => {
|
||||
transitionAudio.pause();
|
||||
});
|
||||
|
||||
video.addEventListener('play', () => {
|
||||
transitionAudio.play();
|
||||
transitionAudio.seek(video.currentTime);
|
||||
|
||||
// Fade in
|
||||
const videoVolume = video.volume;
|
||||
video.volume = 0;
|
||||
videoFader.fadeTo(videoVolume);
|
||||
});
|
||||
video.addEventListener('seeking', () => {
|
||||
transitionAudio.seek(video.currentTime);
|
||||
});
|
||||
|
||||
// Exit just before the end for the transition
|
||||
const transitionBeforeEnd = () => {
|
||||
if (
|
||||
video.currentTime >= video.duration - configGetNumber('secondsBeforeEnd')
|
||||
&& isReadyToCrossfade()
|
||||
) {
|
||||
video.removeEventListener('timeupdate', transitionBeforeEnd);
|
||||
video.addEventListener('pause', () => {
|
||||
transitionAudio.pause();
|
||||
});
|
||||
|
||||
// Go to next video - XXX: does not support "repeat 1" mode
|
||||
document.querySelector<HTMLButtonElement>('.next-button')?.click();
|
||||
}
|
||||
video.addEventListener('play', () => {
|
||||
transitionAudio.play();
|
||||
transitionAudio.seek(video.currentTime);
|
||||
|
||||
// Fade in
|
||||
const videoVolume = video.volume;
|
||||
video.volume = 0;
|
||||
videoFader.fadeTo(videoVolume);
|
||||
});
|
||||
|
||||
// Exit just before the end for the transition
|
||||
const transitionBeforeEnd = async () => {
|
||||
if (
|
||||
video.currentTime >= video.duration - config.secondsBeforeEnd
|
||||
&& isReadyToCrossfade()
|
||||
) {
|
||||
video.removeEventListener('timeupdate', transitionBeforeEnd);
|
||||
|
||||
// Go to next video - XXX: does not support "repeat 1" mode
|
||||
document.querySelector<HTMLButtonElement>('.next-button')?.click();
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', transitionBeforeEnd);
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', transitionBeforeEnd);
|
||||
};
|
||||
const onApiLoaded = () => {
|
||||
watchVideoIDChanges(async (videoID) => {
|
||||
await waitForTransition;
|
||||
const url = await getStreamURL(videoID);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onApiLoaded = () => {
|
||||
watchVideoIDChanges(async (videoID) => {
|
||||
await waitForTransition;
|
||||
const url = await getStreamURL(videoID);
|
||||
if (!url) {
|
||||
createAudioForCrossfade(url);
|
||||
});
|
||||
};
|
||||
|
||||
const crossfade = (cb: () => void) => {
|
||||
if (!isReadyToCrossfade()) {
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
|
||||
createAudioForCrossfade(url);
|
||||
});
|
||||
};
|
||||
let resolveTransition: () => void;
|
||||
waitForTransition = new Promise<void>((resolve) => {
|
||||
resolveTransition = resolve;
|
||||
});
|
||||
|
||||
const crossfade = (cb: () => void) => {
|
||||
if (!isReadyToCrossfade()) {
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
const video = document.querySelector('video')!;
|
||||
|
||||
let resolveTransition: () => void;
|
||||
waitForTransition = new Promise<void>((resolve) => {
|
||||
resolveTransition = resolve;
|
||||
});
|
||||
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
|
||||
initialVolume: video.volume,
|
||||
fadeScaling: config.fadeScaling,
|
||||
fadeDuration: config.fadeOutDuration,
|
||||
});
|
||||
|
||||
const video = document.querySelector('video')!;
|
||||
// Fade out the music
|
||||
video.volume = 0;
|
||||
fader.fadeOut(() => {
|
||||
resolveTransition();
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
|
||||
initialVolume: video.volume,
|
||||
fadeScaling: configGetNumber('fadeScaling'),
|
||||
fadeDuration: configGetNumber('fadeOutDuration'),
|
||||
});
|
||||
|
||||
// Fade out the music
|
||||
video.volume = 0;
|
||||
fader.fadeOut(() => {
|
||||
resolveTransition();
|
||||
cb();
|
||||
});
|
||||
};
|
||||
|
||||
export default () => {
|
||||
crossfadeConfig = configProvider.getAll();
|
||||
|
||||
configProvider.subscribeAll((newConfig) => {
|
||||
crossfadeConfig = newConfig;
|
||||
});
|
||||
|
||||
document.addEventListener('apiLoaded', onApiLoaded, {
|
||||
once: true,
|
||||
passive: true,
|
||||
});
|
||||
};
|
||||
return {
|
||||
onLoad() {
|
||||
document.addEventListener('apiLoaded', async () => {
|
||||
config = await getConfig();
|
||||
onApiLoaded();
|
||||
}, {
|
||||
once: 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';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
export default (_: BrowserWindow, options: ConfigType<'disable-autoplay'>): MenuTemplate => [
|
||||
{
|
||||
label: 'Applies only on startup',
|
||||
type: 'checkbox',
|
||||
checked: options.applyOnce,
|
||||
click() {
|
||||
setMenuOptions('disable-autoplay', {
|
||||
applyOnce: !options.applyOnce,
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
return [
|
||||
{
|
||||
label: 'Applies only on startup',
|
||||
type: 'checkbox',
|
||||
checked: config.applyOnce,
|
||||
async click() {
|
||||
const nowConfig = await getConfig();
|
||||
setConfig({
|
||||
applyOnce: !nowConfig.applyOnce,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@ -1,23 +1,44 @@
|
||||
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) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
e.target.pause();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('apiLoaded', (apiEvent) => {
|
||||
const eventListener = (name: string) => {
|
||||
if (options.applyOnce) {
|
||||
apiEvent.detail.removeEventListener('videodatachange', eventListener);
|
||||
}
|
||||
const eventListener = async (name: string) => {
|
||||
if (config.applyOnce) {
|
||||
apiEvent.detail.removeEventListener('videodatachange', eventListener);
|
||||
}
|
||||
|
||||
if (name === 'dataloaded') {
|
||||
apiEvent.detail.pauseVideo();
|
||||
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
|
||||
}
|
||||
};
|
||||
apiEvent.detail.addEventListener('videodatachange', eventListener);
|
||||
}, { once: true, passive: true });
|
||||
};
|
||||
if (name === 'dataloaded') {
|
||||
apiEvent.detail.pauseVideo();
|
||||
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);
|
||||
}, { 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 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
|
||||
const clientId = '1043858434585526382';
|
||||
@ -51,7 +51,6 @@ const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => {
|
||||
|
||||
info.rpc.login().then(resolve).catch(reject);
|
||||
}, 5000));
|
||||
|
||||
const connectRecursive = () => {
|
||||
if (!info.autoReconnect || info.rpc.isConnected) {
|
||||
return;
|
||||
@ -94,129 +93,133 @@ export const connect = (showError = false) => {
|
||||
let clearActivity: NodeJS.Timeout | undefined;
|
||||
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', () => {
|
||||
if (dev()) {
|
||||
console.log('discord connected');
|
||||
}
|
||||
|
||||
for (const cb of refreshCallbacks) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
|
||||
info.rpc.on('ready', () => {
|
||||
info.ready = true;
|
||||
if (info.lastSongInfo) {
|
||||
updateActivity(info.lastSongInfo);
|
||||
}
|
||||
});
|
||||
|
||||
info.rpc.on('disconnected', () => {
|
||||
resetInfo();
|
||||
|
||||
if (info.autoReconnect) {
|
||||
connectTimeout();
|
||||
}
|
||||
});
|
||||
|
||||
info.autoReconnect = options.autoReconnect;
|
||||
|
||||
window = win;
|
||||
// We get multiple events
|
||||
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
|
||||
// Skip time: PAUSE(N), PLAY(N)
|
||||
updateActivity = (songInfo) => {
|
||||
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
info.lastSongInfo = songInfo;
|
||||
|
||||
// Stop the clear activity timout
|
||||
clearTimeout(clearActivity);
|
||||
|
||||
// Stop early if discord connection is not ready
|
||||
// do this after clearTimeout to avoid unexpected clears
|
||||
if (!info.rpc || !info.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear directly if timeout is 0
|
||||
if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) {
|
||||
info.rpc.user?.clearActivity().catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Song information changed, so lets update the rich presence
|
||||
// @see https://discord.com/developers/docs/topics/gateway#activity-object
|
||||
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
|
||||
const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character
|
||||
if (songInfo.title.length < 2) {
|
||||
songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
|
||||
}
|
||||
if (songInfo.artist.length < 2) {
|
||||
songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
|
||||
}
|
||||
|
||||
const activityInfo: SetActivity = {
|
||||
details: songInfo.title,
|
||||
state: songInfo.artist,
|
||||
largeImageKey: songInfo.imageSrc ?? '',
|
||||
largeImageText: songInfo.album ?? '',
|
||||
buttons: [
|
||||
...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []),
|
||||
...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]),
|
||||
],
|
||||
};
|
||||
|
||||
if (songInfo.isPaused) {
|
||||
// Add a paused icon to show that the song is paused
|
||||
activityInfo.smallImageKey = 'paused';
|
||||
activityInfo.smallImageText = 'Paused';
|
||||
// Set start the timer so the activity gets cleared after a while if enabled
|
||||
if (options.activityTimoutEnabled) {
|
||||
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000);
|
||||
}
|
||||
} else if (!options.hideDurationLeft) {
|
||||
// Add the start and end time of the song
|
||||
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
|
||||
activityInfo.startTimestamp = songStartTime;
|
||||
activityInfo.endTimestamp
|
||||
= songStartTime + (songInfo.songDuration * 1000);
|
||||
}
|
||||
|
||||
info.rpc.user?.setActivity(activityInfo).catch(console.error);
|
||||
};
|
||||
|
||||
// If the page is ready, register the callback
|
||||
win.once('ready-to-show', () => {
|
||||
let lastSongInfo: SongInfo;
|
||||
registerCallback((songInfo) => {
|
||||
lastSongInfo = songInfo;
|
||||
updateActivity(songInfo);
|
||||
});
|
||||
connect();
|
||||
let lastSent = Date.now();
|
||||
ipcMain.on('timeChanged', (_, t: number) => {
|
||||
const currentTime = Date.now();
|
||||
// if lastSent is more than 5 seconds ago, send the new time
|
||||
if (currentTime - lastSent > 5000) {
|
||||
lastSent = currentTime;
|
||||
if (lastSongInfo) {
|
||||
lastSongInfo.elapsedSeconds = t;
|
||||
updateActivity(lastSongInfo);
|
||||
info.rpc.on('connected', () => {
|
||||
if (dev()) {
|
||||
console.log('discord connected');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
app.on('window-all-closed', clear);
|
||||
};
|
||||
|
||||
for (const cb of refreshCallbacks) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
|
||||
info.rpc.on('ready', () => {
|
||||
info.ready = true;
|
||||
if (info.lastSongInfo) {
|
||||
updateActivity(info.lastSongInfo);
|
||||
}
|
||||
});
|
||||
|
||||
info.rpc.on('disconnected', () => {
|
||||
resetInfo();
|
||||
|
||||
if (info.autoReconnect) {
|
||||
connectTimeout();
|
||||
}
|
||||
});
|
||||
|
||||
info.autoReconnect = options.autoReconnect;
|
||||
|
||||
window = win;
|
||||
// We get multiple events
|
||||
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
|
||||
// Skip time: PAUSE(N), PLAY(N)
|
||||
updateActivity = (songInfo) => {
|
||||
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
info.lastSongInfo = songInfo;
|
||||
|
||||
// Stop the clear activity timout
|
||||
clearTimeout(clearActivity);
|
||||
|
||||
// Stop early if discord connection is not ready
|
||||
// do this after clearTimeout to avoid unexpected clears
|
||||
if (!info.rpc || !info.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear directly if timeout is 0
|
||||
if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) {
|
||||
info.rpc.user?.clearActivity().catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Song information changed, so lets update the rich presence
|
||||
// @see https://discord.com/developers/docs/topics/gateway#activity-object
|
||||
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
|
||||
const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character
|
||||
if (songInfo.title.length < 2) {
|
||||
songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
|
||||
}
|
||||
if (songInfo.artist.length < 2) {
|
||||
songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
|
||||
}
|
||||
|
||||
const activityInfo: SetActivity = {
|
||||
details: songInfo.title,
|
||||
state: songInfo.artist,
|
||||
largeImageKey: songInfo.imageSrc ?? '',
|
||||
largeImageText: songInfo.album ?? '',
|
||||
buttons: [
|
||||
...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []),
|
||||
...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]),
|
||||
],
|
||||
};
|
||||
|
||||
if (songInfo.isPaused) {
|
||||
// Add a paused icon to show that the song is paused
|
||||
activityInfo.smallImageKey = 'paused';
|
||||
activityInfo.smallImageText = 'Paused';
|
||||
// Set start the timer so the activity gets cleared after a while if enabled
|
||||
if (options.activityTimoutEnabled) {
|
||||
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000);
|
||||
}
|
||||
} else if (!options.hideDurationLeft) {
|
||||
// Add the start and end time of the song
|
||||
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
|
||||
activityInfo.startTimestamp = songStartTime;
|
||||
activityInfo.endTimestamp
|
||||
= songStartTime + (songInfo.songDuration * 1000);
|
||||
}
|
||||
|
||||
info.rpc.user?.setActivity(activityInfo).catch(console.error);
|
||||
};
|
||||
|
||||
// If the page is ready, register the callback
|
||||
win.once('ready-to-show', () => {
|
||||
let lastSongInfo: SongInfo;
|
||||
registerCallback((songInfo) => {
|
||||
lastSongInfo = songInfo;
|
||||
updateActivity(songInfo);
|
||||
});
|
||||
connect();
|
||||
let lastSent = Date.now();
|
||||
ipcMain.on('timeChanged', (_, t: number) => {
|
||||
const currentTime = Date.now();
|
||||
// if lastSent is more than 5 seconds ago, send the new time
|
||||
if (currentTime - lastSent > 5000) {
|
||||
lastSent = currentTime;
|
||||
if (lastSongInfo) {
|
||||
lastSongInfo.elapsedSeconds = t;
|
||||
updateActivity(lastSongInfo);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
app.on('window-all-closed', clear);
|
||||
},
|
||||
onUnload() {
|
||||
resetInfo();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const clear = () => {
|
||||
if (info.rpc) {
|
||||
|
||||
@ -2,11 +2,13 @@ import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { clear, connect, isConnected, registerRefresh } from './main';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import { setMenuOptions } from '../../config/plugins';
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
import { singleton } from '../../providers/decorators';
|
||||
import { MenuTemplate } from '../../menu';
|
||||
|
||||
import type { MenuTemplate } from '../../menu';
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
||||
@ -15,8 +17,9 @@ const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
||||
|
||||
type DiscordOptions = ConfigType<'discord'>;
|
||||
|
||||
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => {
|
||||
registerRefreshOnce(refreshMenu);
|
||||
export default builder.createMenu(async ({ window, getConfig, setConfig, refresh }): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
registerRefreshOnce(refresh);
|
||||
|
||||
return [
|
||||
{
|
||||
@ -27,10 +30,11 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen
|
||||
{
|
||||
label: 'Auto reconnect',
|
||||
type: 'checkbox',
|
||||
checked: options.autoReconnect,
|
||||
checked: config.autoReconnect,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.autoReconnect = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
autoReconnect: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -40,45 +44,49 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen
|
||||
{
|
||||
label: 'Clear activity after timeout',
|
||||
type: 'checkbox',
|
||||
checked: options.activityTimoutEnabled,
|
||||
checked: config.activityTimoutEnabled,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.activityTimoutEnabled = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
activityTimoutEnabled: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Play on YouTube Music',
|
||||
type: 'checkbox',
|
||||
checked: options.playOnYouTubeMusic,
|
||||
checked: config.playOnYouTubeMusic,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.playOnYouTubeMusic = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
playOnYouTubeMusic: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hide GitHub link Button',
|
||||
type: 'checkbox',
|
||||
checked: options.hideGitHubButton,
|
||||
checked: config.hideGitHubButton,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.hideGitHubButton = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
hideGitHubButton: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hide duration left',
|
||||
type: 'checkbox',
|
||||
checked: options.hideDurationLeft,
|
||||
checked: config.hideDurationLeft,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.hideDurationLeft = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
hideGitHubButton: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Set inactivity timeout',
|
||||
click: () => setInactivityTimeout(win, options),
|
||||
click: () => setInactivityTimeout(window, config),
|
||||
},
|
||||
];
|
||||
};
|
||||
});
|
||||
|
||||
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
|
||||
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 { randomBytes } from 'node:crypto';
|
||||
|
||||
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
|
||||
import { app, BrowserWindow, dialog } from 'electron';
|
||||
import {
|
||||
ClientType,
|
||||
Innertube,
|
||||
@ -27,16 +27,16 @@ import {
|
||||
sendFeedback as sendFeedback_,
|
||||
setBadge,
|
||||
} 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 { isEnabled } from '../../config/plugins';
|
||||
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
|
||||
import { injectCSS } from '../utils/main';
|
||||
import { cache } from '../../providers/decorators';
|
||||
import builder, { DownloaderPluginConfig } from '../index';
|
||||
|
||||
import { fetchFromGenius } from '../../lyrics-genius/main';
|
||||
import { isEnabled } from '../../../config/plugins';
|
||||
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 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 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 };
|
||||
|
||||
@ -89,42 +89,30 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
|
||||
.join(';');
|
||||
};
|
||||
|
||||
export default async (win_: BrowserWindow) => {
|
||||
win = win_;
|
||||
injectCSS(win.webContents, style);
|
||||
let config: DownloaderPluginConfig = builder.config;
|
||||
|
||||
yt = await Innertube.create({
|
||||
cache: new UniversalCache(false),
|
||||
cookie: await getCookieFromWindow(win),
|
||||
generate_session_locally: true,
|
||||
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? new URL(input)
|
||||
: input instanceof URL
|
||||
? input
|
||||
: new URL(input.url);
|
||||
export default builder.createMain(({ handle, getConfig, on }) => {
|
||||
return {
|
||||
async onLoad(win) {
|
||||
config = await getConfig();
|
||||
|
||||
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));
|
||||
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
|
||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||
});
|
||||
ipcMain.on('download-playlist-request', async (_event, url: string) =>
|
||||
downloadPlaylist(url),
|
||||
);
|
||||
};
|
||||
yt = await Innertube.create({
|
||||
cache: new UniversalCache(false),
|
||||
cookie: await getCookieFromWindow(win),
|
||||
generate_session_locally: true,
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
handle('download-song', (_, url: string) => downloadSong(url));
|
||||
on('video-src-changed', (_, data: GetPlayerResponse) => {
|
||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||
});
|
||||
handle('download-playlist-request', async (_event, url: string) => downloadPlaylist(url));
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export async function downloadSong(
|
||||
url: string,
|
||||
@ -209,7 +197,7 @@ async function downloadSongUnsafe(
|
||||
metadata.trackId = trackId;
|
||||
|
||||
const dir =
|
||||
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
|
||||
playlistFolder || config.downloadFolder || app.getPath('downloads');
|
||||
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
||||
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;
|
||||
if (selectedPreset === 'Custom') {
|
||||
presetSetting =
|
||||
config.get('customPresetSetting') ?? DefaultPresetList['Custom'];
|
||||
config.customPresetSetting ?? DefaultPresetList['Custom'];
|
||||
} else if (selectedPreset === 'Source') {
|
||||
presetSetting = DefaultPresetList['Source'];
|
||||
} else {
|
||||
@ -276,7 +264,7 @@ async function downloadSongUnsafe(
|
||||
}
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
if (config.get('skipExisting') && existsSync(filePath)) {
|
||||
if (config.skipExisting && existsSync(filePath)) {
|
||||
sendFeedback(null, -1);
|
||||
return;
|
||||
}
|
||||
@ -517,10 +505,10 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
|
||||
}
|
||||
|
||||
const folder = getFolder(config.get('downloadFolder') ?? '');
|
||||
const folder = getFolder(config.downloadFolder ?? '');
|
||||
const playlistFolder = join(folder, safePlaylistTitle);
|
||||
if (existsSync(playlistFolder)) {
|
||||
if (!config.get('skipExisting')) {
|
||||
if (!config.skipExisting) {
|
||||
sendError(new Error(`The folder ${playlistFolder} already exists`));
|
||||
return;
|
||||
}
|
||||
@ -637,6 +625,7 @@ const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
|
||||
client_type: ClientType.TV_EMBEDDED,
|
||||
generate_session_locally: true,
|
||||
retrieve_player: true,
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
// GetInfo 404s with the bypass, so we use getBasicInfo instead
|
||||
// that's fine as we only need the streaming data
|
||||
@ -1,46 +1,49 @@
|
||||
import { dialog } from 'electron';
|
||||
|
||||
import { downloadPlaylist } from './main';
|
||||
import { defaultMenuDownloadLabel, getFolder } from './utils';
|
||||
import { defaultMenuDownloadLabel, getFolder } from './main/utils';
|
||||
import { DefaultPresetList } from './types';
|
||||
import config from './config';
|
||||
|
||||
import { MenuTemplate } from '../../menu';
|
||||
import builder from './index';
|
||||
|
||||
export default (): MenuTemplate => [
|
||||
{
|
||||
label: defaultMenuDownloadLabel,
|
||||
click: () => downloadPlaylist(),
|
||||
},
|
||||
{
|
||||
label: 'Choose download folder',
|
||||
click() {
|
||||
const result = dialog.showOpenDialogSync({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
|
||||
});
|
||||
if (result) {
|
||||
config.set('downloadFolder', result[0]);
|
||||
} // Else = user pressed cancel
|
||||
export default builder.createMenu(async ({ getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: defaultMenuDownloadLabel,
|
||||
click: () => downloadPlaylist(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Presets',
|
||||
submenu: Object.keys(DefaultPresetList).map((preset) => ({
|
||||
label: preset,
|
||||
type: 'radio',
|
||||
checked: config.get('selectedPreset') === preset,
|
||||
{
|
||||
label: 'Choose download folder',
|
||||
click() {
|
||||
config.set('selectedPreset', preset);
|
||||
const result = dialog.showOpenDialogSync({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: getFolder(config.downloadFolder ?? ''),
|
||||
});
|
||||
if (result) {
|
||||
setConfig({ downloadFolder: result[0] });
|
||||
} // Else = user pressed cancel
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Skip existing files',
|
||||
type: 'checkbox',
|
||||
checked: config.get('skipExisting'),
|
||||
click(item) {
|
||||
config.set('skipExisting', item.checked);
|
||||
},
|
||||
},
|
||||
];
|
||||
{
|
||||
label: 'Presets',
|
||||
submenu: Object.keys(DefaultPresetList).map((preset) => ({
|
||||
label: preset,
|
||||
type: 'radio',
|
||||
checked: config.selectedPreset === preset,
|
||||
click() {
|
||||
setConfig({ selectedPreset: preset });
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Skip existing files',
|
||||
type: 'checkbox',
|
||||
checked: config.skipExisting,
|
||||
click(item) {
|
||||
setConfig({ skipExisting: item.checked });
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import downloadHTML from './templates/download.html?raw';
|
||||
|
||||
import builder from './index';
|
||||
|
||||
import defaultConfig from '../../config/defaults';
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
@ -11,67 +13,71 @@ const downloadButton = ElementFromHtml(downloadHTML);
|
||||
|
||||
let doneFirstLoad = false;
|
||||
|
||||
export default () => {
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
export default builder.createRenderer(() => {
|
||||
return {
|
||||
onLoad() {
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.contains(downloadButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(downloadButton);
|
||||
progress = document.querySelector('#ytmcustom-download');
|
||||
|
||||
if (doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||
});
|
||||
|
||||
window.download = () => {
|
||||
let videoUrl = getSongMenu()
|
||||
// Selector of first button which is always "Start Radio"
|
||||
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
|
||||
?.getAttribute('href');
|
||||
if (videoUrl) {
|
||||
if (videoUrl.startsWith('watch?')) {
|
||||
videoUrl = defaultConfig.url + '/' + videoUrl;
|
||||
}
|
||||
|
||||
if (videoUrl.includes('?playlist=')) {
|
||||
window.ipcRenderer.send('download-playlist-request', videoUrl);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
videoUrl = getSongInfo().url || window.location.href;
|
||||
}
|
||||
|
||||
window.ipcRenderer.send('download-song', videoUrl);
|
||||
};
|
||||
|
||||
document.addEventListener('apiLoaded', () => {
|
||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}, { once: true, passive: true });
|
||||
|
||||
window.ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
|
||||
if (progress) {
|
||||
progress.innerHTML = feedback || 'Download';
|
||||
} else {
|
||||
console.warn('Cannot update progress');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (menu.contains(downloadButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(downloadButton);
|
||||
progress = document.querySelector('#ytmcustom-download');
|
||||
|
||||
if (doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||
});
|
||||
|
||||
window.download = () => {
|
||||
let videoUrl = getSongMenu()
|
||||
// Selector of first button which is always "Start Radio"
|
||||
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
|
||||
?.getAttribute('href');
|
||||
if (videoUrl) {
|
||||
if (videoUrl.startsWith('watch?')) {
|
||||
videoUrl = defaultConfig.url + '/' + videoUrl;
|
||||
}
|
||||
|
||||
if (videoUrl.includes('?playlist=')) {
|
||||
window.ipcRenderer.send('download-playlist-request', videoUrl);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
videoUrl = getSongInfo().url || window.location.href;
|
||||
}
|
||||
|
||||
window.ipcRenderer.send('download-song', videoUrl);
|
||||
};
|
||||
|
||||
document.addEventListener('apiLoaded', () => {
|
||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}, { once: true, passive: true });
|
||||
|
||||
window.ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
|
||||
if (progress) {
|
||||
progress.innerHTML = feedback || 'Download';
|
||||
} else {
|
||||
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
|
||||
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
|
||||
|
||||
import builder from './index';
|
||||
|
||||
const exponentialVolume = () => {
|
||||
// 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
|
||||
@ -38,8 +40,11 @@ const exponentialVolume = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export default () =>
|
||||
document.addEventListener('apiLoaded', exponentialVolume, {
|
||||
once: true,
|
||||
passive: true,
|
||||
});
|
||||
export default builder.createRenderer(() => ({
|
||||
onLoad() {
|
||||
return document.addEventListener('apiLoaded', exponentialVolume, {
|
||||
once: true,
|
||||
passive: true,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@ -2,8 +2,9 @@ import titlebarStyle from './titlebar.css?inline';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
export const builder = createPluginBuilder('in-app-menu', {
|
||||
const builder = createPluginBuilder('in-app-menu', {
|
||||
name: 'In-App Menu',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
hideDOMWindowControls: false,
|
||||
|
||||
@ -2,19 +2,19 @@ import { register } from 'electron-localshortcut';
|
||||
|
||||
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 {
|
||||
onLoad(win) {
|
||||
win.on('close', () => {
|
||||
win.webContents.send('close-all-in-app-menu-panel');
|
||||
send('close-all-in-app-menu-panel');
|
||||
});
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
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-minimize', () => win.minimize());
|
||||
handle('window-maximize', () => win.maximize());
|
||||
win.on('maximize', () => win.webContents.send('window-maximize'));
|
||||
win.on('maximize', () => send('window-maximize'));
|
||||
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) => {
|
||||
const nativeImageIcon = nativeImage.createFromPath(imagePath);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import is from 'electron-is';
|
||||
|
||||
import builder from './';
|
||||
import builder from './index';
|
||||
|
||||
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 { BrowserWindow, net, shell } from 'electron';
|
||||
import { net, shell } from 'electron';
|
||||
|
||||
import builder, { type LastFmPluginConfig } from './index';
|
||||
|
||||
import { setOptions } from '../../config/plugins';
|
||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
||||
import defaultConfig from '../../config/defaults';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
type LastFMOptions = ConfigType<'last-fm'>;
|
||||
import registerCallback, { type SongInfo } from '../../providers/song-info';
|
||||
|
||||
interface LastFmData {
|
||||
method: string,
|
||||
@ -68,7 +65,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
||||
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
|
||||
const data = {
|
||||
method: 'auth.gettoken',
|
||||
@ -81,12 +78,12 @@ const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastF
|
||||
return json?.token;
|
||||
};
|
||||
|
||||
const authenticate = async (config: LastFMOptions) => {
|
||||
const authenticate = async (config: LastFmPluginConfig) => {
|
||||
// Asks the user for authentication
|
||||
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
|
||||
const data = {
|
||||
api_key: config.api_key,
|
||||
@ -114,7 +111,7 @@ const getAndSetSessionKey = async (config: LastFMOptions) => {
|
||||
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
|
||||
if (!config.session_key) {
|
||||
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
|
||||
const data = {
|
||||
method: 'track.scrobble',
|
||||
@ -160,7 +157,7 @@ const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => {
|
||||
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
|
||||
const data = {
|
||||
method: 'track.updateNowPlaying',
|
||||
@ -171,33 +168,33 @@ const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => {
|
||||
// This will store the timeout that will trigger addScrobble
|
||||
let scrobbleTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => {
|
||||
if (!config.api_root) {
|
||||
// Settings are not present, creating them with the default values
|
||||
config = defaultConfig.plugins['last-fm'];
|
||||
config.enabled = true;
|
||||
setOptions('last-fm', config);
|
||||
}
|
||||
export default builder.createMain(({ getConfig, send }) => ({
|
||||
async onLoad(_win) {
|
||||
let config = await getConfig();
|
||||
|
||||
if (!config.session_key) {
|
||||
// Not authenticated
|
||||
config = await getAndSetSessionKey(config);
|
||||
}
|
||||
|
||||
registerCallback((songInfo) => {
|
||||
// Set remove the old scrobble timer
|
||||
clearTimeout(scrobbleTimer);
|
||||
if (!songInfo.isPaused) {
|
||||
setNowPlaying(songInfo, config);
|
||||
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
||||
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
||||
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
||||
// Scrobble still needs to happen
|
||||
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
||||
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
||||
}
|
||||
if (!config.api_root) {
|
||||
config.enabled = true;
|
||||
setOptions('last-fm', config);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default lastfm;
|
||||
if (!config.session_key) {
|
||||
// Not authenticated
|
||||
config = await getAndSetSessionKey(config);
|
||||
}
|
||||
|
||||
registerCallback((songInfo) => {
|
||||
// Set remove the old scrobble timer
|
||||
clearTimeout(scrobbleTimer);
|
||||
if (!songInfo.isPaused) {
|
||||
setNowPlaying(songInfo, config);
|
||||
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
||||
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
||||
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
||||
// Scrobble still needs to happen
|
||||
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
||||
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
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';
|
||||
|
||||
@ -36,8 +38,8 @@ const post = (data: LumiaData) => {
|
||||
'Accept': 'application/json',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
};
|
||||
const url = `http://localhost:${port}/api/media`;
|
||||
} as const;
|
||||
const url = `http://127.0.0.1:${port}/api/media`;
|
||||
|
||||
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
|
||||
.catch((error: { code: number, errno: number }) => {
|
||||
@ -49,34 +51,38 @@ const post = (data: LumiaData) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default (_: BrowserWindow) => {
|
||||
registerCallback((songInfo) => {
|
||||
if (!songInfo.title && !songInfo.artist) {
|
||||
return;
|
||||
export default builder.createMain(() => {
|
||||
return {
|
||||
onLoad() {
|
||||
registerCallback((songInfo) => {
|
||||
if (!songInfo.title && !songInfo.artist) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousStatePaused === null) {
|
||||
data.eventType = 'switchSong';
|
||||
} else if (previousStatePaused !== songInfo.isPaused) {
|
||||
data.eventType = 'playPause';
|
||||
}
|
||||
|
||||
data.duration = secToMilisec(songInfo.songDuration);
|
||||
data.progress = secToMilisec(songInfo.elapsedSeconds);
|
||||
data.url = songInfo.url;
|
||||
data.videoId = songInfo.videoId;
|
||||
data.playlistId = songInfo.playlistId;
|
||||
data.cover = songInfo.imageSrc;
|
||||
data.cover_url = songInfo.imageSrc;
|
||||
data.album_url = songInfo.imageSrc;
|
||||
data.title = songInfo.title;
|
||||
data.artists = [songInfo.artist];
|
||||
data.status = songInfo.isPaused ? 'stopped' : 'playing';
|
||||
data.isPaused = songInfo.isPaused;
|
||||
data.album = songInfo.album;
|
||||
data.views = songInfo.views;
|
||||
post(data);
|
||||
});
|
||||
}
|
||||
|
||||
if (previousStatePaused === null) {
|
||||
data.eventType = 'switchSong';
|
||||
} else if (previousStatePaused !== songInfo.isPaused) {
|
||||
data.eventType = 'playPause';
|
||||
}
|
||||
|
||||
data.duration = secToMilisec(songInfo.songDuration);
|
||||
data.progress = secToMilisec(songInfo.elapsedSeconds);
|
||||
data.url = songInfo.url;
|
||||
data.videoId = songInfo.videoId;
|
||||
data.playlistId = songInfo.playlistId;
|
||||
data.cover = songInfo.imageSrc;
|
||||
data.cover_url = songInfo.imageSrc;
|
||||
data.album_url = songInfo.imageSrc;
|
||||
data.title = songInfo.title;
|
||||
data.artists = [songInfo.artist];
|
||||
data.status = songInfo.isPaused ? 'stopped' : 'playing';
|
||||
data.isPaused = songInfo.isPaused;
|
||||
data.album = songInfo.album;
|
||||
data.views = songInfo.views;
|
||||
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 { convert } from 'html-to-text';
|
||||
|
||||
import style from './style.css';
|
||||
import { GetGeniusLyric } from './types';
|
||||
|
||||
import { cleanupName, SongInfo } from '../../providers/song-info';
|
||||
import builder from './index';
|
||||
|
||||
import { injectCSS } from '../utils/main';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
import { cleanupName, type SongInfo } from '../../providers/song-info';
|
||||
|
||||
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
|
||||
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 (options.romanizedLyrics) {
|
||||
revRomanized = true;
|
||||
}
|
||||
if (config.romanizedLyrics) {
|
||||
revRomanized = true;
|
||||
}
|
||||
|
||||
injectCSS(win.webContents, style);
|
||||
|
||||
ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => {
|
||||
const metadata = extractedSongInfo;
|
||||
return await fetchFromGenius(metadata);
|
||||
});
|
||||
};
|
||||
|
||||
export const toggleRomanized = () => {
|
||||
revRomanized = !revRomanized;
|
||||
};
|
||||
handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => {
|
||||
const metadata = extractedSongInfo;
|
||||
return await fetchFromGenius(metadata);
|
||||
});
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
revRomanized = newConfig.romanizedLyrics;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const fetchFromGenius = async (metadata: SongInfo) => {
|
||||
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';
|
||||
import { MenuTemplate } from '../../menu';
|
||||
|
||||
export default (_: BrowserWindow, options: LyricGeniusType): MenuTemplate => [
|
||||
{
|
||||
label: 'Romanized Lyrics',
|
||||
type: 'checkbox',
|
||||
checked: options.romanizedLyrics,
|
||||
click(item: MenuItem) {
|
||||
options.romanizedLyrics = item.checked;
|
||||
setOptions('lyrics-genius', options);
|
||||
toggleRomanized();
|
||||
return [
|
||||
{
|
||||
label: 'Romanized Lyrics',
|
||||
type: 'checkbox',
|
||||
checked: config.romanizedLyrics,
|
||||
click(item) {
|
||||
setConfig({
|
||||
romanizedLyrics: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
});
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import builder from './index';
|
||||
|
||||
import type { SongInfo } from '../../providers/song-info';
|
||||
|
||||
export default () => {
|
||||
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
|
||||
lyricsContainer.innerHTML = `
|
||||
export default builder.createRenderer(({ on, invoke }) => ({
|
||||
onLoad() {
|
||||
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
|
||||
lyricsContainer.innerHTML = `
|
||||
<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
|
||||
${lyrics?.replaceAll(/\r\n|\r|\n/g, '<br/>') ?? 'Could not retrieve lyrics from genius'}
|
||||
</div>
|
||||
@ -10,96 +13,97 @@ export default () => {
|
||||
</yt-formatted-string>
|
||||
`;
|
||||
|
||||
if (lyrics) {
|
||||
const footer = lyricsContainer.querySelector('.footer');
|
||||
if (lyrics) {
|
||||
const footer = lyricsContainer.querySelector('.footer');
|
||||
|
||||
if (footer) {
|
||||
footer.textContent = 'Source: Genius';
|
||||
if (footer) {
|
||||
footer.textContent = 'Source: Genius';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let unregister: (() => void) | null = null;
|
||||
let unregister: (() => void) | null = null;
|
||||
|
||||
window.ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => {
|
||||
unregister?.();
|
||||
on('update-song-info', (_, extractedSongInfo: SongInfo) => {
|
||||
unregister?.();
|
||||
|
||||
setTimeout(async () => {
|
||||
const tabList = document.querySelectorAll<HTMLElement>('tp-yt-paper-tab');
|
||||
const tabs = {
|
||||
upNext: tabList[0],
|
||||
lyrics: tabList[1],
|
||||
discover: tabList[2],
|
||||
};
|
||||
setTimeout(async () => {
|
||||
const tabList = document.querySelectorAll<HTMLElement>('tp-yt-paper-tab');
|
||||
const tabs = {
|
||||
upNext: tabList[0],
|
||||
lyrics: tabList[1],
|
||||
discover: tabList[2],
|
||||
};
|
||||
|
||||
// Check if disabled
|
||||
if (!tabs.lyrics?.hasAttribute('disabled')) return;
|
||||
// Check if disabled
|
||||
if (!tabs.lyrics?.hasAttribute('disabled')) return;
|
||||
|
||||
const lyrics = await window.ipcRenderer.invoke(
|
||||
'search-genius-lyrics',
|
||||
extractedSongInfo,
|
||||
) as string | null;
|
||||
|
||||
if (!lyrics) {
|
||||
// Delete previous lyrics if tab is open and couldn't get new lyrics
|
||||
tabs.upNext.click();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.electronIs.dev()) {
|
||||
console.log('Fetched lyrics from Genius');
|
||||
}
|
||||
|
||||
const tryToInjectLyric = (callback?: () => void) => {
|
||||
const lyricsContainer = document.querySelector(
|
||||
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
|
||||
const lyrics = await invoke<string | null>(
|
||||
'search-genius-lyrics',
|
||||
extractedSongInfo,
|
||||
);
|
||||
|
||||
if (lyricsContainer) {
|
||||
callback?.();
|
||||
if (!lyrics) {
|
||||
// Delete previous lyrics if tab is open and couldn't get new lyrics
|
||||
tabs.upNext.click();
|
||||
|
||||
setLyrics(lyricsContainer, lyrics);
|
||||
applyLyricsTabState();
|
||||
return;
|
||||
}
|
||||
};
|
||||
const applyLyricsTabState = () => {
|
||||
if (lyrics) {
|
||||
tabs.lyrics.removeAttribute('disabled');
|
||||
tabs.lyrics.removeAttribute('aria-disabled');
|
||||
} else {
|
||||
tabs.lyrics.setAttribute('disabled', '');
|
||||
tabs.lyrics.setAttribute('aria-disabled', '');
|
||||
|
||||
if (window.electronIs.dev()) {
|
||||
console.log('Fetched lyrics from Genius');
|
||||
}
|
||||
};
|
||||
const lyricsTabHandler = () => {
|
||||
const tabContainer = document.querySelector('ytmusic-tab-renderer');
|
||||
if (!tabContainer) return;
|
||||
|
||||
const observer = new MutationObserver((_, observer) => {
|
||||
tryToInjectLyric(() => observer.disconnect());
|
||||
});
|
||||
const tryToInjectLyric = (callback?: () => void) => {
|
||||
const lyricsContainer = document.querySelector(
|
||||
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
|
||||
);
|
||||
|
||||
observer.observe(tabContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
if (lyricsContainer) {
|
||||
callback?.();
|
||||
|
||||
applyLyricsTabState();
|
||||
setLyrics(lyricsContainer, lyrics);
|
||||
applyLyricsTabState();
|
||||
}
|
||||
};
|
||||
const applyLyricsTabState = () => {
|
||||
if (lyrics) {
|
||||
tabs.lyrics.removeAttribute('disabled');
|
||||
tabs.lyrics.removeAttribute('aria-disabled');
|
||||
} else {
|
||||
tabs.lyrics.setAttribute('disabled', '');
|
||||
tabs.lyrics.setAttribute('aria-disabled', '');
|
||||
}
|
||||
};
|
||||
const lyricsTabHandler = () => {
|
||||
const tabContainer = document.querySelector('ytmusic-tab-renderer');
|
||||
if (!tabContainer) return;
|
||||
|
||||
tabs.discover.addEventListener('click', applyLyricsTabState);
|
||||
tabs.lyrics.addEventListener('click', lyricsTabHandler);
|
||||
tabs.upNext.addEventListener('click', applyLyricsTabState);
|
||||
const observer = new MutationObserver((_, observer) => {
|
||||
tryToInjectLyric(() => observer.disconnect());
|
||||
});
|
||||
|
||||
tryToInjectLyric();
|
||||
observer.observe(tabContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
|
||||
unregister = () => {
|
||||
tabs.discover.removeEventListener('click', applyLyricsTabState);
|
||||
tabs.lyrics.removeEventListener('click', lyricsTabHandler);
|
||||
tabs.upNext.removeEventListener('click', applyLyricsTabState);
|
||||
};
|
||||
}, 500);
|
||||
});
|
||||
};
|
||||
applyLyricsTabState();
|
||||
|
||||
tabs.discover.addEventListener('click', applyLyricsTabState);
|
||||
tabs.lyrics.addEventListener('click', lyricsTabHandler);
|
||||
tabs.upNext.addEventListener('click', applyLyricsTabState);
|
||||
|
||||
tryToInjectLyric();
|
||||
|
||||
unregister = () => {
|
||||
tabs.discover.removeEventListener('click', applyLyricsTabState);
|
||||
tabs.lyrics.removeEventListener('click', lyricsTabHandler);
|
||||
tabs.upNext.removeEventListener('click', applyLyricsTabState);
|
||||
};
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
@ -2,8 +2,9 @@ import style from './style.css?inline';
|
||||
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
export const builder = createPluginBuilder('navigation', {
|
||||
const builder = createPluginBuilder('navigation', {
|
||||
name: 'Navigation',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import forwardHTML from './templates/forward.html?raw';
|
||||
import backHTML from './templates/back.html?raw';
|
||||
|
||||
import builder from '.';
|
||||
import builder from './index';
|
||||
|
||||
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 style from './style.css';
|
||||
import style from './style.css?inline';
|
||||
|
||||
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 config from './config';
|
||||
|
||||
import getSongControls from '../../providers/song-controls';
|
||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
||||
@ -14,16 +13,209 @@ import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnp
|
||||
import nextIcon from '../../../assets/media-icons-black/next.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 savedNotification: Notification | undefined;
|
||||
|
||||
export default (win: BrowserWindow) => {
|
||||
type Accessor<T> = () => T;
|
||||
|
||||
export default (
|
||||
win: BrowserWindow,
|
||||
config: Accessor<NotificationsPluginConfig>,
|
||||
{ on, send }: MainPluginContext<NotificationsPluginConfig>,
|
||||
) => {
|
||||
const sendNotification = (songInfo: SongInfo) => {
|
||||
const iconSrc = notificationImage(songInfo, config());
|
||||
|
||||
savedNotification?.close();
|
||||
|
||||
let icon: string;
|
||||
if (typeof iconSrc === 'object') {
|
||||
icon = iconSrc.toDataURL();
|
||||
} else {
|
||||
icon = iconSrc;
|
||||
}
|
||||
|
||||
savedNotification = new Notification({
|
||||
title: songInfo.title || 'Playing',
|
||||
body: songInfo.artist,
|
||||
icon: iconSrc,
|
||||
silent: true,
|
||||
// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root
|
||||
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema
|
||||
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
|
||||
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
|
||||
toastXml: getXml(songInfo, icon),
|
||||
});
|
||||
|
||||
savedNotification.on('close', () => {
|
||||
savedNotification = undefined;
|
||||
});
|
||||
|
||||
savedNotification.show();
|
||||
};
|
||||
|
||||
const getXml = (songInfo: SongInfo, iconSrc: string) => {
|
||||
switch (config().toastStyle) {
|
||||
default:
|
||||
case ToastStyles.logo:
|
||||
case ToastStyles.legacy: {
|
||||
return xmlLogo(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.banner_top_custom: {
|
||||
return xmlBannerTopCustom(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.hero: {
|
||||
return xmlHero(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.banner_bottom: {
|
||||
return xmlBannerBottom(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.banner_centered_bottom: {
|
||||
return xmlBannerCenteredBottom(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.banner_centered_top: {
|
||||
return xmlBannerCenteredTop(songInfo, iconSrc);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectIcon = (kind: keyof typeof mediaIcons): string => {
|
||||
switch (kind) {
|
||||
case 'play':
|
||||
return playIcon;
|
||||
case 'pause':
|
||||
return pauseIcon;
|
||||
case 'next':
|
||||
return nextIcon;
|
||||
case 'previous':
|
||||
return previousIcon;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const display = (kind: keyof typeof mediaIcons) => {
|
||||
if (config().toastStyle === ToastStyles.legacy) {
|
||||
return `content="${mediaIcons[kind]}"`;
|
||||
}
|
||||
|
||||
return `\
|
||||
content="${config().toastStyle ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
|
||||
imageUri="file:///${selectIcon(kind)}"
|
||||
`;
|
||||
};
|
||||
|
||||
const getButton = (kind: keyof typeof mediaIcons) =>
|
||||
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
|
||||
|
||||
const getButtons = (isPaused: boolean) => `\
|
||||
<actions>
|
||||
${getButton('previous')}
|
||||
${isPaused ? getButton('play') : getButton('pause')}
|
||||
${getButton('next')}
|
||||
</actions>\
|
||||
`;
|
||||
|
||||
const toast = (content: string, isPaused: boolean) => `\
|
||||
<toast>
|
||||
<audio silent="true" />
|
||||
<visual>
|
||||
<binding template="ToastGeneric">
|
||||
${content}
|
||||
</binding>
|
||||
</visual>
|
||||
|
||||
${getButtons(isPaused)}
|
||||
</toast>`;
|
||||
|
||||
const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\
|
||||
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
|
||||
<text id="1">${title}</text>
|
||||
<text id="2">${artist}</text>\
|
||||
`, isPaused ?? false);
|
||||
|
||||
const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"');
|
||||
|
||||
const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"');
|
||||
|
||||
const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, '');
|
||||
|
||||
const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\
|
||||
<image id="1" src="${imgSrc}" name="Image" />
|
||||
<text>ㅤ</text>
|
||||
<group>
|
||||
<subgroup>
|
||||
<text hint-style="body">${songInfo.title}</text>
|
||||
<text hint-style="captionSubtle">${songInfo.artist}</text>
|
||||
</subgroup>
|
||||
${xmlMoreData(songInfo)}
|
||||
</group>\
|
||||
`, songInfo.isPaused ?? false);
|
||||
|
||||
const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\
|
||||
<subgroup hint-textStacking="bottom">
|
||||
${album
|
||||
? `<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
|
||||
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)}</text>
|
||||
</subgroup>\
|
||||
`;
|
||||
|
||||
const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
||||
<text>ㅤ</text>
|
||||
<group>
|
||||
<subgroup hint-weight="1" hint-textStacking="center">
|
||||
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
||||
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
||||
</subgroup>
|
||||
</group>
|
||||
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
|
||||
`, isPaused ?? false);
|
||||
|
||||
const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
||||
<image id="1" src="${imgSrc}" name="Image" />
|
||||
<text>ㅤ</text>
|
||||
<group>
|
||||
<subgroup hint-weight="1" hint-textStacking="center">
|
||||
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
||||
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
||||
</subgroup>
|
||||
</group>\
|
||||
`, isPaused ?? false);
|
||||
|
||||
const titleFontPicker = (title: string) => {
|
||||
if (title.length <= 13) {
|
||||
return 'Header';
|
||||
}
|
||||
|
||||
if (title.length <= 22) {
|
||||
return 'Subheader';
|
||||
}
|
||||
|
||||
if (title.length <= 26) {
|
||||
return 'Title';
|
||||
}
|
||||
|
||||
return 'Subtitle';
|
||||
};
|
||||
|
||||
|
||||
songControls = getSongControls(win);
|
||||
|
||||
let currentSeconds = 0;
|
||||
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
|
||||
on('apiLoaded', () => send('setupTimeChangedListener'));
|
||||
|
||||
ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t);
|
||||
on('timeChanged', (t: number) => {
|
||||
currentSeconds = t;
|
||||
});
|
||||
|
||||
let savedSongInfo: SongInfo;
|
||||
let lastUrl: string | undefined;
|
||||
@ -36,14 +228,14 @@ export default (win: BrowserWindow) => {
|
||||
|
||||
savedSongInfo = { ...songInfo };
|
||||
if (!songInfo.isPaused
|
||||
&& (songInfo.url !== lastUrl || config.get('unpauseNotification'))
|
||||
&& (songInfo.url !== lastUrl || config().unpauseNotification)
|
||||
) {
|
||||
lastUrl = songInfo.url;
|
||||
sendNotification(songInfo);
|
||||
}
|
||||
});
|
||||
|
||||
if (config.get('trayControls')) {
|
||||
if (config().trayControls) {
|
||||
setTrayOnClick(() => {
|
||||
if (savedNotification) {
|
||||
savedNotification.close();
|
||||
@ -73,9 +265,9 @@ export default (win: BrowserWindow) => {
|
||||
(cmd) => {
|
||||
if (Object.keys(songControls).includes(cmd)) {
|
||||
songControls[cmd as keyof typeof songControls]();
|
||||
if (config.get('refreshOnPlayPause') && (
|
||||
if (config().refreshOnPlayPause && (
|
||||
cmd === 'pause'
|
||||
|| (cmd === 'play' && !config.get('unpauseNotification'))
|
||||
|| (cmd === 'play' && !config().unpauseNotification)
|
||||
)
|
||||
) {
|
||||
setImmediate(() =>
|
||||
@ -90,183 +282,3 @@ export default (win: BrowserWindow) => {
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function sendNotification(songInfo: SongInfo) {
|
||||
const iconSrc = notificationImage(songInfo);
|
||||
|
||||
savedNotification?.close();
|
||||
|
||||
let icon: string;
|
||||
if (typeof iconSrc === 'object') {
|
||||
icon = iconSrc.toDataURL();
|
||||
} else {
|
||||
icon = iconSrc;
|
||||
}
|
||||
|
||||
savedNotification = new Notification({
|
||||
title: songInfo.title || 'Playing',
|
||||
body: songInfo.artist,
|
||||
icon: iconSrc,
|
||||
silent: true,
|
||||
// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root
|
||||
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema
|
||||
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
|
||||
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
|
||||
toastXml: getXml(songInfo, icon),
|
||||
});
|
||||
|
||||
savedNotification.on('close', () => {
|
||||
savedNotification = undefined;
|
||||
});
|
||||
|
||||
savedNotification.show();
|
||||
}
|
||||
|
||||
const getXml = (songInfo: SongInfo, iconSrc: string) => {
|
||||
switch (config.get('toastStyle')) {
|
||||
default:
|
||||
case ToastStyles.logo:
|
||||
case ToastStyles.legacy: {
|
||||
return xmlLogo(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.banner_top_custom: {
|
||||
return xmlBannerTopCustom(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.hero: {
|
||||
return xmlHero(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.banner_bottom: {
|
||||
return xmlBannerBottom(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.banner_centered_bottom: {
|
||||
return xmlBannerCenteredBottom(songInfo, iconSrc);
|
||||
}
|
||||
|
||||
case ToastStyles.banner_centered_top: {
|
||||
return xmlBannerCenteredTop(songInfo, iconSrc);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectIcon = (kind: keyof typeof mediaIcons): string => {
|
||||
switch (kind) {
|
||||
case 'play':
|
||||
return playIcon;
|
||||
case 'pause':
|
||||
return pauseIcon;
|
||||
case 'next':
|
||||
return nextIcon;
|
||||
case 'previous':
|
||||
return previousIcon;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const display = (kind: keyof typeof mediaIcons) => {
|
||||
if (config.get('toastStyle') === ToastStyles.legacy) {
|
||||
return `content="${mediaIcons[kind]}"`;
|
||||
}
|
||||
|
||||
return `\
|
||||
content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
|
||||
imageUri="file:///${selectIcon(kind)}"
|
||||
`;
|
||||
};
|
||||
|
||||
const getButton = (kind: keyof typeof mediaIcons) =>
|
||||
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
|
||||
|
||||
const getButtons = (isPaused: boolean) => `\
|
||||
<actions>
|
||||
${getButton('previous')}
|
||||
${isPaused ? getButton('play') : getButton('pause')}
|
||||
${getButton('next')}
|
||||
</actions>\
|
||||
`;
|
||||
|
||||
const toast = (content: string, isPaused: boolean) => `\
|
||||
<toast>
|
||||
<audio silent="true" />
|
||||
<visual>
|
||||
<binding template="ToastGeneric">
|
||||
${content}
|
||||
</binding>
|
||||
</visual>
|
||||
|
||||
${getButtons(isPaused)}
|
||||
</toast>`;
|
||||
|
||||
const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\
|
||||
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
|
||||
<text id="1">${title}</text>
|
||||
<text id="2">${artist}</text>\
|
||||
`, isPaused ?? false);
|
||||
|
||||
const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"');
|
||||
|
||||
const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"');
|
||||
|
||||
const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, '');
|
||||
|
||||
const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\
|
||||
<image id="1" src="${imgSrc}" name="Image" />
|
||||
<text>ㅤ</text>
|
||||
<group>
|
||||
<subgroup>
|
||||
<text hint-style="body">${songInfo.title}</text>
|
||||
<text hint-style="captionSubtle">${songInfo.artist}</text>
|
||||
</subgroup>
|
||||
${xmlMoreData(songInfo)}
|
||||
</group>\
|
||||
`, songInfo.isPaused ?? false);
|
||||
|
||||
const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\
|
||||
<subgroup hint-textStacking="bottom">
|
||||
${album
|
||||
? `<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
|
||||
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)}</text>
|
||||
</subgroup>\
|
||||
`;
|
||||
|
||||
const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
||||
<text>ㅤ</text>
|
||||
<group>
|
||||
<subgroup hint-weight="1" hint-textStacking="center">
|
||||
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
||||
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
||||
</subgroup>
|
||||
</group>
|
||||
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
|
||||
`, isPaused ?? false);
|
||||
|
||||
const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
||||
<image id="1" src="${imgSrc}" name="Image" />
|
||||
<text>ㅤ</text>
|
||||
<group>
|
||||
<subgroup hint-weight="1" hint-textStacking="center">
|
||||
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
||||
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
||||
</subgroup>
|
||||
</group>\
|
||||
`, isPaused ?? false);
|
||||
|
||||
const titleFontPicker = (title: string) => {
|
||||
if (title.length <= 13) {
|
||||
return 'Header';
|
||||
}
|
||||
|
||||
if (title.length <= 22) {
|
||||
return 'Subheader';
|
||||
}
|
||||
|
||||
if (title.length <= 26) {
|
||||
return 'Title';
|
||||
}
|
||||
|
||||
return 'Subtitle';
|
||||
};
|
||||
|
||||
@ -1,25 +1,24 @@
|
||||
import { BrowserWindow, Notification } from 'electron';
|
||||
import { Notification } from 'electron';
|
||||
|
||||
import is from 'electron-is';
|
||||
|
||||
import { notificationImage } from './utils';
|
||||
import config from './config';
|
||||
import interactive from './interactive';
|
||||
|
||||
import builder, { NotificationsPluginConfig } from './index';
|
||||
|
||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
type NotificationOptions = ConfigType<'notifications'>;
|
||||
let config: NotificationsPluginConfig = builder.config;
|
||||
|
||||
const notify = (info: SongInfo) => {
|
||||
// Send the notification
|
||||
const currentNotification = new Notification({
|
||||
title: info.title || 'Playing',
|
||||
body: info.artist,
|
||||
icon: notificationImage(info),
|
||||
icon: notificationImage(info, config),
|
||||
silent: true,
|
||||
urgency: config.get('urgency') as 'normal' | 'critical' | 'low',
|
||||
urgency: config.urgency,
|
||||
});
|
||||
currentNotification.show();
|
||||
|
||||
@ -31,7 +30,7 @@ const setup = () => {
|
||||
let currentUrl: string | undefined;
|
||||
|
||||
registerCallback((songInfo: SongInfo) => {
|
||||
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) {
|
||||
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.unpauseNotification)) {
|
||||
// Close the old notification
|
||||
oldNotification?.close();
|
||||
currentUrl = songInfo.url;
|
||||
@ -43,9 +42,17 @@ const setup = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export default (win: BrowserWindow, options: NotificationOptions) => {
|
||||
// Register the callback for new song information
|
||||
is.windows() && options.interactive
|
||||
? interactive(win)
|
||||
: setup();
|
||||
};
|
||||
export default builder.createMain((context) => {
|
||||
return {
|
||||
async onLoad(win) {
|
||||
config = await context.getConfig();
|
||||
|
||||
// Register the callback for new song information
|
||||
if (is.windows() && config.interactive) interactive(win, () => config, context);
|
||||
else setup();
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,93 +1,95 @@
|
||||
import is from 'electron-is';
|
||||
|
||||
import { BrowserWindow, MenuItem } from 'electron';
|
||||
import { MenuItem } from 'electron';
|
||||
|
||||
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 => {
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
const getToastStyleMenuItems = (options: NotificationsPluginConfig) => {
|
||||
const array = Array.from({ length: Object.keys(ToastStyles).length });
|
||||
|
||||
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),
|
||||
},
|
||||
];
|
||||
// ToastStyles index starts from 1
|
||||
for (const [name, index] of Object.entries(ToastStyles)) {
|
||||
array[index - 1] = {
|
||||
label: snakeToCamel(name),
|
||||
type: 'radio',
|
||||
checked: options.toastStyle === index,
|
||||
click: () => setConfig({ toastStyle: index }),
|
||||
} satisfies Electron.MenuItemConstructorOptions;
|
||||
}
|
||||
|
||||
export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
|
||||
const array = Array.from({ length: Object.keys(ToastStyles).length });
|
||||
|
||||
// ToastStyles index starts from 1
|
||||
for (const [name, index] of Object.entries(ToastStyles)) {
|
||||
array[index - 1] = {
|
||||
label: snakeToCamel(name),
|
||||
type: 'radio',
|
||||
checked: options.toastStyle === index,
|
||||
click: () => config.set('toastStyle', index),
|
||||
} 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 config from './config';
|
||||
|
||||
import { cache } from '../../providers/decorators';
|
||||
import { SongInfo } from '../../providers/song-info';
|
||||
|
||||
import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack';
|
||||
import {NotificationsPluginConfig} from "./index";
|
||||
|
||||
|
||||
const userData = app.getPath('userData');
|
||||
@ -27,9 +26,9 @@ export const ToastStyles = {
|
||||
};
|
||||
|
||||
export const urgencyLevels = [
|
||||
{ name: 'Low', value: 'low' },
|
||||
{ name: 'Normal', value: 'normal' },
|
||||
{ name: 'High', value: 'critical' },
|
||||
{ name: 'Low', value: 'low' } as const,
|
||||
{ name: 'Normal', value: 'normal' } as const,
|
||||
{ name: 'High', value: 'critical' } as const,
|
||||
];
|
||||
|
||||
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) {
|
||||
return youtubeMusicIcon;
|
||||
}
|
||||
|
||||
if (!config.get('interactive')) {
|
||||
if (!config.interactive) {
|
||||
return nativeImageToLogo(songInfo.image);
|
||||
}
|
||||
|
||||
switch (config.get('toastStyle')) {
|
||||
switch (config.toastStyle) {
|
||||
case ToastStyles.logo:
|
||||
case ToastStyles.legacy: {
|
||||
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,111 +1,121 @@
|
||||
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 { setOptions as setPluginOptions } from '../../config/plugins';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
export default builder.createMain(({ getConfig, setConfig, send, handle }) => {
|
||||
let isInPiP = false;
|
||||
let originalPosition: number[];
|
||||
let originalSize: number[];
|
||||
let originalFullScreen: boolean;
|
||||
let originalMaximized: boolean;
|
||||
|
||||
let isInPiP = false;
|
||||
let originalPosition: number[];
|
||||
let originalSize: number[];
|
||||
let originalFullScreen: boolean;
|
||||
let originalMaximized: boolean;
|
||||
let win: BrowserWindow;
|
||||
|
||||
let win: BrowserWindow;
|
||||
let config: PictureInPicturePluginConfig;
|
||||
|
||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
||||
const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10];
|
||||
const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275];
|
||||
|
||||
let options: Partial<PiPOptions>;
|
||||
const togglePiP = () => {
|
||||
isInPiP = !isInPiP;
|
||||
setConfig({ isInPiP });
|
||||
|
||||
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
|
||||
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
|
||||
if (isInPiP) {
|
||||
originalFullScreen = win.isFullScreen();
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(false);
|
||||
}
|
||||
|
||||
const setLocalOptions = (_options: Partial<PiPOptions>) => {
|
||||
options = { ...options, ..._options };
|
||||
setPluginOptions('picture-in-picture', _options);
|
||||
};
|
||||
originalMaximized = win.isMaximized();
|
||||
if (originalMaximized) {
|
||||
win.unmaximize();
|
||||
}
|
||||
|
||||
const togglePiP = () => {
|
||||
isInPiP = !isInPiP;
|
||||
setLocalOptions({ isInPiP });
|
||||
originalPosition = win.getPosition();
|
||||
originalSize = win.getSize();
|
||||
|
||||
if (isInPiP) {
|
||||
originalFullScreen = win.isFullScreen();
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(false);
|
||||
handle('before-input-event', blockShortcutsInPiP);
|
||||
|
||||
win.setMaximizable(false);
|
||||
win.setFullScreenable(false);
|
||||
|
||||
send('pip-toggle', true);
|
||||
|
||||
app.dock?.hide();
|
||||
win.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
app.dock?.show();
|
||||
if (config.alwaysOnTop) {
|
||||
win.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
}
|
||||
} else {
|
||||
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
||||
win.setMaximizable(true);
|
||||
win.setFullScreenable(true);
|
||||
|
||||
send('pip-toggle', false);
|
||||
|
||||
win.setVisibleOnAllWorkspaces(false);
|
||||
win.setAlwaysOnTop(false);
|
||||
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(true);
|
||||
}
|
||||
|
||||
if (originalMaximized) {
|
||||
win.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
originalMaximized = win.isMaximized();
|
||||
if (originalMaximized) {
|
||||
win.unmaximize();
|
||||
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
||||
const [w, h] = isInPiP ? pipSize() : originalSize;
|
||||
win.setPosition(x, y);
|
||||
win.setSize(w, h);
|
||||
|
||||
win.setWindowButtonVisibility?.(!isInPiP);
|
||||
};
|
||||
|
||||
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
||||
const key = input.key.toLowerCase();
|
||||
|
||||
if (key === 'f') {
|
||||
event.preventDefault();
|
||||
} else if (key === 'escape') {
|
||||
togglePiP();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
originalPosition = win.getPosition();
|
||||
originalSize = win.getSize();
|
||||
return ({
|
||||
async onLoad(window) {
|
||||
config ??= await getConfig();
|
||||
win ??= window;
|
||||
setConfig({ isInPiP });
|
||||
injectCSS(win.webContents, style);
|
||||
ipcMain.on('picture-in-picture', () => {
|
||||
togglePiP();
|
||||
});
|
||||
|
||||
win.webContents.on('before-input-event', blockShortcutsInPiP);
|
||||
window.on('move', () => {
|
||||
if (config.isInPiP && !config.useNativePiP) {
|
||||
setConfig({ 'pip-position': window.getPosition() as [number, number] });
|
||||
}
|
||||
});
|
||||
|
||||
win.setMaximizable(false);
|
||||
win.setFullScreenable(false);
|
||||
|
||||
win.webContents.send('pip-toggle', true);
|
||||
|
||||
app.dock?.hide();
|
||||
win.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
app.dock?.show();
|
||||
if (options.alwaysOnTop) {
|
||||
win.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
window.on('resize', () => {
|
||||
if (config.isInPiP && !config.useNativePiP) {
|
||||
setConfig({ 'pip-size': window.getSize() as [number, number] });
|
||||
}
|
||||
});
|
||||
},
|
||||
onConfigChange(newConfig) {
|
||||
config = newConfig;
|
||||
}
|
||||
} else {
|
||||
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
||||
win.setMaximizable(true);
|
||||
win.setFullScreenable(true);
|
||||
|
||||
win.webContents.send('pip-toggle', false);
|
||||
|
||||
win.setVisibleOnAllWorkspaces(false);
|
||||
win.setAlwaysOnTop(false);
|
||||
|
||||
if (originalFullScreen) {
|
||||
win.setFullScreen(true);
|
||||
}
|
||||
|
||||
if (originalMaximized) {
|
||||
win.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
||||
const [w, h] = isInPiP ? pipSize() : originalSize;
|
||||
win.setPosition(x, y);
|
||||
win.setSize(w, h);
|
||||
|
||||
win.setWindowButtonVisibility?.(!isInPiP);
|
||||
};
|
||||
|
||||
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
||||
const key = input.key.toLowerCase();
|
||||
|
||||
if (key === 'f') {
|
||||
event.preventDefault();
|
||||
} else if (key === 'escape') {
|
||||
togglePiP();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
export default (_win: BrowserWindow, _options: PiPOptions) => {
|
||||
options ??= _options;
|
||||
win ??= _win;
|
||||
setLocalOptions({ isInPiP });
|
||||
injectCSS(win.webContents, style);
|
||||
ipcMain.on('picture-in-picture', () => {
|
||||
togglePiP();
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
export const setOptions = setLocalOptions;
|
||||
|
||||
@ -1,75 +1,74 @@
|
||||
import prompt from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { setOptions } from './main';
|
||||
import builder from './index';
|
||||
|
||||
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 => [
|
||||
{
|
||||
label: 'Always on top',
|
||||
type: 'checkbox',
|
||||
checked: options.alwaysOnTop,
|
||||
click(item) {
|
||||
setOptions({ alwaysOnTop: item.checked });
|
||||
win.setAlwaysOnTop(item.checked);
|
||||
return [
|
||||
{
|
||||
label: 'Always on top',
|
||||
type: 'checkbox',
|
||||
checked: config.alwaysOnTop,
|
||||
click(item) {
|
||||
setConfig({ alwaysOnTop: item.checked });
|
||||
window.setAlwaysOnTop(item.checked);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Save window position',
|
||||
type: 'checkbox',
|
||||
checked: options.savePosition,
|
||||
click(item) {
|
||||
setOptions({ savePosition: item.checked });
|
||||
{
|
||||
label: 'Save window position',
|
||||
type: 'checkbox',
|
||||
checked: config.savePosition,
|
||||
click(item) {
|
||||
setConfig({ savePosition: item.checked });
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Save window size',
|
||||
type: 'checkbox',
|
||||
checked: options.saveSize,
|
||||
click(item) {
|
||||
setOptions({ saveSize: item.checked });
|
||||
{
|
||||
label: 'Save window size',
|
||||
type: 'checkbox',
|
||||
checked: config.saveSize,
|
||||
click(item) {
|
||||
setConfig({ saveSize: item.checked });
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hotkey',
|
||||
type: 'checkbox',
|
||||
checked: !!options.hotkey,
|
||||
async click(item) {
|
||||
const output = await prompt({
|
||||
title: 'Picture in Picture Hotkey',
|
||||
label: 'Choose a hotkey for toggling Picture in Picture',
|
||||
type: 'keybind',
|
||||
keybindOptions: [{
|
||||
value: 'hotkey',
|
||||
label: 'Hotkey',
|
||||
default: options.hotkey,
|
||||
}],
|
||||
...promptOptions(),
|
||||
}, win);
|
||||
{
|
||||
label: 'Hotkey',
|
||||
type: 'checkbox',
|
||||
checked: !!config.hotkey,
|
||||
async click(item) {
|
||||
const output = await prompt({
|
||||
title: 'Picture in Picture Hotkey',
|
||||
label: 'Choose a hotkey for toggling Picture in Picture',
|
||||
type: 'keybind',
|
||||
keybindOptions: [{
|
||||
value: 'hotkey',
|
||||
label: 'Hotkey',
|
||||
default: config.hotkey,
|
||||
}],
|
||||
...promptOptions(),
|
||||
}, window);
|
||||
|
||||
if (output) {
|
||||
const { value, accelerator } = output[0];
|
||||
setOptions({ [value]: accelerator });
|
||||
if (output) {
|
||||
const { value, accelerator } = output[0];
|
||||
setConfig({ [value]: accelerator });
|
||||
|
||||
item.checked = !!accelerator;
|
||||
} else {
|
||||
// Reset checkbox if prompt was canceled
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
item.checked = !!accelerator;
|
||||
} else {
|
||||
// Reset checkbox if prompt was canceled
|
||||
item.checked = !item.checked;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Use native PiP',
|
||||
type: 'checkbox',
|
||||
checked: options.useNativePiP,
|
||||
click(item) {
|
||||
setOptions({ useNativePiP: item.checked });
|
||||
{
|
||||
label: 'Use native PiP',
|
||||
type: 'checkbox',
|
||||
checked: config.useNativePiP,
|
||||
click(item) {
|
||||
setConfig({ useNativePiP: item.checked });
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
});
|
||||
|
||||
@ -3,14 +3,12 @@ import keyEventAreEqual from 'keyboardevents-areequal';
|
||||
|
||||
import pipHTML from './templates/picture-in-picture.html?raw';
|
||||
|
||||
import builder, { PictureInPicturePluginConfig } from './index';
|
||||
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
||||
|
||||
function $<E extends Element = Element>(selector: string) {
|
||||
return document.querySelector<E>(selector);
|
||||
}
|
||||
@ -135,7 +133,7 @@ const listenForToggle = () => {
|
||||
});
|
||||
};
|
||||
|
||||
function observeMenu(options: PiPOptions) {
|
||||
function observeMenu(options: PictureInPicturePluginConfig) {
|
||||
useNativePiP = options.useNativePiP;
|
||||
document.addEventListener(
|
||||
'apiLoaded',
|
||||
@ -160,18 +158,24 @@ function observeMenu(options: PiPOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
export default (options: PiPOptions) => {
|
||||
observeMenu(options);
|
||||
export default builder.createRenderer(({ getConfig }) => {
|
||||
return {
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
|
||||
if (options.hotkey) {
|
||||
const hotkeyEvent = toKeyEvent(options.hotkey);
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (
|
||||
keyEventAreEqual(event, hotkeyEvent)
|
||||
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
|
||||
) {
|
||||
togglePictureInPicture();
|
||||
observeMenu(config);
|
||||
|
||||
if (config.hotkey) {
|
||||
const hotkeyEvent = toKeyEvent(config.hotkey);
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (
|
||||
keyEventAreEqual(event, hotkeyEvent)
|
||||
&& !$<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened
|
||||
) {
|
||||
togglePictureInPicture();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
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 builder from './index';
|
||||
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
import { singleton } from '../../providers/decorators';
|
||||
@ -32,15 +34,17 @@ const updatePlayBackSpeed = () => {
|
||||
|
||||
let menu: Element | null = null;
|
||||
|
||||
const setupSliderListener = singleton(() => {
|
||||
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', (e) => {
|
||||
playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED;
|
||||
if (isNaN(playbackSpeed)) {
|
||||
playbackSpeed = 1;
|
||||
}
|
||||
const immediateValueChangedListener = (e: Event) => {
|
||||
playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED;
|
||||
if (isNaN(playbackSpeed)) {
|
||||
playbackSpeed = 1;
|
||||
}
|
||||
|
||||
updatePlayBackSpeed();
|
||||
});
|
||||
updatePlayBackSpeed();
|
||||
};
|
||||
|
||||
const setupSliderListener = singleton(() => {
|
||||
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', immediateValueChangedListener);
|
||||
});
|
||||
|
||||
const observePopupContainer = () => {
|
||||
@ -77,26 +81,28 @@ const observeVideo = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const wheelEventListener = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
if (isNaN(playbackSpeed)) {
|
||||
playbackSpeed = 1;
|
||||
}
|
||||
|
||||
// E.deltaY < 0 means wheel-up
|
||||
playbackSpeed = roundToTwo(e.deltaY < 0
|
||||
? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED)
|
||||
: Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED),
|
||||
);
|
||||
|
||||
updatePlayBackSpeed();
|
||||
// Update slider position
|
||||
const playbackSpeedSilder = $<HTMLElement & { value: number }>('#playback-speed-slider');
|
||||
if (playbackSpeedSilder) {
|
||||
playbackSpeedSilder.value = playbackSpeed;
|
||||
}
|
||||
};
|
||||
|
||||
const setupWheelListener = () => {
|
||||
slider.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
if (isNaN(playbackSpeed)) {
|
||||
playbackSpeed = 1;
|
||||
}
|
||||
|
||||
// E.deltaY < 0 means wheel-up
|
||||
playbackSpeed = roundToTwo(e.deltaY < 0
|
||||
? Math.min(playbackSpeed + 0.01, MAX_PLAYBACK_SPEED)
|
||||
: Math.max(playbackSpeed - 0.01, MIN_PLAYBACK_SPEED),
|
||||
);
|
||||
|
||||
updatePlayBackSpeed();
|
||||
// Update slider position
|
||||
const playbackSpeedSilder = $<HTMLElement & { value: number }>('#playback-speed-slider');
|
||||
if (playbackSpeedSilder) {
|
||||
playbackSpeedSilder.value = playbackSpeed;
|
||||
}
|
||||
});
|
||||
slider.addEventListener('wheel', wheelEventListener);
|
||||
};
|
||||
|
||||
function forcePlaybackRate(e: Event) {
|
||||
@ -108,10 +114,24 @@ function forcePlaybackRate(e: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
export default () => {
|
||||
document.addEventListener('apiLoaded', () => {
|
||||
observePopupContainer();
|
||||
observeVideo();
|
||||
setupWheelListener();
|
||||
}, { once: true, passive: true });
|
||||
};
|
||||
export default builder.createRenderer(() => {
|
||||
return {
|
||||
onLoad() {
|
||||
document.addEventListener('apiLoaded', () => {
|
||||
observePopupContainer();
|
||||
observeVideo();
|
||||
setupWheelListener();
|
||||
}, { 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', {
|
||||
name: 'Precise Volume',
|
||||
restartNeeded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
steps: 1, // Percentage of volume to change
|
||||
|
||||
@ -1,19 +1,17 @@
|
||||
import { globalShortcut } from 'electron';
|
||||
|
||||
import builder from '.';
|
||||
import builder from './index';
|
||||
|
||||
export default builder.createMain(({ getConfig, send }) => {
|
||||
return {
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
export default builder.createMain(({ getConfig, send }) => ({
|
||||
async onLoad() {
|
||||
const config = await getConfig();
|
||||
|
||||
if (config.globalShortcuts?.volumeUp) {
|
||||
globalShortcut.register(config.globalShortcuts.volumeUp, () => send('changeVolume', true));
|
||||
}
|
||||
|
||||
if (config.globalShortcuts?.volumeDown) {
|
||||
globalShortcut.register(config.globalShortcuts.volumeDown, () => send('changeVolume', false));
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
if (config.globalShortcuts?.volumeUp) {
|
||||
globalShortcut.register(config.globalShortcuts.volumeUp, () => send('changeVolume', true));
|
||||
}
|
||||
|
||||
if (config.globalShortcuts?.volumeDown) {
|
||||
globalShortcut.register(config.globalShortcuts.volumeDown, () => send('changeVolume', false));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@ -2,13 +2,13 @@ import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow, MenuItem } from 'electron';
|
||||
|
||||
import builder, { PreciseVolumePluginConfig } from '.';
|
||||
import builder, { PreciseVolumePluginConfig } from './index';
|
||||
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
|
||||
export default builder.createMenu(async ({ setConfig, getConfig, window }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
|
||||
function changeOptions(changedOptions: Partial<PreciseVolumePluginConfig>, options: PreciseVolumePluginConfig, win: BrowserWindow) {
|
||||
for (const option in changedOptions) {
|
||||
// HACK: Weird TypeScript error
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { overrideListener } from './override';
|
||||
|
||||
import builder, { type PreciseVolumePluginConfig } from './';
|
||||
import builder, { type PreciseVolumePluginConfig } from './index';
|
||||
|
||||
import { debounce } from '../../providers/decorators';
|
||||
|
||||
@ -12,6 +12,17 @@ function $<E extends Element = Element>(selector: string) {
|
||||
|
||||
let api: YoutubePlayer;
|
||||
|
||||
export const moveVolumeHud = debounce((showVideo: boolean) => {
|
||||
const volumeHud = $<HTMLElement>('#volumeHud');
|
||||
if (!volumeHud) {
|
||||
return;
|
||||
}
|
||||
|
||||
volumeHud.style.top = showVideo
|
||||
? `${($('ytmusic-player')!.clientHeight - $('video')!.clientHeight) / 2}px`
|
||||
: '0';
|
||||
}, 250);
|
||||
|
||||
export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
let options: PreciseVolumePluginConfig = await getConfig();
|
||||
|
||||
@ -19,41 +30,30 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
const writeOptions = debounce(() => {
|
||||
setConfig(options);
|
||||
}, 1000);
|
||||
|
||||
const moveVolumeHud = debounce((showVideo: boolean) => {
|
||||
const volumeHud = $<HTMLElement>('#volumeHud');
|
||||
if (!volumeHud) {
|
||||
return;
|
||||
}
|
||||
|
||||
volumeHud.style.top = showVideo
|
||||
? `${($('ytmusic-player')!.clientHeight - $('video')!.clientHeight) / 2}px`
|
||||
: '0';
|
||||
}, 250);
|
||||
|
||||
|
||||
const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
|
||||
volumeHud.style.opacity = '0';
|
||||
}, 2000);
|
||||
|
||||
|
||||
const hideVolumeSlider = debounce((slider: HTMLElement) => {
|
||||
slider.classList.remove('on-hover');
|
||||
}, 2500);
|
||||
|
||||
|
||||
/** Restore saved volume and setup tooltip */
|
||||
function firstRun() {
|
||||
if (typeof options.savedVolume === 'number') {
|
||||
// Set saved volume as tooltip
|
||||
setTooltip(options.savedVolume);
|
||||
|
||||
|
||||
if (api.getVolume() !== options.savedVolume) {
|
||||
setVolume(options.savedVolume);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setupPlaybar();
|
||||
|
||||
|
||||
setupLocalArrowShortcuts();
|
||||
|
||||
|
||||
// Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue
|
||||
const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none';
|
||||
injectVolumeHud(noVid);
|
||||
@ -66,12 +66,12 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function injectVolumeHud(noVid: boolean) {
|
||||
if (noVid) {
|
||||
const position = 'top: 18px; right: 60px;';
|
||||
const mainStyle = 'font-size: xx-large;';
|
||||
|
||||
|
||||
$('.center-content.ytmusic-nav-bar')?.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<span id="volumeHud" style="${position + mainStyle}"></span>`,
|
||||
@ -79,66 +79,66 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
} else {
|
||||
const position = 'top: 10px; left: 10px;';
|
||||
const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;';
|
||||
|
||||
|
||||
$('#song-video')?.insertAdjacentHTML(
|
||||
'afterend',
|
||||
`<span id="volumeHud" style="${position + mainStyle}"></span>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function showVolumeHud(volume: number) {
|
||||
const volumeHud = $<HTMLElement>('#volumeHud');
|
||||
if (!volumeHud) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
volumeHud.textContent = `${volume}%`;
|
||||
volumeHud.style.opacity = '1';
|
||||
|
||||
|
||||
hideVolumeHud(volumeHud);
|
||||
}
|
||||
|
||||
|
||||
/** Add onwheel event to video player */
|
||||
function setupVideoPlayerOnwheel() {
|
||||
const panel = $<HTMLElement>('#main-panel');
|
||||
if (!panel) return;
|
||||
|
||||
|
||||
panel.addEventListener('wheel', (event) => {
|
||||
event.preventDefault();
|
||||
// Event.deltaY < 0 means wheel-up
|
||||
changeVolume(event.deltaY < 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function saveVolume(volume: number) {
|
||||
options.savedVolume = volume;
|
||||
writeOptions();
|
||||
}
|
||||
|
||||
|
||||
/** Add onwheel event to play bar and also track if play bar is hovered */
|
||||
function setupPlaybar() {
|
||||
const playerbar = $<HTMLElement>('ytmusic-player-bar');
|
||||
if (!playerbar) return;
|
||||
|
||||
|
||||
playerbar.addEventListener('wheel', (event) => {
|
||||
event.preventDefault();
|
||||
// Event.deltaY < 0 means wheel-up
|
||||
changeVolume(event.deltaY < 0);
|
||||
});
|
||||
|
||||
|
||||
// Keep track of mouse position for showVolumeSlider()
|
||||
playerbar.addEventListener('mouseenter', () => {
|
||||
playerbar.classList.add('on-hover');
|
||||
});
|
||||
|
||||
|
||||
playerbar.addEventListener('mouseleave', () => {
|
||||
playerbar.classList.remove('on-hover');
|
||||
});
|
||||
|
||||
|
||||
setupSliderObserver();
|
||||
}
|
||||
|
||||
|
||||
/** Save volume + Update the volume tooltip when volume-slider is manually changed */
|
||||
function setupSliderObserver() {
|
||||
const sliderObserver = new MutationObserver((mutations) => {
|
||||
@ -156,25 +156,25 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const slider = $('#volume-slider');
|
||||
if (!slider) return;
|
||||
|
||||
|
||||
// Observing only changes in 'value' of volume-slider
|
||||
sliderObserver.observe(slider, {
|
||||
attributeFilter: ['value'],
|
||||
attributeOldValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function setVolume(value: number) {
|
||||
api.setVolume(value);
|
||||
// Save the new volume
|
||||
saveVolume(value);
|
||||
|
||||
|
||||
// Change slider position (important)
|
||||
updateVolumeSlider();
|
||||
|
||||
|
||||
// Change tooltips to new value
|
||||
setTooltip(value);
|
||||
// Show volume slider
|
||||
@ -182,7 +182,7 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
// Show volume HUD
|
||||
showVolumeHud(value);
|
||||
}
|
||||
|
||||
|
||||
/** If (toIncrease = false) then volume decrease */
|
||||
function changeVolume(toIncrease: boolean) {
|
||||
// Apply volume change if valid
|
||||
@ -191,7 +191,7 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
? Math.min(api.getVolume() + steps, 100)
|
||||
: Math.max(api.getVolume() - steps, 0));
|
||||
}
|
||||
|
||||
|
||||
function updateVolumeSlider() {
|
||||
const savedVolume = options.savedVolume ?? 0;
|
||||
// Slider value automatically rounds to multiples of 5
|
||||
@ -202,17 +202,17 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function showVolumeSlider() {
|
||||
const slider = $<HTMLElement>('#volume-slider');
|
||||
if (!slider) return;
|
||||
|
||||
|
||||
// This class display the volume slider if not in minimized mode
|
||||
slider.classList.add('on-hover');
|
||||
|
||||
|
||||
hideVolumeSlider(slider);
|
||||
}
|
||||
|
||||
|
||||
// Set new volume as tooltip for volume slider and icon + expanding slider (appears when window size is small)
|
||||
const tooltipTargets = [
|
||||
'#volume-slider',
|
||||
@ -220,7 +220,7 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
'#expand-volume-slider',
|
||||
'#expand-volume',
|
||||
];
|
||||
|
||||
|
||||
function setTooltip(volume: number) {
|
||||
for (const target of tooltipTargets) {
|
||||
const tooltipTargetElement = $<HTMLElement>(target);
|
||||
@ -229,21 +229,21 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function setupLocalArrowShortcuts() {
|
||||
if (options.arrowsShortcut) {
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if ($<HTMLElement & { opened: boolean }>('ytmusic-search-box')?.opened) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
switch (event.code) {
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
changeVolume(true);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
changeVolume(false);
|
||||
@ -253,7 +253,7 @@ export default builder.createRenderer(async ({ on, getConfig, setConfig }) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { createPluginBuilder } from '../utils/builder';
|
||||
|
||||
const builder = createPluginBuilder('quality-changer', {
|
||||
name: 'Quality Changer',
|
||||
name: 'Video Quality Changer',
|
||||
restartNeeded: false,
|
||||
config: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html?raw';
|
||||
|
||||
import builder from './';
|
||||
import builder from './index';
|
||||
|
||||
import { ElementFromHtml } from '../utils/renderer';
|
||||
import { YoutubePlayer } from '../../types/youtube-player';
|
||||
|
||||
// export default () => {
|
||||
// document.addEventListener('apiLoaded', setup, { once: true, passive: true });
|
||||
// };
|
||||
import type { YoutubePlayer } from '../../types/youtube-player';
|
||||
|
||||
export default builder.createRenderer(({ invoke }) => {
|
||||
function $(selector: string): HTMLElement | null {
|
||||
@ -16,34 +13,42 @@ export default builder.createRenderer(({ invoke }) => {
|
||||
|
||||
const qualitySettingsButton = ElementFromHtml(qualitySettingsTemplate);
|
||||
|
||||
let api: YoutubePlayer;
|
||||
|
||||
const chooseQuality = () => {
|
||||
setTimeout(() => $('#player')?.click());
|
||||
|
||||
const qualityLevels = api.getAvailableQualityLevels();
|
||||
|
||||
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
|
||||
|
||||
invoke<{ response: number }>('qualityChanger', api.getAvailableQualityLabels(), currentIndex)
|
||||
.then((promise) => {
|
||||
if (promise.response === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuality = qualityLevels[promise.response];
|
||||
api.setPlaybackQualityRange(newQuality);
|
||||
api.setPlaybackQuality(newQuality);
|
||||
});
|
||||
}
|
||||
|
||||
function setup(event: CustomEvent<YoutubePlayer>) {
|
||||
const api = event.detail;
|
||||
api = event.detail;
|
||||
|
||||
$('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton);
|
||||
|
||||
qualitySettingsButton.addEventListener('click', function chooseQuality() {
|
||||
setTimeout(() => $('#player')?.click());
|
||||
|
||||
const qualityLevels = api.getAvailableQualityLevels();
|
||||
|
||||
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
|
||||
|
||||
invoke<{ response: number }>('qualityChanger', api.getAvailableQualityLabels(), currentIndex)
|
||||
.then((promise) => {
|
||||
if (promise.response === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuality = qualityLevels[promise.response];
|
||||
api.setPlaybackQualityRange(newQuality);
|
||||
api.setPlaybackQuality(newQuality);
|
||||
});
|
||||
});
|
||||
qualitySettingsButton.addEventListener('click', chooseQuality);
|
||||
}
|
||||
|
||||
return {
|
||||
onLoad() {
|
||||
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 builder, { ShortcutMappingType } from './index';
|
||||
|
||||
import getSongControls from '../../providers/song-controls';
|
||||
|
||||
import type { ConfigType } from '../../config/dynamic';
|
||||
|
||||
function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) {
|
||||
globalShortcut.register(shortcut, () => {
|
||||
@ -20,50 +21,57 @@ function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (w
|
||||
});
|
||||
}
|
||||
|
||||
function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>) {
|
||||
const songControls = getSongControls(win);
|
||||
const { playPause, next, previous, search } = songControls;
|
||||
export default builder.createMain(({ getConfig }) => {
|
||||
return {
|
||||
async onLoad(win) {
|
||||
const config = await getConfig();
|
||||
|
||||
if (options.overrideMediaKeys) {
|
||||
_registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause);
|
||||
_registerGlobalShortcut(win.webContents, 'MediaNextTrack', next);
|
||||
_registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous);
|
||||
}
|
||||
const songControls = getSongControls(win);
|
||||
const { playPause, next, previous, search } = songControls;
|
||||
|
||||
_registerLocalShortcut(win, 'CommandOrControl+F', search);
|
||||
_registerLocalShortcut(win, 'CommandOrControl+L', search);
|
||||
|
||||
if (is.linux()) {
|
||||
registerMPRIS(win);
|
||||
}
|
||||
|
||||
const { global, local } = options;
|
||||
const shortcutOptions = { global, local };
|
||||
|
||||
for (const optionType in shortcutOptions) {
|
||||
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
|
||||
}
|
||||
|
||||
function registerAllShortcuts(container: Record<string, string>, type: string) {
|
||||
for (const action in container) {
|
||||
if (!container[action]) {
|
||||
continue; // Action accelerator is empty
|
||||
if (config.overrideMediaKeys) {
|
||||
_registerGlobalShortcut(win.webContents, 'MediaPlayPause', playPause);
|
||||
_registerGlobalShortcut(win.webContents, 'MediaNextTrack', next);
|
||||
_registerGlobalShortcut(win.webContents, 'MediaPreviousTrack', previous);
|
||||
}
|
||||
|
||||
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
|
||||
const actionCallback: () => void = songControls[action as keyof typeof songControls];
|
||||
if (typeof actionCallback !== 'function') {
|
||||
console.warn('Invalid action', action);
|
||||
continue;
|
||||
_registerLocalShortcut(win, 'CommandOrControl+F', search);
|
||||
_registerLocalShortcut(win, 'CommandOrControl+L', search);
|
||||
|
||||
if (is.linux()) {
|
||||
registerMPRIS(win);
|
||||
}
|
||||
|
||||
if (type === 'global') {
|
||||
_registerGlobalShortcut(win.webContents, container[action], actionCallback);
|
||||
} else { // Type === "local"
|
||||
_registerLocalShortcut(win, local[action], actionCallback);
|
||||
const { global, local } = config;
|
||||
const shortcutOptions = { global, local };
|
||||
|
||||
for (const optionType in shortcutOptions) {
|
||||
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
|
||||
}
|
||||
|
||||
function registerAllShortcuts(container: ShortcutMappingType, type: string) {
|
||||
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]) {
|
||||
continue; // Action accelerator is empty
|
||||
}
|
||||
|
||||
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
|
||||
const actionCallback: () => void = songControls[action];
|
||||
if (typeof actionCallback !== 'function') {
|
||||
console.warn('Invalid action', action);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'global') {
|
||||
_registerGlobalShortcut(win.webContents, container[action], actionCallback);
|
||||
} else { // Type === "local"
|
||||
_registerLocalShortcut(win, local[action], actionCallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default registerShortcuts;
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,67 +1,55 @@
|
||||
import prompt, { KeybindOptions } from 'custom-electron-prompt';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { setMenuOptions } from '../../config/plugins';
|
||||
|
||||
import builder, { ShortcutsPluginConfig } from './index';
|
||||
|
||||
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 => [
|
||||
{
|
||||
label: 'Set Global Song Controls',
|
||||
click: () => promptKeybind(options, win),
|
||||
},
|
||||
{
|
||||
label: 'Override MediaKeys',
|
||||
type: 'checkbox',
|
||||
checked: options.overrideMediaKeys,
|
||||
click: (item) => setOption(options, 'overrideMediaKeys', item.checked),
|
||||
},
|
||||
];
|
||||
export default builder.createMenu(async ({ window, getConfig, setConfig }) => {
|
||||
const config = await getConfig();
|
||||
|
||||
function setOption<Key extends keyof ConfigType<'shortcuts'> = keyof ConfigType<'shortcuts'>>(
|
||||
options: ConfigType<'shortcuts'>,
|
||||
key: Key | null = null,
|
||||
newValue: ConfigType<'shortcuts'>[Key] | null = null,
|
||||
) {
|
||||
if (key && newValue !== null) {
|
||||
options[key] = newValue;
|
||||
/**
|
||||
* Helper function for keybind prompt
|
||||
*/
|
||||
const kb = (label_: string, value_: string, default_?: string): KeybindOptions => ({ value: value_, label: label_, default: default_ });
|
||||
|
||||
async function promptKeybind(config: ShortcutsPluginConfig, win: BrowserWindow) {
|
||||
const output = await prompt({
|
||||
title: 'Global Keybinds',
|
||||
label: 'Choose Global Keybinds for Songs Control:',
|
||||
type: 'keybind',
|
||||
keybindOptions: [ // If default=undefined then no default is used
|
||||
kb('Previous', 'previous', config.global?.previous),
|
||||
kb('Play / Pause', 'playPause', config.global?.playPause),
|
||||
kb('Next', 'next', config.global?.next),
|
||||
],
|
||||
height: 270,
|
||||
...promptOptions(),
|
||||
}, win);
|
||||
|
||||
if (output) {
|
||||
const newConfig = { ...config };
|
||||
|
||||
for (const { value, accelerator } of output) {
|
||||
newConfig.global[value as keyof ShortcutsPluginConfig['global']] = accelerator;
|
||||
}
|
||||
|
||||
setConfig(config);
|
||||
}
|
||||
// Else -> pressed cancel
|
||||
}
|
||||
|
||||
setMenuOptions('shortcuts', options);
|
||||
}
|
||||
|
||||
// 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({
|
||||
title: 'Global Keybinds',
|
||||
label: 'Choose Global Keybinds for Songs Control:',
|
||||
type: 'keybind',
|
||||
keybindOptions: [ // If default=undefined then no default is used
|
||||
kb('Previous', 'previous', options.global?.previous),
|
||||
kb('Play / Pause', 'playPause', options.global?.playPause),
|
||||
kb('Next', 'next', options.global?.next),
|
||||
],
|
||||
height: 270,
|
||||
...promptOptions(),
|
||||
}, win);
|
||||
|
||||
if (output) {
|
||||
if (!options.global) {
|
||||
options.global = {};
|
||||
}
|
||||
|
||||
for (const { value, accelerator } of output) {
|
||||
options.global[value] = accelerator;
|
||||
}
|
||||
|
||||
setOption(options);
|
||||
}
|
||||
// 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();
|
||||
|
||||
ipcMain.on('apiLoaded', () => {
|
||||
ipcMain.handle('apiLoaded', () => {
|
||||
win.webContents.send('setupSeekedListener', 'mpris');
|
||||
win.webContents.send('setupTimeChangedListener', '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 hasAudioStarted = false;
|
||||
|
||||
@ -12,109 +12,129 @@ export default (options: SkipSilencesOptions) => {
|
||||
const history = 10;
|
||||
const speakingHistory = Array.from({ length: history }).fill(0) as number[];
|
||||
|
||||
document.addEventListener(
|
||||
'audioCanPlay',
|
||||
(e) => {
|
||||
const video = document.querySelector('video');
|
||||
const { audioContext } = e.detail;
|
||||
const sourceNode = e.detail.audioSource;
|
||||
let playOrSeekHandler: (() => void) | undefined;
|
||||
|
||||
// Use an audio analyser similar to Hark
|
||||
// https://github.com/otalk/hark/blob/master/hark.bundle.js
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = smoothing;
|
||||
const fftBins = new Float32Array(analyser.frequencyBinCount);
|
||||
const getMaxVolume = (analyser: AnalyserNode, fftBins: Float32Array) => {
|
||||
let maxVolume = Number.NEGATIVE_INFINITY;
|
||||
analyser.getFloatFrequencyData(fftBins);
|
||||
|
||||
sourceNode.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
for (let i = 4, ii = fftBins.length; i < ii; i++) {
|
||||
if (fftBins[i] > maxVolume && fftBins[i] < 0) {
|
||||
maxVolume = fftBins[i];
|
||||
}
|
||||
}
|
||||
|
||||
const looper = () => {
|
||||
setTimeout(() => {
|
||||
const currentVolume = getMaxVolume(analyser, fftBins);
|
||||
return maxVolume;
|
||||
};
|
||||
|
||||
let history = 0;
|
||||
if (currentVolume > threshold && isSilent) {
|
||||
// Trigger quickly, short history
|
||||
for (
|
||||
let i = speakingHistory.length - 3;
|
||||
i < speakingHistory.length;
|
||||
i++
|
||||
) {
|
||||
history += speakingHistory[i];
|
||||
}
|
||||
const audioCanPlayListener = (e: CustomEvent<Compressor>) => {
|
||||
const video = document.querySelector('video');
|
||||
const { audioContext } = e.detail;
|
||||
const sourceNode = e.detail.audioSource;
|
||||
|
||||
if (history >= 2) {
|
||||
// Not silent
|
||||
isSilent = false;
|
||||
hasAudioStarted = true;
|
||||
}
|
||||
} else if (currentVolume < threshold && !isSilent) {
|
||||
for (const element of speakingHistory) {
|
||||
history += element;
|
||||
}
|
||||
// Use an audio analyser similar to Hark
|
||||
// https://github.com/otalk/hark/blob/master/hark.bundle.js
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = smoothing;
|
||||
const fftBins = new Float32Array(analyser.frequencyBinCount);
|
||||
|
||||
if (history == 0 // Silent
|
||||
sourceNode.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
|
||||
&& !(
|
||||
video && (
|
||||
video.paused
|
||||
|| video.seeking
|
||||
|| video.ended
|
||||
|| video.muted
|
||||
|| video.volume === 0
|
||||
)
|
||||
)
|
||||
) {
|
||||
isSilent = true;
|
||||
skipSilence();
|
||||
}
|
||||
const looper = () => {
|
||||
setTimeout(() => {
|
||||
const currentVolume = getMaxVolume(analyser, fftBins);
|
||||
|
||||
let history = 0;
|
||||
if (currentVolume > threshold && isSilent) {
|
||||
// Trigger quickly, short history
|
||||
for (
|
||||
let i = speakingHistory.length - 3;
|
||||
i < speakingHistory.length;
|
||||
i++
|
||||
) {
|
||||
history += speakingHistory[i];
|
||||
}
|
||||
|
||||
speakingHistory.shift();
|
||||
speakingHistory.push(Number(currentVolume > threshold));
|
||||
if (history >= 2) {
|
||||
// Not silent
|
||||
isSilent = false;
|
||||
hasAudioStarted = true;
|
||||
}
|
||||
} else if (currentVolume < threshold && !isSilent) {
|
||||
for (const element of speakingHistory) {
|
||||
history += element;
|
||||
}
|
||||
|
||||
looper();
|
||||
}, interval);
|
||||
};
|
||||
if (history == 0 // Silent
|
||||
|
||||
looper();
|
||||
|
||||
const skipSilence = () => {
|
||||
if (options.onlySkipBeginning && hasAudioStarted) {
|
||||
return;
|
||||
&& !(
|
||||
video && (
|
||||
video.paused
|
||||
|| video.seeking
|
||||
|| video.ended
|
||||
|| video.muted
|
||||
|| video.volume === 0
|
||||
)
|
||||
)
|
||||
) {
|
||||
isSilent = true;
|
||||
skipSilence();
|
||||
}
|
||||
}
|
||||
|
||||
if (isSilent && video && !video.paused) {
|
||||
video.currentTime += 0.2; // In s
|
||||
}
|
||||
};
|
||||
speakingHistory.shift();
|
||||
speakingHistory.push(Number(currentVolume > threshold));
|
||||
|
||||
video?.addEventListener('play', () => {
|
||||
hasAudioStarted = false;
|
||||
skipSilence();
|
||||
});
|
||||
looper();
|
||||
}, interval);
|
||||
};
|
||||
|
||||
video?.addEventListener('seeked', () => {
|
||||
hasAudioStarted = false;
|
||||
skipSilence();
|
||||
});
|
||||
looper();
|
||||
|
||||
const skipSilence = () => {
|
||||
if (config.onlySkipBeginning && hasAudioStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSilent && video && !video.paused) {
|
||||
video.currentTime += 0.2; // In s
|
||||
}
|
||||
};
|
||||
|
||||
playOrSeekHandler = () => {
|
||||
hasAudioStarted = false;
|
||||
skipSilence();
|
||||
};
|
||||
|
||||
video?.addEventListener('play', playOrSeekHandler);
|
||||
video?.addEventListener('seeked', playOrSeekHandler);
|
||||
};
|
||||
|
||||
return {
|
||||
async onLoad() {
|
||||
config = await getConfig();
|
||||
|
||||
document.addEventListener(
|
||||
'audioCanPlay',
|
||||
audioCanPlayListener,
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
onUnload() {
|
||||
document.removeEventListener(
|
||||
'audioCanPlay',
|
||||
audioCanPlayListener,
|
||||
);
|
||||
|
||||
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];
|
||||
if (playOrSeekHandler) {
|
||||
const video = document.querySelector('video');
|
||||
video?.removeEventListener('play', playOrSeekHandler);
|
||||
video?.removeEventListener('seeked', playOrSeekHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { sortSegments } from './segments';
|
||||
|
||||
import { SkipSegment } from './types';
|
||||
|
||||
import defaultConfig from '../../config/defaults';
|
||||
import builder from './index';
|
||||
|
||||
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 sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoId}&categories=${JSON.stringify(
|
||||
@ -50,3 +36,16 @@ const fetchSegments = async (apiURL: string, categories: string[], videoId: stri
|
||||
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,34 +1,50 @@
|
||||
import { Segment } from './types';
|
||||
import builder from './index';
|
||||
|
||||
let currentSegments: Segment[] = [];
|
||||
export default builder.createRenderer(({ on }) => {
|
||||
let currentSegments: Segment[] = [];
|
||||
|
||||
export default () => {
|
||||
window.ipcRenderer.on('sponsorblock-skip', (_, segments: Segment[]) => {
|
||||
currentSegments = segments;
|
||||
});
|
||||
const timeUpdateListener = (e: Event) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
const target = e.target;
|
||||
|
||||
document.addEventListener('apiLoaded', () => {
|
||||
const video = document.querySelector<HTMLVideoElement>('video');
|
||||
if (!video) return;
|
||||
|
||||
video.addEventListener('timeupdate', (e) => {
|
||||
if (e.target instanceof HTMLVideoElement) {
|
||||
const target = e.target;
|
||||
|
||||
for (const segment of currentSegments) {
|
||||
if (
|
||||
target.currentTime >= segment[0]
|
||||
&& target.currentTime < segment[1]
|
||||
) {
|
||||
target.currentTime = segment[1];
|
||||
if (window.electronIs.dev()) {
|
||||
console.log('SponsorBlock: skipping segment', segment);
|
||||
}
|
||||
for (const segment of currentSegments) {
|
||||
if (
|
||||
target.currentTime >= segment[0]
|
||||
&& target.currentTime < segment[1]
|
||||
) {
|
||||
target.currentTime = segment[1];
|
||||
if (window.electronIs.dev()) {
|
||||
console.log('SponsorBlock: skipping segment', segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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