From 82bcadcd64ce203452ab2ab2fe7d36844854580a Mon Sep 17 00:00:00 2001 From: JellyBrick Date: Sun, 3 Sep 2023 00:25:48 +0900 Subject: [PATCH] feat: typescript part 1 Co-authored-by: Su-Yong --- .eslintrc.js | 16 +- .gitignore | 1 + config/{defaults.js => defaults.ts} | 33 +- config/dynamic.js | 214 -- config/dynamic.ts | 240 +++ config/index.js | 31 - config/index.ts | 57 + config/plugins.js | 55 - config/plugins.ts | 63 + config/{store.js => store.ts} | 43 +- custom-electron-prompt.d.ts | 46 + index.js => index.ts | 102 +- menu.js => menu.ts | 76 +- package-lock.json | 324 ++- package.json | 23 +- plugins/adblocker/back.js | 13 - plugins/adblocker/back.ts | 20 + plugins/adblocker/{blocker.js => blocker.ts} | 15 +- plugins/adblocker/config.js | 13 - plugins/adblocker/config.ts | 17 + plugins/adblocker/inject.js | 2 + plugins/adblocker/menu.js | 15 - plugins/adblocker/menu.ts | 19 + plugins/adblocker/{preload.js => preload.ts} | 6 +- plugins/navigation/actions.js | 24 - plugins/navigation/actions.ts | 21 + plugins/navigation/back.js | 2 +- .../picture-in-picture/{back.js => back.ts} | 43 +- .../picture-in-picture/{front.js => front.ts} | 6 +- .../picture-in-picture/{menu.js => menu.ts} | 2 +- plugins/utils.js | 68 - plugins/utils.ts | 74 + preload.js => preload.ts | 64 +- providers/app-controls.js | 35 - providers/app-controls.ts | 35 + providers/{decorators.js => decorators.ts} | 98 +- .../{dom-elements.js => dom-elements.ts} | 4 +- .../{extracted-data.js => extracted-data.ts} | 4 +- ...-titlebar.js => prompt-custom-titlebar.ts} | 8 +- .../{prompt-options.js => prompt-options.ts} | 8 +- providers/protocol-handler.js | 45 - providers/protocol-handler.ts | 45 + providers/song-controls-front.js | 8 - providers/song-controls-front.ts | 8 + .../{song-controls.js => song-controls.ts} | 9 +- providers/song-info-front.js | 120 -- providers/song-info-front.ts | 124 ++ providers/{song-info.js => song-info.ts} | 76 +- reset.d.ts | 15 + tray.js => tray.ts | 26 +- tsconfig.json | 23 + types/datahost-get-state.ts | 1823 +++++++++++++++++ types/get-player-response.ts | 464 +++++ types/youtube-player.ts | 189 ++ utils/testing.js | 3 - utils/testing.ts | 3 + utils/type-utils.ts | 5 + 57 files changed, 3958 insertions(+), 968 deletions(-) rename config/{defaults.js => defaults.ts} (89%) delete mode 100644 config/dynamic.js create mode 100644 config/dynamic.ts delete mode 100644 config/index.js create mode 100644 config/index.ts delete mode 100644 config/plugins.js create mode 100644 config/plugins.ts rename config/{store.js => store.ts} (67%) create mode 100644 custom-electron-prompt.d.ts rename index.js => index.ts (81%) rename menu.js => menu.ts (86%) delete mode 100644 plugins/adblocker/back.js create mode 100644 plugins/adblocker/back.ts rename plugins/adblocker/{blocker.js => blocker.ts} (84%) delete mode 100644 plugins/adblocker/config.js create mode 100644 plugins/adblocker/config.ts delete mode 100644 plugins/adblocker/menu.js create mode 100644 plugins/adblocker/menu.ts rename plugins/adblocker/{preload.js => preload.ts} (71%) delete mode 100644 plugins/navigation/actions.js create mode 100644 plugins/navigation/actions.ts rename plugins/picture-in-picture/{back.js => back.ts} (67%) rename plugins/picture-in-picture/{front.js => front.ts} (95%) rename plugins/picture-in-picture/{menu.js => menu.ts} (97%) delete mode 100644 plugins/utils.js create mode 100644 plugins/utils.ts rename preload.js => preload.ts (61%) delete mode 100644 providers/app-controls.js create mode 100644 providers/app-controls.ts rename providers/{decorators.js => decorators.ts} (52%) rename providers/{dom-elements.js => dom-elements.ts} (54%) rename providers/{extracted-data.js => extracted-data.ts} (91%) rename providers/{prompt-custom-titlebar.js => prompt-custom-titlebar.ts} (53%) rename providers/{prompt-options.js => prompt-options.ts} (72%) delete mode 100644 providers/protocol-handler.js create mode 100644 providers/protocol-handler.ts delete mode 100644 providers/song-controls-front.js create mode 100644 providers/song-controls-front.ts rename providers/{song-controls.js => song-controls.ts} (82%) delete mode 100644 providers/song-info-front.js create mode 100644 providers/song-info-front.ts rename providers/{song-info.js => song-info.ts} (64%) create mode 100644 reset.d.ts rename tray.js => tray.ts (68%) create mode 100644 tsconfig.json create mode 100644 types/datahost-get-state.ts create mode 100644 types/get-player-response.ts create mode 100644 types/youtube-player.ts delete mode 100644 utils/testing.js create mode 100644 utils/testing.ts create mode 100644 utils/type-utils.ts diff --git a/.eslintrc.js b/.eslintrc.js index b2bfdd44..e1698f08 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', diff --git a/.gitignore b/.gitignore index b8b851db..ec2a06da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules /dist +/pack electron-builder.yml .vscode/settings.json .idea diff --git a/config/defaults.js b/config/defaults.ts similarity index 89% rename from config/defaults.js rename to config/defaults.ts index a0e07e2b..2d29ef29 100644 --- a/config/defaults.js +++ b/config/defaults.ts @@ -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; diff --git a/config/dynamic.js b/config/dynamic.js deleted file mode 100644 index 5e014218..00000000 --- a/config/dynamic.js +++ /dev/null @@ -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); - }); - }); - } - } -}; diff --git a/config/dynamic.ts b/config/dynamic.ts new file mode 100644 index 00000000..0e4faa07 --- /dev/null +++ b/config/dynamic.ts @@ -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 } = {}; + +/** + * [!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 = typeof defaultConfig.plugins[T]; +type ValueOf = T[keyof T]; +export class PluginConfig { + private name: string; + private config: ConfigType; + private defaultConfig: ConfigType; + private enableFront: boolean; + + private subscribers: { [key in keyof ConfigType]?: (config: ConfigType) => void } = {}; + private allSubscribers: ((config: ConfigType) => 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): Promise>> { + return this.config[key]; + } + + set(key: keyof ConfigType, value: ValueOf>) { + this.config[key] = value; + this.#onChange(key); + this.#save(); + } + + getAll() { + return { ...this.config }; + } + + setAll(options: ConfigType) { + 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) { + 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, value: ValueOf>) { + this.config[key] = value; + setMenuOptions(this.name, this.config); + this.#onChange(key); + } + + subscribe(valueName: keyof ConfigType, fn: (config: ConfigType) => void) { + this.subscribers[valueName] = fn; + } + + subscribeAll(fn: (config: ConfigType) => void) { + this.allSubscribers.push(fn); + } + + /** Called only from back */ + #save() { + setOptions(this.name, this.config); + } + + #onChange(valueName: keyof ConfigType, single: boolean = true) { + this.subscribers[valueName]?.(this.config[valueName] as ConfigType); + 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) { + 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) => 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) => { + fn(value); + }, + ); + ipcRenderer.send(`${this.name}-config-subscribe`, valueName); + }; + + this.subscribeAll = (fn: (config: ConfigType) => void) => { + ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType) => { + fn(value); + }); + ipcRenderer.send(`${this.name}-config-subscribe-all`); + }; + } + } else if (process.type === 'browser') { + for (const [fnName, fn] of Object.entries(this) as Entries) { + 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) => { + 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); + }); + }); + } + } +} diff --git a/config/index.js b/config/index.js deleted file mode 100644 index b8adb91f..00000000 --- a/config/index.js +++ /dev/null @@ -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, -}; diff --git a/config/index.ts b/config/index.ts new file mode 100644 index 00000000..a27666dc --- /dev/null +++ b/config/index.ts @@ -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 extends string | number ? + P extends string | number ? + `${K}${'' extends P ? '' : '.'}${P}` + : never : never; +type Paths = [D] extends [never] ? never : T extends object ? + { [K in keyof T]-?: K extends string | number ? + `${K}` | Join> + : never + }[keyof T] : '' + +type FirstKey = T extends `${infer K}.${string}` ? K : T; +type NextKey = T extends `${string}.${infer K}` ? K : T; +type PathValue = ( + T extends object + ? FirstKey extends keyof T + ? PathValue], NextKey> + : T + : T +); +const get = >(key: Key) => store.get(key) as PathValue; + +export default { + defaultConfig, + get, + set, + setMenuOption, + edit: () => store.openInEditor(), + watch(cb: Parameters[1]) { + store.onDidChange('options', cb); + store.onDidChange('plugins', cb); + }, + plugins, +}; diff --git a/config/plugins.js b/config/plugins.js deleted file mode 100644 index 5589600a..00000000 --- a/config/plugins.js +++ /dev/null @@ -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, -}; diff --git a/config/plugins.ts b/config/plugins.ts new file mode 100644 index 00000000..f9ed8d64 --- /dev/null +++ b/config/plugins.ts @@ -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).filter(([plugin]) => + isEnabled(plugin), + ); +} + +export function isEnabled(plugin: string) { + const pluginConfig = (store.get('plugins') as Record)[plugin]; + return pluginConfig !== undefined && pluginConfig.enabled; +} + +export function setOptions(plugin: string, options: T) { + const plugins = store.get('plugins') as Record; + store.set('plugins', { + ...plugins, + [plugin]: { + ...plugins[plugin], + ...options, + }, + }); +} + +export function setMenuOptions(plugin: string, options: T) { + setOptions(plugin, options); + if (store.get('options.restartOnConfigChanges')) { + restart(); + } +} + +export function getOptions(plugin: string): T { + return (store.get('plugins') as Record)[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, +}; diff --git a/config/store.js b/config/store.ts similarity index 67% rename from config/store.js rename to config/store.ts index c755b1c2..f5fdac9d 100644 --- a/config/store.js +++ b/config/store.ts @@ -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>, 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>) { 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>) { 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>) { 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>) { 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>) { + const options = store.get('plugins.shortcuts') as Record>; 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 = {}; + 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>) { 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>) { + 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 = { 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, diff --git a/custom-electron-prompt.d.ts b/custom-electron-prompt.d.ts new file mode 100644 index 00000000..9fdceaf0 --- /dev/null +++ b/custom-electron-prompt.d.ts @@ -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; + keybindOptions?: PromptKeybindOptions[]; + counterOptions?: PromptCounterOptions; + icon?: string; + useHtmlLabel?: boolean; + customStylesheet?: string; + menuBarVisible?: boolean; + skipTaskbar?: boolean; + frame?: boolean; + customScript?: string; + enableRemoteModule?: boolean; + inputAttrs: Partial; + } + + const prompt: (options?: PromptOptions, parent?: BrowserWindow) => Promise; + + export default prompt; +} diff --git a/index.js b/index.ts similarity index 81% rename from index.js rename to index.ts index f88d71a1..ddb892f6 100644 --- a/index.js +++ b/index.ts @@ -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('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('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('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('picture-in-picture').saveSize) { lateSave('pip-size', windowSize, setPiPOptions); } }); - const savedTimeouts = {}; + const savedTimeouts: Record = {}; - 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 & { 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; 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, listener: ResolverListener) => { if (accumulator.cancel) { return accumulator; } - const result = await listener.apply(); + const result = listener.apply(); return { ...accumulator, ...result }; }, { cancel: false }, diff --git a/menu.js b/menu.ts similarity index 86% rename from menu.js rename to menu.ts index a67def73..629d7299 100644 --- a/menu.js +++ b/menu.ts @@ -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)', diff --git a/package-lock.json b/package-lock.json index 2cd08e2c..0a5d5edd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 9e0d3a56..5a7dccb6 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/plugins/adblocker/back.js b/plugins/adblocker/back.js deleted file mode 100644 index 3255f161..00000000 --- a/plugins/adblocker/back.js +++ /dev/null @@ -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, - ); - } -}; diff --git a/plugins/adblocker/back.ts b/plugins/adblocker/back.ts new file mode 100644 index 00000000..1360ea46 --- /dev/null +++ b/plugins/adblocker/back.ts @@ -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, + ); + } +}; diff --git a/plugins/adblocker/blocker.js b/plugins/adblocker/blocker.ts similarity index 84% rename from plugins/adblocker/blocker.js rename to plugins/adblocker/blocker.ts index 49c6085f..9fd0d5e6 100644 --- a/plugins/adblocker/blocker.js +++ b/plugins/adblocker/blocker.ts @@ -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 } diff --git a/plugins/adblocker/config.js b/plugins/adblocker/config.js deleted file mode 100644 index ed0ad43f..00000000 --- a/plugins/adblocker/config.js +++ /dev/null @@ -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 }; diff --git a/plugins/adblocker/config.ts b/plugins/adblocker/config.ts new file mode 100644 index 00000000..c8c95c45 --- /dev/null +++ b/plugins/adblocker/config.ts @@ -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), +}; diff --git a/plugins/adblocker/inject.js b/plugins/adblocker/inject.js index 8fadec0e..513bc960 100644 --- a/plugins/adblocker/inject.js +++ b/plugins/adblocker/inject.js @@ -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 diff --git a/plugins/adblocker/menu.js b/plugins/adblocker/menu.js deleted file mode 100644 index 889cbebb..00000000 --- a/plugins/adblocker/menu.js +++ /dev/null @@ -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); - }, - })), - }, -]; diff --git a/plugins/adblocker/menu.ts b/plugins/adblocker/menu.ts new file mode 100644 index 00000000..2b08aaeb --- /dev/null +++ b/plugins/adblocker/menu.ts @@ -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); + }, + })), + }, + ]; +}; diff --git a/plugins/adblocker/preload.js b/plugins/adblocker/preload.ts similarity index 71% rename from plugins/adblocker/preload.js rename to plugins/adblocker/preload.ts index 4aeef0c6..9885df46 100644 --- a/plugins/adblocker/preload.js +++ b/plugins/adblocker/preload.ts @@ -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'); } }; diff --git a/plugins/navigation/actions.js b/plugins/navigation/actions.js deleted file mode 100644 index 4a845d2f..00000000 --- a/plugins/navigation/actions.js +++ /dev/null @@ -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, - }, -}; diff --git a/plugins/navigation/actions.ts b/plugins/navigation/actions.ts new file mode 100644 index 00000000..692ab4e7 --- /dev/null +++ b/plugins/navigation/actions.ts @@ -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, + }, +}; diff --git a/plugins/navigation/back.js b/plugins/navigation/back.js index b1661b36..c2436c0b 100644 --- a/plugins/navigation/back.js +++ b/plugins/navigation/back.js @@ -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'); diff --git a/plugins/picture-in-picture/back.js b/plugins/picture-in-picture/back.ts similarity index 67% rename from plugins/picture-in-picture/back.js rename to plugins/picture-in-picture/back.ts index 85046057..1a588649 100644 --- a/plugins/picture-in-picture/back.js +++ b/plugins/picture-in-picture/back.ts @@ -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; 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) => { 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; diff --git a/plugins/picture-in-picture/front.js b/plugins/picture-in-picture/front.ts similarity index 95% rename from plugins/picture-in-picture/front.js rename to plugins/picture-in-picture/front.ts index 188cc694..a4f83d72 100644 --- a/plugins/picture-in-picture/front.js +++ b/plugins/picture-in-picture/front.ts @@ -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'); diff --git a/plugins/picture-in-picture/menu.js b/plugins/picture-in-picture/menu.ts similarity index 97% rename from plugins/picture-in-picture/menu.js rename to plugins/picture-in-picture/menu.ts index 8c8e7a63..107e9b06 100644 --- a/plugins/picture-in-picture/menu.js +++ b/plugins/picture-in-picture/menu.ts @@ -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'); diff --git a/plugins/utils.js b/plugins/utils.js deleted file mode 100644 index 51c117a3..00000000 --- a/plugins/utils.js +++ /dev/null @@ -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)); -}; diff --git a/plugins/utils.ts b/plugins/utils.ts new file mode 100644 index 00000000..431b08b1 --- /dev/null +++ b/plugins/utils.ts @@ -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 = (channel: string, action: ValueOf, ...args: Parameters) => ipcRenderer.send(channel, action, ...args); + +export const triggerActionSync = (channel: string, action: ValueOf, ...args: Parameters): unknown => ipcRenderer.sendSync(channel, action, ...args); + +export const listenAction = (channel: string, callback: (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)); +}; diff --git a/preload.js b/preload.ts similarity index 61% rename from preload.js rename to preload.ts index 1c0100c3..7c250793 100644 --- a/preload.js +++ b/preload.ts @@ -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, + actions: Record 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; 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; 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 = { diff --git a/providers/app-controls.js b/providers/app-controls.js deleted file mode 100644 index 2726ef47..00000000 --- a/providers/app-controls.js +++ /dev/null @@ -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'); - }; diff --git a/providers/app-controls.ts b/providers/app-controls.ts new file mode 100644 index 00000000..6137871e --- /dev/null +++ b/providers/app-controls.ts @@ -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'); + }; diff --git a/providers/decorators.js b/providers/decorators.ts similarity index 52% rename from providers/decorators.js rename to providers/decorators.ts index 8ed24c0f..3b59e8a8 100644 --- a/providers/decorators.js +++ b/providers/decorators.ts @@ -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 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 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 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 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 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 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, +}; diff --git a/providers/dom-elements.js b/providers/dom-elements.ts similarity index 54% rename from providers/dom-elements.js rename to providers/dom-elements.ts index b3b4bed4..6e86d78b 100644 --- a/providers/dom-elements.js +++ b/providers/dom-elements.ts @@ -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 }; diff --git a/providers/extracted-data.js b/providers/extracted-data.ts similarity index 91% rename from providers/extracted-data.js rename to providers/extracted-data.ts index d852a8cb..f1afa096 100644 --- a/providers/extracted-data.js +++ b/providers/extracted-data.ts @@ -1,4 +1,4 @@ -const startingPages = { +export const startingPages: Record = { '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, }; diff --git a/providers/prompt-custom-titlebar.js b/providers/prompt-custom-titlebar.ts similarity index 53% rename from providers/prompt-custom-titlebar.js rename to providers/prompt-custom-titlebar.ts index 852013a8..3a66afe2 100644 --- a/providers/prompt-custom-titlebar.js +++ b/providers/prompt-custom-titlebar.ts @@ -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'; diff --git a/providers/prompt-options.js b/providers/prompt-options.ts similarity index 72% rename from providers/prompt-options.js rename to providers/prompt-options.ts index a86e7c73..87e27f13 100644 --- a/providers/prompt-options.js +++ b/providers/prompt-options.ts @@ -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; diff --git a/providers/protocol-handler.js b/providers/protocol-handler.js deleted file mode 100644 index 08a8ef29..00000000 --- a/providers/protocol-handler.js +++ /dev/null @@ -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, -}; - diff --git a/providers/protocol-handler.ts b/providers/protocol-handler.ts new file mode 100644 index 00000000..49dd88b2 --- /dev/null +++ b/providers/protocol-handler.ts @@ -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, +}; + diff --git a/providers/song-controls-front.js b/providers/song-controls-front.js deleted file mode 100644 index 80901188..00000000 --- a/providers/song-controls-front.js +++ /dev/null @@ -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 }); -}; diff --git a/providers/song-controls-front.ts b/providers/song-controls-front.ts new file mode 100644 index 00000000..012a75b4 --- /dev/null +++ b/providers/song-controls-front.ts @@ -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 }); +}; diff --git a/providers/song-controls.js b/providers/song-controls.ts similarity index 82% rename from providers/song-controls.js rename to providers/song-controls.ts index e881a186..eae0fd60 100644 --- a/providers/song-controls.js +++ b/providers/song-controls.ts @@ -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'), diff --git a/providers/song-info-front.js b/providers/song-info-front.js deleted file mode 100644 index f41e371c..00000000 --- a/providers/song-info-front.js +++ /dev/null @@ -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 }); -}; diff --git a/providers/song-info-front.ts b/providers/song-info-front.ts new file mode 100644 index 00000000..9e51aa20 --- /dev/null +++ b/providers/song-info-front.ts @@ -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 $ = (s: string): E => document.querySelector(s) as E; +const $$ = (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 = $('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 = $$( + '.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 }); +}; diff --git a/providers/song-info.js b/providers/song-info.ts similarity index 64% rename from providers/song-info.js rename to providers/song-info.ts index 4c80b574..655a5336 100644 --- a/providers/song-info.js +++ b/providers/song-info.ts @@ -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} - */ - async (src) => { +export const getImage = cache( + async (src: string): Promise => { + 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; diff --git a/reset.d.ts b/reset.d.ts new file mode 100644 index 00000000..31759405 --- /dev/null +++ b/reset.d.ts @@ -0,0 +1,15 @@ +import '@total-typescript/ts-reset'; +import { YoutubePlayer } from './types/youtube-player'; + +declare global { + interface DocumentEventMap { + 'apiLoaded': CustomEvent; + } + + interface Window { + /** + * YouTube Music internal variable (Last interaction time) + */ + _lact: number; + } +} diff --git a/tray.js b/tray.ts similarity index 68% rename from tray.js rename to tray.ts index 91172cb8..f7537057 100644 --- a/tray.js +++ b/tray.ts @@ -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() { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..3d764405 --- /dev/null +++ b/tsconfig.json @@ -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"] + } +} diff --git a/types/datahost-get-state.ts b/types/datahost-get-state.ts new file mode 100644 index 00000000..3f796f05 --- /dev/null +++ b/types/datahost-get-state.ts @@ -0,0 +1,1823 @@ +export interface GetState { + castStatus: CastStatus; + entities: Entities; + download: Download; + likeStatus: LikeStatus; + multiSelect: MultiSelect; + navigation: Navigation; + player: Player; + playerPage: PlayerPage; + queue: Queue; + subscribeStatus: SubscribeStatus; + toggleStates: ToggleStates; + ui: UI; + uploads: Uploads; +} + +export interface CastStatus { + castAvailable: boolean; + castConnectionData: CastConnectionData; + remoteWatchEndpoint: null; +} + +export interface CastConnectionData { + castConnectionState: string; + castReceiverName: string; +} + +export interface Download { + isLeaderTab: boolean; +} + +export interface Entities { +} + +export interface LikeStatus { + videos: Videos; + playlists: Entities; +} + +export interface Videos { + tNVTuUEeWP0: Kqp1PyPRBzA; + KQP1PyPrBzA: Kqp1PyPRBzA; + 'o1iz4L-5zkQ': Kqp1PyPRBzA; +} + +export enum Kqp1PyPRBzA { + Dislike = 'DISLIKE', + Indifferent = 'INDIFFERENT', + Like = 'LIKE', +} + +export interface MultiSelect { + multiSelectedItems: Entities; + latestMultiSelectIndex: number; + multiSelectParams: string; +} + +export interface Navigation { + artistDiscographyBrowseCommand: null; + isLoadingIndicatorShowing: boolean; + libraryTabBrowseCommand: null; + mainContent: MainContent; + playerUiState: string; + playerPageInfo: PlayerPageInfo; +} + +export interface MainContent { + endpoint: MainContentEndpoint; + response: Response; +} + +export interface MainContentEndpoint { + data: Data; + clickTrackingVe: TriggerElement; + createScreenConfig: null; + JSC$8515_innertubePath: string; +} + +export interface TriggerElement { + veData: VeData; + csn: string; +} + +export interface VeData { + veType: number; + veCounter: number; +} + +export interface Data { + query: string; + suggestStats: SuggestStats; +} + +export interface SuggestStats { + validationStatus: string; + parameterValidationStatus: string; + clientName: string; + searchMethod: string; + inputMethods: string[]; + originalQuery: string; + availableSuggestions: unknown[]; + zeroPrefixEnabled: boolean; + firstEditTimeMsec: number; + lastEditTimeMsec: number; +} + +export interface Response { + responseContext: ResponseResponseContext; + contents: ResponseContents; + trackingParams: string; +} + +export interface ResponseContents { + tabbedSearchResultsRenderer: TabbedSearchResultsRenderer; +} + +export interface TabbedSearchResultsRenderer { + tabs: TabbedSearchResultsRendererTab[]; +} + +export interface TabbedSearchResultsRendererTab { + tabRenderer: PurpleTabRenderer; +} + +export interface PurpleTabRenderer { + title: string; + selected?: boolean; + content: PurpleContent; + tabIdentifier: string; + trackingParams: string; + endpoint?: BottomEndpointClass; +} + +export interface PurpleContent { + sectionListRenderer: SectionListRenderer; +} + +export interface SectionListRenderer { + contents?: SectionListRendererContent[]; + trackingParams: string; + header?: SectionListRendererHeader; + continuations?: Continuation[]; +} + +export interface SectionListRendererContent { + musicCardShelfRenderer?: MusicCardShelfRenderer; + musicShelfRenderer?: MusicShelfRenderer; +} + +export interface MusicCardShelfRenderer { + trackingParams: string; + thumbnail: MusicResponsiveListItemRendererThumbnail; + title: Title; + subtitle: LongBylineText; + contents: MusicCardShelfRendererContent[]; + buttons: MusicCardShelfRendererButton[]; + menu: MusicCardShelfRendererMenu; + onTap: OnTap; + header: MusicCardShelfRendererHeader; + thumbnailOverlay: ThumbnailOverlayClass; +} + +export interface MusicCardShelfRendererButton { + buttonRenderer: ButtonButtonRenderer; +} + +export interface ButtonButtonRenderer { + style: string; + size?: string; + isDisabled?: boolean; + text: Subtitle; + icon: DefaultIconClass; + accessibility: AccessibilityDataAccessibility; + trackingParams: string; + accessibilityData: AccessibilityPauseDataClass; + command: ButtonRendererCommand; +} + +export interface AccessibilityDataAccessibility { + label: string; +} + +export interface AccessibilityPauseDataClass { + accessibilityData: AccessibilityDataAccessibility; +} + +export interface ButtonRendererCommand { + clickTrackingParams: string; + watchEndpoint?: CommandWatchEndpoint; + addToPlaylistEndpoint?: Target; +} + +export interface Target { + videoId: string; +} + +export interface CommandWatchEndpoint { + videoId: string; + params: PurpleParams; + watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs; +} + +export enum PurpleParams { + WAEB = 'wAEB', +} + +export interface PurpleWatchEndpointMusicSupportedConfigs { + watchEndpointMusicConfig: PurpleWatchEndpointMusicConfig; +} + +export interface PurpleWatchEndpointMusicConfig { + musicVideoType: MusicVideoType; +} + +export enum MusicVideoType { + MusicVideoTypeAtv = 'MUSIC_VIDEO_TYPE_ATV', + MusicVideoTypeOmv = 'MUSIC_VIDEO_TYPE_OMV', + MusicVideoTypeUgc = 'MUSIC_VIDEO_TYPE_UGC', +} + +export interface DefaultIconClass { + iconType: IconType; +} + +export enum IconType { + AddToPlaylist = 'ADD_TO_PLAYLIST', + AddToRemoteQueue = 'ADD_TO_REMOTE_QUEUE', + Album = 'ALBUM', + Artist = 'ARTIST', + Favorite = 'FAVORITE', + Flag = 'FLAG', + LibraryAdd = 'LIBRARY_ADD', + LibrarySaved = 'LIBRARY_SAVED', + Mix = 'MIX', + MusicShuffle = 'MUSIC_SHUFFLE', + Pause = 'PAUSE', + PlayArrow = 'PLAY_ARROW', + PlaylistAdd = 'PLAYLIST_ADD', + QueuePlayNext = 'QUEUE_PLAY_NEXT', + Remove = 'REMOVE', + Share = 'SHARE', + Unfavorite = 'UNFAVORITE', + VolumeUp = 'VOLUME_UP', +} + +export interface Subtitle { + runs: ShortBylineTextRun[]; +} + +export interface ShortBylineTextRun { + text: string; +} + +export interface MusicCardShelfRendererContent { + messageRenderer?: MessageRenderer; + musicResponsiveListItemRenderer?: PurpleMusicResponsiveListItemRenderer; +} + +export interface MessageRenderer { + text: Subtitle; + trackingParams: string; + style: MessageRendererStyle; +} + +export interface MessageRendererStyle { + value: string; +} + +export interface PurpleMusicResponsiveListItemRenderer { + trackingParams: string; + thumbnail: MusicResponsiveListItemRendererThumbnail; + overlay: ThumbnailOverlayClass; + flexColumns: FlexColumn[]; + menu: PurpleMenu; + playlistItemData: Target; + flexColumnDisplayStyle: string; + itemHeight: string; +} + +export interface FlexColumn { + musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer; +} + +export interface MusicResponsiveListItemFlexColumnRenderer { + text: Text; + displayPriority: DisplayPriority; +} + +export enum DisplayPriority { + MusicResponsiveListItemColumnDisplayPriorityHigh = 'MUSIC_RESPONSIVE_LIST_ITEM_COLUMN_DISPLAY_PRIORITY_HIGH', +} + +export interface Text { + runs: PurpleRun[]; +} + +export interface PurpleRun { + text: string; + navigationEndpoint?: PurpleNavigationEndpoint; +} + +export interface PurpleNavigationEndpoint { + clickTrackingParams: string; + watchEndpoint?: OnTapWatchEndpoint; + browseEndpoint?: PurpleBrowseEndpoint; +} + +export interface PurpleBrowseEndpoint { + browseId: string; + browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs; +} + +export interface BrowseEndpointContextSupportedConfigs { + browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig; +} + +export interface BrowseEndpointContextMusicConfig { + pageType: PageType; +} + +export enum PageType { + MusicPageTypeAlbum = 'MUSIC_PAGE_TYPE_ALBUM', + MusicPageTypeArtist = 'MUSIC_PAGE_TYPE_ARTIST', + MusicPageTypePlaylist = 'MUSIC_PAGE_TYPE_PLAYLIST', + MusicPageTypeTrackLyrics = 'MUSIC_PAGE_TYPE_TRACK_LYRICS', + MusicPageTypeTrackRelated = 'MUSIC_PAGE_TYPE_TRACK_RELATED', + MusicPageTypeUserChannel = 'MUSIC_PAGE_TYPE_USER_CHANNEL', +} + +export interface OnTapWatchEndpoint { + videoId: string; + watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs; +} + +export interface PurpleMenu { + menuRenderer: PurpleMenuRenderer; +} + +export interface PurpleMenuRenderer { + items: PurpleItem[]; + trackingParams: string; + accessibility: AccessibilityPauseDataClass; +} + +export interface PurpleItem { + menuNavigationItemRenderer?: MenuItemRenderer; + menuServiceItemRenderer?: MenuItemRenderer; + toggleMenuServiceItemRenderer?: PurpleToggleMenuServiceItemRenderer; +} + +export interface MenuItemRenderer { + text: Subtitle; + icon: DefaultIconClass; + navigationEndpoint?: MenuNavigationItemRendererNavigationEndpoint; + trackingParams: string; + serviceEndpoint?: MenuNavigationItemRendererServiceEndpoint; +} + +export interface MenuNavigationItemRendererNavigationEndpoint { + clickTrackingParams: string; + watchEndpoint?: PurpleWatchEndpoint; + addToPlaylistEndpoint?: AddToPlaylistEndpoint; + browseEndpoint?: PurpleBrowseEndpoint; + shareEntityEndpoint?: ShareEntityEndpoint; + watchPlaylistEndpoint?: WatchPlaylistEndpoint; +} + +export interface AddToPlaylistEndpoint { + videoId?: string; + playlistId?: string; +} + +export interface ShareEntityEndpoint { + serializedShareEntity: string; + sharePanelType: SharePanelType; +} + +export enum SharePanelType { + SharePanelTypeUnifiedSharePanel = 'SHARE_PANEL_TYPE_UNIFIED_SHARE_PANEL', +} + +export interface PurpleWatchEndpoint { + videoId: string; + playlistId: string; + params: PurpleParams; + loggingContext: LoggingContext; + watchEndpointMusicSupportedConfigs: PurpleWatchEndpointMusicSupportedConfigs; +} + +export interface LoggingContext { + vssLoggingContext: VssLoggingContext; +} + +export interface VssLoggingContext { + serializedContextData: string; +} + +export interface WatchPlaylistEndpoint { + playlistId: string; + params: string; +} + +export interface MenuNavigationItemRendererServiceEndpoint { + clickTrackingParams: string; + queueAddEndpoint?: QueueAddEndpoint; + removeFromQueueEndpoint?: RemoveFromQueueEndpoint; + getReportFormEndpoint?: GetReportFormEndpoint; +} + +export interface GetReportFormEndpoint { + params: string; +} + +export interface QueueAddEndpoint { + queueTarget: AddToPlaylistEndpoint; + queueInsertPosition: QueueInsertPosition; + commands: CommandElement[]; +} + +export interface CommandElement { + clickTrackingParams: string; + addToToastAction: AddToToastAction; +} + +export interface AddToToastAction { + item: AddToToastActionItem; +} + +export interface AddToToastActionItem { + notificationTextRenderer: NotificationTextRenderer; +} + +export interface NotificationTextRenderer { + successResponseText: Subtitle; + trackingParams: string; +} + +export enum QueueInsertPosition { + InsertAfterCurrentVideo = 'INSERT_AFTER_CURRENT_VIDEO', + InsertAtEnd = 'INSERT_AT_END', +} + +export interface RemoveFromQueueEndpoint { + videoId: string; + commands: CommandElement[]; + itemId: string; +} + +export interface PurpleToggleMenuServiceItemRenderer { + defaultText: Subtitle; + defaultIcon: DefaultIconClass; + defaultServiceEndpoint: PurpleDefaultServiceEndpoint; + toggledText: Subtitle; + toggledIcon: DefaultIconClass; + toggledServiceEndpoint: PurpleToggledServiceEndpoint; + trackingParams: string; +} + +export interface PurpleDefaultServiceEndpoint { + clickTrackingParams: string; + feedbackEndpoint?: FeedbackEndpoint; + likeEndpoint?: PurpleLikeEndpoint; +} + +export interface FeedbackEndpoint { + feedbackToken: string; +} + +export interface PurpleLikeEndpoint { + status: Kqp1PyPRBzA; + target: Target; + actions?: LikeEndpointAction[]; +} + +export interface LikeEndpointAction { + clickTrackingParams: string; + musicLibraryStatusUpdateCommand: MusicLibraryStatusUpdateCommand; +} + +export interface MusicLibraryStatusUpdateCommand { + libraryStatus: string; + addToLibraryFeedbackToken: string; +} + +export interface PurpleToggledServiceEndpoint { + clickTrackingParams: string; + feedbackEndpoint?: FeedbackEndpoint; + likeEndpoint?: FluffyLikeEndpoint; +} + +export interface FluffyLikeEndpoint { + status: Kqp1PyPRBzA; + target: Target; +} + +export interface ThumbnailOverlayClass { + musicItemThumbnailOverlayRenderer: ThumbnailOverlayMusicItemThumbnailOverlayRenderer; +} + +export interface ThumbnailOverlayMusicItemThumbnailOverlayRenderer { + background: Background; + content: FluffyContent; + contentPosition: string; + displayStyle: string; +} + +export interface Background { + verticalGradient: VerticalGradient; +} + +export interface VerticalGradient { + gradientLayerColors: string[]; +} + +export interface FluffyContent { + musicPlayButtonRenderer: PurpleMusicPlayButtonRenderer; +} + +export interface PurpleMusicPlayButtonRenderer { + playNavigationEndpoint: OnTap; + trackingParams: string; + playIcon: DefaultIconClass; + pauseIcon: DefaultIconClass; + iconColor: number; + backgroundColor: number; + activeBackgroundColor: number; + loadingIndicatorColor: number; + playingIcon: DefaultIconClass; + iconLoadingColor: number; + activeScaleFactor: number; + buttonSize: string; + rippleTarget: string; + accessibilityPlayData: AccessibilityPauseDataClass; + accessibilityPauseData: AccessibilityPauseDataClass; +} + +export interface OnTap { + clickTrackingParams: string; + watchEndpoint: OnTapWatchEndpoint; +} + +export interface MusicResponsiveListItemRendererThumbnail { + musicThumbnailRenderer: MusicThumbnailRenderer; +} + +export interface MusicThumbnailRenderer { + thumbnail: ThumbnailDetailsClass; + thumbnailCrop: string; + thumbnailScale: string; + trackingParams: string; +} + +export interface ThumbnailDetailsClass { + thumbnails: ThumbnailElement[]; +} + +export interface ThumbnailElement { + url: string; + width: number; + height: number; +} + +export interface MusicCardShelfRendererHeader { + musicCardShelfHeaderBasicRenderer: MusicCardShelfHeaderBasicRenderer; +} + +export interface MusicCardShelfHeaderBasicRenderer { + title: Subtitle; + trackingParams: string; +} + +export interface MusicCardShelfRendererMenu { + menuRenderer: FluffyMenuRenderer; +} + +export interface FluffyMenuRenderer { + items: FluffyItem[]; + trackingParams: string; + accessibility: AccessibilityPauseDataClass; +} + +export interface FluffyItem { + menuNavigationItemRenderer?: MenuItemRenderer; + menuServiceItemRenderer?: MenuItemRenderer; + toggleMenuServiceItemRenderer?: FluffyToggleMenuServiceItemRenderer; +} + +export interface FluffyToggleMenuServiceItemRenderer { + defaultText: Subtitle; + defaultIcon: DefaultIconClass; + defaultServiceEndpoint: PurpleServiceEndpoint; + toggledText: Subtitle; + toggledIcon: DefaultIconClass; + toggledServiceEndpoint: PurpleServiceEndpoint; + trackingParams: string; +} + +export interface PurpleServiceEndpoint { + clickTrackingParams: string; + likeEndpoint: FluffyLikeEndpoint; +} + +export interface LongBylineText { + runs: LongBylineTextRun[]; +} + +export interface LongBylineTextRun { + text: string; + navigationEndpoint?: RunEndpoint; +} + +export interface RunEndpoint { + clickTrackingParams: string; + browseEndpoint: PurpleBrowseEndpoint; +} + +export interface Title { + runs: FluffyRun[]; +} + +export interface FluffyRun { + text: string; + navigationEndpoint: OnTap; +} + +export interface MusicShelfRenderer { + title: Subtitle; + contents: MusicShelfRendererContent[]; + trackingParams: string; + bottomText: Subtitle; + bottomEndpoint: BottomEndpointClass; + shelfDivider: ShelfDivider; +} + +export interface BottomEndpointClass { + clickTrackingParams: string; + searchEndpoint: SearchEndpoint; +} + +export interface SearchEndpoint { + query: string; + params: string; +} + +export interface MusicShelfRendererContent { + musicResponsiveListItemRenderer: FluffyMusicResponsiveListItemRenderer; +} + +export interface FluffyMusicResponsiveListItemRenderer { + trackingParams: string; + thumbnail: MusicResponsiveListItemRendererThumbnail; + overlay: PurpleOverlay; + flexColumns: FlexColumn[]; + menu: FluffyMenu; + playlistItemData?: Target; + flexColumnDisplayStyle: string; + itemHeight: string; + navigationEndpoint?: RunEndpoint; +} + +export interface FluffyMenu { + menuRenderer: TentacledMenuRenderer; +} + +export interface TentacledMenuRenderer { + items: TentacledItem[]; + trackingParams: string; + accessibility: AccessibilityPauseDataClass; +} + +export interface TentacledItem { + menuNavigationItemRenderer?: MenuItemRenderer; + menuServiceItemRenderer?: MenuItemRenderer; + toggleMenuServiceItemRenderer?: TentacledToggleMenuServiceItemRenderer; +} + +export interface TentacledToggleMenuServiceItemRenderer { + defaultText: Subtitle; + defaultIcon: DefaultIconClass; + defaultServiceEndpoint: FluffyDefaultServiceEndpoint; + toggledText: Subtitle; + toggledIcon: DefaultIconClass; + toggledServiceEndpoint: FluffyToggledServiceEndpoint; + trackingParams: string; +} + +export interface FluffyDefaultServiceEndpoint { + clickTrackingParams: string; + feedbackEndpoint?: FeedbackEndpoint; + likeEndpoint?: TentacledLikeEndpoint; +} + +export interface TentacledLikeEndpoint { + status: Kqp1PyPRBzA; + target: AddToPlaylistEndpoint; + actions?: LikeEndpointAction[]; +} + +export interface FluffyToggledServiceEndpoint { + clickTrackingParams: string; + feedbackEndpoint?: FeedbackEndpoint; + likeEndpoint?: StickyLikeEndpoint; +} + +export interface StickyLikeEndpoint { + status: Kqp1PyPRBzA; + target: AddToPlaylistEndpoint; +} + +export interface PurpleOverlay { + musicItemThumbnailOverlayRenderer: PurpleMusicItemThumbnailOverlayRenderer; +} + +export interface PurpleMusicItemThumbnailOverlayRenderer { + background: Background; + content: TentacledContent; + contentPosition: string; + displayStyle: string; +} + +export interface TentacledContent { + musicPlayButtonRenderer: FluffyMusicPlayButtonRenderer; +} + +export interface FluffyMusicPlayButtonRenderer { + playNavigationEndpoint: PlayNavigationEndpoint; + trackingParams: string; + playIcon: DefaultIconClass; + pauseIcon: DefaultIconClass; + iconColor: number; + backgroundColor: number; + activeBackgroundColor: number; + loadingIndicatorColor: number; + playingIcon: DefaultIconClass; + iconLoadingColor: number; + activeScaleFactor: number; + buttonSize: string; + rippleTarget: string; + accessibilityPlayData: AccessibilityPauseDataClass; + accessibilityPauseData: AccessibilityPauseDataClass; +} + +export interface PlayNavigationEndpoint { + clickTrackingParams: string; + watchEndpoint?: OnTapWatchEndpoint; + watchPlaylistEndpoint?: WatchPlaylistEndpoint; +} + +export interface ShelfDivider { + musicShelfDividerRenderer: MusicShelfDividerRenderer; +} + +export interface MusicShelfDividerRenderer { + hidden: boolean; +} + +export interface Continuation { + reloadContinuationData: ReloadContinuationData; +} + +export interface ReloadContinuationData { + continuation: string; + clickTrackingParams: string; +} + +export interface SectionListRendererHeader { + chipCloudRenderer: ChipCloudRenderer; +} + +export interface ChipCloudRenderer { + chips: ChipCloudRendererChip[]; + collapsedRowCount: number; + trackingParams: string; + horizontalScrollable: boolean; +} + +export interface ChipCloudRendererChip { + chipCloudChipRenderer: PurpleChipCloudChipRenderer; +} + +export interface PurpleChipCloudChipRenderer { + style: ChipCloudChipRendererStyle; + text: Subtitle; + navigationEndpoint: BottomEndpointClass; + trackingParams: string; + accessibilityData: AccessibilityPauseDataClass; + isSelected: boolean; + uniqueId: string; +} + +export interface ChipCloudChipRendererStyle { + styleType: string; +} + +export interface ResponseResponseContext { + serviceTrackingParams: ServiceTrackingParam[]; + maxAgeSeconds: number; +} + +export interface ServiceTrackingParam { + service: string; + params: Param[]; +} + +export interface Param { + key: string; + value: string; +} + +export interface PlayerPageInfo { + open: boolean; + triggerElement: TriggerElement; +} + +export interface Player { + adPlaying: boolean; + captionsAvailable: boolean; + captionsVisible: boolean; + fullscreened: boolean; + miniPlayerEnabled: boolean; + muted: boolean; + nerdStatsVisible: boolean; + playerResponse: PlayerResponse; + playerTriggerInfo: PlayerTriggerInfo; + preloadedEndpoint_: null; + volume: number; + playbackRate: number; +} + +export interface PlayerResponse { + responseContext: ResponseResponseContext; + playabilityStatus: PlayabilityStatus; + streamingData: StreamingData; + heartbeatParams: HeartbeatParams; + playbackTracking: PlaybackTracking; + captions: Captions; + videoDetails: PlayerResponseVideoDetails; + annotations: Annotation[]; + playerConfig: PlayerConfig; + storyboards: Storyboards; + microformat: Microformat; + trackingParams: string; + attestation: Attestation; + endscreen: Endscreen; + adBreakHeartbeatParams: string; +} + +export interface Annotation { + playerAnnotationsExpandedRenderer: PlayerAnnotationsExpandedRenderer; +} + +export interface PlayerAnnotationsExpandedRenderer { + featuredChannel: FeaturedChannel; + allowSwipeDismiss: boolean; +} + +export interface FeaturedChannel { + startTimeMs: string; + endTimeMs: string; + watermark: ThumbnailDetailsClass; + trackingParams: string; + navigationEndpoint: FeaturedChannelNavigationEndpoint; + channelName: string; + subscribeButton: SubscribeButtonClass; +} + +export interface FeaturedChannelNavigationEndpoint { + clickTrackingParams: string; + browseEndpoint: FluffyBrowseEndpoint; +} + +export interface FluffyBrowseEndpoint { + browseId: string; +} + +export interface SubscribeButtonClass { + subscribeButtonRenderer: SubscribeButtonRenderer; +} + +export interface SubscribeButtonRenderer { + buttonText: Subtitle; + subscribed: boolean; + enabled: boolean; + type: string; + channelId: string; + showPreferences: boolean; + subscribedButtonText: Subtitle; + unsubscribedButtonText: Subtitle; + trackingParams: string; + unsubscribeButtonText: Subtitle; + serviceEndpoints: SubscribeButtonRendererServiceEndpoint[]; +} + +export interface SubscribeButtonRendererServiceEndpoint { + clickTrackingParams: string; + subscribeEndpoint?: SubscribeEndpoint; + signalServiceEndpoint?: SignalServiceEndpoint; +} + +export interface SignalServiceEndpoint { + signal: string; + actions: SignalServiceEndpointAction[]; +} + +export interface SignalServiceEndpointAction { + clickTrackingParams: string; + openPopupAction: OpenPopupAction; +} + +export interface OpenPopupAction { + popup: Popup; + popupType: string; +} + +export interface Popup { + confirmDialogRenderer: ConfirmDialogRenderer; +} + +export interface ConfirmDialogRenderer { + trackingParams: string; + dialogMessages: Subtitle[]; + confirmButton: CancelButtonClass; + cancelButton: CancelButtonClass; +} + +export interface CancelButtonClass { + buttonRenderer: CancelButtonButtonRenderer; +} + +export interface CancelButtonButtonRenderer { + style: string; + isDisabled: boolean; + text: Subtitle; + accessibility?: AccessibilityDataAccessibility; + trackingParams: string; + serviceEndpoint?: UnsubscribeCommand; +} + +export interface UnsubscribeCommand { + clickTrackingParams: string; + unsubscribeEndpoint: SubscribeEndpoint; +} + +export interface SubscribeEndpoint { + channelIds: string[]; + params: 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[]; + defaultCaptionTrackIndex: number; + visibility: string; + hasDefaultTrack: boolean; + captionsInitialState: string; +} + +export interface CaptionTrack { + baseUrl: string; + name: Subtitle; + vssId: string; + languageCode: string; + kind?: string; + isTranslatable: boolean; +} + +export interface TranslationLanguage { + languageCode: string; + languageName: Subtitle; +} + +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: ThumbnailDetailsClass; + icon?: EndscreenElementRendererIcon; + left: number; + width: number; + top: number; + aspectRatio: number; + startMs: string; + endMs: string; + title: LengthText; + metadata: Subtitle; + callToAction?: Subtitle; + dismiss?: Subtitle; + endpoint: EndscreenElementRendererEndpoint; + hovercardButton?: SubscribeButtonClass; + trackingParams: string; + isSubscribe?: boolean; + useClassicSubscribeButton?: boolean; + id: string; + thumbnailOverlays?: ThumbnailOverlay[]; +} + +export interface EndscreenElementRendererEndpoint { + clickTrackingParams: string; + browseEndpoint?: FluffyBrowseEndpoint; + commandMetadata?: Entities; + watchEndpoint?: Target; +} + +export interface EndscreenElementRendererIcon { + thumbnails: URLEndpoint[]; +} + +export interface URLEndpoint { + url: string; +} + +export interface ThumbnailOverlay { + thumbnailOverlayTimeStatusRenderer: ThumbnailOverlayTimeStatusRenderer; +} + +export interface ThumbnailOverlayTimeStatusRenderer { + text: LengthText; + style: string; +} + +export interface LengthText { + runs: ShortBylineTextRun[]; + accessibility: AccessibilityPauseDataClass; +} + +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: ThumbnailDetailsClass; + 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: Date; + category: string; + uploadDate: Date; +} + +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: HeaderElement[]; +} + +export interface HeaderElement { + headerType: HeaderType; +} + +export enum HeaderType { + PlusPageID = 'PLUS_PAGE_ID', + UserAuth = 'USER_AUTH', + VisitorID = 'VISITOR_ID', +} + +export interface PtrackingURLClass { + baseUrl: string; + headers: HeaderElement[]; +} + +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 Storyboards { + playerStoryboardSpecRenderer: PlayerStoryboardSpecRenderer; +} + +export interface PlayerStoryboardSpecRenderer { + spec: string; + recommendedLevel: number; +} + +export interface StreamingData { + expiresInSeconds: string; + formats: Format[]; + adaptiveFormats: AdaptiveFormat[]; + probeUrl: string; +} + +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; + matrixCoefficients: 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 PlayerResponseVideoDetails { + videoId: string; + title: string; + lengthSeconds: string; + channelId: string; + isOwnerViewing: boolean; + isCrawlable: boolean; + thumbnail: ThumbnailDetailsClass; + allowRatings: boolean; + viewCount: string; + author: string; + isPrivate: boolean; + isUnpluggedCorpus: boolean; + musicVideoType: MusicVideoType; + isLiveContent: boolean; + elapsedSeconds: number; + isPaused: boolean; +} + +export interface PlayerTriggerInfo { + screenLayer: number; +} + +export interface PlayerPage { + playerOverlay: PlayerOverlay; + playerPageTabs: PlayerPageTabElement[]; + playerPageTabsContent: Entities; + playerPageTabSelectedIndex: number; + playerPageWatchNextAutomixParams: string; + playerPageWatchNextContinuationParams: string; + playerPageWatchNextMetadata: null; + playerPageWatchNextResponse: PlayerPageWatchNextResponse; + watchNextOverlay: null; +} + +export interface PlayerOverlay { + playerOverlayRenderer: PlayerOverlayRenderer; +} + +export interface PlayerOverlayRenderer { + actions: PlayerOverlayRendererAction[]; + browserMediaSession: BrowserMediaSession; +} + +export interface PlayerOverlayRendererAction { + likeButtonRenderer: LikeButtonRenderer; +} + +export interface LikeButtonRenderer { + target: Target; + likeStatus: Kqp1PyPRBzA; + trackingParams: string; + likesAllowed: boolean; + serviceEndpoints: ServiceEndpoint[]; +} + +export interface ServiceEndpoint { + clickTrackingParams: string; + likeEndpoint: ServiceEndpointLikeEndpoint; +} + +export interface ServiceEndpointLikeEndpoint { + status: Kqp1PyPRBzA; + target: Target; + likeParams?: LikeParams; + dislikeParams?: LikeParams; + removeLikeParams?: LikeParams; +} + +export enum LikeParams { + Oai3D = 'OAI%3D', +} + +export interface BrowserMediaSession { + browserMediaSessionRenderer: BrowserMediaSessionRenderer; +} + +export interface BrowserMediaSessionRenderer { + thumbnailDetails: ThumbnailDetailsClass; +} + +export interface PlayerPageTabElement { + tabRenderer: PlayerPageTabTabRenderer; +} + +export interface PlayerPageTabTabRenderer { + title: string; + content?: StickyContent; + trackingParams: string; + endpoint?: RunEndpoint; + unselectable?: boolean; +} + +export interface StickyContent { + musicQueueRenderer: MusicQueueRenderer; +} + +export interface MusicQueueRenderer { + hack: boolean; +} + +export interface PlayerPageWatchNextResponse { + responseContext: PlayerPageWatchNextResponseResponseContext; + contents: PlayerPageWatchNextResponseContents; + currentVideoEndpoint: CurrentVideoEndpoint; + trackingParams: string; + playerOverlays: PlayerOverlay; + videoReporting: VideoReporting; +} + +export interface PlayerPageWatchNextResponseContents { + singleColumnMusicWatchNextResultsRenderer: SingleColumnMusicWatchNextResultsRenderer; +} + +export interface SingleColumnMusicWatchNextResultsRenderer { + tabbedRenderer: TabbedRenderer; +} + +export interface TabbedRenderer { + watchNextTabbedResultsRenderer: WatchNextTabbedResultsRenderer; +} + +export interface WatchNextTabbedResultsRenderer { + tabs: PlayerPageTabElement[]; +} + +export interface CurrentVideoEndpoint { + clickTrackingParams: string; + watchEndpoint: CurrentVideoEndpointWatchEndpoint; +} + +export interface CurrentVideoEndpointWatchEndpoint { + videoId: string; + playlistId: PlaylistID; + index: number; + playlistSetVideoId: string; + loggingContext: LoggingContext; +} + +export enum PlaylistID { + RDAMVMrkaNKAvksDE = 'RDAMVMrkaNKAvksDE', +} + +export interface PlayerPageWatchNextResponseResponseContext { + serviceTrackingParams: ServiceTrackingParam[]; +} + +export interface VideoReporting { + reportFormModalRenderer: ReportFormModalRenderer; +} + +export interface ReportFormModalRenderer { + optionsSupportedRenderers: OptionsSupportedRenderers; + trackingParams: string; + title: Subtitle; + submitButton: CancelButtonClass; + cancelButton: CancelButtonClass; + footer: Footer; +} + +export interface Footer { + runs: FooterRun[]; +} + +export interface FooterRun { + text: string; + navigationEndpoint?: FluffyNavigationEndpoint; +} + +export interface FluffyNavigationEndpoint { + clickTrackingParams: string; + urlEndpoint: URLEndpoint; +} + +export interface OptionsSupportedRenderers { + optionsRenderer: OptionsRenderer; +} + +export interface OptionsRenderer { + items: OptionsRendererItem[]; + trackingParams: string; +} + +export interface OptionsRendererItem { + optionSelectableItemRenderer: OptionSelectableItemRenderer; +} + +export interface OptionSelectableItemRenderer { + text: Subtitle; + trackingParams: string; + submitEndpoint: SubmitEndpoint; +} + +export interface SubmitEndpoint { + clickTrackingParams: string; + flagEndpoint: FlagEndpoint; +} + +export interface FlagEndpoint { + flagAction: string; +} + +export interface Queue { + automixItems: unknown[]; + autoplay: boolean; + hasShownAutoplay: boolean; + hasUserChangedDefaultAutoplayMode: boolean; + header: QueueHeader; + impressedVideoIds: Entities; + isFetchingChipSteer: boolean; + isGenerating: boolean; + isInfinite: boolean; + isPrefetchingContinuations: boolean; + isRaarEnabled: boolean; + isRaarSkip: boolean; + items: QueueItem[]; + nextQueueItemId: number; + playbackContentMode: string; + queueContextParams: string; + repeatMode: string; + responsiveSignals: ResponsiveSignals; + selectedItemIndex: number; + shuffleEnabled: boolean; + shuffleEndpoints: null; + steeringChips: SteeringChips; + watchNextType: null; +} + +export interface QueueHeader { + title: Subtitle; + subtitle: Subtitle; + buttons: HeaderButton[]; + trackingParams: string; +} + +export interface HeaderButton { + chipCloudChipRenderer: ButtonChipCloudChipRenderer; +} + +export interface ButtonChipCloudChipRenderer { + style: ChipCloudChipRendererStyle; + text: Subtitle; + navigationEndpoint: TentacledNavigationEndpoint; + trackingParams: string; + icon: DefaultIconClass; + accessibilityData: AccessibilityPauseDataClass; + isSelected: boolean; + uniqueId: string; +} + +export interface TentacledNavigationEndpoint { + clickTrackingParams: string; + saveQueueToPlaylistCommand: Entities; +} + +export interface QueueItem { + playlistPanelVideoWrapperRenderer?: PlaylistPanelVideoWrapperRenderer; + playlistPanelVideoRenderer?: ItemPlaylistPanelVideoRenderer; +} + +export interface ItemPlaylistPanelVideoRenderer { + title: Subtitle; + longBylineText: LongBylineText; + thumbnail: ThumbnailDetailsClass; + lengthText: LengthText; + selected: boolean; + navigationEndpoint: PlaylistPanelVideoRendererNavigationEndpoint; + videoId: string; + shortBylineText: Subtitle; + trackingParams: string; + menu: TentacledMenu; + playlistSetVideoId?: string; + canReorder: boolean; +} + +export interface TentacledMenu { + menuRenderer: StickyMenuRenderer; +} + +export interface StickyMenuRenderer { + items: StickyItem[]; + trackingParams: string; + accessibility: AccessibilityPauseDataClass; +} + +export interface StickyItem { + menuNavigationItemRenderer?: MenuItemRenderer; + menuServiceItemRenderer?: MenuItemRenderer; + toggleMenuServiceItemRenderer?: StickyToggleMenuServiceItemRenderer; +} + +export interface StickyToggleMenuServiceItemRenderer { + defaultText: Subtitle; + defaultIcon: DefaultIconClass; + defaultServiceEndpoint: ServiceEndpoint; + toggledText: Subtitle; + toggledIcon: DefaultIconClass; + toggledServiceEndpoint: ServiceEndpoint; + trackingParams: string; +} + +export interface PlaylistPanelVideoRendererNavigationEndpoint { + clickTrackingParams: string; + watchEndpoint: FluffyWatchEndpoint; +} + +export interface FluffyWatchEndpoint { + videoId: string; + playlistId?: PlaylistID; + index: number; + params: FluffyParams; + playerParams?: PlayerParams; + playlistSetVideoId?: string; + loggingContext?: LoggingContext; + watchEndpointMusicSupportedConfigs: FluffyWatchEndpointMusicSupportedConfigs; +} + +export enum FluffyParams { + OAHyAQIIAQ3D3D = 'OAHyAQIIAQ%3D%3D', +} + +export enum PlayerParams { + The8Aub = '8AUB', +} + +export interface FluffyWatchEndpointMusicSupportedConfigs { + watchEndpointMusicConfig: FluffyWatchEndpointMusicConfig; +} + +export interface FluffyWatchEndpointMusicConfig { + hasPersistentPlaylistPanel: boolean; + musicVideoType: MusicVideoType; +} + +export interface PlaylistPanelVideoWrapperRenderer { + primaryRenderer: PrimaryRenderer; + counterpart: Counterpart[]; +} + +export interface Counterpart { + counterpartRenderer: CounterpartRenderer; + segmentMap: SegmentMap; +} + +export interface CounterpartRenderer { + playlistPanelVideoRenderer: CounterpartRendererPlaylistPanelVideoRenderer; +} + +export interface CounterpartRendererPlaylistPanelVideoRenderer { + title: Subtitle; + longBylineText: LongBylineText; + thumbnail: ThumbnailDetailsClass; + lengthText: LengthText; + selected: boolean; + navigationEndpoint: PlaylistPanelVideoRendererNavigationEndpoint; + videoId: string; + shortBylineText: Subtitle; + trackingParams: string; + menu: StickyMenu; + playlistSetVideoId?: string; + canReorder: boolean; +} + +export interface StickyMenu { + menuRenderer: IndigoMenuRenderer; +} + +export interface IndigoMenuRenderer { + items: IndigoItem[]; + trackingParams: string; + accessibility: AccessibilityPauseDataClass; +} + +export interface IndigoItem { + menuNavigationItemRenderer?: MenuItemRenderer; + menuServiceItemRenderer?: MenuItemRenderer; + toggleMenuServiceItemRenderer?: IndigoToggleMenuServiceItemRenderer; +} + +export interface IndigoToggleMenuServiceItemRenderer { + defaultText: Subtitle; + defaultIcon: DefaultIconClass; + defaultServiceEndpoint: FluffyServiceEndpoint; + toggledText: Subtitle; + toggledIcon: DefaultIconClass; + toggledServiceEndpoint: FluffyServiceEndpoint; + trackingParams: string; +} + +export interface FluffyServiceEndpoint { + clickTrackingParams: string; + likeEndpoint?: ServiceEndpointLikeEndpoint; + feedbackEndpoint?: FeedbackEndpoint; +} + +export interface SegmentMap { + segment: Segment[]; +} + +export interface Segment { + primaryVideoStartTimeMilliseconds: string; + counterpartVideoStartTimeMilliseconds: string; + durationMilliseconds: string; +} + +export interface PrimaryRenderer { + playlistPanelVideoRenderer: ItemPlaylistPanelVideoRenderer; +} + +export interface ResponsiveSignals { + videoInteraction: VideoInteraction[]; +} + +export interface VideoInteraction { + queueImpress?: Entities; + videoId: string; + queueIndex: number; + playbackSkip?: Entities; +} + +export interface SteeringChips { + chips: SteeringChipsChip[]; + trackingParams: string; +} + +export interface SteeringChipsChip { + chipCloudChipRenderer: FluffyChipCloudChipRenderer; +} + +export interface FluffyChipCloudChipRenderer { + text: Subtitle; + navigationEndpoint: StickyNavigationEndpoint; + trackingParams: string; + accessibilityData: AccessibilityPauseDataClass; + isSelected: boolean; + uniqueId: string; +} + +export interface StickyNavigationEndpoint { + clickTrackingParams: string; + queueUpdateCommand: QueueUpdateCommand; +} + +export interface QueueUpdateCommand { + queueUpdateSection: QueueUpdateSection; + fetchContentsCommand: FetchContentsCommand; + dedupeAgainstLocalQueue: boolean; +} + +export interface FetchContentsCommand { + clickTrackingParams: string; + watchEndpoint: FetchContentsCommandWatchEndpoint; +} + +export interface FetchContentsCommandWatchEndpoint { + playlistId: string; + params: string; + loggingContext: LoggingContext; + index: number; +} + +export enum QueueUpdateSection { + QueueUpdateSectionQueue = 'QUEUE_UPDATE_SECTION_QUEUE', +} + +export interface SubscribeStatus { + subscribeStatusByChannelId: Entities; +} + +export interface ToggleStates { + feedbackToggleStates: Entities; +} + +export interface UI { + viewportInfo: ViewportInfo; + isGuideCollapsed: boolean; +} + +export interface ViewportInfo { + size: number; + fluid: boolean; +} + +export interface Uploads { + fileUploads: unknown[]; +} diff --git a/types/get-player-response.ts b/types/get-player-response.ts new file mode 100644 index 00000000..3a195500 --- /dev/null +++ b/types/get-player-response.ts @@ -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; +} diff --git a/types/youtube-player.ts b/types/youtube-player.ts new file mode 100644 index 00000000..171c9a49 --- /dev/null +++ b/types/youtube-player.ts @@ -0,0 +1,189 @@ +// TODO: fully type definitions for youtube-player + +import { GetPlayerResponse } from './get-player-response'; + +export interface YoutubePlayer { + getInternalApiInterface: (...params: Parameters) => Return; + getApiInterface: (...params: Parameters) => Return; + cueVideoByPlayerVars: (...params: Parameters) => Return; + loadVideoByPlayerVars: (...params: Parameters) => Return; + preloadVideoByPlayerVars: (...params: Parameters) => Return; + getAdState: (...params: Parameters) => Return; + sendAbandonmentPing: (...params: Parameters) => Return; + setLoopRange: (...params: Parameters) => Return; + getLoopRange: (...params: Parameters) => Return; + setAutonavState: (...params: Parameters) => Return; + seekToLiveHead: (...params: Parameters) => Return; + requestSeekToWallTimeSeconds: (...params: Parameters) => Return; + seekToStreamTime: (...params: Parameters) => Return; + startSeekCsiAction: (...params: Parameters) => Return; + getStreamTimeOffset: (...params: Parameters) => Return; + getVideoData: (...params: Parameters) => Return; + setInlinePreview: (...params: Parameters) => Return; + updateDownloadState: (...params: Parameters) => Return; + queueOfflineAction: (...params: Parameters) => Return; + pauseVideoDownload: (...params: Parameters) => Return; + resumeVideoDownload: (...params: Parameters) => Return; + refreshAllStaleEntities: (...params: Parameters) => Return; + isOrchestrationLeader: (...params: Parameters) => Return; + getAppState: (...params: Parameters) => Return; + updateLastActiveTime: (...params: Parameters) => Return; + setBlackout: (...params: Parameters) => Return; + setUserEngagement: (...params: Parameters) => Return; + updateSubtitlesUserSettings: (...params: Parameters) => Return; + getPresentingPlayerType: (...params: Parameters) => Return; + canPlayType: (...params: Parameters) => Return; + updatePlaylist: (...params: Parameters) => Return; + updateVideoData: (...params: Parameters) => Return; + updateEnvironmentData: (...params: Parameters) => Return; + sendVideoStatsEngageEvent: (...params: Parameters) => Return; + productsInVideoVisibilityUpdated: (...params: Parameters) => Return; + setSafetyMode: (...params: Parameters) => Return; + isAtLiveHead: (...params: Parameters) => Return; + getVideoAspectRatio: (...params: Parameters) => Return; + getPreferredQuality: (...params: Parameters) => Return; + getPlaybackQualityLabel: (...params: Parameters) => Return; + setPlaybackQualityRange: (...params: Parameters) => Return; + onAdUxClicked: (...params: Parameters) => Return; + getFeedbackProductData: (...params: Parameters) => Return; + getStoryboardFrame: (...params: Parameters) => Return; + getStoryboardFrameIndex: (...params: Parameters) => Return; + getStoryboardLevel: (...params: Parameters) => Return; + getNumberOfStoryboardLevels: (...params: Parameters) => Return; + getCaptionWindowContainerId: (...params: Parameters) => Return; + getAvailableQualityLabels: (...params: Parameters) => Return; + addUtcCueRange: (...params: Parameters) => Return; + showAirplayPicker: (...params: Parameters) => Return; + dispatchReduxAction: (...params: Parameters) => Return; + getPlayerResponse: () => GetPlayerResponse; + getHeartbeatResponse: (...params: Parameters) => Return; + changeMarkerVisibility: (...params: Parameters) => Return; + setAutonav: (...params: Parameters) => Return; + isNotServable: (...params: Parameters) => Return; + channelSubscribed: (...params: Parameters) => Return; + channelUnsubscribed: (...params: Parameters) => Return; + togglePictureInPicture: (...params: Parameters) => Return; + supportsGaplessAudio: () => boolean; + supportsGaplessShorts: () => boolean; + enqueueVideoByPlayerVars: (...params: Parameters) => Return; + clearQueue: (...params: Parameters) => Return; + getAudioTrack: (...params: Parameters) => Return; + setAudioTrack: (...params: Parameters) => Return; + getAvailableAudioTracks: (...params: Parameters) => Return; + getMaxPlaybackQuality: (...params: Parameters) => Return; + getUserPlaybackQualityPreference: (...params: Parameters) => Return; + getSubtitlesUserSettings: (...params: Parameters) => Return; + resetSubtitlesUserSettings: (...params: Parameters) => Return; + setMinimized: (...params: Parameters) => Return; + setOverlayVisibility: (...params: Parameters) => Return; + confirmYpcRental: (...params: Parameters) => Return; + toggleSubtitlesOn: (...params: Parameters) => Return; + isSubtitlesOn: (...params: Parameters) => Return; + queueNextVideo: (...params: Parameters) => Return; + handleExternalCall: (...params: Parameters) => Return; + logApiCall: (...params: Parameters) => Return; + isExternalMethodAvailable: (...params: Parameters) => Return; + setScreenLayer: (...params: Parameters) => Return; + getCurrentPlaylistSequence: (...params: Parameters) => Return; + getPlaylistSequenceForTime: (...params: Parameters) => Return; + shouldSendVisibilityState: (...params: Parameters) => Return; + syncVolume: (...params: Parameters) => Return; + highlightSettingsMenuItem: (...params: Parameters) => Return; + openSettingsMenuItem: (...params: Parameters) => Return; + getVisibilityState: (...params: Parameters) => Return; + isMutedByMutedAutoplay: (...params: Parameters) => Return; + setGlobalCrop: (...params: Parameters) => Return; + setInternalSize: (...params: Parameters) => Return; + seekBy: (seconds: number) => void; + showControls: (...params: Parameters) => Return; + hideControls: (...params: Parameters) => Return; + cancelPlayback: (...params: Parameters) => Return; + getProgressState: (...params: Parameters) => Return; + isInline: (...params: Parameters) => Return; + setInline: (...params: Parameters) => Return; + setLoopVideo: (...params: Parameters) => Return; + getLoopVideo: (...params: Parameters) => Return; + getVideoContentRect: (...params: Parameters) => Return; + getVideoStats: (...params: Parameters) => Return; + getStoryboardFormat: (...params: Parameters) => Return; + toggleFullscreen: (...params: Parameters) => Return; + isFullscreen: (...params: Parameters) => Return; + getPlayerSize: (...params: Parameters) => Return; + toggleSubtitles: (...params: Parameters) => Return; + setCenterCrop: (...params: Parameters) => Return; + setFauxFullscreen: (...params: Parameters) => Return; + setSizeStyle: (...params: Parameters) => Return; + handleGlobalKeyDown: (...params: Parameters) => Return; + handleGlobalKeyUp: (...params: Parameters) => Return; + wakeUpControls: (...params: Parameters) => Return; + cueVideoById: (...params: Parameters) => Return; + loadVideoById: (...params: Parameters) => Return; + cueVideoByUrl: (...params: Parameters) => Return; + loadVideoByUrl: (...params: Parameters) => Return; + playVideo: (...params: Parameters) => Return; + pauseVideo: (...params: Parameters) => Return; + stopVideo: (...params: Parameters) => Return; + clearVideo: (...params: Parameters) => Return; + getVideoBytesLoaded: (...params: Parameters) => Return; + getVideoBytesTotal: (...params: Parameters) => Return; + getVideoLoadedFraction: (...params: Parameters) => Return; + getVideoStartBytes: (...params: Parameters) => Return; + cuePlaylist: (...params: Parameters) => Return; + loadPlaylist: (...params: Parameters) => Return; + nextVideo: (...params: Parameters) => Return; + previousVideo: (...params: Parameters) => Return; + playVideoAt: (...params: Parameters) => Return; + setShuffle: (...params: Parameters) => Return; + setLoop: (...params: Parameters) => Return; + getPlaylist: (...params: Parameters) => Return; + getPlaylistIndex: (...params: Parameters) => Return; + getPlaylistId: (...params: Parameters) => Return; + loadModule: (...params: Parameters) => Return; + unloadModule: (...params: Parameters) => Return; + setOption: (...params: Parameters) => Return; + getOption: (...params: Parameters) => Return; + getOptions: (...params: Parameters) => Return; + mute: (...params: Parameters) => Return; + unMute: (...params: Parameters) => Return; + isMuted: (...params: Parameters) => Return; + setVolume: (...params: Parameters) => Return; + getVolume: (...params: Parameters) => Return; + seekTo: (seconds: number) => void; + getPlayerMode: (...params: Parameters) => Return; + getPlayerState: (...params: Parameters) => Return; + getAvailablePlaybackRates: (...params: Parameters) => Return; + getPlaybackQuality: (...params: Parameters) => Return; + setPlaybackQuality: (...params: Parameters) => Return; + getAvailableQualityLevels: (...params: Parameters) => Return; + getCurrentTime: (...params: Parameters) => Return; + getDuration: (...params: Parameters) => Return; + addEventListener: (...params: Parameters) => Return; + removeEventListener: (...params: Parameters) => Return; + getDebugText: (...params: Parameters) => Return; + addCueRange: (...params: Parameters) => Return; + removeCueRange: (...params: Parameters) => Return; + setSize: (...params: Parameters) => Return; + destroy: (...params: Parameters) => Return; + getSphericalProperties: (...params: Parameters) => Return; + setSphericalProperties: (...params: Parameters) => Return; + mutedAutoplay: (...params: Parameters) => Return; + getVideoEmbedCode: (...params: Parameters) => Return; + getVideoUrl: (...params: Parameters) => Return; + getMediaReferenceTime: (...params: Parameters) => Return; + getSize: (...params: Parameters) => Return; + logImaAdEvent: (...params: Parameters) => Return; + preloadVideoById: (...params: Parameters) => Return; + setAccountLinkState: (...params: Parameters) => Return; + updateAccountLinkingConfig: (...params: Parameters) => Return; + getAvailableQualityData: (...params: Parameters) => Return; + setCompositeParam: (...params: Parameters) => Return; + getStatsForNerds: (...params: Parameters) => Return; + showVideoInfo: (...params: Parameters) => Return; + hideVideoInfo: (...params: Parameters) => Return; + isVideoInfoVisible: (...params: Parameters) => Return; + getPlaybackRate: (...params: Parameters) => Return; + setPlaybackRate: (...params: Parameters) => Return; + updateFullerscreenEduButtonSubtleModeState: (...params: Parameters) => Return; + updateFullerscreenEduButtonVisibility: (...params: Parameters) => Return; + addEmbedsConversionTrackingParams: (...params: Parameters) => Return; +} diff --git a/utils/testing.js b/utils/testing.js deleted file mode 100644 index a497d84a..00000000 --- a/utils/testing.js +++ /dev/null @@ -1,3 +0,0 @@ -const isTesting = () => process.env.NODE_ENV === 'test'; - -module.exports = { isTesting }; diff --git a/utils/testing.ts b/utils/testing.ts new file mode 100644 index 00000000..42c31f28 --- /dev/null +++ b/utils/testing.ts @@ -0,0 +1,3 @@ +export const isTesting = () => process.env.NODE_ENV === 'test'; + +export default { isTesting }; diff --git a/utils/type-utils.ts b/utils/type-utils.ts new file mode 100644 index 00000000..36f281b4 --- /dev/null +++ b/utils/type-utils.ts @@ -0,0 +1,5 @@ +export type Entries = { + [K in keyof T]: [K, T[K]]; +}[keyof T][]; + +export type ValueOf = T[keyof T];