feat: typescript part 1

Co-authored-by: Su-Yong <simssy2205@gmail.com>
This commit is contained in:
JellyBrick
2023-09-03 00:25:48 +09:00
parent 3e3fdb3c3f
commit 82bcadcd64
57 changed files with 3958 additions and 968 deletions

View File

@ -2,14 +2,26 @@ module.exports = {
extends: [
'eslint:recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
plugins: ['import'],
plugins: ['@typescript-eslint', 'import'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
project: './tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
ecmaVersion: 'latest'
},
rules: {
'arrow-parens': ['error', 'always'],
'object-curly-spacing': ['error', 'always'],
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-misused-promises': ['off', { checksVoidReturn: false }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
"@typescript-eslint/no-non-null-assertion": "off",
'import/first': 'error',
'import/newline-after-import': 'error',
'import/no-default-export': 'off',

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
node_modules
/dist
/pack
electron-builder.yml
.vscode/settings.json
.idea

View File

@ -1,22 +1,38 @@
export interface WindowSizeConfig {
width: number;
height: number;
}
const defaultConfig = {
'window-size': {
width: 1100,
height: 550,
},
'window-maximized': false,
'window-position': {
x: -1,
y: -1,
},
'url': 'https://music.youtube.com',
'options': {
tray: false,
appVisible: true,
autoUpdates: true,
alwaysOnTop: false,
hideMenu: false,
hideMenuWarned: false,
startAtLogin: false,
disableHardwareAcceleration: false,
removeUpgradeButton: false,
restartOnConfigChanges: false,
trayClickPlayPause: false,
autoResetAppCache: false,
resumeOnStart: true,
likeButtons: '',
proxy: '',
startingPage: '',
overrideUserAgent: false,
themes: {} as string[],
},
'plugins': {
// Enabled plugins
@ -26,7 +42,9 @@ const defaultConfig = {
'adblocker': {
enabled: true,
cache: true,
blocker: 'In player',
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
disableDefaultLists: [],
},
// Disabled plugins
'shortcuts': {
@ -92,11 +110,14 @@ const defaultConfig = {
forceHide: false,
},
'picture-in-picture': {
enabled: false,
alwaysOnTop: true,
savePosition: true,
saveSize: false,
hotkey: 'P',
'enabled': false,
'alwaysOnTop': true,
'savePosition': true,
'saveSize': false,
'hotkey': 'P',
'pip-position': [10, 10],
'pip-size': [450, 275],
'isInPiP': false,
},
'captions-selector': {
enabled: false,
@ -181,4 +202,4 @@ const defaultConfig = {
},
};
module.exports = defaultConfig;
export default defaultConfig;

View File

@ -1,214 +0,0 @@
const { ipcRenderer, ipcMain } = require('electron');
const defaultConfig = require('./defaults');
const { getOptions, setOptions, setMenuOptions } = require('./plugins');
const { sendToFront } = require('../providers/app-controls');
const activePlugins = {};
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
module.exports.getActivePlugins
= process.type === 'renderer'
? async () => ipcRenderer.invoke('get-active-plugins')
: () => activePlugins;
if (process.type === 'browser') {
ipcMain.handle('get-active-plugins', this.getActivePlugins);
}
/**
* [!IMPORTANT!]
* The method is **sync** in the main process and **async** in the renderer process.
*/
module.exports.isActive
= process.type === 'renderer'
? async (plugin) =>
plugin in (await ipcRenderer.invoke('get-active-plugins'))
: (plugin) => plugin in activePlugins;
/**
* 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.
*
* @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);
* };
*/
module.exports.PluginConfig = class PluginConfig {
#name;
#config;
#defaultConfig;
#enableFront;
#subscribers = {};
#allSubscribers = [];
constructor(name, { enableFront = false, initialOptions = undefined } = {}) {
const pluginDefaultConfig = defaultConfig.plugins[name] || {};
const pluginConfig = initialOptions || getOptions(name) || {};
this.#name = name;
this.#enableFront = enableFront;
this.#defaultConfig = pluginDefaultConfig;
this.#config = { ...pluginDefaultConfig, ...pluginConfig };
if (this.#enableFront) {
this.#setupFront();
}
activePlugins[name] = this;
}
get = (option) => this.#config[option];
set = (option, value) => {
this.#config[option] = value;
this.#onChange(option);
this.#save();
};
toggle = (option) => {
this.#config[option] = !this.#config[option];
this.#onChange(option);
this.#save();
};
getAll = () => ({ ...this.#config });
setAll = (options) => {
if (!options || typeof options !== 'object') {
throw new Error('Options must be an object.');
}
let changed = false;
for (const [key, value] of Object.entries(options)) {
if (this.#config[key] !== value) {
this.#config[key] = value;
this.#onChange(key, false);
changed = true;
}
}
if (changed) {
for (const fn of this.#allSubscribers) {
fn(this.#config);
}
}
this.#save();
};
getDefaultConfig = () => 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 = (option, value) => {
this.#config[option] = value;
setMenuOptions(this.#name, this.#config);
this.#onChange(option);
};
subscribe = (valueName, fn) => {
this.#subscribers[valueName] = fn;
};
subscribeAll = (fn) => {
this.#allSubscribers.push(fn);
};
/** Called only from back */
#save() {
setOptions(this.#name, this.#config);
}
#onChange(valueName, single = true) {
this.#subscribers[valueName]?.(this.#config[valueName]);
if (single) {
for (const fn of this.#allSubscribers) {
fn(this.#config);
}
}
}
#setupFront() {
const ignoredMethods = ['subscribe', 'subscribeAll'];
if (process.type === 'renderer') {
for (const [fnName, fn] of Object.entries(this)) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
this[fnName] = async (...args) => await ipcRenderer.invoke(
`${this.#name}-config-${fnName}`,
...args,
);
this.subscribe = (valueName, fn) => {
if (valueName in this.#subscribers) {
console.error(`Already subscribed to ${valueName}`);
}
this.#subscribers[valueName] = fn;
ipcRenderer.on(
`${this.#name}-config-changed-${valueName}`,
(_, value) => {
fn(value);
},
);
ipcRenderer.send(`${this.#name}-config-subscribe`, valueName);
};
this.subscribeAll = (fn) => {
ipcRenderer.on(`${this.#name}-config-changed`, (_, value) => {
fn(value);
});
ipcRenderer.send(`${this.#name}-config-subscribe-all`);
};
}
} else if (process.type === 'browser') {
for (const [fnName, fn] of Object.entries(this)) {
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
return;
}
ipcMain.handle(`${this.#name}-config-${fnName}`, (_, ...args) => fn(...args));
}
ipcMain.on(`${this.#name}-config-subscribe`, (_, valueName) => {
this.subscribe(valueName, (value) => {
sendToFront(`${this.#name}-config-changed-${valueName}`, value);
});
});
ipcMain.on(`${this.#name}-config-subscribe-all`, () => {
this.subscribeAll((value) => {
sendToFront(`${this.#name}-config-changed`, value);
});
});
}
}
};

240
config/dynamic.ts Normal file
View File

@ -0,0 +1,240 @@
/* eslint-disable @typescript-eslint/require-await */
import { ipcMain, ipcRenderer } from 'electron';
import defaultConfig from './defaults';
import { getOptions, setMenuOptions, setOptions } from './plugins';
import { sendToFront } from '../providers/app-controls';
import { Entries } from '../utils/type-utils';
type DefaultPluginsConfig = typeof defaultConfig.plugins;
type OneOfDefaultConfigKey = keyof DefaultPluginsConfig;
type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey];
// 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;
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;
interface PluginConfigOptions {
enableFront: boolean;
initialOptions?: OneOfDefaultConfig;
}
/**
* 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.
*
* @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 ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T];
type ValueOf<T> = T[keyof T];
export class PluginConfig<T extends OneOfDefaultConfigKey> {
private name: string;
private config: ConfigType<T>;
private defaultConfig: ConfigType<T>;
private 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;
}
async get(key: keyof ConfigType<T>): Promise<ValueOf<ConfigType<T>>> {
return this.config[key];
}
set(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
this.config[key] = value;
this.#onChange(key);
this.#save();
}
getAll() {
return { ...this.config };
}
setAll(options: 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) {
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 */
#save() {
setOptions(this.name, this.config);
}
#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);
}
}
}
#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));
}
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,31 +0,0 @@
const defaultConfig = require('./defaults');
const plugins = require('./plugins');
const store = require('./store');
const { restart } = require('../providers/app-controls');
const set = (key, value) => {
store.set(key, value);
};
function setMenuOption(key, value) {
set(key, value);
if (store.get('options.restartOnConfigChanges')) {
restart();
}
}
const get = (key) => store.get(key);
module.exports = {
defaultConfig,
get,
set,
setMenuOption,
edit: () => store.openInEditor(),
watch(cb) {
store.onDidChange('options', cb);
store.onDidChange('plugins', cb);
},
plugins,
};

57
config/index.ts Normal file
View File

@ -0,0 +1,57 @@
import Store from 'electron-store';
import defaultConfig from './defaults';
import plugins from './plugins';
import store from './store';
import { restart } from '../providers/app-controls';
const set = (key: string, value: unknown) => {
store.set(key, value);
};
function setMenuOption(key: string, value: unknown) {
set(key, value);
if (store.get('options.restartOnConfigChanges')) {
restart();
}
}
// MAGIC OF TYPESCRIPT
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
type Join<K, P> = K extends string | number ?
P extends string | number ?
`${K}${'' extends P ? '' : '.'}${P}`
: never : never;
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
{ [K in keyof T]-?: K extends string | number ?
`${K}` | Join<K, Paths<T[K], Prev[D]>>
: never
}[keyof T] : ''
type FirstKey<T extends string> = T extends `${infer K}.${string}` ? K : T;
type NextKey<T extends string> = T extends `${string}.${infer K}` ? K : T;
type PathValue<T, Key extends string> = (
T extends object
? FirstKey<Key> extends keyof T
? PathValue<T[FirstKey<Key>], NextKey<Key>>
: T
: T
);
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) => store.get(key) as PathValue<typeof defaultConfig, typeof key>;
export default {
defaultConfig,
get,
set,
setMenuOption,
edit: () => store.openInEditor(),
watch(cb: Parameters<Store['onDidChange']>[1]) {
store.onDidChange('options', cb);
store.onDidChange('plugins', cb);
},
plugins,
};

View File

@ -1,55 +0,0 @@
const store = require('./store');
const { restart } = require('../providers/app-controls');
function getEnabled() {
const plugins = store.get('plugins');
return Object.entries(plugins).filter(([plugin]) =>
isEnabled(plugin),
);
}
function isEnabled(plugin) {
const pluginConfig = store.get('plugins')[plugin];
return pluginConfig !== undefined && pluginConfig.enabled;
}
function setOptions(plugin, options) {
const plugins = store.get('plugins');
store.set('plugins', {
...plugins,
[plugin]: {
...plugins[plugin],
...options,
},
});
}
function setMenuOptions(plugin, options) {
setOptions(plugin, options);
if (store.get('options.restartOnConfigChanges')) {
restart();
}
}
function getOptions(plugin) {
return store.get('plugins')[plugin];
}
function enable(plugin) {
setMenuOptions(plugin, { enabled: true });
}
function disable(plugin) {
setMenuOptions(plugin, { enabled: false });
}
module.exports = {
isEnabled,
getEnabled,
enable,
disable,
setOptions,
setMenuOptions,
getOptions,
};

63
config/plugins.ts Normal file
View File

@ -0,0 +1,63 @@
import store from './store';
import defaultConfig from './defaults';
import { restart } from '../providers/app-controls';
import { Entries } from '../utils/type-utils';
interface Plugin {
enabled: boolean;
}
type DefaultPluginsConfig = typeof defaultConfig.plugins;
export function getEnabled() {
const plugins = store.get('plugins') as DefaultPluginsConfig;
return (Object.entries(plugins) as Entries<DefaultPluginsConfig>).filter(([plugin]) =>
isEnabled(plugin),
);
}
export function isEnabled(plugin: string) {
const pluginConfig = (store.get('plugins') as Record<string, Plugin>)[plugin];
return pluginConfig !== undefined && pluginConfig.enabled;
}
export function setOptions<T>(plugin: string, options: T) {
const plugins = store.get('plugins') as Record<string, T>;
store.set('plugins', {
...plugins,
[plugin]: {
...plugins[plugin],
...options,
},
});
}
export function setMenuOptions<T>(plugin: string, options: T) {
setOptions(plugin, options);
if (store.get('options.restartOnConfigChanges')) {
restart();
}
}
export function getOptions<T>(plugin: string): T {
return (store.get('plugins') as Record<string, T>)[plugin];
}
export function enable(plugin: string) {
setMenuOptions(plugin, { enabled: true });
}
export function disable(plugin: string) {
setMenuOptions(plugin, { enabled: false });
}
export default {
isEnabled,
getEnabled,
enable,
disable,
setOptions,
setMenuOptions,
getOptions,
};

View File

@ -1,15 +1,16 @@
const Store = require('electron-store');
import Store from 'electron-store';
import Conf from 'conf';
const defaults = require('./defaults');
import defaults from './defaults';
const setDefaultPluginOptions = (store, plugin) => {
const setDefaultPluginOptions = (store: Conf<Record<string, unknown>>, plugin: keyof typeof defaults.plugins) => {
if (!store.get(`plugins.${plugin}`)) {
store.set(`plugins.${plugin}`, defaults.plugins[plugin]);
}
};
const migrations = {
'>=1.20.0'(store) {
'>=1.20.0'(store: Conf<Record<string, unknown>>) {
setDefaultPluginOptions(store, 'visualizer');
if (store.get('plugins.notifications.toastStyle') === undefined) {
@ -25,14 +26,14 @@ const migrations = {
store.set('options.likeButtons', 'force');
}
},
'>=1.17.0'(store) {
'>=1.17.0'(store: Conf<Record<string, unknown>>) {
setDefaultPluginOptions(store, 'picture-in-picture');
if (store.get('plugins.video-toggle.mode') === undefined) {
store.set('plugins.video-toggle.mode', 'custom');
}
},
'>=1.14.0'(store) {
'>=1.14.0'(store: Conf<Record<string, unknown>>) {
if (
typeof store.get('plugins.precise-volume.globalShortcuts') !== 'object'
) {
@ -44,18 +45,25 @@ const migrations = {
store.set('plugins.video-toggle.enabled', true);
}
},
'>=1.13.0'(store) {
'>=1.13.0'(store: Conf<Record<string, unknown>>) {
if (store.get('plugins.discord.listenAlong') === undefined) {
store.set('plugins.discord.listenAlong', true);
}
},
'>=1.12.0'(store) {
const options = store.get('plugins.shortcuts');
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
const options = store.get('plugins.shortcuts') as Record<string, {
action: string;
shortcut: unknown;
}[] | Record<string, unknown>>;
let updated = false;
for (const optionType of ['global', 'local']) {
if (Array.isArray(options[optionType])) {
const updatedOptions = {};
for (const optionObject of options[optionType]) {
const optionsArray = options[optionType] as {
action: string;
shortcut: unknown;
}[];
const updatedOptions: Record<string, unknown> = {};
for (const optionObject of optionsArray) {
if (optionObject.action && optionObject.shortcut) {
updatedOptions[optionObject.action] = optionObject.shortcut;
}
@ -70,20 +78,21 @@ const migrations = {
store.set('plugins.shortcuts', options);
}
},
'>=1.11.0'(store) {
'>=1.11.0'(store: Conf<Record<string, unknown>>) {
if (store.get('options.resumeOnStart') === undefined) {
store.set('options.resumeOnStart', true);
}
},
'>=1.7.0'(store) {
const enabledPlugins = store.get('plugins');
'>=1.7.0'(store: Conf<Record<string, unknown>>) {
const enabledPlugins = store.get('plugins') as string[];
if (!Array.isArray(enabledPlugins)) {
console.warn('Plugins are not in array format, cannot migrate');
return;
}
// Include custom options
const plugins = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const plugins: Record<string, any> = {
adblocker: {
enabled: true,
cache: true,
@ -95,7 +104,9 @@ const migrations = {
downloadFolder: undefined, // Custom download folder (absolute path)
},
};
for (const enabledPlugin of enabledPlugins) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
plugins[enabledPlugin] = {
...plugins[enabledPlugin],
enabled: true,
@ -106,7 +117,7 @@ const migrations = {
},
};
module.exports = new Store({
export default new Store({
defaults,
clearInvalidConfig: false,
migrations,

46
custom-electron-prompt.d.ts vendored Normal file
View File

@ -0,0 +1,46 @@
declare module 'custom-electron-prompt' {
import { BrowserWindow } from 'electron';
export interface PromptCounterOptions {
minimum?: number;
maximum?: number;
multiFire?: boolean;
}
export interface PromptKeybindOptions {
value: string;
label: string;
default: string;
}
export interface PromptOptions {
width?: number;
height?: number;
resizable?: boolean;
title?: string;
label?: string;
buttonLabels?: {
ok?: string;
cancel?: string;
};
alwaysOnTop?: boolean;
value?: string;
type?: 'input' | 'select' | 'counter';
selectOptions?: Record<string, string>;
keybindOptions?: PromptKeybindOptions[];
counterOptions?: PromptCounterOptions;
icon?: string;
useHtmlLabel?: boolean;
customStylesheet?: string;
menuBarVisible?: boolean;
skipTaskbar?: boolean;
frame?: boolean;
customScript?: string;
enableRemoteModule?: boolean;
inputAttrs: Partial<HTMLInputElement>;
}
const prompt: (options?: PromptOptions, parent?: BrowserWindow) => Promise<string | null>;
export default prompt;
}

View File

@ -1,20 +1,23 @@
'use strict';
const path = require('node:path');
import path from 'node:path';
const electron = require('electron');
const enhanceWebRequest = require('electron-better-web-request').default;
const is = require('electron-is');
const unhandled = require('electron-unhandled');
const { autoUpdater } = require('electron-updater');
import electron, { BrowserWindow } from 'electron';
import enhanceWebRequest from 'electron-better-web-request';
import is from 'electron-is';
import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater';
import electronDebug from 'electron-debug';
import { BetterWebRequest } from 'electron-better-web-request/lib/electron-better-web-request';
import config from './config';
import { setApplicationMenu } from './menu';
import { fileExists, injectCSS } from './plugins/utils';
import { isTesting } from './utils/testing';
import { setUpTray } from './tray';
import { setupSongInfo } from './providers/song-info';
import { restart, setupAppControls } from './providers/app-controls';
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler';
const config = require('./config');
const { setApplicationMenu } = require('./menu');
const { fileExists, injectCSS } = require('./plugins/utils');
const { isTesting } = require('./utils/testing');
const { setUpTray } = require('./tray');
const { setupSongInfo } = require('./providers/song-info');
const { setupAppControls, restart } = require('./providers/app-controls');
const { APP_PROTOCOL, setupProtocolHandler, handleProtocol } = require('./providers/protocol-handler');
// Catch errors and log them
unhandled({
@ -27,7 +30,7 @@ process.env.NODE_OPTIONS = '';
const { app } = electron;
// Prevent window being garbage collected
let mainWindow;
let mainWindow: Electron.BrowserWindow | null;
autoUpdater.autoDownload = false;
const gotTheLock = app.requestSingleInstanceLock();
@ -45,7 +48,7 @@ if (config.get('options.disableHardwareAcceleration')) {
}
if (is.linux() && config.plugins.isEnabled('shortcuts')) {
// Stops chromium from launching it's own mpris service
// Stops chromium from launching its own MPRIS service
app.commandLine.appendSwitch('disable-features', 'MediaSessionService');
}
@ -54,7 +57,7 @@ if (config.get('options.proxy')) {
}
// Adds debug features like hotkeys for triggering dev tools and reload
require('electron-debug')({
electronDebug({
showDevTools: false, // Disable automatic devTools on new window
});
@ -67,15 +70,14 @@ if (process.platform === 'win32') {
function onClosed() {
// Dereference the window
// For multiple windows store them in an array
// For multiple Windows store them in an array
mainWindow = null;
}
/** @param {Electron.BrowserWindow} win */
function loadPlugins(win) {
function loadPlugins(win: BrowserWindow) {
injectCSS(win.webContents, path.join(__dirname, 'youtube-music.css'));
// Load user CSS
const themes = config.get('options.themes');
const themes: string[] = config.get('options.themes');
if (Array.isArray(themes)) {
for (const cssFile of themes) {
fileExists(
@ -101,7 +103,8 @@ function loadPlugins(win) {
console.log('Loaded plugin - ' + plugin);
const pluginPath = path.join(__dirname, 'plugins', plugin, 'back.js');
fileExists(pluginPath, () => {
const handle = require(pluginPath);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const handle = require(pluginPath) as (window: BrowserWindow, option: typeof options) => void;
handle(win, options);
});
}
@ -110,7 +113,7 @@ function loadPlugins(win) {
function createMainWindow() {
const windowSize = config.get('window-size');
const windowMaximized = config.get('window-maximized');
const windowPosition = config.get('window-position');
const windowPosition: Electron.Point = config.get('window-position');
const useInlineMenu = config.plugins.isEnabled('in-app-menu');
const win = new electron.BrowserWindow({
@ -120,12 +123,11 @@ function createMainWindow() {
backgroundColor: '#000',
show: false,
webPreferences: {
// TODO: re-enable contextIsolation once it can work with ffmepg.wasm
// TODO: re-enable contextIsolation once it can work with FFMpeg.wasm
// Possible bundling? https://github.com/ffmpegwasm/ffmpeg.wasm/issues/126
contextIsolation: false,
preload: path.join(__dirname, 'preload.js'),
nodeIntegrationInSubFrames: true,
affinity: 'main-window', // Main window, and addition windows should work in one process
...(isTesting()
? undefined
: {
@ -158,7 +160,7 @@ function createMainWindow() {
// Window is offscreen
if (is.dev()) {
console.log(
`Window tried to render offscreen, windowSize=${winSize}, displaySize=${displaySize}, position=${windowPosition}`,
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`,
);
}
} else {
@ -180,10 +182,12 @@ function createMainWindow() {
win.webContents.loadURL(urlToLoad);
win.on('closed', onClosed);
type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture'];
const setPiPOptions = config.plugins.isEnabled('picture-in-picture')
? (key, value) => require('./plugins/picture-in-picture/back').setOptions({ [key]: value })
: () => {
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
? (key: string, value: unknown) => (require('./plugins/picture-in-picture/back') as typeof import('./plugins/picture-in-picture/back'))
.setOptions({ [key]: value })
: () => {};
win.on('move', () => {
if (win.isMaximized()) {
@ -191,17 +195,18 @@ function createMainWindow() {
}
const position = win.getPosition();
const isPiPEnabled
const isPiPEnabled: boolean
= config.plugins.isEnabled('picture-in-picture')
&& config.plugins.getOptions('picture-in-picture').isInPiP;
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
if (!isPiPEnabled) {
lateSave('window-position', { x: position[0], y: position[1] });
} else if (config.plugins.getOptions('picture-in-picture').savePosition) {
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').savePosition) {
lateSave('pip-position', position, setPiPOptions);
}
});
let winWasMaximized;
let winWasMaximized: boolean;
win.on('resize', () => {
const windowSize = win.getSize();
@ -209,7 +214,7 @@ function createMainWindow() {
const isPiPEnabled
= config.plugins.isEnabled('picture-in-picture')
&& config.plugins.getOptions('picture-in-picture').isInPiP;
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
winWasMaximized = isMaximized;
@ -225,14 +230,14 @@ function createMainWindow() {
width: windowSize[0],
height: windowSize[1],
});
} else if (config.plugins.getOptions('picture-in-picture').saveSize) {
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').saveSize) {
lateSave('pip-size', windowSize, setPiPOptions);
}
});
const savedTimeouts = {};
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
function lateSave(key, value, fn = config.set) {
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) {
if (savedTimeouts[key]) {
clearTimeout(savedTimeouts[key]);
}
@ -243,7 +248,7 @@ function createMainWindow() {
}, 600);
}
win.webContents.on('render-process-gone', (event, webContents, details) => {
app.on('render-process-gone', (event, webContents, details) => {
showUnresponsiveDialog(win, details);
});
@ -434,7 +439,7 @@ app.on('ready', () => {
autoUpdater.on('update-available', () => {
const downloadLink
= 'https://github.com/th-ch/youtube-music/releases/latest';
const dialogOptions = {
const dialogOptions: Electron.MessageBoxOptions = {
type: 'info',
buttons: ['OK', 'Download', 'Disable updates'],
title: 'Application Update',
@ -486,13 +491,13 @@ app.on('ready', () => {
// Hide the window instead of quitting (quit is available in tray options)
if (!forceQuit) {
event.preventDefault();
mainWindow.hide();
mainWindow!.hide();
}
});
}
});
function showUnresponsiveDialog(win, details) {
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) {
if (details) {
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
}
@ -501,7 +506,7 @@ function showUnresponsiveDialog(win, details) {
type: 'error',
title: 'Window Unresponsive',
message: 'The Application is Unresponsive',
details: 'We are sorry for the inconvenience! please choose what to do:',
detail: 'We are sorry for the inconvenience! please choose what to do:',
buttons: ['Wait', 'Relaunch', 'Quit'],
cancelId: 0,
}).then((result) => {
@ -519,8 +524,10 @@ function showUnresponsiveDialog(win, details) {
});
}
// HACK: electron-better-web-request's typing is wrong
type BetterSession = Omit<Electron.Session, 'webRequest'> & { webRequest: BetterWebRequest & Electron.WebRequest };
function removeContentSecurityPolicy(
session = electron.session.defaultSession,
session: BetterSession = electron.session.defaultSession as BetterSession,
) {
// Allows defining multiple "onHeadersReceived" listeners
// by enhancing the session.
@ -538,15 +545,16 @@ function removeContentSecurityPolicy(
callback({ cancel: false, responseHeaders: details.responseHeaders });
});
type ResolverListener = { apply: () => Record<string, unknown>; context: unknown };
// When multiple listeners are defined, apply them all
session.webRequest.setResolver('onHeadersReceived', (listeners) => {
session.webRequest.setResolver('onHeadersReceived', (listeners: ResolverListener[]) => {
return listeners.reduce(
async (accumulator, listener) => {
(accumulator: Record<string, unknown>, listener: ResolverListener) => {
if (accumulator.cancel) {
return accumulator;
}
const result = await listener.apply();
const result = listener.apply();
return { ...accumulator, ...result };
},
{ cancel: false },

View File

@ -1,24 +1,26 @@
const { existsSync } = require('node:fs');
const path = require('node:path');
import { existsSync } from 'node:fs';
import path from 'node:path';
const { app, clipboard, Menu, dialog } = require('electron');
const is = require('electron-is');
const prompt = require('custom-electron-prompt');
import is from 'electron-is';
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
import prompt from 'custom-electron-prompt';
const { restart } = require('./providers/app-controls');
const { getAllPlugins } = require('./plugins/utils');
const config = require('./config');
const { startingPages } = require('./providers/extracted-data');
const promptOptions = require('./providers/prompt-options');
import { restart } from './providers/app-controls';
import { getAllPlugins } from './plugins/utils';
import config from './config';
import { startingPages } from './providers/extracted-data';
import promptOptions from './providers/prompt-options';
export type MenuTemplate = (Electron.MenuItemConstructorOptions | Electron.MenuItem)[];
// True only if in-app-menu was loaded on launch
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
const pluginEnabledMenu = (plugin, label = '', hasSubmenu = false, refreshMenu = undefined) => ({
const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
label: label || plugin,
type: 'checkbox',
checked: config.plugins.isEnabled(plugin),
click(item) {
click(item: Electron.MenuItem) {
if (item.checked) {
config.plugins.enable(plugin);
} else {
@ -26,14 +28,14 @@ const pluginEnabledMenu = (plugin, label = '', hasSubmenu = false, refreshMenu =
}
if (hasSubmenu) {
refreshMenu();
refreshMenu?.();
}
},
});
const mainMenuTemplate = (win) => {
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
const refreshMenu = () => {
this.setApplicationMenu(win);
setApplicationMenu(win);
if (inAppMenuActive) {
win.webContents.send('refreshMenu');
}
@ -55,7 +57,9 @@ const mainMenuTemplate = (win) => {
return pluginEnabledMenu(plugin, pluginLabel, true, refreshMenu);
}
const getPluginMenu = require(pluginPath);
type PluginType = (window: BrowserWindow, plugins: string, func: () => void) => Electron.MenuItemConstructorOptions[];
// eslint-disable-next-line @typescript-eslint/no-var-requires
const getPluginMenu = require(pluginPath) as PluginType;
return {
label: pluginLabel,
submenu: [
@ -63,12 +67,11 @@ const mainMenuTemplate = (win) => {
{ type: 'separator' },
...getPluginMenu(win, config.plugins.getOptions(plugin), refreshMenu),
],
};
} satisfies Electron.MenuItemConstructorOptions;
}
return pluginEnabledMenu(plugin);
})
,
}),
},
{
label: 'Options',
@ -208,7 +211,7 @@ const mainMenuTemplate = (win) => {
},
},
]
: []),
: []) satisfies Electron.MenuItemConstructorOptions[],
...(is.windows() || is.macOS()
? // Only works on Win/Mac
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
@ -222,7 +225,7 @@ const mainMenuTemplate = (win) => {
},
},
]
: []),
: []) satisfies Electron.MenuItemConstructorOptions[],
{
label: 'Tray',
submenu: [
@ -238,8 +241,7 @@ const mainMenuTemplate = (win) => {
{
label: 'Enabled + app visible',
type: 'radio',
checked:
config.get('options.tray') && config.get('options.appVisible'),
checked: !!(config.get('options.tray') && config.get('options.appVisible')),
click() {
config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', true);
@ -248,8 +250,7 @@ const mainMenuTemplate = (win) => {
{
label: 'Enabled + app hidden',
type: 'radio',
checked:
config.get('options.tray') && !config.get('options.appVisible'),
checked: !!(config.get('options.tray') && !config.get('options.appVisible')),
click() {
config.setMenuOption('options.tray', true);
config.setMenuOption('options.appVisible', false);
@ -320,8 +321,7 @@ const mainMenuTemplate = (win) => {
if (webContents.isDevToolsOpened()) {
webContents.closeDevTools();
} else {
const devToolsOptions = {};
webContents.openDevTools(devToolsOptions);
webContents.openDevTools();
}
},
}
@ -384,10 +384,8 @@ const mainMenuTemplate = (win) => {
},
];
};
module.exports.mainMenuTemplate = mainMenuTemplate;
module.exports.setApplicationMenu = (win) => {
const menuTemplate = [...mainMenuTemplate(win)];
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
if (process.platform === 'darwin') {
const { name } = app;
menuTemplate.unshift({
@ -396,17 +394,13 @@ module.exports.setApplicationMenu = (win) => {
{ role: 'about' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideothers' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
selector: 'selectAll:',
},
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', selector: 'cut:' },
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' },
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' },
{ role: 'selectAll' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ type: 'separator' },
{ role: 'minimize' },
{ role: 'close' },
@ -419,7 +413,7 @@ module.exports.setApplicationMenu = (win) => {
Menu.setApplicationMenu(menu);
};
async function setProxy(item, win) {
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
const output = await prompt({
title: 'Set Proxy',
label: 'Enter Proxy Address: (leave empty to disable)',

324
package-lock.json generated
View File

@ -19,6 +19,7 @@
"browser-id3-writer": "5.0.0",
"butterchurn": "2.6.7",
"butterchurn-presets": "2.4.7",
"conf": "10.2.0",
"custom-electron-prompt": "1.5.7",
"custom-electron-titlebar": "4.1.6",
"electron-better-web-request": "1.0.1",
@ -42,6 +43,9 @@
},
"devDependencies": {
"@playwright/test": "1.37.1",
"@total-typescript/ts-reset": "0.5.1",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0",
"del-cli": "5.0.1",
"electron": "27.0.0-alpha.5",
@ -52,7 +56,8 @@
"eslint-plugin-prettier": "5.0.0",
"node-gyp": "9.4.0",
"patch-package": "^8.0.0",
"playwright": "1.37.1"
"playwright": "1.37.1",
"typescript": "5.2.2"
},
"engines": {
"node": ">=16.0.0"
@ -1128,6 +1133,12 @@
"node": ">= 10"
}
},
"node_modules/@total-typescript/ts-reset": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.5.1.tgz",
"integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==",
"dev": true
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@ -1194,6 +1205,12 @@
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
"integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
},
"node_modules/@types/json-schema": {
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
"integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==",
"dev": true
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@ -1250,6 +1267,12 @@
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.1.tgz",
"integrity": "sha512-cJRQXpObxfNKkFAZbJl2yjWtJCqELQIdShsogr1d2MilP8dKD9TE/nEKHkJgUNHdGKCQaf9HbIynuV2csLGVLg==",
"dev": true
},
"node_modules/@types/verror": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.6.tgz",
@ -1266,6 +1289,231 @@
"@types/node": "*"
}
},
"node_modules/@types/youtube-player": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/@types/youtube-player/-/youtube-player-5.5.7.tgz",
"integrity": "sha512-W8F4eoTIvzXeNrT3JroQPimZLXnlJA8smYygHZUKFPVoYwgs/OhJkA1VBhL3iSs57OQkuINqHlY4SmMT5wtnJg==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.5.0.tgz",
"integrity": "sha512-2pktILyjvMaScU6iK3925uvGU87E+N9rh372uGZgiMYwafaw9SXq86U04XPq3UH6tzRvNgBsub6x2DacHc33lw==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "6.5.0",
"@typescript-eslint/type-utils": "6.5.0",
"@typescript-eslint/utils": "6.5.0",
"@typescript-eslint/visitor-keys": "6.5.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
"natural-compare": "^1.4.0",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
"eslint": "^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/parser": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.5.0.tgz",
"integrity": "sha512-LMAVtR5GN8nY0G0BadkG0XIe4AcNMeyEy3DyhKGAh9k4pLSMBO7rF29JvDBpZGCmp5Pgz5RLHP6eCpSYZJQDuQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.5.0",
"@typescript-eslint/types": "6.5.0",
"@typescript-eslint/typescript-estree": "6.5.0",
"@typescript-eslint/visitor-keys": "6.5.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.5.0.tgz",
"integrity": "sha512-A8hZ7OlxURricpycp5kdPTH3XnjG85UpJS6Fn4VzeoH4T388gQJ/PGP4ole5NfKt4WDVhmLaQ/dBLNDC4Xl/Kw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.5.0",
"@typescript-eslint/visitor-keys": "6.5.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.5.0.tgz",
"integrity": "sha512-f7OcZOkRivtujIBQ4yrJNIuwyCQO1OjocVqntl9dgSIZAdKqicj3xFDqDOzHDlGCZX990LqhLQXWRnQvsapq8A==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "6.5.0",
"@typescript-eslint/utils": "6.5.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/types": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.5.0.tgz",
"integrity": "sha512-eqLLOEF5/lU8jW3Bw+8auf4lZSbbljHR2saKnYqON12G/WsJrGeeDHWuQePoEf9ro22+JkbPfWQwKEC5WwLQ3w==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.5.0.tgz",
"integrity": "sha512-q0rGwSe9e5Kk/XzliB9h2LBc9tmXX25G0833r7kffbl5437FPWb2tbpIV9wAATebC/018pGa9fwPDuvGN+LxWQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.5.0",
"@typescript-eslint/visitor-keys": "6.5.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.5.0.tgz",
"integrity": "sha512-9nqtjkNykFzeVtt9Pj6lyR9WEdd8npPhhIPM992FWVkZuS6tmxHfGVnlUcjpUP2hv8r4w35nT33mlxd+Be1ACQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "6.5.0",
"@typescript-eslint/types": "6.5.0",
"@typescript-eslint/typescript-estree": "6.5.0",
"semver": "^7.5.4"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.5.0.tgz",
"integrity": "sha512-yCB/2wkbv3hPsh02ZS8dFQnij9VVQXJMN/gbQsaaY+zxALkZnxa/wagvLEFsAWMPv7d7lxQmNsIzGU1w/T/WyA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.5.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@xhayper/discord-rpc": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@xhayper/discord-rpc/-/discord-rpc-1.0.22.tgz",
@ -1631,6 +1879,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/array.prototype.findlastindex": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz",
@ -2526,6 +2783,19 @@
"typescript": "^4.0.2"
}
},
"node_modules/config-file-ts/node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@ -2624,6 +2894,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/debounce-fn/node_modules/mimic-fn": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
"integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@ -3977,18 +4255,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa/node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa/node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
@ -5966,11 +6232,15 @@
}
},
"node_modules/mimic-fn": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
"integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"engines": {
"node": ">=8"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
@ -8271,6 +8541,18 @@
"utf8-byte-length": "^1.0.1"
}
},
"node_modules/ts-api-utils": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.2.tgz",
"integrity": "sha512-Cbu4nIqnEdd+THNEsBdkolnOXhg0I8XteoHaEKgvsxpsbWda4IsUut2c187HxywQCvveojow0Dgw/amxtSKVkQ==",
"dev": true,
"engines": {
"node": ">=16.13.0"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/tsconfig-paths": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz",
@ -8397,16 +8679,16 @@
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/uglify-js": {

View File

@ -76,17 +76,20 @@
}
}
]
},
"directories": {
"output": "./pack/"
}
},
"scripts": {
"test": "playwright test",
"test:debug": "DEBUG=pw:browser* playwright test",
"start": "electron .",
"start:debug": "ELECTRON_ENABLE_LOGGING=1 electron .",
"start": "tsc && electron ./dist/index.js",
"start:debug": "ELECTRON_ENABLE_LOGGING=1 electron ./dist/index.js",
"generate:package": "node utils/generate-package-json.js",
"postinstall": "npm run plugins",
"clean": "del-cli dist",
"build": "npm run clean && electron-builder --win --mac --linux -p never",
"clean": "del-cli dist && del-cli pack",
"build": "npm run clean && tsc && electron-builder --win --mac --linux -p never",
"build:linux": "npm run clean && electron-builder --linux -p never",
"build:mac": "npm run clean && electron-builder --mac dmg:x64 -p never",
"build:mac:arm64": "npm run clean && electron-builder --mac dmg:arm64 -p never",
@ -95,11 +98,12 @@
"lint": "xo",
"changelog": "auto-changelog",
"plugins": "npm run plugin:adblocker && npm run plugin:bypass-age-restrictions",
"plugin:adblocker": "del-cli plugins/adblocker/ad-blocker-engine.bin && node plugins/adblocker/blocker.js",
"plugin:adblocker": "del-cli plugins/adblocker/ad-blocker-engine.bin && node plugins/adblocker/blocker.ts",
"plugin:bypass-age-restrictions": "del-cli node_modules/simple-youtube-age-restriction-bypass/package.json && npm run generate:package simple-youtube-age-restriction-bypass",
"release:linux": "npm run clean && electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "npm run clean && electron-builder --mac -p always",
"release:win": "npm run clean && electron-builder --win -p always"
"release:win": "npm run clean && electron-builder --win -p always",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"engines": {
"node": ">=16.0.0"
@ -114,6 +118,7 @@
"browser-id3-writer": "5.0.0",
"butterchurn": "2.6.7",
"butterchurn-presets": "2.4.7",
"conf": "10.2.0",
"custom-electron-prompt": "1.5.7",
"custom-electron-titlebar": "4.1.6",
"electron-better-web-request": "1.0.1",
@ -143,6 +148,9 @@
},
"devDependencies": {
"@playwright/test": "1.37.1",
"@total-typescript/ts-reset": "0.5.1",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0",
"del-cli": "5.0.1",
"electron": "27.0.0-alpha.5",
@ -153,7 +161,8 @@
"eslint-plugin-prettier": "5.0.0",
"node-gyp": "9.4.0",
"patch-package": "^8.0.0",
"playwright": "1.37.1"
"playwright": "1.37.1",
"typescript": "5.2.2"
},
"auto-changelog": {
"hideCredit": true,

View File

@ -1,13 +0,0 @@
const { loadAdBlockerEngine } = require('./blocker');
const config = require('./config');
module.exports = async (win, options) => {
if (await config.shouldUseBlocklists()) {
loadAdBlockerEngine(
win.webContents.session,
options.cache,
options.additionalBlockLists,
options.disableDefaultLists,
);
}
};

20
plugins/adblocker/back.ts Normal file
View File

@ -0,0 +1,20 @@
import { BrowserWindow } from 'electron';
import { loadAdBlockerEngine } from './blocker';
import config from './config';
import pluginConfig from '../../config';
const AdBlockOptionsObj = pluginConfig.get('plugins.adblocker');
type AdBlockOptions = typeof AdBlockOptionsObj;
export default async (win: BrowserWindow, options: AdBlockOptions) => {
if (await config.shouldUseBlocklists()) {
loadAdBlockerEngine(
win.webContents.session,
options.cache,
options.additionalBlockLists,
options.disableDefaultLists,
);
}
};

View File

@ -1,7 +1,8 @@
const { promises } = require('node:fs'); // Used for caching
const path = require('node:path');
// Used for caching
import path from 'node:path';
import { promises } from 'node:fs';
const { ElectronBlocker } = require('@cliqz/adblocker-electron');
import { ElectronBlocker } from '@cliqz/adblocker-electron';
const SOURCES = [
'https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt',
@ -15,11 +16,11 @@ const SOURCES = [
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
];
const loadAdBlockerEngine = (
session = undefined,
export const loadAdBlockerEngine = (
session: Electron.Session | undefined = undefined,
cache = true,
additionalBlockLists = [],
disableDefaultLists = false,
disableDefaultLists: boolean | string[] = false,
) => {
// Only use cache if no additional blocklists are passed
const cachingOptions
@ -56,7 +57,7 @@ const loadAdBlockerEngine = (
.catch((error) => console.log('Error loading adBlocker engine', error));
};
module.exports = { loadAdBlockerEngine };
export default { loadAdBlockerEngine };
if (require.main === module) {
loadAdBlockerEngine(); // Generate the engine without enabling it
}

View File

@ -1,13 +0,0 @@
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('adblocker', { enableFront: true });
const blockers = {
WithBlocklists: 'With blocklists',
InPlayer: 'In player',
};
const shouldUseBlocklists = async () =>
(await config.get('blocker')) !== blockers.InPlayer;
module.exports = { shouldUseBlocklists, blockers, ...config };

View File

@ -0,0 +1,17 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('adblocker', { enableFront: true });
export const blockers = {
WithBlocklists: 'With blocklists',
InPlayer: 'In player',
};
export const shouldUseBlocklists = async () => await config.get('blocker') !== blockers.InPlayer;
export default {
shouldUseBlocklists,
blockers,
get: config.get.bind(this),
set: config.set.bind(this),
};

View File

@ -1,3 +1,5 @@
/* eslint-disable */
// Source: https://addons.mozilla.org/en-US/firefox/addon/adblock-for-youtube/
// https://robwu.nl/crxviewer/?crx=https%3A%2F%2Faddons.mozilla.org%2Fen-US%2Ffirefox%2Faddon%2Fadblock-for-youtube%2F

View File

@ -1,15 +0,0 @@
const config = require('./config');
module.exports = () => [
{
label: 'Blocker',
submenu: Object.values(config.blockers).map((blocker) => ({
label: blocker,
type: 'radio',
checked: (config.get('blocker') || config.blockers.WithBlocklists) === blocker,
click() {
config.set('blocker', blocker);
},
})),
},
];

19
plugins/adblocker/menu.ts Normal file
View File

@ -0,0 +1,19 @@
import config from './config';
export default async () => {
const blockerConfig = await config.get('blocker');
return [
{
label: 'Blocker',
submenu: Object.values(config.blockers).map((blocker) => ({
label: blocker,
type: 'radio',
checked: (blockerConfig || config.blockers.WithBlocklists) === blocker,
click() {
config.set('blocker', blocker);
},
})),
},
];
};

View File

@ -1,10 +1,10 @@
const config = require('./config');
import config from './config';
module.exports = async () => {
export default async () => {
if (await config.shouldUseBlocklists()) {
// Preload adblocker to inject scripts/styles
require('@cliqz/adblocker-electron-preload');
} else if ((await config.get('blocker')) === config.blockers.InPlayer) {
require('./inject');
require('./inject.js');
}
};

View File

@ -1,24 +0,0 @@
const { triggerAction } = require('../utils');
const CHANNEL = 'navigation';
const ACTIONS = {
NEXT: 'next',
BACK: 'back',
};
function goToNextPage() {
triggerAction(CHANNEL, ACTIONS.NEXT);
}
function goToPreviousPage() {
triggerAction(CHANNEL, ACTIONS.BACK);
}
module.exports = {
CHANNEL,
ACTIONS,
actions: {
goToNextPage,
goToPreviousPage,
},
};

View File

@ -0,0 +1,21 @@
import { Actions, triggerAction } from '../utils';
export const CHANNEL = 'navigation';
export const ACTIONS = Actions;
export function goToNextPage() {
triggerAction(CHANNEL, Actions.NEXT);
}
export function goToPreviousPage() {
triggerAction(CHANNEL, Actions.BACK);
}
export default {
CHANNEL,
ACTIONS,
actions: {
goToNextPage,
goToPreviousPage,
},
};

View File

@ -1,6 +1,6 @@
const path = require('node:path');
const { ACTIONS, CHANNEL } = require('./actions.js');
const { ACTIONS, CHANNEL } = require('./actions.ts');
const { injectCSS, listenAction } = require('../utils');

View File

@ -1,28 +1,35 @@
const path = require('node:path');
import path from 'node:path';
const { app, ipcMain } = require('electron');
import { app, BrowserWindow, ipcMain } from 'electron';
const { setOptions } = require('../../config/plugins');
const { injectCSS } = require('../utils');
import { setOptions as setPluginOptions } from '../../config/plugins';
import { injectCSS } from '../utils';
import config from '../../config';
let isInPiP = false;
let originalPosition;
let originalSize;
let originalFullScreen;
let originalMaximized;
let originalPosition: number[];
let originalSize: number[];
let originalFullScreen: boolean;
let originalMaximized: boolean;
let win;
let options;
let win: BrowserWindow;
// Magic of TypeScript
const PiPOptionsObj = config.get('plugins.picture-in-picture');
type PiPOptions = typeof PiPOptionsObj;
let options: Partial<PiPOptions>;
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
const setLocalOptions = (_options) => {
const setLocalOptions = (_options: Partial<PiPOptions>) => {
options = { ...options, ..._options };
setOptions('picture-in-picture', _options);
setPluginOptions('picture-in-picture', _options);
};
const togglePiP = async () => {
const togglePiP = () => {
isInPiP = !isInPiP;
setLocalOptions({ isInPiP });
@ -82,7 +89,7 @@ const togglePiP = async () => {
win.setWindowButtonVisibility?.(!isInPiP);
};
const blockShortcutsInPiP = (event, input) => {
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
const key = input.key.toLowerCase();
if (key === 'f') {
@ -93,14 +100,14 @@ const blockShortcutsInPiP = (event, input) => {
}
};
module.exports = (_win, _options) => {
export default (_win: BrowserWindow, _options: PiPOptions) => {
options ??= _options;
win ??= _win;
setLocalOptions({ isInPiP });
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
ipcMain.on('picture-in-picture', async () => {
await togglePiP();
ipcMain.on('picture-in-picture', () => {
togglePiP();
});
};
module.exports.setOptions = setLocalOptions;
export const setOptions = setLocalOptions;

View File

@ -1,6 +1,6 @@
const { ipcRenderer } = require('electron');
const { toKeyEvent } = require('keyboardevent-from-electron-accelerator');
const keyEventAreEqual = require('keyboardevents-areequal');
import { ipcRenderer } from 'electron';
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
import keyEventAreEqual from 'keyboardevents-areequal';
const { getSongMenu } = require('../../providers/dom-elements');
const { ElementFromFile, templatePath } = require('../utils');

View File

@ -1,6 +1,6 @@
const prompt = require('custom-electron-prompt');
const { setOptions } = require('./back.js');
const { setOptions } = require('./back.ts');
const promptOptions = require('../../providers/prompt-options');

View File

@ -1,68 +0,0 @@
const fs = require('node:fs');
const path = require('node:path');
const { ipcMain, ipcRenderer } = require('electron');
// Creates a DOM element from a HTML string
module.exports.ElementFromHtml = (html) => {
const template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
};
// Creates a DOM element from a HTML file
module.exports.ElementFromFile = (filepath) => module.exports.ElementFromHtml(fs.readFileSync(filepath, 'utf8'));
module.exports.templatePath = (pluginPath, name) => path.join(pluginPath, 'templates', name);
module.exports.triggerAction = (channel, action, ...args) => ipcRenderer.send(channel, action, ...args);
module.exports.triggerActionSync = (channel, action, ...args) => ipcRenderer.sendSync(channel, action, ...args);
module.exports.listenAction = (channel, callback) => ipcMain.on(channel, callback);
module.exports.fileExists = (
path,
callbackIfExists,
callbackIfError = undefined,
) => {
fs.access(path, fs.F_OK, (error) => {
if (error) {
if (callbackIfError) {
callbackIfError();
}
return;
}
callbackIfExists();
});
};
const cssToInject = new Map();
module.exports.injectCSS = (webContents, filepath, cb = undefined) => {
if (cssToInject.size === 0) {
setupCssInjection(webContents);
}
cssToInject.set(filepath, cb);
};
const setupCssInjection = (webContents) => {
webContents.on('did-finish-load', () => {
cssToInject.forEach(async (cb, filepath) => {
await webContents.insertCSS(fs.readFileSync(filepath, 'utf8'));
cb?.();
});
});
};
module.exports.getAllPlugins = () => {
const isDirectory = (source) => fs.lstatSync(source).isDirectory();
return fs
.readdirSync(__dirname)
.map((name) => path.join(__dirname, name))
.filter(isDirectory)
.map((name) => path.basename(name));
};

74
plugins/utils.ts Normal file
View File

@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import { ipcMain, ipcRenderer } from 'electron';
import { ValueOf } from '../utils/type-utils';
// Creates a DOM element from an HTML string
export const ElementFromHtml = (html: string) => {
const template = document.createElement('template');
html = html.trim(); // Never return a text node of whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
};
// 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: <Parameters extends unknown[]>(event: Electron.IpcMainEvent, ...args: Parameters) => void) => ipcMain.on(channel, callback);
export const fileExists = (
path: fs.PathLike,
callbackIfExists: { (): void; (): void; (): void; },
callbackIfError: (() => void) | undefined = undefined,
) => {
fs.access(path, fs.constants.F_OK, (error) => {
if (error) {
callbackIfError?.();
return;
}
callbackIfExists();
});
};
const cssToInject = new Map();
export const injectCSS = (webContents: Electron.WebContents, filepath: unknown, cb = undefined) => {
if (cssToInject.size === 0) {
setupCssInjection(webContents);
}
cssToInject.set(filepath, cb);
};
const setupCssInjection = (webContents: Electron.WebContents) => {
webContents.on('did-finish-load', () => {
cssToInject.forEach(async (callback: () => void | undefined, filepath: fs.PathOrFileDescriptor) => {
await webContents.insertCSS(fs.readFileSync(filepath, 'utf8'));
callback?.();
});
});
};
export const getAllPlugins = () => {
const isDirectory = (source: fs.PathLike) => fs.lstatSync(source).isDirectory();
return fs
.readdirSync(__dirname)
.map((name) => path.join(__dirname, name))
.filter(isDirectory)
.map((name) => path.basename(name));
};

View File

@ -1,17 +1,24 @@
const { ipcRenderer } = require('electron');
const is = require('electron-is');
import { ipcRenderer } from 'electron';
import is from 'electron-is';
import config from './config';
import { fileExists } from './plugins/utils';
import setupSongInfo from './providers/song-info-front';
import { setupSongControls } from './providers/song-controls-front';
import { startingPages } from './providers/extracted-data';
const config = require('./config');
const { fileExists } = require('./plugins/utils');
const setupSongInfo = require('./providers/song-info-front');
const { setupSongControls } = require('./providers/song-controls-front');
const { startingPages } = require('./providers/extracted-data');
const plugins = config.plugins.getEnabled();
const $ = document.querySelector.bind(document);
let api;
let api: Element | null = null;
interface Actions {
CHANNEL: string;
ACTIONS: Record<string, string>,
actions: Record<string, () => void>,
}
plugins.forEach(async ([plugin, options]) => {
const preloadPath = await ipcRenderer.invoke(
@ -20,9 +27,10 @@ plugins.forEach(async ([plugin, options]) => {
'plugins',
plugin,
'preload.js',
);
) as string;
fileExists(preloadPath, () => {
const run = require(preloadPath);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const run = require(preloadPath) as (config: typeof options) => Promise<void>;
run(options);
});
@ -32,14 +40,16 @@ plugins.forEach(async ([plugin, options]) => {
'plugins',
plugin,
'actions.js',
);
) as string;
fileExists(actionPath, () => {
const actions = require(actionPath).actions || {};
// eslint-disable-next-line @typescript-eslint/no-var-requires
const actions = (require(actionPath) as Actions).actions ?? {};
// TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld(plugin + "Actions", actions);
for (const actionName of Object.keys(actions)) {
global[actionName] = actions[actionName];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
(global as any)[actionName] = actions[actionName];
}
});
});
@ -52,9 +62,10 @@ document.addEventListener('DOMContentLoaded', () => {
'plugins',
plugin,
'front.js',
);
) as string;
fileExists(pluginPath, () => {
const run = require(pluginPath);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const run = require(pluginPath) as (config: typeof options) => Promise<void>;
run(options);
});
});
@ -69,14 +80,15 @@ document.addEventListener('DOMContentLoaded', () => {
setupSongControls();
// Add action for reloading
global.reload = () => ipcRenderer.send('reload');
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
(global as any).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) => {
ipcRenderer.on('log', (_event, log: string) => {
console.log(JSON.parse(log));
});
}
@ -100,8 +112,12 @@ function listenForApiLoad() {
observer.observe(document.documentElement, { childList: true, subtree: true });
}
interface YouTubeMusicAppElement extends HTMLElement {
navigate_(page: string): void;
}
function onApiLoaded() {
const video = $('video');
const video = $('video')!;
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(video);
audioSource.connect(audioContext.destination);
@ -126,29 +142,29 @@ function onApiLoaded() {
);
},
{ passive: true },
);
);!
document.dispatchEvent(new CustomEvent('apiLoaded', { detail: api }));
ipcRenderer.send('apiLoaded');
// Navigate to "Starting page"
const startingPage = config.get('options.startingPage');
const startingPage: string = config.get('options.startingPage');
if (startingPage && startingPages[startingPage]) {
$('ytmusic-app')?.navigate_(startingPages[startingPage]);
($('ytmusic-app') as YouTubeMusicAppElement)?.navigate_(startingPages[startingPage]);
}
// Remove upgrade button
if (config.get('options.removeUpgradeButton')) {
const upgradeButton = $('ytmusic-pivot-bar-item-renderer[tab-id="SPunlimited"]');
const upgradeButton: HTMLElement | null = $('ytmusic-pivot-bar-item-renderer[tab-id="SPunlimited"]');
if (upgradeButton) {
upgradeButton.style.display = 'none';
}
}
// Hide / Force show like buttons
const likeButtonsOptions = config.get('options.likeButtons');
const likeButtonsOptions: string = config.get('options.likeButtons');
if (likeButtonsOptions) {
const likeButtons = $('ytmusic-like-button-renderer');
const likeButtons: HTMLElement | null = $('ytmusic-like-button-renderer');
if (likeButtons) {
likeButtons.style.display
= {

View File

@ -1,35 +0,0 @@
const path = require('node:path');
const { app, BrowserWindow, ipcMain, ipcRenderer } = require('electron');
const config = require('../config');
module.exports.restart = () => {
process.type === 'browser' ? restart() : ipcRenderer.send('restart');
};
module.exports.setupAppControls = () => {
ipcMain.on('restart', restart);
ipcMain.handle('getDownloadsFolder', () => app.getPath('downloads'));
ipcMain.on('reload', () => BrowserWindow.getFocusedWindow().webContents.loadURL(config.get('url')));
ipcMain.handle('getPath', (_, ...args) => path.join(...args));
};
function restart() {
app.relaunch({ execPath: process.env.PORTABLE_EXECUTABLE_FILE });
// ExecPath will be undefined if not running portable app, resulting in default behavior
app.quit();
}
function sendToFront(channel, ...args) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(channel, ...args);
}
}
module.exports.sendToFront
= process.type === 'browser'
? sendToFront
: () => {
console.error('sendToFront called from renderer');
};

35
providers/app-controls.ts Normal file
View File

@ -0,0 +1,35 @@
import path from 'node:path';
import { app, BrowserWindow, ipcMain, ipcRenderer } from 'electron';
import config from '../config';
export const restart = () => {
process.type === 'browser' ? restartInternal() : ipcRenderer.send('restart');
};
export const setupAppControls = () => {
ipcMain.on('restart', restart);
ipcMain.handle('getDownloadsFolder', () => app.getPath('downloads'));
ipcMain.on('reload', () => BrowserWindow.getFocusedWindow()?.webContents.loadURL(config.get('url')));
ipcMain.handle('getPath', (_, ...args: string[]) => path.join(...args));
};
function restartInternal() {
app.relaunch({ execPath: process.env.PORTABLE_EXECUTABLE_FILE });
// ExecPath will be undefined if not running portable app, resulting in default behavior
app.quit();
}
function sendToFrontInternal(channel: string, ...args: unknown[]) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(channel, ...args);
}
}
export const sendToFront
= process.type === 'browser'
? sendToFrontInternal
: () => {
console.error('sendToFront called from renderer');
};

View File

@ -1,52 +1,28 @@
module.exports = {
singleton,
debounce,
cache,
throttle,
memoize,
retry,
};
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function singleton(fn) {
export function singleton<T extends (...params: never[]) => unknown>(fn: T): T {
let called = false;
return (...args) => {
return ((...args) => {
if (called) {
return;
}
called = true;
return fn(...args);
};
}) as T;
}
/**
* @template T
* @param {T} fn
* @param {number} delay
* @returns {T}
*/
function debounce(fn, delay) {
let timeout;
return (...args) => {
export function debounce<T extends (...params: never[]) => unknown>(fn: T, delay: number): T {
let timeout: NodeJS.Timeout;
return ((...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}) as T;
}
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function cache(fn) {
let lastArgs;
let lastResult;
return (...args) => {
export function cache<T extends (...params: P) => R, P extends never[], R>(fn: T): T {
let lastArgs: P;
let lastResult: R;
return ((...args: P) => {
if (
args.length !== lastArgs?.length
|| args.some((arg, i) => arg !== lastArgs[i])
@ -56,22 +32,16 @@ function cache(fn) {
}
return lastResult;
};
}) as T;
}
/*
The following are currently unused, but potentially useful in the future
*/
/**
* @template T
* @param {T} fn
* @param {number} delay
* @returns {T}
*/
function throttle(fn, delay) {
let timeout;
return (...args) => {
export function throttle<T extends (...params: unknown[]) => unknown>(fn: T, delay: number): T {
let timeout: NodeJS.Timeout | undefined;
return ((...args) => {
if (timeout) {
return;
}
@ -80,33 +50,24 @@ function throttle(fn, delay) {
timeout = undefined;
fn(...args);
}, delay);
};
}) as T;
}
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function memoize(fn) {
function memoize<T extends (...params: unknown[]) => unknown>(fn: T): T {
const cache = new Map();
return (...args) => {
return ((...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn(...args));
}
return cache.get(key);
};
return cache.get(key) as unknown;
}) as T;
}
/**
* @template T
* @param {T} fn
* @returns {T}
*/
function retry(fn, { retries = 3, delay = 1000 } = {}) {
return (...args) => {
function retry<T extends (...params: unknown[]) => unknown>(fn: T, { retries = 3, delay = 1000 } = {}): T {
return ((...args) => {
try {
return fn(...args);
} catch (error) {
@ -117,5 +78,14 @@ function retry(fn, { retries = 3, delay = 1000 } = {}) {
throw error;
}
}
};
}) as T;
}
export default {
singleton,
debounce,
cache,
throttle,
memoize,
retry,
};

View File

@ -1,4 +1,4 @@
const getSongMenu = () =>
export const getSongMenu = () =>
document.querySelector('ytmusic-menu-popup-renderer tp-yt-paper-listbox');
module.exports = { getSongMenu };
export default { getSongMenu };

View File

@ -1,4 +1,4 @@
const startingPages = {
export const startingPages: Record<string, string> = {
'Default': '',
'Home': 'FEmusic_home',
'Explore': 'FEmusic_explore',
@ -18,6 +18,6 @@ const startingPages = {
'Uploaded Artists': 'FEmusic_library_privately_owned_artists',
};
module.exports = {
export default {
startingPages,
};

View File

@ -1,13 +1,13 @@
const { Titlebar, Color } = require('custom-electron-titlebar');
import { Titlebar, Color } from 'custom-electron-titlebar';
module.exports = () => {
export default () => {
new Titlebar({
backgroundColor: Color.fromHex('#050505'),
minimizable: false,
maximizable: false,
menu: null,
menu: undefined,
});
const mainStyle = document.querySelector('#container').style;
const mainStyle = (document.querySelector('#container') as HTMLElement)!.style;
mainStyle.width = '100%';
mainStyle.position = 'fixed';
mainStyle.border = 'unset';

View File

@ -1,8 +1,8 @@
const path = require('node:path');
import path from 'node:path';
const is = require('electron-is');
import is from 'electron-is';
const { isEnabled } = require('../config/plugins');
import { isEnabled } from '../config/plugins';
const iconPath = path.join(__dirname, '..', 'assets', 'youtube-music-tray.png');
const customTitlebarPath = path.join(__dirname, 'prompt-custom-titlebar.js');
@ -17,4 +17,4 @@ const promptOptions = !is.macOS() && isEnabled('in-app-menu') ? {
icon: iconPath,
};
module.exports = () => promptOptions;
export default () => promptOptions;

View File

@ -1,45 +0,0 @@
const path = require('node:path');
const { app } = require('electron');
const getSongControls = require('./song-controls');
const APP_PROTOCOL = 'youtubemusic';
let protocolHandler;
function setupProtocolHandler(win) {
if (process.defaultApp && process.argv.length >= 2) {
app.setAsDefaultProtocolClient(
APP_PROTOCOL,
process.execPath,
[path.resolve(process.argv[1])],
);
} else {
app.setAsDefaultProtocolClient(APP_PROTOCOL);
}
const songControls = getSongControls(win);
protocolHandler = (cmd) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd]();
}
};
}
function handleProtocol(cmd) {
protocolHandler(cmd);
}
function changeProtocolHandler(f) {
protocolHandler = f;
}
module.exports = {
APP_PROTOCOL,
setupProtocolHandler,
handleProtocol,
changeProtocolHandler,
};

View File

@ -0,0 +1,45 @@
import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import getSongControls from './song-controls';
export const APP_PROTOCOL = 'youtubemusic';
let protocolHandler: ((cmd: string) => void) | undefined;
export function setupProtocolHandler(win: BrowserWindow) {
if (process.defaultApp && process.argv.length >= 2) {
app.setAsDefaultProtocolClient(
APP_PROTOCOL,
process.execPath,
[path.resolve(process.argv[1])],
);
} else {
app.setAsDefaultProtocolClient(APP_PROTOCOL);
}
const songControls = getSongControls(win);
protocolHandler = ((cmd: keyof typeof songControls) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd]();
}
}) as (cmd: string) => void;
}
export function handleProtocol(cmd: string) {
protocolHandler?.(cmd);
}
export function changeProtocolHandler(f: (cmd: string) => void) {
protocolHandler = f;
}
export default {
APP_PROTOCOL,
setupProtocolHandler,
handleProtocol,
changeProtocolHandler,
};

View File

@ -1,8 +0,0 @@
const { ipcRenderer } = require('electron');
module.exports.setupSongControls = () => {
document.addEventListener('apiLoaded', (e) => {
ipcRenderer.on('seekTo', (_, t) => e.detail.seekTo(t));
ipcRenderer.on('seekBy', (_, t) => e.detail.seekBy(t));
}, { once: true, passive: true });
};

View File

@ -0,0 +1,8 @@
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));
}, { once: true, passive: true });
};

View File

@ -1,13 +1,16 @@
// This is used for to control the songs
const pressKey = (window, key, modifiers = []) => {
import { BrowserWindow } from 'electron';
type Modifiers = (Electron.MouseInputEvent | Electron.MouseWheelInputEvent | Electron.KeyboardInputEvent)['modifiers'];
export const pressKey = (window: BrowserWindow, key: string, modifiers: Modifiers = []) => {
window.webContents.sendInputEvent({
type: 'keydown',
type: 'keyDown',
modifiers,
keyCode: key,
});
};
module.exports = (win) => {
export default (win: BrowserWindow) => {
const commands = {
// Playback
previous: () => pressKey(win, 'k'),

View File

@ -1,120 +0,0 @@
const { ipcRenderer } = require('electron');
const { getImage } = require('./song-info');
const { singleton } = require('../providers/decorators');
global.songInfo = {};
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
ipcRenderer.on('update-song-info', async (_, extractedSongInfo) => {
global.songInfo = JSON.parse(extractedSongInfo);
global.songInfo.image = await getImage(global.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)
const srcChangedEvent = new CustomEvent('srcChanged');
const setupSeekedListener = singleton(() => {
$('video')?.addEventListener('seeked', (v) => ipcRenderer.send('seeked', v.target.currentTime));
});
module.exports.setupSeekedListener = setupSeekedListener;
const setupTimeChangedListener = singleton(() => {
const progressObserver = new MutationObserver((mutations) => {
ipcRenderer.send('timeChanged', mutations[0].target.value);
global.songInfo.elapsedSeconds = mutations[0].target.value;
});
progressObserver.observe($('#progress-bar'), { attributeFilter: ['value'] });
});
module.exports.setupTimeChangedListener = setupTimeChangedListener;
const setupRepeatChangedListener = singleton(() => {
const repeatObserver = new MutationObserver((mutations) => {
ipcRenderer.send('repeatChanged', mutations[0].target.__dataHost.getState().queue.repeatMode);
});
repeatObserver.observe($('#right-controls .repeat'), { attributeFilter: ['title'] });
// Emit the initial value as well; as it's persistent between launches.
ipcRenderer.send('repeatChanged', $('ytmusic-player-bar').getState().queue.repeatMode);
});
module.exports.setupRepeatChangedListener = setupRepeatChangedListener;
const setupVolumeChangedListener = singleton((api) => {
$('video').addEventListener('volumechange', () => {
ipcRenderer.send('volumeChanged', api.getVolume());
});
// Emit the initial value as well; as it's persistent between launches.
ipcRenderer.send('volumeChanged', api.getVolume());
});
module.exports.setupVolumeChangedListener = setupVolumeChangedListener;
module.exports = () => {
document.addEventListener('apiLoaded', (apiEvent) => {
ipcRenderer.on('setupTimeChangedListener', async () => {
setupTimeChangedListener();
});
ipcRenderer.on('setupRepeatChangedListener', async () => {
setupRepeatChangedListener();
});
ipcRenderer.on('setupVolumeChangedListener', async () => {
setupVolumeChangedListener(apiEvent.detail);
});
ipcRenderer.on('setupSeekedListener', async () => {
setupSeekedListener();
});
const playPausedHandler = (e, status) => {
if (Math.round(e.target.currentTime) > 0) {
ipcRenderer.send('playPaused', {
isPaused: status === 'pause',
elapsedSeconds: Math.floor(e.target.currentTime),
});
}
};
const playPausedHandlers = {
playing: (e) => playPausedHandler(e, 'playing'),
pause: (e) => playPausedHandler(e, 'pause'),
};
// Name = "dataloaded" and abit later "dataupdated"
apiEvent.detail.addEventListener('videodatachange', (name) => {
if (name !== 'dataloaded') {
return;
}
const video = $('video');
video.dispatchEvent(srcChangedEvent);
for (const status of ['playing', 'pause']) { // for fix issue that pause event not fired
video.addEventListener(status, playPausedHandlers[status]);
}
setTimeout(sendSongInfo, 200);
});
const video = $('video');
for (const status of ['playing', 'pause']) {
video.addEventListener(status, playPausedHandlers[status]);
}
function sendSongInfo() {
const data = apiEvent.detail.getPlayerResponse();
data.videoDetails.album = $$(
'.byline.ytmusic-player-bar > .yt-simple-endpoint',
).find((e) =>
e.href?.includes('browse/FEmusic_library_privately_owned_release')
|| e.href?.includes('browse/MPREb'),
)?.textContent;
data.videoDetails.elapsedSeconds = 0;
data.videoDetails.isPaused = false;
ipcRenderer.send('video-src-changed', JSON.stringify(data));
}
}, { once: true, passive: true });
};

View File

@ -0,0 +1,124 @@
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';
let songInfo: SongInfo = {} as SongInfo;
const $ = <E extends HTMLElement>(s: string): E => document.querySelector(s) as E;
const $$ = <E extends HTMLElement>(s: string): E[] => [...document.querySelectorAll(s)!] as E[];
ipcRenderer.on('update-song-info', async (_, extractedSongInfo: string) => {
songInfo = JSON.parse(extractedSongInfo) as SongInfo;
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)
const srcChangedEvent = new CustomEvent('srcChanged');
export const setupSeekedListener = singleton(() => {
$('video')?.addEventListener('seeked', (v) => ipcRenderer.send('seeked', (v.target as HTMLVideoElement).currentTime));
});
export const setupTimeChangedListener = singleton(() => {
const progressObserver = new MutationObserver((mutations) => {
const target = mutations[0].target as HTMLInputElement;
ipcRenderer.send('timeChanged', target.value);
songInfo.elapsedSeconds = Number(target.value);
});
progressObserver.observe($('#progress-bar'), { attributeFilter: ['value'] });
});
export const setupRepeatChangedListener = singleton(() => {
const repeatObserver = new MutationObserver((mutations) => {
// provided by YouTube music
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
ipcRenderer.send('repeatChanged', ((mutations[0].target as any).__dataHost.getState() as GetState).queue.repeatMode);
});
repeatObserver.observe($('#right-controls .repeat')!, { attributeFilter: ['title'] });
// Emit the initial value as well; as it's persistent between launches.
// provided by YouTube music
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unnecessary-type-assertion
ipcRenderer.send('repeatChanged', (($('ytmusic-player-bar') as any).getState() as GetState).queue.repeatMode);
});
export const setupVolumeChangedListener = singleton((api: YoutubePlayer) => {
$('video').addEventListener('volumechange', () => {
ipcRenderer.send('volumeChanged', api.getVolume());
});
// Emit the initial value as well; as it's persistent between launches.
ipcRenderer.send('volumeChanged', api.getVolume());
});
export default () => {
document.addEventListener('apiLoaded', (apiEvent) => {
ipcRenderer.on('setupTimeChangedListener', () => {
setupTimeChangedListener();
});
ipcRenderer.on('setupRepeatChangedListener', () => {
setupRepeatChangedListener();
});
ipcRenderer.on('setupVolumeChangedListener', () => {
setupVolumeChangedListener(apiEvent.detail);
});
ipcRenderer.on('setupSeekedListener', () => {
setupSeekedListener();
});
const playPausedHandler = (e: Event, status: string) => {
if (Math.round((e.target as HTMLVideoElement).currentTime) > 0) {
ipcRenderer.send('playPaused', {
isPaused: status === 'pause',
elapsedSeconds: Math.floor((e.target as HTMLVideoElement).currentTime),
});
}
};
const playPausedHandlers = {
playing: (e: Event) => playPausedHandler(e, 'playing'),
pause: (e: Event) => playPausedHandler(e, 'pause'),
};
// Name = "dataloaded" and abit later "dataupdated"
apiEvent.detail.addEventListener('videodatachange', (name: string) => {
if (name !== 'dataloaded') {
return;
}
const video = $<HTMLVideoElement>('video');
video.dispatchEvent(srcChangedEvent);
for (const status of ['playing', 'pause'] as const) { // for fix issue that pause event not fired
video.addEventListener(status, playPausedHandlers[status]);
}
setTimeout(sendSongInfo, 200);
});
const video = $('video')!;
for (const status of ['playing', 'pause'] as const) {
video.addEventListener(status, playPausedHandlers[status]);
}
function sendSongInfo() {
const data = apiEvent.detail.getPlayerResponse();
data.videoDetails.album = $$<HTMLAnchorElement>(
'.byline.ytmusic-player-bar > .yt-simple-endpoint',
).find((e) =>
e.href?.includes('browse/FEmusic_library_privately_owned_release')
|| e.href?.includes('browse/MPREb'),
)?.textContent;
data.videoDetails.elapsedSeconds = 0;
data.videoDetails.isPaused = false;
ipcRenderer.send('video-src-changed', JSON.stringify(data));
}
}, { once: true, passive: true });
};

View File

@ -1,13 +1,28 @@
const { ipcMain, nativeImage, net } = require('electron');
import { BrowserWindow, ipcMain, nativeImage, net } from 'electron';
const config = require('../config');
const { cache } = require('../providers/decorators');
import { cache } from './decorators';
import config from '../config';
import { GetPlayerResponse } from '../types/get-player-response';
export interface SongInfo {
title: string;
artist: string;
views: number;
uploadDate: string;
imageSrc?: string | null;
image?: Electron.NativeImage | null;
isPaused?: boolean;
songDuration: number;
elapsedSeconds: number;
url: string;
album?: string | null;
videoId: string;
playlistId: string;
}
// Fill songInfo with empty values
/**
* @typedef {songInfo} SongInfo
*/
const songInfo = {
export const songInfo: SongInfo = {
title: '',
artist: '',
views: 0,
@ -24,11 +39,9 @@ const songInfo = {
};
// Grab the native image using the src
const getImage = cache(
/**
* @returns {Promise<Electron.NativeImage>}
*/
async (src) => {
export const getImage = cache(
async (src: string): Promise<Electron.NativeImage> => {
const result = await net.fetch(src);
const buffer = await result.arrayBuffer();
const output = nativeImage.createFromBuffer(Buffer.from(buffer));
@ -40,8 +53,8 @@ const getImage = cache(
},
);
const handleData = async (responseText, win) => {
const data = JSON.parse(responseText);
const handleData = async (responseText: string, win: Electron.BrowserWindow) => {
const data = JSON.parse(responseText) as GetPlayerResponse;
if (!data) {
return;
}
@ -50,7 +63,7 @@ const handleData = async (responseText, win) => {
if (microformat) {
songInfo.uploadDate = microformat.uploadDate;
songInfo.url = microformat.urlCanonical?.split('&')[0];
songInfo.playlistId = new URL(microformat.urlCanonical).searchParams.get('list');
songInfo.playlistId = new URL(microformat.urlCanonical).searchParams.get('list') ?? '';
// Used for options.resumeOnStart
config.set('url', microformat.urlCanonical);
}
@ -59,8 +72,8 @@ const handleData = async (responseText, win) => {
if (videoDetails) {
songInfo.title = cleanupName(videoDetails.title);
songInfo.artist = cleanupName(videoDetails.author);
songInfo.views = videoDetails.viewCount;
songInfo.songDuration = videoDetails.lengthSeconds;
songInfo.views = Number(videoDetails.viewCount);
songInfo.songDuration = Number(videoDetails.lengthSeconds);
songInfo.elapsedSeconds = videoDetails.elapsedSeconds;
songInfo.isPaused = videoDetails.isPaused;
songInfo.videoId = videoDetails.videoId;
@ -68,33 +81,26 @@ const handleData = async (responseText, win) => {
const thumbnails = videoDetails.thumbnail?.thumbnails;
songInfo.imageSrc = thumbnails.at(-1)?.url.split('?')[0];
songInfo.image = await getImage(songInfo.imageSrc);
if (songInfo.imageSrc) songInfo.image = await getImage(songInfo.imageSrc);
win.webContents.send('update-song-info', JSON.stringify(songInfo));
}
};
// This variable will be filled with the callbacks once they register
const callbacks = [];
type SongInfoCallback = (songInfo: SongInfo, event: string) => void;
const callbacks: SongInfoCallback[] = [];
// This function will allow plugins to register callback that will be triggered when data changes
/**
* @callback songInfoCallback
* @param {songInfo} songInfo
* @returns {void}
*/
/**
* @param {songInfoCallback} callback
*/
const registerCallback = (callback) => {
const registerCallback = (callback: SongInfoCallback) => {
callbacks.push(callback);
};
let handlingData = false;
const registerProvider = (win) => {
const registerProvider = (win: BrowserWindow) => {
// This will be called when the song-info-front finds a new request with song data
ipcMain.on('video-src-changed', async (_, responseText) => {
ipcMain.on('video-src-changed', async (_, responseText: string) => {
handlingData = true;
await handleData(responseText, win);
handlingData = false;
@ -102,7 +108,7 @@ const registerProvider = (win) => {
c(songInfo, 'video-src-changed');
}
});
ipcMain.on('playPaused', (_, { isPaused, elapsedSeconds }) => {
ipcMain.on('playPaused', (_, { isPaused, elapsedSeconds }: { isPaused: boolean, elapsedSeconds: number }) => {
songInfo.isPaused = isPaused;
songInfo.elapsedSeconds = elapsedSeconds;
if (handlingData) {
@ -122,7 +128,7 @@ const suffixesToRemove = [
' (clip officiel)',
];
function cleanupName(name) {
export function cleanupName(name: string): string {
if (!name) {
return name;
}
@ -138,7 +144,5 @@ function cleanupName(name) {
return name;
}
module.exports = registerCallback;
module.exports.setupSongInfo = registerProvider;
module.exports.getImage = getImage;
module.exports.cleanupName = cleanupName;
export default registerCallback;
export const setupSongInfo = registerProvider;

15
reset.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
import '@total-typescript/ts-reset';
import { YoutubePlayer } from './types/youtube-player';
declare global {
interface DocumentEventMap {
'apiLoaded': CustomEvent<YoutubePlayer>;
}
interface Window {
/**
* YouTube Music internal variable (Last interaction time)
*/
_lact: number;
}
}

View File

@ -1,17 +1,19 @@
const path = require('node:path');
import path from 'node:path';
const { Menu, nativeImage, Tray } = require('electron');
import { Menu, nativeImage, Tray } from 'electron';
const { restart } = require('./providers/app-controls');
const config = require('./config');
const getSongControls = require('./providers/song-controls');
import { restart } from './providers/app-controls';
import config from './config';
import getSongControls from './providers/song-controls';
import type { MenuTemplate } from './menu';
// Prevent tray being garbage collected
let tray: Electron.Tray | undefined;
/** @type {Electron.Tray} */
let tray;
type TrayEvent = (event: Electron.KeyboardEvent, bounds: Electron.Rectangle) => void;
module.exports.setTrayOnClick = (fn) => {
export const setTrayOnClick = (fn: TrayEvent) => {
if (!tray) {
return;
}
@ -20,8 +22,8 @@ module.exports.setTrayOnClick = (fn) => {
tray.on('click', fn);
};
// Wont do anything on macos since its disabled
module.exports.setTrayOnDoubleClick = (fn) => {
// Won't do anything on macOS since its disabled
export const setTrayOnDoubleClick = (fn: TrayEvent) => {
if (!tray) {
return;
}
@ -30,7 +32,7 @@ module.exports.setTrayOnDoubleClick = (fn) => {
tray.on('double-click', fn);
};
module.exports.setUpTray = (app, win) => {
export const setUpTray = (app: Electron.App, win: Electron.BrowserWindow) => {
if (!config.get('options.tray')) {
tray = undefined;
return;
@ -63,7 +65,7 @@ module.exports.setUpTray = (app, win) => {
}
});
const template = [
const template: MenuTemplate = [
{
label: 'Play/Pause',
click() {

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"baseUrl": ".",
"outDir": "./dist",
"strict": true,
"noImplicitAny": true,
"strictFunctionTypes": true,
"skipLibCheck": true
},
"exclude": [
"*.config.ts",
"./dist"
],
"paths": {
"*": ["*.d.ts"]
}
}

1823
types/datahost-get-state.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,464 @@
export interface GetPlayerResponse {
responseContext: ResponseContext;
playabilityStatus: PlayabilityStatus;
streamingData: StreamingData;
heartbeatParams: HeartbeatParams;
playbackTracking: PlaybackTracking;
captions: Captions;
videoDetails: GetPlayerResponseVideoDetails;
playerConfig: PlayerConfig;
storyboards: Storyboards;
microformat: Microformat;
trackingParams: string;
attestation: Attestation;
endscreen: Endscreen;
adBreakHeartbeatParams: string;
}
export interface Attestation {
playerAttestationRenderer: PlayerAttestationRenderer;
}
export interface PlayerAttestationRenderer {
challenge: string;
botguardData: BotguardData;
}
export interface BotguardData {
program: string;
interpreterSafeUrl: InterpreterSafeURL;
serverEnvironment: number;
}
export interface InterpreterSafeURL {
privateDoNotAccessOrElseTrustedResourceUrlWrappedValue: string;
}
export interface Captions {
playerCaptionsTracklistRenderer: PlayerCaptionsTracklistRenderer;
}
export interface PlayerCaptionsTracklistRenderer {
captionTracks: CaptionTrack[];
audioTracks: AudioTrack[];
translationLanguages: TranslationLanguage[];
defaultAudioTrackIndex: number;
}
export interface AudioTrack {
captionTrackIndices: number[];
}
export interface CaptionTrack {
baseUrl: string;
name: Name;
vssId: string;
languageCode: string;
kind: string;
isTranslatable: boolean;
}
export interface Name {
runs: Run[];
}
export interface Run {
text: string;
}
export interface TranslationLanguage {
languageCode: string;
languageName: Name;
}
export interface Endscreen {
endscreenRenderer: EndscreenRenderer;
}
export interface EndscreenRenderer {
elements: Element[];
startMs: string;
trackingParams: string;
}
export interface Element {
endscreenElementRenderer: EndscreenElementRenderer;
}
export interface EndscreenElementRenderer {
style: string;
image: ImageClass;
left: number;
width: number;
top: number;
aspectRatio: number;
startMs: string;
endMs: string;
title: Title;
metadata: Name;
endpoint: Endpoint;
trackingParams: string;
id: string;
thumbnailOverlays: ThumbnailOverlay[];
}
export interface Endpoint {
clickTrackingParams: string;
commandMetadata: CommandMetadata;
watchEndpoint: WatchEndpoint;
}
export interface CommandMetadata {
}
export interface WatchEndpoint {
videoId: string;
}
export interface ImageClass {
thumbnails: ThumbnailElement[];
}
export interface ThumbnailElement {
url: string;
width: number;
height: number;
}
export interface ThumbnailOverlay {
thumbnailOverlayTimeStatusRenderer: ThumbnailOverlayTimeStatusRenderer;
}
export interface ThumbnailOverlayTimeStatusRenderer {
text: Title;
style: string;
}
export interface Title {
runs: Run[];
accessibility: Accessibility;
}
export interface Accessibility {
accessibilityData: AccessibilityData;
}
export interface AccessibilityData {
label: string;
}
export interface HeartbeatParams {
heartbeatToken: string;
intervalMilliseconds: string;
maxRetries: string;
drmSessionId: string;
softFailOnError: boolean;
heartbeatServerData: string;
}
export interface Microformat {
microformatDataRenderer: MicroformatDataRenderer;
}
export interface MicroformatDataRenderer {
urlCanonical: string;
title: string;
description: string;
thumbnail: ImageClass;
siteName: string;
appName: string;
androidPackage: string;
iosAppStoreId: string;
iosAppArguments: string;
ogType: string;
urlApplinksIos: string;
urlApplinksAndroid: string;
urlTwitterIos: string;
urlTwitterAndroid: string;
twitterCardType: string;
twitterSiteHandle: string;
schemaDotOrgType: string;
noindex: boolean;
unlisted: boolean;
paid: boolean;
familySafe: boolean;
tags: string[];
availableCountries: string[];
pageOwnerDetails: PageOwnerDetails;
videoDetails: MicroformatDataRendererVideoDetails;
linkAlternates: LinkAlternate[];
viewCount: string;
publishDate: string;
category: string;
uploadDate: string;
}
export interface LinkAlternate {
hrefUrl: string;
title?: string;
alternateType?: string;
}
export interface PageOwnerDetails {
name: string;
externalChannelId: string;
youtubeProfileUrl: string;
}
export interface MicroformatDataRendererVideoDetails {
externalVideoId: string;
durationSeconds: string;
durationIso8601: string;
}
export interface PlayabilityStatus {
status: string;
playableInEmbed: boolean;
audioOnlyPlayability: AudioOnlyPlayability;
miniplayer: Miniplayer;
contextParams: string;
}
export interface AudioOnlyPlayability {
audioOnlyPlayabilityRenderer: AudioOnlyPlayabilityRenderer;
}
export interface AudioOnlyPlayabilityRenderer {
trackingParams: string;
audioOnlyAvailability: string;
}
export interface Miniplayer {
miniplayerRenderer: MiniplayerRenderer;
}
export interface MiniplayerRenderer {
playbackMode: string;
}
export interface PlaybackTracking {
videostatsPlaybackUrl: PtrackingURLClass;
videostatsDelayplayUrl: AtrURLClass;
videostatsWatchtimeUrl: PtrackingURLClass;
ptrackingUrl: PtrackingURLClass;
qoeUrl: PtrackingURLClass;
atrUrl: AtrURLClass;
videostatsScheduledFlushWalltimeSeconds: number[];
videostatsDefaultFlushIntervalSeconds: number;
googleRemarketingUrl: AtrURLClass;
}
export interface AtrURLClass {
baseUrl: string;
elapsedMediaTimeSeconds: number;
headers: Header[];
}
export interface Header {
headerType: HeaderType;
}
export enum HeaderType {
PlusPageID = 'PLUS_PAGE_ID',
UserAuth = 'USER_AUTH',
VisitorID = 'VISITOR_ID',
}
export interface PtrackingURLClass {
baseUrl: string;
headers: Header[];
}
export interface PlayerConfig {
audioConfig: AudioConfig;
streamSelectionConfig: StreamSelectionConfig;
mediaCommonConfig: MediaCommonConfig;
webPlayerConfig: WebPlayerConfig;
}
export interface AudioConfig {
loudnessDb: number;
perceptualLoudnessDb: number;
enablePerFormatLoudness: boolean;
}
export interface MediaCommonConfig {
dynamicReadaheadConfig: DynamicReadaheadConfig;
}
export interface DynamicReadaheadConfig {
maxReadAheadMediaTimeMs: number;
minReadAheadMediaTimeMs: number;
readAheadGrowthRateMs: number;
}
export interface StreamSelectionConfig {
maxBitrate: string;
}
export interface WebPlayerConfig {
useCobaltTvosDash: boolean;
webPlayerActionsPorting: WebPlayerActionsPorting;
gatewayExperimentGroup: string;
}
export interface WebPlayerActionsPorting {
subscribeCommand: SubscribeCommand;
unsubscribeCommand: UnsubscribeCommand;
addToWatchLaterCommand: AddToWatchLaterCommand;
removeFromWatchLaterCommand: RemoveFromWatchLaterCommand;
}
export interface AddToWatchLaterCommand {
clickTrackingParams: string;
playlistEditEndpoint: AddToWatchLaterCommandPlaylistEditEndpoint;
}
export interface AddToWatchLaterCommandPlaylistEditEndpoint {
playlistId: string;
actions: PurpleAction[];
}
export interface PurpleAction {
addedVideoId: string;
action: string;
}
export interface RemoveFromWatchLaterCommand {
clickTrackingParams: string;
playlistEditEndpoint: RemoveFromWatchLaterCommandPlaylistEditEndpoint;
}
export interface RemoveFromWatchLaterCommandPlaylistEditEndpoint {
playlistId: string;
actions: FluffyAction[];
}
export interface FluffyAction {
action: string;
removedVideoId: string;
}
export interface SubscribeCommand {
clickTrackingParams: string;
subscribeEndpoint: SubscribeEndpoint;
}
export interface SubscribeEndpoint {
channelIds: string[];
params: string;
}
export interface UnsubscribeCommand {
clickTrackingParams: string;
unsubscribeEndpoint: SubscribeEndpoint;
}
export interface ResponseContext {
serviceTrackingParams: ServiceTrackingParam[];
maxAgeSeconds: number;
}
export interface ServiceTrackingParam {
service: string;
params: Param[];
}
export interface Param {
key: string;
value: string;
}
export interface Storyboards {
playerStoryboardSpecRenderer: PlayerStoryboardSpecRenderer;
}
export interface PlayerStoryboardSpecRenderer {
spec: string;
recommendedLevel: number;
}
export interface StreamingData {
expiresInSeconds: string;
formats: Format[];
adaptiveFormats: AdaptiveFormat[];
}
export interface AdaptiveFormat {
itag: number;
mimeType: string;
bitrate: number;
width?: number;
height?: number;
initRange: Range;
indexRange: Range;
lastModified: string;
contentLength: string;
quality: string;
fps?: number;
qualityLabel?: string;
projectionType: ProjectionType;
averageBitrate: number;
approxDurationMs: string;
signatureCipher: string;
colorInfo?: ColorInfo;
highReplication?: boolean;
audioQuality?: string;
audioSampleRate?: string;
audioChannels?: number;
loudnessDb?: number;
}
export interface ColorInfo {
primaries: string;
transferCharacteristics: string;
}
export interface Range {
start: string;
end: string;
}
export enum ProjectionType {
Rectangular = 'RECTANGULAR',
}
export interface Format {
itag: number;
mimeType: string;
bitrate: number;
width: number;
height: number;
lastModified: string;
quality: string;
fps: number;
qualityLabel: string;
projectionType: ProjectionType;
audioQuality: string;
approxDurationMs: string;
audioSampleRate: string;
audioChannels: number;
signatureCipher: string;
}
export interface GetPlayerResponseVideoDetails {
videoId: string;
title: string;
lengthSeconds: string;
channelId: string;
isOwnerViewing: boolean;
isCrawlable: boolean;
thumbnail: ImageClass;
allowRatings: boolean;
viewCount: string;
author: string;
isPrivate: boolean;
isUnpluggedCorpus: boolean;
musicVideoType: string;
isLiveContent: boolean;
elapsedSeconds: number;
isPaused: boolean;
// youtube-music only
album?: string | null;
}

189
types/youtube-player.ts Normal file
View File

@ -0,0 +1,189 @@
// TODO: fully type definitions for youtube-player
import { GetPlayerResponse } from './get-player-response';
export interface YoutubePlayer {
getInternalApiInterface: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getApiInterface: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
cueVideoByPlayerVars: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
loadVideoByPlayerVars: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
preloadVideoByPlayerVars: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAdState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
sendAbandonmentPing: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setLoopRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getLoopRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setAutonavState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
seekToLiveHead: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
requestSeekToWallTimeSeconds: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
seekToStreamTime: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
startSeekCsiAction: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getStreamTimeOffset: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoData: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setInlinePreview: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updateDownloadState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
queueOfflineAction: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
pauseVideoDownload: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
resumeVideoDownload: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
refreshAllStaleEntities: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isOrchestrationLeader: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAppState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updateLastActiveTime: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setBlackout: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setUserEngagement: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updateSubtitlesUserSettings: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPresentingPlayerType: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
canPlayType: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updatePlaylist: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updateVideoData: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updateEnvironmentData: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
sendVideoStatsEngageEvent: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
productsInVideoVisibilityUpdated: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setSafetyMode: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isAtLiveHead: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoAspectRatio: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPreferredQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaybackQualityLabel: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setPlaybackQualityRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
onAdUxClicked: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getFeedbackProductData: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getStoryboardFrame: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getStoryboardFrameIndex: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getStoryboardLevel: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getNumberOfStoryboardLevels: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getCaptionWindowContainerId: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailableQualityLabels: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
addUtcCueRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
showAirplayPicker: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
dispatchReduxAction: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlayerResponse: () => GetPlayerResponse;
getHeartbeatResponse: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
changeMarkerVisibility: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setAutonav: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isNotServable: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
channelSubscribed: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
channelUnsubscribed: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
togglePictureInPicture: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
supportsGaplessAudio: () => boolean;
supportsGaplessShorts: () => boolean;
enqueueVideoByPlayerVars: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
clearQueue: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAudioTrack: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setAudioTrack: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailableAudioTracks: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getMaxPlaybackQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getUserPlaybackQualityPreference: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getSubtitlesUserSettings: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
resetSubtitlesUserSettings: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setMinimized: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setOverlayVisibility: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
confirmYpcRental: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
toggleSubtitlesOn: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isSubtitlesOn: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
queueNextVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
handleExternalCall: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
logApiCall: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isExternalMethodAvailable: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setScreenLayer: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getCurrentPlaylistSequence: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaylistSequenceForTime: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
shouldSendVisibilityState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
syncVolume: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
highlightSettingsMenuItem: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
openSettingsMenuItem: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVisibilityState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isMutedByMutedAutoplay: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setGlobalCrop: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setInternalSize: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
seekBy: (seconds: number) => void;
showControls: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
hideControls: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
cancelPlayback: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getProgressState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isInline: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setInline: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setLoopVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getLoopVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoContentRect: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoStats: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getStoryboardFormat: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
toggleFullscreen: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isFullscreen: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlayerSize: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
toggleSubtitles: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setCenterCrop: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setFauxFullscreen: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setSizeStyle: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
handleGlobalKeyDown: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
handleGlobalKeyUp: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
wakeUpControls: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
cueVideoById: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
loadVideoById: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
cueVideoByUrl: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
loadVideoByUrl: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
playVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
pauseVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
stopVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
clearVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoBytesLoaded: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoBytesTotal: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoLoadedFraction: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoStartBytes: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
cuePlaylist: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
loadPlaylist: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
nextVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
previousVideo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
playVideoAt: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setShuffle: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setLoop: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaylist: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaylistIndex: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaylistId: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
loadModule: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
unloadModule: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setOption: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getOption: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getOptions: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
mute: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
unMute: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isMuted: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setVolume: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVolume: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
seekTo: (seconds: number) => void;
getPlayerMode: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlayerState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailablePlaybackRates: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaybackQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setPlaybackQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailableQualityLevels: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getCurrentTime: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getDuration: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
addEventListener: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
removeEventListener: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getDebugText: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
addCueRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
removeCueRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setSize: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
destroy: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getSphericalProperties: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setSphericalProperties: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
mutedAutoplay: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoEmbedCode: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVideoUrl: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getMediaReferenceTime: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getSize: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
logImaAdEvent: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
preloadVideoById: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setAccountLinkState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updateAccountLinkingConfig: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailableQualityData: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setCompositeParam: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getStatsForNerds: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
showVideoInfo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
hideVideoInfo: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isVideoInfoVisible: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaybackRate: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setPlaybackRate: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updateFullerscreenEduButtonSubtleModeState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
updateFullerscreenEduButtonVisibility: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
addEmbedsConversionTrackingParams: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
}

View File

@ -1,3 +0,0 @@
const isTesting = () => process.env.NODE_ENV === 'test';
module.exports = { isTesting };

3
utils/testing.ts Normal file
View File

@ -0,0 +1,3 @@
export const isTesting = () => process.env.NODE_ENV === 'test';
export default { isTesting };

5
utils/type-utils.ts Normal file
View File

@ -0,0 +1,5 @@
export type Entries<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
export type ValueOf<T> = T[keyof T];