feat: enable context-isolation (#1361)

This commit is contained in:
JellyBrick
2023-11-06 17:21:29 +09:00
committed by GitHub
parent 6e52178074
commit 6366dc026e
35 changed files with 655 additions and 474 deletions

View File

@ -0,0 +1,182 @@
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");
* 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,12 +1,9 @@
/* eslint-disable @typescript-eslint/require-await */
import { ipcMain, ipcRenderer } from 'electron';
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';
@ -17,30 +14,15 @@ export type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfig
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
export const getActivePlugins
= process.type === 'renderer'
? async () => ipcRenderer.invoke('get-active-plugins')
: () => activePlugins;
export const getActivePlugins = () => activePlugins;
if (process.type === 'browser') {
ipcMain.handle('get-active-plugins', getActivePlugins);
}
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
export const isActive
= process.type === 'renderer'
? async (plugin: string) =>
plugin in (await ipcRenderer.invoke('get-active-plugins'))
: (plugin: string): boolean => plugin in activePlugins;
export const isActive = (plugin: string): boolean => plugin in activePlugins;
interface PluginConfigOptions {
export interface PluginConfigOptions {
enableFront: boolean;
initialOptions?: OneOfDefaultConfig;
}
@ -48,9 +30,6 @@ interface PluginConfigOptions {
/**
* This class is used to create a dynamic synced config for plugins.
*
* [!IMPORTANT!]
* The methods are **sync** in the main process and **async** in the renderer process.
*
* @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.
@ -72,7 +51,7 @@ interface PluginConfigOptions {
*/
export type ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T];
type ValueOf<T> = T[keyof T];
type Mode<T, Mode extends 'r' | 'm'> = Mode extends 'r' ? Promise<T> : T;
export class PluginConfig<T extends OneOfDefaultConfigKey> {
private readonly name: string;
private readonly config: ConfigType<T>;
@ -180,62 +159,25 @@ export class PluginConfig<T extends OneOfDefaultConfigKey> {
private setupFront() {
const ignoredMethods = ['subscribe', 'subscribeAll'];
if (process.type === 'renderer') {
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 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;
ipcRenderer.on(
`${this.name}-config-changed-${String(valueName)}`,
(_, value: ConfigType<T>) => {
fn(value);
},
);
ipcRenderer.send(`${this.name}-config-subscribe`, valueName);
};
this.subscribeAll = (fn: (config: ConfigType<T>) => void) => {
ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType<T>) => {
fn(value);
});
ipcRenderer.send(`${this.name}-config-subscribe-all`);
};
}
} else if (process.type === 'browser') {
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));
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
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);
});
});
// 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

@ -1,3 +1,5 @@
import { deepmerge } from '@fastify/deepmerge';
import store from './store';
import defaultConfig from './defaults';
@ -9,11 +11,12 @@ interface Plugin {
}
type DefaultPluginsConfig = typeof defaultConfig.plugins;
const deepmergeFn = deepmerge();
export function getEnabled() {
const plugins = store.get('plugins') as DefaultPluginsConfig;
return (Object.entries(plugins) as Entries<DefaultPluginsConfig>).filter(([plugin]) =>
isEnabled(plugin),
const plugins = deepmergeFn(defaultConfig.plugins, (store.get('plugins') as DefaultPluginsConfig));
return (Object.entries(plugins) as Entries<DefaultPluginsConfig>).filter(([, options]) =>
(options as Plugin).enabled,
);
}

View File

@ -1,4 +1,6 @@
import path from 'node:path';
import url from 'node:url';
import fs from 'node:fs';
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request';
@ -195,11 +197,8 @@ async function createMainWindow() {
backgroundColor: '#000',
show: false,
webPreferences: {
// TODO: re-enable contextIsolation once it can work with FFMpeg.wasm
// Possible bundling? https://github.com/ffmpegwasm/ffmpeg.wasm/issues/126
contextIsolation: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
nodeIntegrationInSubFrames: true,
...(isTesting()
? undefined
: {
@ -335,6 +334,14 @@ async function createMainWindow() {
removeContentSecurityPolicy();
win.webContents.on('dom-ready', () => {
const rendererScriptPath = path.join(__dirname, 'renderer.js');
win.webContents.executeJavaScriptInIsolatedWorld(0, [{
code: fs.readFileSync(rendererScriptPath, 'utf-8') + ';0',
url: url.pathToFileURL(rendererScriptPath).toString(),
}], true);
});
win.webContents.loadURL(urlToLoad);
return win;

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { blockers } from './blocker-types';

View File

@ -1,6 +1,6 @@
import { FastAverageColor } from 'fast-average-color';
import { ConfigType } from '../../config/dynamic';
import type { ConfigType } from '../../config/dynamic';
function hexToHSL(H: string) {
// Convert hex to RGB first

View File

@ -1,6 +1,4 @@
import { ipcRenderer } from 'electron';
import { ConfigType } from '../../config/dynamic';
import type { ConfigType } from '../../config/dynamic';
export default (config: ConfigType<'ambient-mode'>) => {
let interpolationTime = config.interpolationTime; // interpolation time (ms)
@ -30,7 +28,7 @@ export default (config: ConfigType<'ambient-mode'>) => {
/* effect */
let lastEffectWorkId: number | null = null;
let lastImageData: ImageData | null = null;
const onSync = () => {
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
@ -40,6 +38,7 @@ export default (config: ConfigType<'ambient-mode'>) => {
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) {
@ -50,8 +49,7 @@ export default (config: ConfigType<'ambient-mode'>) => {
}
context.drawImage(video, 0, 0, width, height);
const nowImageData = context.getImageData(0, 0, width, height);
lastImageData = nowImageData;
lastImageData = context.getImageData(0, 0, width, height); // current image data
lastEffectWorkId = null;
});
@ -102,8 +100,8 @@ export default (config: ConfigType<'ambient-mode'>) => {
applyVideoAttributes();
};
ipcRenderer.on('ambient-mode:config-change', onConfigSync);
window.ipcRenderer.on('ambient-mode:config-change', onConfigSync);
/* hooking */
let canvasInterval: NodeJS.Timeout | null = null;
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer)));
@ -135,17 +133,17 @@ export default (config: ConfigType<'ambient-mode'>) => {
observer.disconnect();
resizeObserver.disconnect();
ipcRenderer.off('ambient-mode:config-change', onConfigSync);
window.ipcRenderer.removeListener('ambient-mode:config-change', onConfigSync);
window.removeEventListener('resize', applyVideoAttributes);
wrapper.removeChild(blurCanvas);
};
};
const playerPage = document.querySelector<HTMLElement>('#player-page');
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes') {
@ -164,4 +162,4 @@ export default (config: ConfigType<'ambient-mode'>) => {
if (playerPage) {
observer.observe(playerPage, { attributes: true });
}
};
};

View File

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

View File

@ -1,13 +1,8 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { ipcRenderer } from 'electron';
import configProvider from './config';
import configProvider from './config-renderer';
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html';
import { ElementFromHtml } from '../utils';
import { ElementFromHtml } from '../utils-renderer';
import { YoutubePlayer } from '../../types/youtube-player';
import type { ConfigType } from '../../config/dynamic';
@ -25,18 +20,17 @@ interface LanguageOptions {
vss_id: string;
}
let config: ConfigType<'captions-selector'>;
let captionsSelectorConfig: ConfigType<'captions-selector'>;
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
export default async () => {
// RENDERER
config = await configProvider.getAll();
export default () => {
captionsSelectorConfig = configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
captionsSelectorConfig = newConfig;
});
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
};
@ -47,7 +41,7 @@ function setup(api: YoutubePlayer) {
let captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
$('video').addEventListener('srcChanged', () => {
if (config.disableCaptions) {
if (captionsSelectorConfig.disableCaptions) {
setTimeout(() => api.unloadModule('captions'), 100);
captionsSettingsButton.style.display = 'none';
return;
@ -58,9 +52,9 @@ function setup(api: YoutubePlayer) {
setTimeout(() => {
captionTrackList = api.getOption('captions', 'tracklist') ?? [];
if (config.autoload && config.lastCaptionsCode) {
if (captionsSelectorConfig.autoload && captionsSelectorConfig.lastCaptionsCode) {
api.setOption('captions', 'track', {
languageCode: config.lastCaptionsCode,
languageCode: captionsSelectorConfig.lastCaptionsCode,
});
}
@ -82,7 +76,7 @@ function setup(api: YoutubePlayer) {
'None',
];
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
currentIndex = await window.ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
if (currentIndex === null) {
return;
}

View File

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

View File

@ -1,13 +1,9 @@
/* eslint-disable @typescript-eslint/await-thenable */
/* renderer */
import { ipcRenderer } from 'electron';
import { Howl } from 'howler';
// Extracted from https://github.com/bitfasching/VolumeFader
import { VolumeFader } from './fader';
import configProvider from './config';
import configProvider from './config-renderer';
import defaultConfigs from '../../config/defaults';
@ -19,11 +15,11 @@ let waitForTransition: Promise<unknown>;
const defaultConfig = defaultConfigs.plugins.crossfade;
let config: ConfigType<'crossfade'>;
let crossfadeConfig: ConfigType<'crossfade'>;
const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(config[key]) || (defaultConfig[key] as number);
const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(crossfadeConfig[key]) || (defaultConfig[key] as number);
const getStreamURL = async (videoID: string) => ipcRenderer.invoke('audio-url', videoID) as Promise<string>;
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');
@ -119,7 +115,7 @@ const onApiLoaded = () => {
return;
}
await createAudioForCrossfade(url);
createAudioForCrossfade(url);
});
};
@ -150,11 +146,11 @@ const crossfade = (cb: () => void) => {
});
};
export default async () => {
config = await configProvider.getAll();
export default () => {
crossfadeConfig = configProvider.getAll();
configProvider.subscribeAll((newConfig) => {
config = newConfig;
crossfadeConfig = newConfig;
});
document.addEventListener('apiLoaded', onApiLoaded, {

View File

@ -1,10 +1,8 @@
import { ipcRenderer } from 'electron';
import downloadHTML from './templates/download.html';
import defaultConfig from '../../config/defaults';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils';
import { ElementFromHtml } from '../utils-renderer';
import { getSongInfo } from '../../providers/song-info-front';
let menu: Element | null = null;
@ -13,55 +11,55 @@ const downloadButton = ElementFromHtml(downloadHTML);
let doneFirstLoad = false;
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="0"] #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=')) {
ipcRenderer.send('download-playlist-request', videoUrl);
return;
}
} else {
videoUrl = getSongInfo().url || window.location.href;
}
ipcRenderer.send('download-song', videoUrl);
};
export default () => {
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,
@ -69,7 +67,7 @@ export default () => {
});
}, { once: true, passive: true });
ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
window.ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
if (progress) {
progress.innerHTML = feedback || 'Download';
} else {

View File

@ -1,6 +1,6 @@
import { register } from 'electron-localshortcut';
import { BrowserWindow, Menu, MenuItem, ipcMain } from 'electron';
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron';
import titlebarStyle from './titlebar.css';
@ -64,4 +64,9 @@ export default (win: BrowserWindow) => {
win.on('maximize', () => win.webContents.send('window-maximize'));
ipcMain.handle('window-unmaximize', () => win.unmaximize());
win.on('unmaximize', () => win.webContents.send('window-unmaximize'));
ipcMain.handle('image-path-to-data-url', (_, imagePath: string) => {
const nativeImageIcon = nativeImage.createFromPath(imagePath);
return nativeImageIcon?.toDataURL();
});
};

View File

@ -1,5 +1,3 @@
import { ipcRenderer, Menu } from 'electron';
import { createPanel } from './menu/panel';
import logo from './assets/menu.svg';
@ -8,8 +6,7 @@ import minimize from './assets/minimize.svg';
import maximize from './assets/maximize.svg';
import unmaximize from './assets/unmaximize.svg';
import { isEnabled } from '../../config/plugins';
import config from '../../config';
import type { Menu } from 'electron';
function $<E extends Element = Element>(selector: string) {
return document.querySelector<E>(selector);
@ -19,8 +16,8 @@ const isMacOS = navigator.userAgent.includes('Macintosh');
const isNotWindowsOrMacOS = !navigator.userAgent.includes('Windows') && !isMacOS;
export default async () => {
const hideDOMWindowControls = config.get('plugins.in-app-menu.hideDOMWindowControls');
let hideMenu = config.get('options.hideMenu');
const hideDOMWindowControls = window.mainConfig.get('plugins.in-app-menu.hideDOMWindowControls');
let hideMenu = window.mainConfig.get('options.hideMenu');
const titleBar = document.createElement('title-bar');
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
let maximizeButton: HTMLButtonElement;
@ -42,7 +39,7 @@ export default async () => {
};
logo.onclick = logoClick;
ipcRenderer.on('toggleMenu', logoClick);
window.ipcRenderer.on('toggleMenu', logoClick);
if (!isMacOS) titleBar.appendChild(logo);
document.body.appendChild(titleBar);
@ -55,10 +52,10 @@ export default async () => {
const minimizeButton = document.createElement('button');
minimizeButton.classList.add('window-control');
minimizeButton.appendChild(minimize);
minimizeButton.onclick = () => ipcRenderer.invoke('window-minimize');
minimizeButton.onclick = () => window.ipcRenderer.invoke('window-minimize');
maximizeButton = document.createElement('button');
if (await ipcRenderer.invoke('window-is-maximized')) {
if (await window.ipcRenderer.invoke('window-is-maximized')) {
maximizeButton.classList.add('window-control');
maximizeButton.appendChild(unmaximize);
} else {
@ -66,27 +63,27 @@ export default async () => {
maximizeButton.appendChild(maximize);
}
maximizeButton.onclick = async () => {
if (await ipcRenderer.invoke('window-is-maximized')) {
if (await window.ipcRenderer.invoke('window-is-maximized')) {
// change icon to maximize
maximizeButton.removeChild(maximizeButton.firstChild!);
maximizeButton.appendChild(maximize);
// call unmaximize
await ipcRenderer.invoke('window-unmaximize');
await window.ipcRenderer.invoke('window-unmaximize');
} else {
// change icon to unmaximize
maximizeButton.removeChild(maximizeButton.firstChild!);
maximizeButton.appendChild(unmaximize);
// call maximize
await ipcRenderer.invoke('window-maximize');
await window.ipcRenderer.invoke('window-maximize');
}
};
const closeButton = document.createElement('button');
closeButton.classList.add('window-control');
closeButton.appendChild(close);
closeButton.onclick = () => ipcRenderer.invoke('window-close');
closeButton.onclick = () => window.ipcRenderer.invoke('window-close');
// Create a container div for the window control buttons
const windowControlsContainer = document.createElement('div');
@ -118,7 +115,7 @@ export default async () => {
if (child !== logo) child.remove();
});
const menu = await ipcRenderer.invoke('get-menu') as Menu | null;
const menu = await window.ipcRenderer.invoke('get-menu') as Menu | null;
if (!menu) return;
menu.items.forEach((menuItem) => {
@ -137,22 +134,22 @@ export default async () => {
document.title = 'Youtube Music';
ipcRenderer.on('refreshMenu', () => updateMenu());
ipcRenderer.on('window-maximize', () => {
window.ipcRenderer.on('refreshMenu', () => updateMenu());
window.ipcRenderer.on('window-maximize', () => {
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
maximizeButton.removeChild(maximizeButton.firstChild);
maximizeButton.appendChild(unmaximize);
}
});
ipcRenderer.on('window-unmaximize', () => {
window.ipcRenderer.on('window-unmaximize', () => {
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
maximizeButton.removeChild(maximizeButton.firstChild);
maximizeButton.appendChild(unmaximize);
}
});
if (isEnabled('picture-in-picture')) {
ipcRenderer.on('pip-toggle', () => {
if (window.mainConfig.plugins.isEnabled('picture-in-picture')) {
window.ipcRenderer.on('pip-toggle', () => {
updateMenu();
});
}

View File

@ -1,8 +1,8 @@
import { nativeImage, type MenuItem, ipcRenderer, Menu } from 'electron';
import Icons from './icons';
import { ElementFromHtml } from '../../utils';
import { ElementFromHtml } from '../../utils-renderer';
import type { MenuItem } from 'electron';
interface PanelOptions {
placement?: 'bottom' | 'right';
@ -19,7 +19,7 @@ export const createPanel = (
const panel = document.createElement('menu-panel');
panel.style.zIndex = `${options.order}`;
const updateIconState = (iconWrapper: HTMLElement, item: MenuItem) => {
const updateIconState = async (iconWrapper: HTMLElement, item: MenuItem) => {
if (item.type === 'checkbox') {
if (item.checked) iconWrapper.innerHTML = Icons.checkbox;
else iconWrapper.innerHTML = '';
@ -27,8 +27,8 @@ export const createPanel = (
if (item.checked) iconWrapper.innerHTML = Icons.radio.checked;
else iconWrapper.innerHTML = Icons.radio.unchecked;
} else {
const nativeImageIcon = typeof item.icon === 'string' ? nativeImage.createFromPath(item.icon) : item.icon;
const iconURL = nativeImageIcon?.toDataURL();
const iconURL = typeof item.icon === 'string' ?
await window.ipcRenderer.invoke('image-path-to-data-url') as string : item.icon?.toDataURL();
if (iconURL) iconWrapper.style.background = `url(${iconURL})`;
}
@ -46,8 +46,8 @@ export const createPanel = (
menu.append(item.label);
menu.addEventListener('click', async () => {
await ipcRenderer.invoke('menu-event', item.commandId);
const menuItem = await ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
await window.ipcRenderer.invoke('menu-event', item.commandId);
const menuItem = await window.ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
if (menuItem) {
updateIconState(iconWrapper, menuItem);
@ -56,7 +56,7 @@ export const createPanel = (
await Promise.all(
radioGroups.map(async ([item, iconWrapper]) => {
if (item.commandId === menuItem.commandId) return;
const newItem = await ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
const newItem = await window.ipcRenderer.invoke('get-menu-by-id', item.commandId) as MenuItem | null;
if (newItem) updateIconState(iconWrapper, newItem);
})

View File

@ -1,6 +1,3 @@
import { ipcRenderer } from 'electron';
import is from 'electron-is';
import type { SongInfo } from '../../providers/song-info';
export default () => {
@ -22,9 +19,9 @@ export default () => {
}
};
let unregister: (() => void) | null = null;
let unregister: (() => void) | null = null;
ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => {
window.ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => {
unregister?.();
setTimeout(async () => {
@ -38,7 +35,7 @@ export default () => {
// Check if disabled
if (!tabs.lyrics?.hasAttribute('disabled')) return;
const lyrics = await ipcRenderer.invoke(
const lyrics = await window.ipcRenderer.invoke(
'search-genius-lyrics',
extractedSongInfo,
) as string | null;
@ -50,7 +47,7 @@ export default () => {
return;
}
if (is.dev()) {
if (window.electronIs.dev()) {
console.log('Fetched lyrics from Genius');
}
@ -58,7 +55,7 @@ export default () => {
const lyricsContainer = document.querySelector(
'[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer',
);
if (lyricsContainer) {
callback?.();

View File

@ -1,12 +1,10 @@
import { ipcRenderer } from 'electron';
import forwardHTML from './templates/forward.html';
import backHTML from './templates/back.html';
import { ElementFromHtml } from '../utils';
import { ElementFromHtml } from '../utils-renderer';
export function run() {
ipcRenderer.on('navigation-css-ready', () => {
window.ipcRenderer.on('navigation-css-ready', () => {
const forwardButton = ElementFromHtml(forwardHTML);
const backButton = ElementFromHtml(backHTML);
const menu = document.querySelector('#right-content');

View File

@ -1,4 +1,3 @@
import { ipcRenderer } from 'electron';
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
import keyEventAreEqual from 'keyboardevents-areequal';
@ -6,7 +5,7 @@ import pipHTML from './templates/picture-in-picture.html';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils';
import { ElementFromHtml } from '../utils-renderer';
import type { ConfigType } from '../../config/dynamic';
@ -85,7 +84,7 @@ const togglePictureInPicture = async () => {
}
}
ipcRenderer.send('picture-in-picture');
window.ipcRenderer.send('picture-in-picture');
return false;
};
// For UI (HTML)
@ -105,7 +104,7 @@ const listenForToggle = () => {
const titlebar = $<HTMLElement>('.cet-titlebar');
ipcRenderer.on('pip-toggle', (_, isPip: boolean) => {
window.ipcRenderer.on('pip-toggle', (_, isPip: boolean) => {
if (originalExitButton && player) {
if (isPip) {
replaceButton('.exit-fullscreen-button', originalExitButton)?.addEventListener('click', () => togglePictureInPicture());

View File

@ -1,7 +1,7 @@
import sliderHTML from './templates/slider.html';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromHtml } from '../utils';
import { ElementFromHtml } from '../utils-renderer';
import { singleton } from '../../providers/decorators';

View File

@ -1,6 +1,3 @@
import { ipcRenderer } from 'electron';
import { setOptions, setMenuOptions, isEnabled } from '../../config/plugins';
import { debounce } from '../../providers/decorators';
import { YoutubePlayer } from '../../types/youtube-player';
@ -18,15 +15,15 @@ export default (_options: ConfigType<'precise-volume'>) => {
options = _options;
document.addEventListener('apiLoaded', (e) => {
api = e.detail;
ipcRenderer.on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease));
ipcRenderer.on('setVolume', (_, value: number) => setVolume(value));
window.ipcRenderer.on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease));
window.ipcRenderer.on('setVolume', (_, value: number) => setVolume(value));
firstRun();
}, { once: true, passive: true });
};
// Without this function it would rewrite config 20 time when volume change by 20
const writeOptions = debounce(() => {
setOptions('precise-volume', options);
window.mainConfig.plugins.setOptions('precise-volume', options);
}, 1000);
export const moveVolumeHud = debounce((showVideo: boolean) => {
@ -68,7 +65,7 @@ function firstRun() {
injectVolumeHud(noVid);
if (!noVid) {
setupVideoPlayerOnwheel();
if (!isEnabled('video-toggle')) {
if (!window.mainConfig.plugins.isEnabled('video-toggle')) {
// Video-toggle handles hud positioning on its own
const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV';
$('video')?.addEventListener('srcChanged', () => moveVolumeHud(videoMode()));
@ -76,9 +73,9 @@ function firstRun() {
}
// Change options from renderer to keep sync
ipcRenderer.on('setOptions', (_event, newOptions = {}) => {
window.ipcRenderer.on('setOptions', (_event, newOptions = {}) => {
Object.assign(options, newOptions);
setMenuOptions('precise-volume', options);
window.mainConfig.plugins.setMenuOptions('precise-volume', options);
});
}

View File

@ -1,8 +1,6 @@
import { ipcRenderer } from 'electron';
import qualitySettingsTemplate from './templates/qualitySettingsTemplate.html';
import { ElementFromHtml } from '../utils';
import { ElementFromHtml } from '../utils-renderer';
import { YoutubePlayer } from '../../types/youtube-player';
function $(selector: string): HTMLElement | null {
@ -23,7 +21,7 @@ function setup(event: CustomEvent<YoutubePlayer>) {
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise: { response: number }) => {
window.ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise: { response: number }) => {
if (promise.response === -1) {
return;
}

View File

@ -1,12 +1,9 @@
import { ipcRenderer } from 'electron';
import is from 'electron-is';
import { Segment } from './types';
let currentSegments: Segment[] = [];
export default () => {
ipcRenderer.on('sponsorblock-skip', (_, segments: Segment[]) => {
window.ipcRenderer.on('sponsorblock-skip', (_, segments: Segment[]) => {
currentSegments = segments;
});
@ -24,7 +21,7 @@ export default () => {
&& target.currentTime < segment[1]
) {
target.currentTime = segment[1];
if (is.dev()) {
if (window.electronIs.dev()) {
console.log('SponsorBlock: skipping segment', segment);
}
}

View File

@ -0,0 +1,8 @@
// Creates a DOM element from an HTML string
export const ElementFromHtml = (html: string): HTMLElement => {
const template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstElementChild as HTMLElement;
};

View File

@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, ipcMain, ipcRenderer } from 'electron';
import { app } from 'electron';
import is from 'electron-is';
import { ValueOf } from '../utils/type-utils';
@ -34,31 +34,6 @@ export const saveMediaIcon = () => {
}
};
// Creates a DOM element from an HTML string
export const ElementFromHtml = (html: string): HTMLElement => {
const template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstElementChild as HTMLElement;
};
// Creates a DOM element from a HTML file
export const ElementFromFile = (filepath: fs.PathOrFileDescriptor) => ElementFromHtml(fs.readFileSync(filepath, 'utf8'));
export const templatePath = (pluginPath: string, name: string) => path.join(pluginPath, 'templates', name);
export const Actions = {
NEXT: 'next',
BACK: 'back',
};
export const triggerAction = <Parameters extends unknown[]>(channel: string, action: ValueOf<typeof Actions>, ...args: Parameters) => ipcRenderer.send(channel, action, ...args);
export const triggerActionSync = <Parameters extends unknown[]>(channel: string, action: ValueOf<typeof Actions>, ...args: Parameters): unknown => ipcRenderer.sendSync(channel, action, ...args);
export const listenAction = (channel: string, callback: (event: Electron.IpcMainEvent, action: string) => void) => ipcMain.on(channel, callback);
export const fileExists = (
path: fs.PathLike,
callbackIfExists: { (): void; (): void; (): void; },

View File

@ -1,7 +1,6 @@
import buttonTemplate from './templates/button_template.html';
import { ElementFromHtml } from '../utils';
import { setOptions, isEnabled } from '../../config/plugins';
import { ElementFromHtml } from '../utils-renderer';
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/front';
@ -10,7 +9,7 @@ import { ThumbnailElement } from '../../types/get-player-response';
import type { ConfigType } from '../../config/dynamic';
const moveVolumeHud = isEnabled('precise-volume') ? preciseVolumeMoveVolumeHud : () => {};
const moveVolumeHud = window.mainConfig.plugins.isEnabled('precise-volume') ? preciseVolumeMoveVolumeHud : () => {};
function $<E extends Element = Element>(selector: string): E | null {
return document.querySelector<E>(selector);
@ -99,7 +98,7 @@ function setup(e: CustomEvent<YoutubePlayer>) {
function setVideoState(showVideo: boolean) {
options.hideVideo = !showVideo;
setOptions('video-toggle', options);
window.mainConfig.plugins.setOptions('video-toggle', options);
const checkbox = $<HTMLInputElement>('.video-switch-button-checkbox'); // custom mode
if (checkbox) checkbox.checked = !options.hideVideo;

View File

@ -3,7 +3,7 @@ import ButterchurnPresets from 'butterchurn-presets';
import { Visualizer } from './visualizer';
import { ConfigType } from '../../../config/dynamic';
import type { ConfigType } from '../../../config/dynamic';
class ButterchurnVisualizer extends Visualizer<Butterchurn> {
name = 'butterchurn';

View File

@ -1,40 +1,14 @@
import { ipcRenderer } from 'electron';
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import is from 'electron-is';
import config from './config';
import setupSongInfo from './providers/song-info-front';
import { setupSongControls } from './providers/song-controls-front';
import { startingPages } from './providers/extracted-data';
import albumColorThemeRenderer from './plugins/album-color-theme/front';
import ambientModeRenderer from './plugins/ambient-mode/front';
import audioCompressorRenderer from './plugins/audio-compressor/front';
import bypassAgeRestrictionsRenderer from './plugins/bypass-age-restrictions/front';
import captionsSelectorRenderer from './plugins/captions-selector/front';
import compactSidebarRenderer from './plugins/compact-sidebar/front';
import crossfadeRenderer from './plugins/crossfade/front';
import disableAutoplayRenderer from './plugins/disable-autoplay/front';
import downloaderRenderer from './plugins/downloader/front';
import exponentialVolumeRenderer from './plugins/exponential-volume/front';
import inAppMenuRenderer from './plugins/in-app-menu/front';
import lyricsGeniusRenderer from './plugins/lyrics-genius/front';
import navigationRenderer from './plugins/navigation/front';
import noGoogleLogin from './plugins/no-google-login/front';
import pictureInPictureRenderer from './plugins/picture-in-picture/front';
import playbackSpeedRenderer from './plugins/playback-speed/front';
import preciseVolumeRenderer from './plugins/precise-volume/front';
import qualityChangerRenderer from './plugins/quality-changer/front';
import skipSilencesRenderer from './plugins/skip-silences/front';
import sponsorblockRenderer from './plugins/sponsorblock/front';
import videoToggleRenderer from './plugins/video-toggle/front';
import visualizerRenderer from './plugins/visualizer/front';
import adblockerPreload from './plugins/adblocker/preload';
import preciseVolumePreload from './plugins/precise-volume/preload';
import type { ConfigType, OneOfDefaultConfigKey } from './config/dynamic';
type PluginMapper<Type extends 'renderer' | 'preload' | 'backend'> = {
export type PluginMapper<Type extends 'renderer' | 'preload' | 'backend'> = {
[Key in OneOfDefaultConfigKey]?: (
Type extends 'renderer' ? (options: ConfigType<Key>) => (Promise<void> | void) :
Type extends 'preload' ? () => (Promise<void> | void) :
@ -42,31 +16,6 @@ type PluginMapper<Type extends 'renderer' | 'preload' | 'backend'> = {
)
};
const rendererPlugins: PluginMapper<'renderer'> = {
'album-color-theme': albumColorThemeRenderer,
'ambient-mode': ambientModeRenderer,
'audio-compressor': audioCompressorRenderer,
'bypass-age-restrictions': bypassAgeRestrictionsRenderer,
'captions-selector': captionsSelectorRenderer,
'compact-sidebar': compactSidebarRenderer,
'crossfade': crossfadeRenderer,
'disable-autoplay': disableAutoplayRenderer,
'downloader': downloaderRenderer,
'exponential-volume': exponentialVolumeRenderer,
'in-app-menu': inAppMenuRenderer,
'lyrics-genius': lyricsGeniusRenderer,
'navigation': navigationRenderer,
'no-google-login': noGoogleLogin,
'picture-in-picture': pictureInPictureRenderer,
'playback-speed': playbackSpeedRenderer,
'precise-volume': preciseVolumeRenderer,
'quality-changer': qualityChangerRenderer,
'skip-silences': skipSilencesRenderer,
'sponsorblock': sponsorblockRenderer,
'video-toggle': videoToggleRenderer,
'visualizer': visualizerRenderer,
};
const preloadPlugins: PluginMapper<'preload'> = {
'adblocker': adblockerPreload,
'precise-volume': preciseVolumePreload,
@ -74,10 +23,6 @@ const preloadPlugins: PluginMapper<'preload'> = {
const enabledPluginNameAndOptions = config.plugins.getEnabled();
const $ = document.querySelector.bind(document);
let api: Element | null = null;
enabledPluginNameAndOptions.forEach(async ([plugin, options]) => {
if (Object.hasOwn(preloadPlugins, plugin)) {
const handler = preloadPlugins[plugin];
@ -89,119 +34,17 @@ enabledPluginNameAndOptions.forEach(async ([plugin, options]) => {
}
});
document.addEventListener('DOMContentLoaded', () => {
enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => {
if (Object.hasOwn(rendererPlugins, pluginName)) {
const handler = rendererPlugins[pluginName];
try {
await handler?.(options as never);
} catch (error) {
console.error(`Error in plugin "${pluginName}": ${String(error)}`);
}
}
});
// Wait for complete load of YouTube api
listenForApiLoad();
// Inject song-info provider
setupSongInfo();
// Inject song-controls
setupSongControls();
// Add action for reloading
window.reload = () => ipcRenderer.send('reload');
// Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min
setInterval(() => window._lact = Date.now(), 900_000);
// Setup back to front logger
if (is.dev()) {
ipcRenderer.on('log', (_event, log: string) => {
console.log(JSON.parse(log));
});
}
contextBridge.exposeInMainWorld('mainConfig', config);
contextBridge.exposeInMainWorld('electronIs', is);
contextBridge.exposeInMainWorld('ipcRenderer', {
on: (channel: string, listener: (event: IpcRendererEvent, ...args: unknown[]) => void) => ipcRenderer.on(channel, listener),
off: (channel: string, listener: (...args: unknown[]) => void) => ipcRenderer.off(channel, listener),
once: (channel: string, listener: (event: IpcRendererEvent, ...args: unknown[]) => void) => ipcRenderer.once(channel, listener),
send: (channel: string, ...args: unknown[]) => ipcRenderer.send(channel, ...args),
removeListener: (channel: string, listener: (...args: unknown[]) => void) => ipcRenderer.removeListener(channel, listener),
removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel),
invoke: async (channel: string, ...args: unknown[]): Promise<unknown> => ipcRenderer.invoke(channel, ...args),
sendSync: (channel: string, ...args: unknown[]): unknown => ipcRenderer.sendSync(channel, ...args),
sendToHost: (channel: string, ...args: unknown[]) => ipcRenderer.sendToHost(channel, ...args),
});
function listenForApiLoad() {
api = $('#movie_player');
if (api) {
onApiLoaded();
return;
}
const observer = new MutationObserver(() => {
api = $('#movie_player');
if (api) {
observer.disconnect();
onApiLoaded();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
interface YouTubeMusicAppElement extends HTMLElement {
navigate_(page: string): void;
}
function onApiLoaded() {
const video = $('video')!;
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(video);
audioSource.connect(audioContext.destination);
video.addEventListener(
'loadstart',
() => {
// Emit "audioCanPlay" for each video
video.addEventListener(
'canplaythrough',
() => {
document.dispatchEvent(
new CustomEvent('audioCanPlay', {
detail: {
audioContext,
audioSource,
},
}),
);
},
{ once: true },
);
},
{ passive: true },
);!
document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api }));
ipcRenderer.send('apiLoaded');
// Navigate to "Starting page"
const startingPage: string = config.get('options.startingPage');
if (startingPage && startingPages[startingPage]) {
$<YouTubeMusicAppElement>('ytmusic-app')?.navigate_(startingPages[startingPage]);
}
// Remove upgrade button
if (config.get('options.removeUpgradeButton')) {
const styles = document.createElement('style');
styles.innerHTML = `ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer:nth-child(4) {
display: none;
}`;
document.head.appendChild(styles);
}
// Hide / Force show like buttons
const likeButtonsOptions: string = config.get('options.likeButtons');
if (likeButtonsOptions) {
const likeButtons: HTMLElement | null = $('ytmusic-like-button-renderer');
if (likeButtons) {
likeButtons.style.display
= {
hide: 'none',
force: 'inherit',
}[likeButtonsOptions] || '';
}
}
}
contextBridge.exposeInMainWorld('reload', () => ipcRenderer.send('reload'));

View File

@ -1,8 +1,6 @@
import { ipcRenderer } from 'electron';
export const setupSongControls = () => {
document.addEventListener('apiLoaded', (event) => {
ipcRenderer.on('seekTo', (_, t: number) => event.detail.seekTo(t));
ipcRenderer.on('seekBy', (_, t: number) => event.detail.seekBy(t));
window.ipcRenderer.on('seekTo', (_, t: number) => event.detail.seekTo(t));
window.ipcRenderer.on('seekBy', (_, t: number) => event.detail.seekBy(t));
}, { once: true, passive: true });
};

View File

@ -1,21 +1,18 @@
import { ipcRenderer } from 'electron';
import { singleton } from './decorators';
import { getImage, SongInfo } from './song-info';
import { YoutubePlayer } from '../types/youtube-player';
import { GetState } from '../types/datahost-get-state';
import { VideoDataChangeValue } from '../types/player-api-events';
import type { YoutubePlayer } from '../types/youtube-player';
import type { GetState } from '../types/datahost-get-state';
import type { VideoDataChangeValue } from '../types/player-api-events';
import type { SongInfo } from './song-info';
let songInfo: SongInfo = {} as SongInfo;
export const getSongInfo = () => songInfo;
const $ = <E extends Element = Element>(s: string): E | null => document.querySelector<E>(s);
const $$ = <E extends Element = Element>(s: string): NodeListOf<E> => document.querySelectorAll<E>(s);
ipcRenderer.on('update-song-info', async (_, extractedSongInfo: SongInfo) => {
window.ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => {
songInfo = extractedSongInfo;
if (songInfo.imageSrc) songInfo.image = await getImage(songInfo.imageSrc);
});
// Used because 'loadeddata' or 'loadedmetadata' weren't firing on song start for some users (https://github.com/th-ch/youtube-music/issues/473)
@ -24,7 +21,7 @@ const srcChangedEvent = new CustomEvent('srcChanged');
export const setupSeekedListener = singleton(() => {
$('video')?.addEventListener('seeked', (v) => {
if (v.target instanceof HTMLVideoElement) {
ipcRenderer.send('seeked', v.target.currentTime);
window.ipcRenderer.send('seeked', v.target.currentTime);
}
});
});
@ -33,7 +30,7 @@ export const setupTimeChangedListener = singleton(() => {
const progressObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
const target = mutation.target as Node & { value: string };
ipcRenderer.send('timeChanged', target.value);
window.ipcRenderer.send('timeChanged', target.value);
songInfo.elapsedSeconds = Number(target.value);
}
});
@ -47,7 +44,7 @@ export const setupRepeatChangedListener = singleton(() => {
const repeatObserver = new MutationObserver((mutations) => {
// provided by YouTube Music
ipcRenderer.send(
window.ipcRenderer.send(
'repeatChanged',
(mutations[0].target as Node & {
__dataHost: {
@ -60,7 +57,7 @@ export const setupRepeatChangedListener = singleton(() => {
// Emit the initial value as well; as it's persistent between launches.
// provided by YouTube Music
ipcRenderer.send(
window.ipcRenderer.send(
'repeatChanged',
$<HTMLElement & {
getState: () => GetState;
@ -70,33 +67,33 @@ export const setupRepeatChangedListener = singleton(() => {
export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => {
$('video')?.addEventListener('volumechange', () => {
ipcRenderer.send('volumeChanged', api.getVolume());
window.ipcRenderer.send('volumeChanged', api.getVolume());
});
// Emit the initial value as well; as it's persistent between launches.
ipcRenderer.send('volumeChanged', api.getVolume());
window.ipcRenderer.send('volumeChanged', api.getVolume());
});
export default () => {
document.addEventListener('apiLoaded', (apiEvent) => {
ipcRenderer.on('setupTimeChangedListener', () => {
window.ipcRenderer.on('setupTimeChangedListener', () => {
setupTimeChangedListener();
});
ipcRenderer.on('setupRepeatChangedListener', () => {
window.ipcRenderer.on('setupRepeatChangedListener', () => {
setupRepeatChangedListener();
});
ipcRenderer.on('setupVolumeChangedListener', () => {
window.ipcRenderer.on('setupVolumeChangedListener', () => {
setupVolumeChangedListener(apiEvent.detail);
});
ipcRenderer.on('setupSeekedListener', () => {
window.ipcRenderer.on('setupSeekedListener', () => {
setupSeekedListener();
});
const playPausedHandler = (e: Event, status: string) => {
if (e.target instanceof HTMLVideoElement && Math.round(e.target.currentTime) > 0) {
ipcRenderer.send('playPaused', {
window.ipcRenderer.send('playPaused', {
isPaused: status === 'pause',
elapsedSeconds: Math.floor(e.target.currentTime),
});
@ -143,7 +140,7 @@ export default () => {
data.videoDetails.author = data.microformat.microformatDataRenderer.pageOwnerDetails.name;
}
ipcRenderer.send('video-src-changed', data);
window.ipcRenderer.send('video-src-changed', data);
}
}, { once: true, passive: true });
};

171
src/renderer.ts Normal file
View File

@ -0,0 +1,171 @@
import setupSongInfo from './providers/song-info-front';
import { setupSongControls } from './providers/song-controls-front';
import { startingPages } from './providers/extracted-data';
import albumColorThemeRenderer from './plugins/album-color-theme/front';
import ambientModeRenderer from './plugins/ambient-mode/front';
import audioCompressorRenderer from './plugins/audio-compressor/front';
import bypassAgeRestrictionsRenderer from './plugins/bypass-age-restrictions/front';
import captionsSelectorRenderer from './plugins/captions-selector/front';
import compactSidebarRenderer from './plugins/compact-sidebar/front';
import crossfadeRenderer from './plugins/crossfade/front';
import disableAutoplayRenderer from './plugins/disable-autoplay/front';
import downloaderRenderer from './plugins/downloader/front';
import exponentialVolumeRenderer from './plugins/exponential-volume/front';
import inAppMenuRenderer from './plugins/in-app-menu/front';
import lyricsGeniusRenderer from './plugins/lyrics-genius/front';
import navigationRenderer from './plugins/navigation/front';
import noGoogleLogin from './plugins/no-google-login/front';
import pictureInPictureRenderer from './plugins/picture-in-picture/front';
import playbackSpeedRenderer from './plugins/playback-speed/front';
import preciseVolumeRenderer from './plugins/precise-volume/front';
import qualityChangerRenderer from './plugins/quality-changer/front';
import skipSilencesRenderer from './plugins/skip-silences/front';
import sponsorblockRenderer from './plugins/sponsorblock/front';
import videoToggleRenderer from './plugins/video-toggle/front';
import visualizerRenderer from './plugins/visualizer/front';
import type { PluginMapper } from './preload';
const rendererPlugins: PluginMapper<'renderer'> = {
'album-color-theme': albumColorThemeRenderer,
'ambient-mode': ambientModeRenderer,
'audio-compressor': audioCompressorRenderer,
'bypass-age-restrictions': bypassAgeRestrictionsRenderer,
'captions-selector': captionsSelectorRenderer,
'compact-sidebar': compactSidebarRenderer,
'crossfade': crossfadeRenderer,
'disable-autoplay': disableAutoplayRenderer,
'downloader': downloaderRenderer,
'exponential-volume': exponentialVolumeRenderer,
'in-app-menu': inAppMenuRenderer,
'lyrics-genius': lyricsGeniusRenderer,
'navigation': navigationRenderer,
'no-google-login': noGoogleLogin,
'picture-in-picture': pictureInPictureRenderer,
'playback-speed': playbackSpeedRenderer,
'precise-volume': preciseVolumeRenderer,
'quality-changer': qualityChangerRenderer,
'skip-silences': skipSilencesRenderer,
'sponsorblock': sponsorblockRenderer,
'video-toggle': videoToggleRenderer,
'visualizer': visualizerRenderer,
};
const enabledPluginNameAndOptions = window.mainConfig.plugins.getEnabled();
let api: Element | null = null;
function listenForApiLoad() {
api = document.querySelector('#movie_player');
if (api) {
onApiLoaded();
return;
}
const observer = new MutationObserver(() => {
api = document.querySelector('#movie_player');
if (api) {
observer.disconnect();
onApiLoaded();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
interface YouTubeMusicAppElement extends HTMLElement {
navigate_(page: string): void;
}
function onApiLoaded() {
const video = document.querySelector('video')!;
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(video);
audioSource.connect(audioContext.destination);
video.addEventListener(
'loadstart',
() => {
// Emit "audioCanPlay" for each video
video.addEventListener(
'canplaythrough',
() => {
document.dispatchEvent(
new CustomEvent('audioCanPlay', {
detail: {
audioContext,
audioSource,
},
}),
);
},
{ once: true },
);
},
{ passive: true },
);!
document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api }));
window.ipcRenderer.send('apiLoaded');
// Navigate to "Starting page"
const startingPage: string = window.mainConfig.get('options.startingPage');
if (startingPage && startingPages[startingPage]) {
document.querySelector<YouTubeMusicAppElement>('ytmusic-app')?.navigate_(startingPages[startingPage]);
}
// Remove upgrade button
if (window.mainConfig.get('options.removeUpgradeButton')) {
const styles = document.createElement('style');
styles.innerHTML = `ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer:last-child {
display: none;
}`;
document.head.appendChild(styles);
}
// Hide / Force show like buttons
const likeButtonsOptions: string = window.mainConfig.get('options.likeButtons');
if (likeButtonsOptions) {
const likeButtons: HTMLElement | null = document.querySelector('ytmusic-like-button-renderer');
if (likeButtons) {
likeButtons.style.display
= {
hide: 'none',
force: 'inherit',
}[likeButtonsOptions] || '';
}
}
}
(() => {
enabledPluginNameAndOptions.forEach(async ([pluginName, options]) => {
if (Object.hasOwn(rendererPlugins, pluginName)) {
const handler = rendererPlugins[pluginName];
try {
await handler?.(options as never);
} catch (error) {
console.error(`Error in plugin "${pluginName}": ${String(error)}`);
}
}
});
// Inject song-info provider
setupSongInfo();
// Inject song-controls
setupSongControls();
// Wait for complete load of YouTube api
listenForApiLoad();
// Blocks the "Are You Still There?" popup by setting the last active time to Date.now every 15min
setInterval(() => window._lact = Date.now(), 900_000);
// Setup back to front logger
if (window.electronIs.dev()) {
window.ipcRenderer.on('log', (_event, log: string) => {
console.log(JSON.parse(log));
});
}
})();

8
src/reset.d.ts vendored
View File

@ -1,4 +1,9 @@
import '@total-typescript/ts-reset';
import { ipcRenderer as electronIpcRenderer } from 'electron';
import is from 'electron-is';
import config from './config';
import { YoutubePlayer } from './types/youtube-player';
declare global {
@ -13,6 +18,9 @@ declare global {
}
interface Window {
ipcRenderer: typeof electronIpcRenderer;
mainConfig: typeof config;
electronIs: typeof is;
/**
* YouTube Music internal variable (Last interaction time)
*/