feat: migrate to new plugin api

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

View File

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

View File

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

View File

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

View File

@ -25,9 +25,7 @@ import { mainPlugins } from 'virtual:MainPlugins';
import { pluginBuilders } from 'virtual:PluginBuilders';
/* 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> = {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,13 @@
Parts of this code is derived from set-constant.js:
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;

View File

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

View File

@ -1,21 +1,22 @@
import config from './config';
import { blockers } from './types';
import builder from './index';
import { blockers } from './blocker-types';
import type { MenuTemplate } from '../../menu';
import { MenuTemplate } from '../../menu';
export default builder.createMenu(async ({ getConfig, setConfig }): Promise<MenuTemplate> => {
const config = await getConfig();
export default (): MenuTemplate => {
return [
{
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 });
},
})),
},
];
};
});

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import builder from './';
import builder from './index';
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
const qualityList = [10, 25, 50, 100, 200, 500, 1000];

View File

@ -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?.();
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,26 @@
import config from './config';
import builder from './index';
import { MenuTemplate } from '../../menu';
import type { MenuTemplate } from '../../menu';
export default (): MenuTemplate => [
{
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 });
},
},
},
];
];
});

View File

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

View File

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

View File

@ -1,10 +1,27 @@
export default () => {
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();
}
}
};
});

View File

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

View File

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

View File

@ -15,8 +15,6 @@
* v0.2.0, 07/2016
*/
'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?

View File

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

View File

@ -1,11 +1,18 @@
import { ipcMain } from 'electron';
import { Innertube } from 'youtubei.js';
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);
});
}
}));

View File

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

View File

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

View File

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

View File

@ -1,20 +1,19 @@
import { BrowserWindow } from 'electron';
import builder from './index';
import { setMenuOptions } from '../../config/plugins';
export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
import { MenuTemplate } from '../../menu';
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,
});
},
},
];
});

View File

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

View File

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

View File

@ -4,9 +4,9 @@ import { dev } from 'electron-is';
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import 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) {

View File

@ -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({

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import {
import { join } from 'node:path';
import { 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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
// 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,
});
},
}));

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -1,14 +1,11 @@
import crypto from 'node:crypto';
import { 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);
}
}
});
}
}));

View File

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

View File

@ -1,4 +1,6 @@
import { BrowserWindow , net } from 'electron';
import { net } from 'electron';
import builder from './index';
import registerCallback from '../../providers/song-info';
@ -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);
});
};
};
});

View File

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

View File

@ -1,37 +1,35 @@
import { BrowserWindow, ipcMain, net } from 'electron';
import { net } from 'electron';
import is from 'electron-is';
import { 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)}`;

View File

@ -1,19 +1,18 @@
import { BrowserWindow, MenuItem } from 'electron';
import builder from './index';
import { LyricGeniusType, toggleRomanized } from './main';
export default builder.createMenu(async ({ getConfig, setConfig }) => {
const config = await getConfig();
import { setOptions } from '../../config/plugins';
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,
});
},
},
},
];
];
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import { app, BrowserWindow, ipcMain, Notification } from 'electron';
import { app, BrowserWindow, Notification } from 'electron';
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
import 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';
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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;

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
import sliderHTML from './templates/slider.html?raw';
import 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);
}
};
});

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -4,9 +4,10 @@ import electronLocalshortcut from 'electron-localshortcut';
import registerMPRIS from './mpris';
import 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;
};
});

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import type { ConfigType } from '../../config/dynamic';
import builder, { type SkipSilencesPluginConfig } from './index';
type SkipSilencesOptions = ConfigType<'skip-silences'>;
export default builder.createRenderer(({ getConfig }) => {
let config: SkipSilencesPluginConfig;
export default (options: SkipSilencesOptions) => {
let isSilent = false;
let 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;
}
};
});

View File

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

View File

@ -1,26 +1,12 @@
import { BrowserWindow, ipcMain } from 'electron';
import is from 'electron-is';
import { 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);
});
}
}));

View File

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

View File

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

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