feat: migration to TypeScript FINAL

Co-authored-by: Su-Yong <simssy2205@gmail.com>
This commit is contained in:
JellyBrick
2023-09-04 02:27:53 +09:00
parent c0d7972da3
commit 53f5bda382
72 changed files with 1290 additions and 693 deletions

View File

@ -83,6 +83,16 @@ const defaultConfig = {
'shortcuts': {
enabled: false,
overrideMediaKeys: false,
global: {
previous: '',
playPause: '',
next: '',
} as Record<string, string>,
local: {
previous: '',
playPause: '',
next: '',
} as Record<string, string>,
},
'downloader': {
enabled: false,
@ -130,7 +140,7 @@ const defaultConfig = {
volumeUp: '',
volumeDown: '',
},
savedVolume: undefined, // Plugin save volume between session here
savedVolume: undefined as number | undefined, // Plugin save volume between session here
},
'sponsorblock': {
enabled: false,
@ -146,8 +156,10 @@ const defaultConfig = {
},
'video-toggle': {
enabled: false,
hideVideo: false,
mode: 'custom',
forceHide: false,
align: '',
},
'picture-in-picture': {
'enabled': false,
@ -158,6 +170,7 @@ const defaultConfig = {
'pip-position': [10, 10],
'pip-size': [450, 275],
'isInPiP': false,
'useNativePiP': false,
},
'captions-selector': {
enabled: false,

View File

@ -1,19 +1,29 @@
declare module 'custom-electron-prompt' {
import { BrowserWindow } from 'electron';
export interface PromptCounterOptions {
export type SelectOptions = Record<string, string>;
export interface CounterOptions {
minimum?: number;
maximum?: number;
multiFire?: boolean;
}
export interface PromptKeybindOptions {
export interface KeybindOptions {
value: string;
label: string;
default: string;
default?: string;
}
export interface PromptOptions {
export interface InputOptions {
label: string;
value: unknown;
inputAttrs?: Partial<HTMLInputElement>;
selectOptions?: SelectOptions;
}
interface BasePromptOptions<T extends string> {
type?: T;
width?: number;
height?: number;
resizable?: boolean;
@ -25,10 +35,6 @@ declare module 'custom-electron-prompt' {
};
alwaysOnTop?: boolean;
value?: unknown;
type?: 'input' | 'select' | 'counter' | 'multiInput';
selectOptions?: Record<string, string>;
keybindOptions?: PromptKeybindOptions[];
counterOptions?: PromptCounterOptions;
icon?: string;
useHtmlLabel?: boolean;
customStylesheet?: string;
@ -38,15 +44,42 @@ declare module 'custom-electron-prompt' {
customScript?: string;
enableRemoteModule?: boolean;
inputAttrs?: Partial<HTMLInputElement>;
multiInputOptions?: {
label: string;
value: unknown;
inputAttrs?: Partial<HTMLInputElement>;
selectOptions?: Record<string, string>;
}[];
}
const prompt: (options?: PromptOptions, parent?: BrowserWindow) => Promise<string | null>;
export type InputPromptOptions = BasePromptOptions<'input'>;
export interface SelectPromptOptions extends BasePromptOptions<'select'> {
selectOptions: SelectOptions;
}
export interface CounterPromptOptions extends BasePromptOptions<'counter'> {
counterOptions: CounterOptions;
}
export interface MultiInputPromptOptions extends BasePromptOptions<'multiInput'> {
multiInputOptions: InputOptions[];
}
export interface KeybindPromptOptions extends BasePromptOptions<'keybind'> {
keybindOptions: KeybindOptions[];
}
export type PromptOptions<T extends string> = (
T extends 'input' ? InputPromptOptions :
T extends 'select' ? SelectPromptOptions :
T extends 'counter' ? CounterPromptOptions :
T extends 'keybind' ? KeybindPromptOptions :
T extends 'multiInput' ? MultiInputPromptOptions :
never
);
type PromptResult<T extends string> = T extends 'input' ? string :
T extends 'select' ? string :
T extends 'counter' ? number :
T extends 'keybind' ? {
value: string;
accelerator: string
}[] :
T extends 'multiInput' ? string[] :
never;
const prompt: <T extends Type>(options?: PromptOptions<T> & { type: T }, parent?: BrowserWindow) => Promise<PromptResult<T> | null>;
export default prompt;
}

View File

@ -58,8 +58,9 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
}
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;
// eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-unsafe-member-access
const getPluginMenu = require(pluginPath).default as PluginType;
return {
label: pluginLabel,
submenu: [
@ -274,7 +275,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
{
label: 'Proxy',
type: 'checkbox',
checked: Boolean(config.get('options.proxy')),
checked: !!(config.get('options.proxy')),
click(item) {
setProxy(item, win);
},

250
package-lock.json generated
View File

@ -52,6 +52,7 @@
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0",
"copyfiles": "2.4.1",
"del-cli": "5.0.1",
"electron": "27.0.0-alpha.5",
"electron-builder": "24.6.3",
@ -2834,6 +2835,76 @@
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"dev": true
},
"node_modules/copyfiles": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz",
"integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==",
"dev": true,
"dependencies": {
"glob": "^7.0.5",
"minimatch": "^3.0.3",
"mkdirp": "^1.0.4",
"noms": "0.0.0",
"through2": "^2.0.1",
"untildify": "^4.0.0",
"yargs": "^16.1.0"
},
"bin": {
"copyfiles": "copyfiles",
"copyup": "copyfiles"
}
},
"node_modules/copyfiles/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/copyfiles/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"dev": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/copyfiles/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/copyfiles/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"dev": true,
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
@ -3162,6 +3233,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/del/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -3543,6 +3629,21 @@
"unzip-crx-3": "^0.2.0"
}
},
"node_modules/electron-devtools-installer/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/electron-is": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/electron-is/-/electron-is-3.0.0.tgz",
@ -4507,6 +4608,21 @@
"node": ">=12.0.0"
}
},
"node_modules/flat-cache/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/flatted": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
@ -6596,6 +6712,21 @@
"node": "^12.13 || ^14.13 || >=16"
}
},
"node_modules/node-gyp/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/node-id3": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/node-id3/-/node-id3-0.2.6.tgz",
@ -6615,6 +6746,40 @@
"node": ">=0.10.0"
}
},
"node_modules/noms": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz",
"integrity": "sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==",
"dev": true,
"dependencies": {
"inherits": "^2.0.1",
"readable-stream": "~1.0.31"
}
},
"node_modules/noms/node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
"dev": true
},
"node_modules/noms/node_modules/readable-stream": {
"version": "1.0.34",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
"integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==",
"dev": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"node_modules/noms/node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"dev": true
},
"node_modules/nopt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
@ -7617,21 +7782,6 @@
"node": ">=0.10.0"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
@ -8476,6 +8626,52 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"node_modules/through2": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
"integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
"dev": true,
"dependencies": {
"readable-stream": "~2.3.6",
"xtend": "~4.0.1"
}
},
"node_modules/through2/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true
},
"node_modules/through2/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/through2/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
},
"node_modules/through2/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/titleize": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz",
@ -8522,6 +8718,21 @@
"tmp": "^0.2.0"
}
},
"node_modules/tmp-promise/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/tmp-promise/node_modules/tmp": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
@ -9079,6 +9290,15 @@
"node": ">=8.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -84,21 +84,22 @@
"scripts": {
"test": "playwright test",
"test:debug": "DEBUG=pw:browser* playwright test",
"start": "tsc && electron ./dist/index.js",
"start": "tsc && npm run copy-files && 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 && 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",
"build:win": "npm run clean && electron-builder --win -p never",
"build:win:x64": "npm run clean && electron-builder --win nsis:x64 -p never",
"copy-files": "copyfiles -u 1 plugins/**/*.html plugins/**/*.css plugins/**/*.bin plugins/**/*.js dist/plugins/",
"build": "npm run clean && tsc && npm run copy-files && electron-builder --win --mac --linux -p never",
"build:linux": "npm run clean && tsc && npm run copy-files && electron-builder --linux -p never",
"build:mac": "npm run clean && tsc && npm run copy-files && electron-builder --mac dmg:x64 -p never",
"build:mac:arm64": "npm run clean && tsc && npm run copy-files && electron-builder --mac dmg:arm64 -p never",
"build:win": "npm run clean && tsc && npm run copy-files && electron-builder --win -p never",
"build:win:x64": "npm run clean && tsc && npm run copy-files && electron-builder --win nsis:x64 -p never",
"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 && tsc plugins/adblocker/blocker.ts && node dist/plugins/adblocker/blocker.js",
"plugin:adblocker": "del-cli plugins/adblocker/ad-blocker-engine.bin && tsc && node dist/plugins/adblocker/blocker.js",
"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",
@ -157,6 +158,7 @@
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0",
"copyfiles": "2.4.1",
"del-cli": "5.0.1",
"electron": "27.0.0-alpha.5",
"electron-builder": "24.6.3",

View File

@ -3,10 +3,9 @@ import { BrowserWindow } from 'electron';
import { loadAdBlockerEngine } from './blocker';
import config from './config';
import pluginConfig from '../../config';
import type { ConfigType } from '../../config/dynamic';
const AdBlockOptionsObj = pluginConfig.get('plugins.adblocker');
type AdBlockOptions = typeof AdBlockOptionsObj;
type AdBlockOptions = ConfigType<'adblocker'>;
export default async (win: BrowserWindow, options: AdBlockOptions) => {
if (await config.shouldUseBlocklists()) {

View File

@ -7,7 +7,7 @@ import configProvider from './config';
import { ElementFromFile, templatePath } from '../utils';
import { YoutubePlayer } from '../../types/youtube-player';
import { ConfigType } from '../../config/dynamic';
import type { ConfigType } from '../../config/dynamic';
interface LanguageOptions {
displayName: string;

View File

@ -10,7 +10,7 @@ import { VolumeFader } from './fader';
import configProvider from './config';
import defaultConfigs from '../../config/defaults';
import { ConfigType } from '../../config/dynamic';
import type { ConfigType } from '../../config/dynamic';
let transitionAudio: Howl; // Howler audio used to fade out the current music
let firstVideo = true;

View File

@ -6,7 +6,7 @@ import config from './config';
import promptOptions from '../../providers/prompt-options';
import configOptions from '../../config/defaults';
import { ConfigType } from '../../config/dynamic';
import type { ConfigType } from '../../config/dynamic';
const defaultOptions = configOptions.plugins.crossfade;

View File

@ -5,7 +5,7 @@ import { dev } from 'electron-is';
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import registerCallback from '../../providers/song-info';
import pluginConfig from '../../config';
import type { ConfigType } from '../../config/dynamic';
// Application ID registered by @Zo-Bro-23
const clientId = '1043858434585526382';
@ -118,8 +118,7 @@ export const connect = (showError = false) => {
let clearActivity: NodeJS.Timeout | undefined;
let updateActivity: import('../../providers/song-info').SongInfoCallback;
const DiscordOptionsObj = pluginConfig.get('plugins.discord');
type DiscordOptions = typeof DiscordOptionsObj;
type DiscordOptions = ConfigType<'discord'>;
export default (
win: Electron.BrowserWindow,

View File

@ -7,14 +7,14 @@ import { clear, connect, isConnected, registerRefresh } from './back';
import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options';
import { singleton } from '../../providers/decorators';
import config from '../../config';
import type { ConfigType } from '../../config/dynamic';
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
registerRefresh(refreshMenu);
});
const DiscordOptionsObj = config.get('plugins.discord');
type DiscordOptions = typeof DiscordOptionsObj;
type DiscordOptions = ConfigType<'discord'>;
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void) => {
registerRefreshOnce(refreshMenu);

View File

@ -411,7 +411,7 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' });
const folder = getFolder(config.get('downloadFolder'));
const folder = getFolder(config.get('downloadFolder') ?? '');
const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) {
if (!config.get('skipExisting')) {

View File

@ -16,7 +16,7 @@ export default (): MenuTemplate => [
click() {
const result = dialog.showOpenDialogSync({
properties: ['openDirectory', 'createDirectory'],
defaultPath: getFolder(config.get('downloadFolder')),
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
});
if (result) {
config.set('downloadFolder', result[0]);

View File

@ -13,7 +13,7 @@ setupTitlebar();
// Tracks menu visibility
module.exports = (win: BrowserWindow) => {
export default (win: BrowserWindow) => {
// Css for custom scrollbar + disable drag area(was causing bugs)
injectCSS(win.webContents, path.join(__dirname, 'style.css'));

View File

@ -9,8 +9,8 @@ function $(selector: string) {
return document.querySelector(selector);
}
module.exports = () => {
const visible = () => Boolean($('.cet-menubar')?.firstChild);
export default () => {
const visible = () => !!($('.cet-menubar')?.firstChild);
const bar = new Titlebar({
icon: 'https://cdn-icons-png.flaticon.com/512/5358/5358672.png',
backgroundColor: Color.fromHex('#050505'),

View File

@ -4,10 +4,9 @@ import md5 from 'md5';
import { setOptions } from '../../config/plugins';
import registerCallback, { SongInfo } from '../../providers/song-info';
import defaultConfig from '../../config/defaults';
import config from '../../config';
import type { ConfigType } from '../../config/dynamic';
const LastFMOptionsObj = config.get('plugins.last-fm');
type LastFMOptions = typeof LastFMOptionsObj;
type LastFMOptions = ConfigType<'last-fm'>;
interface LastFmData {
method: string,
@ -188,4 +187,4 @@ const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => {
});
};
module.exports = lastfm;
export default lastfm;

View File

@ -9,13 +9,13 @@ import { GetGeniusLyric } from './types';
import { cleanupName, SongInfo } from '../../providers/song-info';
import { injectCSS } from '../utils';
import config from '../../config';
import type { ConfigType } from '../../config/dynamic';
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false;
const LyricGeniusTypeObj = config.get('plugins.lyric-genius');
export type LyricGeniusType = typeof LyricGeniusTypeObj;
export type LyricGeniusType = ConfigType<'lyric-genius'>;
export default (win: BrowserWindow, options: LyricGeniusType) => {
if (options.romanizedLyrics) {

View File

@ -4,7 +4,7 @@ import { LyricGeniusType, toggleRomanized } from './back';
import { setOptions } from '../../config/plugins';
module.exports = (win: BrowserWindow, options: LyricGeniusType) => [
export default (_: BrowserWindow, options: LyricGeniusType) => [
{
label: 'Romanized Lyrics',
type: 'checkbox',

View File

@ -1,15 +1,17 @@
const path = require('node:path');
import path from 'node:path';
const { ACTIONS, CHANNEL } = require('./actions.ts');
import { BrowserWindow } from 'electron';
const { injectCSS, listenAction } = require('../utils');
import { ACTIONS, CHANNEL } from './actions';
function handle(win) {
import { injectCSS, listenAction } from '../utils';
export function handle(win: BrowserWindow) {
injectCSS(win.webContents, path.join(__dirname, 'style.css'), () => {
win.webContents.send('navigation-css-ready');
});
listenAction(CHANNEL, (event, action) => {
listenAction(CHANNEL, (_, action) => {
switch (action) {
case ACTIONS.NEXT: {
if (win.webContents.canGoForward()) {
@ -34,4 +36,4 @@ function handle(win) {
});
}
module.exports = handle;
export default handle;

View File

@ -1,8 +1,8 @@
const { ipcRenderer } = require('electron');
import { ipcRenderer } from 'electron';
const { ElementFromFile, templatePath } = require('../utils');
import { ElementFromFile, templatePath } from '../utils';
function run() {
export function run() {
ipcRenderer.on('navigation-css-ready', () => {
const forwardButton = ElementFromFile(
templatePath(__dirname, 'forward.html'),
@ -16,4 +16,4 @@ function run() {
});
}
module.exports = run;
export default run;

View File

@ -1,7 +0,0 @@
const path = require('node:path');
const { injectCSS } = require('../utils');
module.exports = (win) => {
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
};

View File

@ -0,0 +1,9 @@
import path from 'node:path';
import { BrowserWindow } from 'electron';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
};

View File

@ -18,7 +18,7 @@ function removeLoginElements() {
const menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer',
);
for (const item of menuEntries) {
menuEntries.forEach((item) => {
const icon = item.querySelector('path');
if (icon) {
observer.disconnect();
@ -26,7 +26,7 @@ function removeLoginElements() {
item.remove();
}
}
}
});
});
observer.observe(document.documentElement, {
childList: true,
@ -34,4 +34,4 @@ function removeLoginElements() {
});
}
module.exports = removeLoginElements;
export default removeLoginElements;

View File

@ -1,49 +0,0 @@
const { Notification } = require('electron');
const is = require('electron-is');
const { notificationImage } = require('./utils');
const config = require('./config');
const registerCallback = require('../../providers/song-info');
const notify = (info) => {
// Fill the notification with content
const notification = {
title: info.title || 'Playing',
body: info.artist,
icon: notificationImage(info),
silent: true,
urgency: config.get('urgency'),
};
// Send the notification
const currentNotification = new Notification(notification);
currentNotification.show();
return currentNotification;
};
const setup = () => {
let oldNotification;
let currentUrl;
registerCallback((songInfo) => {
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) {
// Close the old notification
oldNotification?.close();
currentUrl = songInfo.url;
// This fixes a weird bug that would cause the notification to be updated instead of showing
setTimeout(() => {
oldNotification = notify(songInfo);
}, 10);
}
});
};
/** @param {Electron.BrowserWindow} win */
module.exports = (win, options) => {
// Register the callback for new song information
is.windows() && options.interactive
? require('./interactive')(win)
: setup();
};

View File

@ -0,0 +1,51 @@
import { BrowserWindow, Notification } from 'electron';
import is from 'electron-is';
import { notificationImage } from './utils';
import config from './config';
import interactive from './interactive';
import registerCallback, { SongInfo } from '../../providers/song-info';
import type { ConfigType } from '../../config/dynamic';
type NotificationOptions = ConfigType<'notifications'>;
const notify = (info: SongInfo) => {
// Send the notification
const currentNotification = new Notification({
title: info.title || 'Playing',
body: info.artist,
icon: notificationImage(info),
silent: true,
urgency: config.get('urgency') as 'normal' | 'critical' | 'low',
});
currentNotification.show();
return currentNotification;
};
const setup = () => {
let oldNotification: Notification;
let currentUrl: string | undefined;
registerCallback((songInfo: SongInfo) => {
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) {
// Close the old notification
oldNotification?.close();
currentUrl = songInfo.url;
// This fixes a weird bug that would cause the notification to be updated instead of showing
setTimeout(() => {
oldNotification = notify(songInfo);
}, 10);
}
});
};
export default (win: BrowserWindow, options: NotificationOptions) => {
// Register the callback for new song information
is.windows() && options.interactive
? interactive(win)
: setup();
};

View File

@ -1,5 +0,0 @@
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('notifications');
module.exports = { ...config };

View File

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

View File

@ -1,38 +1,32 @@
const path = require('node:path');
import path from 'node:path';
const { Notification, app, ipcMain } = require('electron');
import { app, BrowserWindow, ipcMain, Notification } from 'electron';
const { notificationImage, icons, saveTempIcon, secondsToMinutes, ToastStyles } = require('./utils');
import { icons, notificationImage, saveTempIcon, secondsToMinutes, ToastStyles } from './utils';
import config from './config';
/**
* @type {PluginConfig}
*/
const config = require('./config');
import getSongControls from '../../providers/song-controls';
import registerCallback, { SongInfo } from '../../providers/song-info';
import { changeProtocolHandler } from '../../providers/protocol-handler';
import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray';
const getSongControls = require('../../providers/song-controls');
const registerCallback = require('../../providers/song-info');
const { changeProtocolHandler } = require('../../providers/protocol-handler');
const { setTrayOnClick, setTrayOnDoubleClick } = require('../../tray');
let songControls: ReturnType<typeof getSongControls>;
let savedNotification: Notification | undefined;
let songControls;
let savedNotification;
/** @param {Electron.BrowserWindow} win */
module.exports = (win) => {
export default (win: BrowserWindow) => {
songControls = getSongControls(win);
let currentSeconds = 0;
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t);
if (app.isPackaged) {
saveTempIcon();
}
let savedSongInfo;
let lastUrl;
let savedSongInfo: SongInfo;
let lastUrl: string | undefined;
// Register songInfoCallback
registerCallback((songInfo) => {
@ -78,7 +72,7 @@ module.exports = (win) => {
changeProtocolHandler(
(cmd) => {
if (Object.keys(songControls).includes(cmd)) {
songControls[cmd]();
songControls[cmd as keyof typeof songControls]();
if (config.get('refreshOnPlayPause') && (
cmd === 'pause'
|| (cmd === 'play' && !config.get('unpauseNotification'))
@ -97,11 +91,18 @@ module.exports = (win) => {
);
};
function sendNotification(songInfo) {
function sendNotification(songInfo: SongInfo) {
const iconSrc = notificationImage(songInfo);
savedNotification?.close();
let icon: string;
if (typeof iconSrc === 'object') {
icon = iconSrc.toDataURL();
} else {
icon = iconSrc;
}
savedNotification = new Notification({
title: songInfo.title || 'Playing',
body: songInfo.artist,
@ -111,7 +112,7 @@ function sendNotification(songInfo) {
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
toastXml: getXml(songInfo, iconSrc),
toastXml: getXml(songInfo, icon),
});
savedNotification.on('close', () => {
@ -121,7 +122,7 @@ function sendNotification(songInfo) {
savedNotification.show();
}
const getXml = (songInfo, iconSrc) => {
const getXml = (songInfo: SongInfo, iconSrc: string) => {
switch (config.get('toastStyle')) {
default:
case ToastStyles.logo:
@ -155,7 +156,7 @@ const iconLocation = app.isPackaged
? path.resolve(app.getPath('userData'), 'icons')
: path.resolve(__dirname, '..', '..', 'assets/media-icons-black');
const display = (kind) => {
const display = (kind: keyof typeof icons) => {
if (config.get('toastStyle') === ToastStyles.legacy) {
return `content="${icons[kind]}"`;
}
@ -166,10 +167,10 @@ const display = (kind) => {
`;
};
const getButton = (kind) =>
const getButton = (kind: keyof typeof icons) =>
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
const getButtons = (isPaused) => `\
const getButtons = (isPaused: boolean) => `\
<actions>
${getButton('previous')}
${isPaused ? getButton('play') : getButton('pause')}
@ -177,7 +178,7 @@ const getButtons = (isPaused) => `\
</actions>\
`;
const toast = (content, isPaused) => `\
const toast = (content: string, isPaused: boolean) => `\
<toast>
<audio silent="true" />
<visual>
@ -189,19 +190,19 @@ const toast = (content, isPaused) => `\
${getButtons(isPaused)}
</toast>`;
const xmlImage = ({ title, artist, isPaused }, imgSrc, placement) => toast(`\
const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
<text id="1">${title}</text>
<text id="2">${artist}</text>\
`, isPaused);
`, isPaused ?? false);
const xmlLogo = (songInfo, imgSrc) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"');
const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"');
const xmlHero = (songInfo, imgSrc) => xmlImage(songInfo, imgSrc, 'placement="hero"');
const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"');
const xmlBannerBottom = (songInfo, imgSrc) => xmlImage(songInfo, imgSrc, '');
const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, '');
const xmlBannerTopCustom = (songInfo, imgSrc) => toast(`\
const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\
<image id="1" src="${imgSrc}" name="Image" />
<text></text>
<group>
@ -211,17 +212,17 @@ const xmlBannerTopCustom = (songInfo, imgSrc) => toast(`\
</subgroup>
${xmlMoreData(songInfo)}
</group>\
`, songInfo.isPaused);
`, songInfo.isPaused ?? false);
const xmlMoreData = ({ album, elapsedSeconds, songDuration }) => `\
const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\
<subgroup hint-textStacking="bottom">
${album
? `<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds)} / ${secondsToMinutes(songDuration)}</text>
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)}</text>
</subgroup>\
`;
const xmlBannerCenteredBottom = ({ title, artist, isPaused }, imgSrc) => toast(`\
const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
<text></text>
<group>
<subgroup hint-weight="1" hint-textStacking="center">
@ -230,9 +231,9 @@ const xmlBannerCenteredBottom = ({ title, artist, isPaused }, imgSrc) => toast(`
</subgroup>
</group>
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
`, isPaused);
`, isPaused ?? false);
const xmlBannerCenteredTop = ({ title, artist, isPaused }, imgSrc) => toast(`\
const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
<image id="1" src="${imgSrc}" name="Image" />
<text></text>
<group>
@ -241,9 +242,9 @@ const xmlBannerCenteredTop = ({ title, artist, isPaused }, imgSrc) => toast(`\
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
</subgroup>
</group>\
`, isPaused);
`, isPaused ?? false);
const titleFontPicker = (title) => {
const titleFontPicker = (title: string) => {
if (title.length <= 13) {
return 'Header';
}

View File

@ -1,9 +1,14 @@
const is = require('electron-is');
import is from 'electron-is';
const { urgencyLevels, ToastStyles, snakeToCamel } = require('./utils');
const config = require('./config');
import {BrowserWindow, MenuItem} from 'electron';
module.exports = (_win, options) => [
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
import config from './config';
import type { ConfigType } from '../../config/dynamic';
export default (_win: BrowserWindow, options: ConfigType<'notifications'>) => [
...(is.linux()
? [
{
@ -24,7 +29,7 @@ module.exports = (_win, options) => [
type: 'checkbox',
checked: options.interactive,
// Doesn't update until restart
click: (item) => config.setAndMaybeRestart('interactive', item.checked),
click: (item: MenuItem) => config.setAndMaybeRestart('interactive', item.checked),
},
{
// Submenu with settings for interactive notifications (name shouldn't be too long)
@ -34,19 +39,19 @@ module.exports = (_win, options) => [
label: 'Open/Close on tray click',
type: 'checkbox',
checked: options.trayControls,
click: (item) => config.set('trayControls', item.checked),
click: (item: MenuItem) => config.set('trayControls', item.checked),
},
{
label: 'Hide Button Text',
type: 'checkbox',
checked: options.hideButtonText,
click: (item) => config.set('hideButtonText', item.checked),
click: (item: MenuItem) => config.set('hideButtonText', item.checked),
},
{
label: 'Refresh on Play/Pause',
type: 'checkbox',
checked: options.refreshOnPlayPause,
click: (item) => config.set('refreshOnPlayPause', item.checked),
click: (item: MenuItem) => config.set('refreshOnPlayPause', item.checked),
},
],
},
@ -60,11 +65,11 @@ module.exports = (_win, options) => [
label: 'Show notification on unpause',
type: 'checkbox',
checked: options.unpauseNotification,
click: (item) => config.set('unpauseNotification', item.checked),
click: (item: MenuItem) => config.set('unpauseNotification', item.checked),
},
];
function getToastStyleMenuItems(options) {
export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
const array = Array.from({ length: Object.keys(ToastStyles).length });
// ToastStyles index starts from 1

View File

@ -1,19 +1,20 @@
const path = require('node:path');
import path from 'node:path';
import fs from 'node:fs';
const fs = require('node:fs');
import { app, NativeImage } from 'electron';
const { app } = require('electron');
import config from './config';
const config = require('./config');
import { cache } from '../../providers/decorators';
import { SongInfo } from '../../providers/song-info';
const icon = 'assets/youtube-music.png';
const userData = app.getPath('userData');
const temporaryIcon = path.join(userData, 'tempIcon.png');
const temporaryBanner = path.join(userData, 'tempBanner.png');
const { cache } = require('../../providers/decorators');
module.exports.ToastStyles = {
export const ToastStyles = {
logo: 1,
banner_centered_top: 2,
hero: 3,
@ -23,20 +24,20 @@ module.exports.ToastStyles = {
legacy: 7,
};
module.exports.icons = {
export const icons = {
play: '\u{1405}', // ᐅ
pause: '\u{2016}', // ‖
next: '\u{1433}', //
previous: '\u{1438}', //
};
module.exports.urgencyLevels = [
export const urgencyLevels = [
{ name: 'Low', value: 'low' },
{ name: 'Normal', value: 'normal' },
{ name: 'High', value: 'critical' },
];
const nativeImageToLogo = cache((nativeImage) => {
const nativeImageToLogo = cache((nativeImage: NativeImage) => {
const temporaryImage = nativeImage.resize({ height: 256 });
const margin = Math.max(temporaryImage.getSize().width - 256, 0);
@ -48,7 +49,7 @@ const nativeImageToLogo = cache((nativeImage) => {
});
});
module.exports.notificationImage = (songInfo) => {
export const notificationImage = (songInfo: SongInfo) => {
if (!songInfo.image) {
return icon;
}
@ -58,30 +59,30 @@ module.exports.notificationImage = (songInfo) => {
}
switch (config.get('toastStyle')) {
case module.exports.ToastStyles.logo:
case module.exports.ToastStyles.legacy: {
return this.saveImage(nativeImageToLogo(songInfo.image), temporaryIcon);
case ToastStyles.logo:
case ToastStyles.legacy: {
return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon);
}
default: {
return this.saveImage(songInfo.image, temporaryBanner);
return saveImage(songInfo.image, temporaryBanner);
}
}
};
module.exports.saveImage = cache((img, savePath) => {
export const saveImage = cache((img: NativeImage, savePath: string) => {
try {
fs.writeFileSync(savePath, img.toPNG());
} catch (error) {
console.log(`Error writing song icon to disk:\n${error.toString()}`);
} catch (error: unknown) {
console.log(`Error writing song icon to disk:\n${String(error)}`);
return icon;
}
return savePath;
});
module.exports.saveTempIcon = () => {
for (const kind of Object.keys(module.exports.icons)) {
export const saveTempIcon = () => {
for (const kind of Object.keys(icons)) {
const destinationPath = path.join(userData, 'icons', `${kind}.png`);
if (fs.existsSync(destinationPath)) {
continue;
@ -94,13 +95,13 @@ module.exports.saveTempIcon = () => {
}
};
module.exports.snakeToCamel = (string_) => string_.replaceAll(/([-_][a-z]|^[a-z])/g, (group) =>
export const snakeToCamel = (string_: string) => string_.replaceAll(/([-_][a-z]|^[a-z])/g, (group) =>
group.toUpperCase()
.replace('-', ' ')
.replace('_', ' '),
);
module.exports.secondsToMinutes = (seconds) => {
export const secondsToMinutes = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const secondsLeft = seconds % 60;
return `${minutes}:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`;

View File

@ -5,7 +5,7 @@ import { app, BrowserWindow, ipcMain } from 'electron';
import { setOptions as setPluginOptions } from '../../config/plugins';
import { injectCSS } from '../utils';
import config from '../../config';
import type { ConfigType } from '../../config/dynamic';
let isInPiP = false;
let originalPosition: number[];
@ -15,9 +15,7 @@ let originalMaximized: boolean;
let win: BrowserWindow;
// Magic of TypeScript
const PiPOptionsObj = config.get('plugins.picture-in-picture');
type PiPOptions = typeof PiPOptionsObj;
type PiPOptions = ConfigType<'picture-in-picture'>;
let options: Partial<PiPOptions>;

View File

@ -2,31 +2,44 @@ 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');
import { getSongMenu } from '../../providers/dom-elements';
function $(selector) {
import { ElementFromFile, templatePath } from '../utils';
import type { ConfigType } from '../../config/dynamic';
type PiPOptions = ConfigType<'picture-in-picture'>;
function $(selector: string) {
return document.querySelector(selector);
}
let useNativePiP = false;
let menu = null;
let menu: Element | null = null;
const pipButton = ElementFromFile(
templatePath(__dirname, 'picture-in-picture.html'),
);
// Will also clone
function replaceButton(query, button) {
const svg = button.querySelector('#icon svg').cloneNode(true);
button.replaceWith(button.cloneNode(true));
button.remove();
const newButton = $(query);
newButton.querySelector('#icon').append(svg);
return newButton;
function replaceButton(query: string, button: Element) {
const svg = button.querySelector('#icon svg')?.cloneNode(true);
if (svg) {
button.replaceWith(button.cloneNode(true));
button.remove();
const newButton = $(query);
if (newButton) {
newButton.querySelector('#icon')?.append(svg);
}
return newButton;
}
return null;
}
function cloneButton(query) {
replaceButton(query, $(query));
function cloneButton(query: string) {
const button = $(query);
if (button) {
replaceButton(query, button);
}
return $(query);
}
@ -38,13 +51,18 @@ const observer = new MutationObserver(() => {
}
}
if (menu.contains(pipButton) || !menu.parentElement.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar')) {
if (
menu.contains(pipButton) ||
!(menu.parentElement as (HTMLElement & { eventSink_: Element }) | null)
?.eventSink_
?.matches('ytmusic-menu-renderer.ytmusic-player-bar')
) {
return;
}
const menuUrl = $(
const menuUrl = ($(
'tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint',
)?.href;
) as HTMLAnchorElement)?.href;
if (menuUrl && !menuUrl.includes('watch?')) {
return;
}
@ -55,15 +73,15 @@ const observer = new MutationObserver(() => {
const togglePictureInPicture = async () => {
if (useNativePiP) {
const isInPiP = document.pictureInPictureElement !== null;
const video = $('video');
const video = $('video') as HTMLVideoElement | null;
const togglePiP = () =>
isInPiP
? document.exitPictureInPicture.call(document)
: video.requestPictureInPicture.call(video);
: video?.requestPictureInPicture?.call(video);
try {
await togglePiP();
$('#icon').click(); // Close the menu
($('#icon') as HTMLButtonElement | null)?.click(); // Close the menu
return true;
} catch {
}
@ -72,24 +90,26 @@ const togglePictureInPicture = async () => {
ipcRenderer.send('picture-in-picture');
return false;
};
global.togglePictureInPicture = togglePictureInPicture;
// For UI (HTML)
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
(global as any).togglePictureInPicture = togglePictureInPicture;
const listenForToggle = () => {
const originalExitButton = $('.exit-fullscreen-button');
const appLayout = $('ytmusic-app-layout');
const expandMenu = $('#expanding-menu');
const middleControls = $('.middle-controls');
const playerPage = $('ytmusic-player-page');
const togglePlayerPageButton = $('.toggle-player-page-button');
const fullScreenButton = $('.fullscreen-button');
const player = $('#player');
const onPlayerDblClick = player.onDoubleClick_;
const originalExitButton = $('.exit-fullscreen-button') as HTMLButtonElement;
const appLayout = $('ytmusic-app-layout') as HTMLElement;
const expandMenu = $('#expanding-menu') as HTMLElement;
const middleControls = $('.middle-controls') as HTMLButtonElement;
const playerPage = $('ytmusic-player-page') as HTMLElement & { playerPageOpen_: boolean };
const togglePlayerPageButton = $('.toggle-player-page-button') as HTMLButtonElement;
const fullScreenButton = $('.fullscreen-button') as HTMLButtonElement;
const player = ($('#player') as (HTMLVideoElement & { onDoubleClick_: () => void | undefined }));
const onPlayerDblClick = player?.onDoubleClick_;
const titlebar = $('.cet-titlebar');
const titlebar = $('.cet-titlebar') as HTMLElement;
ipcRenderer.on('pip-toggle', (_, isPip) => {
ipcRenderer.on('pip-toggle', (_, isPip: boolean) => {
if (isPip) {
replaceButton('.exit-fullscreen-button', originalExitButton).addEventListener('click', () => togglePictureInPicture());
replaceButton('.exit-fullscreen-button', originalExitButton)?.addEventListener('click', () => togglePictureInPicture());
player.onDoubleClick_ = () => {
};
@ -104,9 +124,9 @@ const listenForToggle = () => {
titlebar.style.display = 'none';
}
} else {
$('.exit-fullscreen-button').replaceWith(originalExitButton);
$('.exit-fullscreen-button')?.replaceWith(originalExitButton);
player.onDoubleClick_ = onPlayerDblClick;
expandMenu.onmouseleave = undefined;
expandMenu.onmouseleave = null;
originalExitButton.click();
appLayout.classList.remove('pip');
if (titlebar) {
@ -116,22 +136,23 @@ const listenForToggle = () => {
});
};
function observeMenu(options) {
function observeMenu(options: PiPOptions) {
useNativePiP = options.useNativePiP;
document.addEventListener(
'apiLoaded',
() => {
listenForToggle();
cloneButton('.player-minimize-button').addEventListener('click', async () => {
cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
await togglePictureInPicture();
setTimeout(() => $('#player').click());
setTimeout(() => ($('#player') as HTMLButtonElement | undefined)?.click());
});
// Allows easily closing the menu by programmatically clicking outside of it
$('#expanding-menu').removeAttribute('no-cancel-on-outside-click');
$('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click');
// TODO: think about wether an additional button in songMenu is needed
observer.observe($('ytmusic-popup-container'), {
const popupContainer = $('ytmusic-popup-container');
if (popupContainer) observer.observe(popupContainer, {
childList: true,
subtree: true,
});
@ -140,7 +161,7 @@ function observeMenu(options) {
);
}
module.exports = (options) => {
export default (options: PiPOptions) => {
observeMenu(options);
if (options.hotkey) {
@ -148,7 +169,7 @@ module.exports = (options) => {
window.addEventListener('keydown', (event) => {
if (
keyEventAreEqual(event, hotkeyEvent)
&& !$('ytmusic-search-box').opened
&& !($('ytmusic-search-box') as (HTMLElement & { opened: boolean }) | undefined)?.opened
) {
togglePictureInPicture();
}

View File

@ -0,0 +1,12 @@
declare module 'keyboardevent-from-electron-accelerator' {
interface KeyboardEvent {
key?: string;
code?: string;
metaKey?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
}
export const toKeyEvent: (accelerator: string) => KeyboardEvent;
}

View File

@ -0,0 +1,14 @@
declare module 'keyboardevents-areequal' {
interface KeyboardEvent {
key?: string;
code?: string;
metaKey?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
}
const areEqual: (event1: KeyboardEvent, event2: KeyboardEvent) => boolean;
export default areEqual;
}

View File

@ -1,10 +1,14 @@
const prompt = require('custom-electron-prompt');
import prompt from 'custom-electron-prompt';
const { setOptions } = require('./back.ts');
import { BrowserWindow } from 'electron';
const promptOptions = require('../../providers/prompt-options');
import { setOptions } from './back';
module.exports = (win, options) => [
import promptOptions from '../../providers/prompt-options';
import type { ConfigType } from '../../config/dynamic';
import { MenuTemplate } from '../../menu';
export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [
{
label: 'Always on top',
type: 'checkbox',
@ -33,7 +37,7 @@ module.exports = (win, options) => [
{
label: 'Hotkey',
type: 'checkbox',
checked: options.hotkey,
checked: !!options.hotkey,
async click(item) {
const output = await prompt({
title: 'Picture in Picture Hotkey',
@ -51,7 +55,7 @@ module.exports = (win, options) => [
const { value, accelerator } = output[0];
setOptions({ [value]: accelerator });
item.checked = Boolean(accelerator);
item.checked = !!accelerator;
} else {
// Reset checkbox if prompt was canceled
item.checked = !item.checked;

View File

@ -1,14 +1,15 @@
const { getSongMenu } = require('../../providers/dom-elements');
const { ElementFromFile, templatePath } = require('../utils');
const { singleton } = require('../../providers/decorators');
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromFile, templatePath } from '../utils';
import { singleton } from '../../providers/decorators';
function $(selector) {
function $(selector: string) {
return document.querySelector(selector);
}
const slider = ElementFromFile(templatePath(__dirname, 'slider.html'));
const roundToTwo = (n) => Math.round(n * 1e2) / 1e2;
const roundToTwo = (n: number) => Math.round(n * 1e2) / 1e2;
const MIN_PLAYBACK_SPEED = 0.07;
const MAX_PLAYBACK_SPEED = 16;
@ -16,19 +17,19 @@ const MAX_PLAYBACK_SPEED = 16;
let playbackSpeed = 1;
const updatePlayBackSpeed = () => {
$('video').playbackRate = playbackSpeed;
($('video') as HTMLVideoElement).playbackRate = playbackSpeed;
const playbackSpeedElement = $('#playback-speed-value');
if (playbackSpeedElement) {
playbackSpeedElement.innerHTML = playbackSpeed;
playbackSpeedElement.innerHTML = String(playbackSpeed);
}
};
let menu;
let menu: Element | null = null;
const setupSliderListener = singleton(() => {
$('#playback-speed-slider').addEventListener('immediate-value-changed', (e) => {
playbackSpeed = e.detail.value || MIN_PLAYBACK_SPEED;
$('#playback-speed-slider')?.addEventListener('immediate-value-changed', (e) => {
playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) {
playbackSpeed = 1;
}
@ -43,20 +44,28 @@ const observePopupContainer = () => {
menu = getSongMenu();
}
if (menu && menu.parentElement.eventSink_?.matches('ytmusic-menu-renderer.ytmusic-player-bar') && !menu.contains(slider)) {
if (
menu &&
(menu.parentElement as HTMLElement & { eventSink_: Element | null })
?.eventSink_
?.matches('ytmusic-menu-renderer.ytmusic-player-bar')&& !menu.contains(slider)
) {
menu.prepend(slider);
setupSliderListener();
}
});
observer.observe($('ytmusic-popup-container'), {
childList: true,
subtree: true,
});
const popupContainer = $('ytmusic-popup-container');
if (popupContainer) {
observer.observe(popupContainer, {
childList: true,
subtree: true,
});
}
};
const observeVideo = () => {
const video = $('video');+
const video = $('video') as HTMLVideoElement;
video.addEventListener('ratechange', forcePlaybackRate);
video.addEventListener('srcChanged', forcePlaybackRate);
};
@ -76,17 +85,18 @@ const setupWheelListener = () => {
updatePlayBackSpeed();
// Update slider position
$('#playback-speed-slider').value = playbackSpeed;
($('#playback-speed-slider') as HTMLElement & { value: number }).value = playbackSpeed;
});
};
function forcePlaybackRate(e) {
if (e.target.playbackRate !== playbackSpeed) {
e.target.playbackRate = playbackSpeed;
function forcePlaybackRate(e: Event) {
const videoElement = (e.target as HTMLVideoElement);
if (videoElement.playbackRate !== playbackSpeed) {
videoElement.playbackRate = playbackSpeed;
}
}
module.exports = () => {
export default () => {
document.addEventListener('apiLoaded', () => {
observePopupContainer();
observeVideo();

View File

@ -1,17 +1,20 @@
const path = require('node:path');
import path from 'node:path';
const { globalShortcut } = require('electron');
import { globalShortcut, BrowserWindow } from 'electron';
const { injectCSS } = require('../utils');
import { injectCSS } from '../utils';
import type { ConfigType } from '../../config/dynamic';
/*
This is used to determine if plugin is actually active
(not if its only enabled in options)
(not if it's only enabled in options)
*/
let enabled = false;
let isEnabled = false;
module.exports = (win, options) => {
enabled = true;
export const enabled = () => isEnabled;
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => {
isEnabled = true;
injectCSS(win.webContents, path.join(__dirname, 'volume-hud.css'));
if (options.globalShortcuts?.volumeUp) {
@ -22,5 +25,3 @@ module.exports = (win, options) => {
globalShortcut.register((options.globalShortcuts.volumeDown), () => win.webContents.send('changeVolume', false));
}
};
module.exports.enabled = () => enabled;

View File

@ -1,22 +1,25 @@
const { ipcRenderer } = require('electron');
import { ipcRenderer } from 'electron';
const { setOptions, setMenuOptions, isEnabled } = require('../../config/plugins');
import { setOptions, setMenuOptions, isEnabled } from '../../config/plugins';
import { debounce } from '../../providers/decorators';
function $(selector) {
import { YoutubePlayer } from '../../types/youtube-player';
import type { ConfigType } from '../../config/dynamic';
function $(selector: string) {
return document.querySelector(selector);
}
const { debounce } = require('../../providers/decorators');
let api: YoutubePlayer;
let options: ConfigType<'precise-volume'>;
let api;
let options;
module.exports = (_options) => {
export default (_options: ConfigType<'precise-volume'>) => {
options = _options;
document.addEventListener('apiLoaded', (e) => {
api = e.detail;
ipcRenderer.on('changeVolume', (_, toIncrease) => changeVolume(toIncrease));
ipcRenderer.on('setVolume', (_, value) => setVolume(value));
ipcRenderer.on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease));
ipcRenderer.on('setVolume', (_, value: number) => setVolume(value));
firstRun();
}, { once: true, passive: true });
};
@ -26,23 +29,22 @@ const writeOptions = debounce(() => {
setOptions('precise-volume', options);
}, 1000);
const moveVolumeHud = debounce((showVideo) => {
const volumeHud = $('#volumeHud');
export const moveVolumeHud = debounce((showVideo: boolean) => {
const volumeHud = $('#volumeHud') as HTMLElement | undefined;
if (!volumeHud) {
return;
}
volumeHud.style.top = showVideo
? `${($('ytmusic-player').clientHeight - $('video').clientHeight) / 2}px`
: 0;
? `${($('ytmusic-player')!.clientHeight - $('video')!.clientHeight) / 2}px`
: '0';
}, 250);
module.exports.moveVolumeHud = moveVolumeHud;
const hideVolumeHud = debounce((volumeHud) => {
volumeHud.style.opacity = 0;
const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
volumeHud.style.opacity = '0';
}, 2000);
const hideVolumeSlider = debounce((slider) => {
const hideVolumeSlider = debounce((slider: HTMLElement) => {
slider.classList.remove('on-hover');
}, 2500);
@ -61,14 +63,15 @@ function firstRun() {
setupLocalArrowShortcuts();
const noVid = $('#main-panel')?.computedStyleMap().get('display').value === 'none';
// Workaround: computedStyleMap().get(string) returns CSSKeywordValue instead of CSSStyleValue
const noVid = ($('#main-panel')?.computedStyleMap().get('display') as CSSKeywordValue)?.value === 'none';
injectVolumeHud(noVid);
if (!noVid) {
setupVideoPlayerOnwheel();
if (!isEnabled('video-toggle')) {
// Video-toggle handles hud positioning on its own
const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV';
$('video').addEventListener('srcChanged', () => moveVolumeHud(videoMode()));
$('video')?.addEventListener('srcChanged', () => moveVolumeHud(videoMode()));
}
}
@ -79,51 +82,55 @@ function firstRun() {
});
}
function injectVolumeHud(noVid) {
function injectVolumeHud(noVid: boolean) {
if (noVid) {
const position = 'top: 18px; right: 60px;';
const mainStyle = 'font-size: xx-large;';
$('.center-content.ytmusic-nav-bar').insertAdjacentHTML('beforeend',
$('.center-content.ytmusic-nav-bar')?.insertAdjacentHTML('beforeend',
`<span id="volumeHud" style="${position + mainStyle}"></span>`);
} else {
const position = 'top: 10px; left: 10px;';
const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;';
$('#song-video').insertAdjacentHTML('afterend',
$('#song-video')?.insertAdjacentHTML('afterend',
`<span id="volumeHud" style="${position + mainStyle}"></span>`);
}
}
function showVolumeHud(volume) {
const volumeHud = $('#volumeHud');
function showVolumeHud(volume: number) {
const volumeHud = $('#volumeHud') as HTMLElement | undefined;
if (!volumeHud) {
return;
}
volumeHud.textContent = `${volume}%`;
volumeHud.style.opacity = 1;
volumeHud.style.opacity = '1';
hideVolumeHud(volumeHud);
}
/** Add onwheel event to video player */
function setupVideoPlayerOnwheel() {
$('#main-panel').addEventListener('wheel', (event) => {
const panel = $('#main-panel') as HTMLElement | undefined;
if (!panel) return;
panel.addEventListener('wheel', (event) => {
event.preventDefault();
// Event.deltaY < 0 means wheel-up
changeVolume(event.deltaY < 0);
});
}
function saveVolume(volume) {
function saveVolume(volume: number) {
options.savedVolume = volume;
writeOptions();
}
/** Add onwheel event to play bar and also track if play bar is hovered */
function setupPlaybar() {
const playerbar = $('ytmusic-player-bar');
const playerbar = $('ytmusic-player-bar') as HTMLElement | undefined;
if (!playerbar) return;
playerbar.addEventListener('wheel', (event) => {
event.preventDefault();
@ -148,23 +155,28 @@ function setupSliderObserver() {
const sliderObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// This checks that volume-slider was manually set
if (mutation.oldValue !== mutation.target.value
&& (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - mutation.target.value) > 4)) {
const target = mutation.target as HTMLInputElement;
const targetValueNumeric = Number(target.value);
if (mutation.oldValue !== target.value
&& (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - targetValueNumeric) > 4)) {
// Diff>4 means it was manually set
setTooltip(mutation.target.value);
saveVolume(mutation.target.value);
setTooltip(targetValueNumeric);
saveVolume(targetValueNumeric);
}
}
});
const slider = $('#volume-slider');
if (!slider) return;
// Observing only changes in 'value' of volume-slider
sliderObserver.observe($('#volume-slider'), {
sliderObserver.observe(slider, {
attributeFilter: ['value'],
attributeOldValue: true,
});
}
function setVolume(value) {
function setVolume(value: number) {
api.setVolume(value);
// Save the new volume
saveVolume(value);
@ -181,7 +193,7 @@ function setVolume(value) {
}
/** If (toIncrease = false) then volume decrease */
function changeVolume(toIncrease) {
function changeVolume(toIncrease: boolean) {
// Apply volume change if valid
const steps = Number(options.steps || 1);
setVolume(toIncrease
@ -190,17 +202,20 @@ function changeVolume(toIncrease) {
}
function updateVolumeSlider() {
const savedVolume = options.savedVolume ?? 0;
// Slider value automatically rounds to multiples of 5
for (const slider of ['#volume-slider', '#expand-volume-slider']) {
$(slider).value
= options.savedVolume > 0 && options.savedVolume < 5
($(slider) as HTMLInputElement).value
= String(savedVolume > 0 && savedVolume < 5
? 5
: options.savedVolume;
: savedVolume);
}
}
function showVolumeSlider() {
const slider = $('#volume-slider');
const slider = $('#volume-slider') as HTMLElement | null;
if (!slider) return;
// This class display the volume slider if not in minimized mode
slider.classList.add('on-hover');
@ -215,16 +230,16 @@ const tooltipTargets = [
'#expand-volume',
];
function setTooltip(volume) {
function setTooltip(volume: number) {
for (const target of tooltipTargets) {
$(target).title = `${volume}%`;
($(target) as HTMLElement).title = `${volume}%`;
}
}
function setupLocalArrowShortcuts() {
if (options.arrowsShortcut) {
window.addEventListener('keydown', (event) => {
if ($('ytmusic-search-box').opened) {
if (($('ytmusic-search-box') as (HTMLElement & { opened: boolean }) | null)?.opened) {
return;
}

View File

@ -1,15 +1,20 @@
const prompt = require('custom-electron-prompt');
import prompt, { KeybindOptions } from 'custom-electron-prompt';
const { enabled } = require('./back');
import { BrowserWindow, MenuItem } from 'electron';
const { setMenuOptions } = require('../../config/plugins');
const promptOptions = require('../../providers/prompt-options');
import { enabled } from './back';
function changeOptions(changedOptions, options, win) {
import { setMenuOptions } from '../../config/plugins';
import promptOptions from '../../providers/prompt-options';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
function changeOptions(changedOptions: Partial<ConfigType<'precise-volume'>>, options: ConfigType<'precise-volume'>, win: BrowserWindow) {
for (const option in changedOptions) {
options[option] = changedOptions[option];
// HACK: Weird TypeScript error
(options as Record<string, unknown>)[option] = (changedOptions as Record<string, unknown>)[option];
}
// Dynamically change setting if plugin is enabled
if (enabled()) {
win.webContents.send('setOptions', changedOptions);
@ -18,7 +23,7 @@ function changeOptions(changedOptions, options, win) {
}
}
module.exports = (win, options) => [
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>): MenuTemplate => [
{
label: 'Local Arrowkeys Controls',
type: 'checkbox',
@ -40,9 +45,9 @@ module.exports = (win, options) => [
];
// Helper function for globalShortcuts prompt
const kb = (label_, value_, default_) => ({ value: value_, label: label_, default: default_ || undefined });
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ 'value': value_, 'label': label_, 'default': default_ || undefined });
async function promptVolumeSteps(win, options) {
async function promptVolumeSteps(win: BrowserWindow, options: ConfigType<'precise-volume'>) {
const output = await prompt({
title: 'Volume Steps',
label: 'Choose Volume Increase/Decrease Steps',
@ -58,7 +63,7 @@ async function promptVolumeSteps(win, options) {
}
}
async function promptGlobalShortcuts(win, options, item) {
async function promptGlobalShortcuts(win: BrowserWindow, options: ConfigType<'precise-volume'>, item: MenuItem) {
const output = await prompt({
title: 'Global Volume Keybinds',
label: 'Choose Global Volume Keybinds:',
@ -71,9 +76,12 @@ async function promptGlobalShortcuts(win, options, item) {
}, win);
if (output) {
const newGlobalShortcuts = {};
const newGlobalShortcuts: {
volumeUp: string;
volumeDown: string;
} = { volumeUp: '', volumeDown: '' };
for (const { value, accelerator } of output) {
newGlobalShortcuts[value] = accelerator;
newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator;
}
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win);

View File

@ -1,32 +0,0 @@
const is = require('electron-is');
let ignored = {
id: ['volume-slider', 'expand-volume-slider'],
types: ['mousewheel', 'keydown', 'keyup'],
};
function overrideAddEventListener() {
// Save native addEventListener
Element.prototype._addEventListener = Element.prototype.addEventListener;
// Override addEventListener to Ignore specific events in volume-slider
Element.prototype.addEventListener = function (type, listener, useCapture = false) {
if (!(
ignored.id.includes(this.id)
&& ignored.types.includes(type)
)) {
this._addEventListener(type, listener, useCapture);
} else if (is.dev()) {
console.log(`Ignoring event: "${this.id}.${type}()"`);
}
};
}
module.exports = () => {
overrideAddEventListener();
// Restore original function after finished loading to avoid keeping Element.prototype altered
window.addEventListener('load', () => {
Element.prototype.addEventListener = Element.prototype._addEventListener;
Element.prototype._addEventListener = undefined;
ignored = undefined;
}, { once: true });
};

View File

@ -0,0 +1,41 @@
/* what */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import is from 'electron-is';
const ignored = {
id: ['volume-slider', 'expand-volume-slider'],
types: ['mousewheel', 'keydown', 'keyup'],
};
function overrideAddEventListener() {
// YO WHAT ARE YOU DOING NOW?!?!
// Save native addEventListener
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/unbound-method
Element.prototype._addEventListener = Element.prototype.addEventListener;
// Override addEventListener to Ignore specific events in volume-slider
Element.prototype.addEventListener = function (type: string, listener: (event: Event) => void, useCapture = false) {
if (!(
ignored.id.includes(this.id)
&& ignored.types.includes(type)
)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
(this as any)._addEventListener(type, listener, useCapture);
} else if (is.dev()) {
console.log(`Ignoring event: "${this.id}.${type}()"`);
}
};
}
export default () => {
overrideAddEventListener();
// Restore original function after finished loading to avoid keeping Element.prototype altered
window.addEventListener('load', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
Element.prototype.addEventListener = (Element.prototype as any)._addEventListener;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
(Element.prototype as any)._addEventListener = undefined;
}, { once: true });
};

View File

@ -1,7 +1,7 @@
const { ipcMain, dialog } = require('electron');
import { ipcMain, dialog } from 'electron';
module.exports = () => {
ipcMain.handle('qualityChanger', async (_, qualityLabels, currentIndex) => await dialog.showMessageBox({
export default () => {
ipcMain.handle('qualityChanger', async (_, qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox({
type: 'question',
buttons: qualityLabels,
defaultId: currentIndex,

View File

@ -1,8 +1,9 @@
const { ipcRenderer } = require('electron');
import { ipcRenderer } from 'electron';
const { ElementFromFile, templatePath } = require('../utils');
import { ElementFromFile, templatePath } from '../utils';
import { YoutubePlayer } from '../../types/youtube-player';
function $(selector) {
function $(selector: string): HTMLElement | null {
return document.querySelector(selector);
}
@ -10,32 +11,19 @@ const qualitySettingsButton = ElementFromFile(
templatePath(__dirname, 'qualitySettingsTemplate.html'),
);
module.exports = () => {
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
};
function setup(event) {
/**
* @type {{
* getAvailableQualityLevels: () => string[],
* getPlaybackQuality: () => string,
* getAvailableQualityLabels: () => string[],
* setPlaybackQualityRange: (quality: string) => void,
* setPlaybackQuality: (quality: string) => void,
* }}
*/
function setup(event: CustomEvent<YoutubePlayer>) {
const api = event.detail;
$('.top-row-buttons.ytmusic-player').prepend(qualitySettingsButton);
$('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton);
qualitySettingsButton.addEventListener('click', function chooseQuality() {
setTimeout(() => $('#player').click());
setTimeout(() => $('#player')?.click());
const qualityLevels = api.getAvailableQualityLevels();
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality());
ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise) => {
ipcRenderer.invoke('qualityChanger', api.getAvailableQualityLabels(), currentIndex).then((promise: { response: number }) => {
if (promise.response === -1) {
return;
}
@ -46,3 +34,7 @@ function setup(event) {
});
});
}
export default () => {
document.addEventListener('apiLoaded', setup, { once: true, passive: true });
};

View File

@ -1,24 +1,26 @@
const { globalShortcut } = require('electron');
const is = require('electron-is');
const electronLocalshortcut = require('electron-localshortcut');
import { BrowserWindow, globalShortcut } from 'electron';
import is from 'electron-is';
import electronLocalshortcut from 'electron-localshortcut';
const registerMPRIS = require('./mpris');
import registerMPRIS from './mpris';
const getSongControls = require('../../providers/song-controls');
import getSongControls from '../../providers/song-controls';
function _registerGlobalShortcut(webContents, shortcut, action) {
import type { ConfigType } from '../../config/dynamic';
function _registerGlobalShortcut(webContents: Electron.WebContents, shortcut: string, action: (webContents: Electron.WebContents) => void) {
globalShortcut.register(shortcut, () => {
action(webContents);
});
}
function _registerLocalShortcut(win, shortcut, action) {
function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) {
electronLocalshortcut.register(win, shortcut, () => {
action(win.webContents);
});
}
function registerShortcuts(win, options) {
function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>) {
const songControls = getSongControls(win);
const { playPause, next, previous, search } = songControls;
@ -39,28 +41,29 @@ function registerShortcuts(win, options) {
const shortcutOptions = { global, local };
for (const optionType in shortcutOptions) {
registerAllShortcuts(shortcutOptions[optionType], optionType);
registerAllShortcuts(shortcutOptions[optionType as 'global' | 'local'], optionType);
}
function registerAllShortcuts(container, type) {
function registerAllShortcuts(container: Record<string, string>, type: string) {
for (const action in container) {
if (!container[action]) {
continue; // Action accelerator is empty
}
console.debug(`Registering ${type} shortcut`, container[action], ':', action);
if (!songControls[action]) {
const actionCallback: () => void = songControls[action as keyof typeof songControls];
if (typeof actionCallback !== 'function') {
console.warn('Invalid action', action);
continue;
}
if (type === 'global') {
_registerGlobalShortcut(win.webContents, container[action], songControls[action]);
_registerGlobalShortcut(win.webContents, container[action], actionCallback);
} else { // Type === "local"
_registerLocalShortcut(win, local[action], songControls[action]);
_registerLocalShortcut(win, local[action], actionCallback);
}
}
}
}
module.exports = registerShortcuts;
export default registerShortcuts;

View File

@ -1,9 +1,14 @@
const prompt = require('custom-electron-prompt');
import prompt, { KeybindOptions } from 'custom-electron-prompt';
const { setMenuOptions } = require('../../config/plugins');
const promptOptions = require('../../providers/prompt-options');
import { BrowserWindow } from 'electron';
module.exports = (win, options) => [
import { setMenuOptions } from '../../config/plugins';
import type { ConfigType } from '../../config/dynamic';
import promptOptions from '../../providers/prompt-options';
import { MenuTemplate } from '../../menu';
export default (win: BrowserWindow, options: ConfigType<'shortcuts'>): MenuTemplate => [
{
label: 'Set Global Song Controls',
click: () => promptKeybind(options, win),
@ -16,7 +21,11 @@ module.exports = (win, options) => [
},
];
function setOption(options, key = null, newValue = null) {
function setOption<Key extends keyof ConfigType<'shortcuts'> = keyof ConfigType<'shortcuts'>>(
options: ConfigType<'shortcuts'>,
key: Key | null = null,
newValue: ConfigType<'shortcuts'>[Key] | null = null,
) {
if (key && newValue !== null) {
options[key] = newValue;
}
@ -25,9 +34,9 @@ function setOption(options, key = null, newValue = null) {
}
// Helper function for keybind prompt
const kb = (label_, value_, default_) => ({ value: value_, label: label_, default: default_ });
const kb = (label_: string, value_: string, default_: string): KeybindOptions => ({ value: value_, label: label_, default: default_ });
async function promptKeybind(options, win) {
async function promptKeybind(options: ConfigType<'shortcuts'>, win: BrowserWindow) {
const output = await prompt({
title: 'Global Keybinds',
label: 'Choose Global Keybinds for Songs Control:',

129
plugins/shortcuts/mpris-service.d.ts vendored Normal file
View File

@ -0,0 +1,129 @@
declare module 'mpris-service' {
import { EventEmitter } from 'events';
import dbus from 'dbus-next';
interface RootInterfaceOptions {
identity: string;
supportedUriSchemes: string[];
supportedMimeTypes: string[];
desktopEntry: string;
}
export interface Track {
'mpris:trackid'?: string;
'mpris:length'?: number;
'mpris:artUrl'?: string;
'xesam:album'?: string;
'xesam:albumArtist'?: string[];
'xesam:artist'?: string[];
'xesam:asText'?: string;
'xesam:audioBPM'?: number;
'xesam:autoRating'?: number;
'xesam:comment'?: string[];
'xesam:composer'?: string[];
'xesam:contentCreated'?: string;
'xesam:discNumber'?: number;
'xesam:firstUsed'?: string;
'xesam:genre'?: string[];
'xesam:lastUsed'?: string;
'xesam:lyricist'?: string[];
'xesam:title'?: string;
'xesam:trackNumber'?: number;
'xesam:url'?: string;
'xesam:useCount'?: number;
'xesam:userRating'?: number;
}
declare class Player extends EventEmitter {
constructor(opts: {
name: string;
identity: string;
supportedMimeTypes?: string[];
supportedInterfaces?: string[];
});
name: string;
identity: string;
fullscreen: boolean;
supportedUriSchemes: string[];
supportedMimeTypes: string[];
canQuit: boolean;
canRaise: boolean;
canSetFullscreen: boolean;
hasTrackList: boolean;
desktopEntry: string;
playbackStatus: string;
loopStatus: string;
shuffle: boolean;
metadata: object;
volume: number;
canControl: boolean;
canPause: boolean;
canPlay: boolean;
canSeek: boolean;
canGoNext: boolean;
canGoPrevious: boolean;
rate: number;
minimumRate: number;
maximumRate: number;
playlists: unknown[];
activePlaylist: string;
init(opts: RootInterfaceOptions): void;
objectPath(subpath?: string): string;
seeked(position: number): void;
getTrackIndex(trackId: string): number;
getTrack(trackId: string): Track;
addTrack(track: Track): void;
removeTrack(trackId: string): void;
getPlaylistIndex(playlistId: string): number;
setPlaylists(playlists: Track[]): void;
setActivePlaylist(playlistId: string): void;
static PLAYBACK_STATUS_PLAYING: 'Playing';
static PLAYBACK_STATUS_PAUSED: 'Paused';
static PLAYBACK_STATUS_STOPPED: 'Stopped';
static LOOP_STATUS_NONE: 'None';
static LOOP_STATUS_TRACK: 'Track';
static LOOP_STATUS_PLAYLIST: 'Playlist';
}
interface MprisInterface extends dbus.interface.Interface {
setProperty(property: string, valuePlain: unknown): void;
}
interface RootInterface {
}
interface PlayerInterface {
}
interface TracklistInterface {
TrackListReplaced(tracks: Track[]): void;
TrackAdded(afterTrack: string): void;
TrackRemoved(trackId: string): void;
}
interface PlaylistsInterface {
PlaylistChanged(playlist: unknown[]): void;
setActivePlaylistId(playlistId: string): void;
}
export default Player;
}

View File

@ -1,32 +1,34 @@
const { ipcMain } = require('electron');
const mpris = require('mpris-service');
import { BrowserWindow, ipcMain } from 'electron';
const registerCallback = require('../../providers/song-info');
const getSongControls = require('../../providers/song-controls');
const config = require('../../config');
import mpris, { Track } from 'mpris-service';
import registerCallback from '../../providers/song-info';
import getSongControls from '../../providers/song-controls';
import config from '../../config';
function setupMPRIS() {
return mpris({
const instance = new mpris({
name: 'youtube-music',
identity: 'YouTube Music',
canRaise: true,
supportedUriSchemes: ['https'],
supportedMimeTypes: ['audio/mpeg'],
supportedInterfaces: ['player'],
desktopEntry: 'youtube-music',
});
instance.canRaise = true;
instance.supportedUriSchemes = ['https'];
instance.desktopEntry = 'youtube-music';
return instance;
}
/** @param {Electron.BrowserWindow} win */
function registerMPRIS(win) {
function registerMPRIS(win: BrowserWindow) {
const songControls = getSongControls(win);
const { playPause, next, previous, volumeMinus10, volumePlus10, shuffle } = songControls;
try {
const secToMicro = (n) => Math.round(Number(n) * 1e6);
const microToSec = (n) => Math.round(Number(n) / 1e6);
// TODO: "Typing" for this arguments
const secToMicro = (n: unknown) => Math.round(Number(n) * 1e6);
const microToSec = (n: unknown) => Math.round(Number(n) / 1e6);
const seekTo = (e) => win.webContents.send('seekTo', microToSec(e.position));
const seekBy = (o) => win.webContents.send('seekBy', microToSec(o));
const seekTo = (e: { position: unknown }) => win.webContents.send('seekTo', microToSec(e.position));
const seekBy = (o: unknown) => win.webContents.send('seekBy', microToSec(o));
const player = setupMPRIS();
@ -37,12 +39,12 @@ function registerMPRIS(win) {
win.webContents.send('setupVolumeChangedListener', 'mpris');
});
ipcMain.on('seeked', (_, t) => player.seeked(secToMicro(t)));
ipcMain.on('seeked', (_, t: number) => player.seeked(secToMicro(t)));
let currentSeconds = 0;
ipcMain.on('timeChanged', (_, t) => currentSeconds = t);
ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t);
ipcMain.on('repeatChanged', (_, mode) => {
ipcMain.on('repeatChanged', (_, mode: string) => {
switch (mode) {
case 'NONE': {
player.loopStatus = mpris.LOOP_STATUS_NONE;
@ -64,7 +66,7 @@ function registerMPRIS(win) {
}
}
});
player.on('loopStatus', (status) => {
player.on('loopStatus', (status: string) => {
// SwitchRepeat cycles between states in that order
const switches = [mpris.LOOP_STATUS_NONE, mpris.LOOP_STATUS_PLAYLIST, mpris.LOOP_STATUS_TRACK];
const currentIndex = switches.indexOf(player.loopStatus);
@ -75,8 +77,6 @@ function registerMPRIS(win) {
songControls.switchRepeat(delta);
});
player.getPosition = () => secToMicro(currentSeconds);
player.on('raise', () => {
win.setSkipTaskbar(false);
win.show();
@ -114,7 +114,7 @@ function registerMPRIS(win) {
let mprisVolNewer = false;
let autoUpdate = false;
ipcMain.on('volumeChanged', (_, newVol) => {
if (Number.parseInt(player.volume * 100) !== newVol) {
if (~~(player.volume * 100) !== newVol) {
if (mprisVolNewer) {
mprisVolNewer = false;
autoUpdate = false;
@ -130,8 +130,8 @@ function registerMPRIS(win) {
player.on('volume', (newVolume) => {
if (config.plugins.isEnabled('precise-volume')) {
// With precise volume we can set the volume to the exact value.
const newVol = Number.parseInt(newVolume * 100);
if (Number.parseInt(player.volume * 100) !== newVol && !autoUpdate) {
const newVol = ~~(newVolume * 100);
if (~~(player.volume * 100) !== newVol && !autoUpdate) {
mprisVolNewer = true;
autoUpdate = false;
win.webContents.send('setVolume', newVol);
@ -155,9 +155,9 @@ function registerMPRIS(win) {
registerCallback((songInfo) => {
if (player) {
const data = {
const data: Track = {
'mpris:length': secToMicro(songInfo.songDuration),
'mpris:artUrl': songInfo.imageSrc,
'mpris:artUrl': songInfo.imageSrc ?? undefined,
'xesam:title': songInfo.title,
'xesam:url': songInfo.url,
'xesam:artist': [songInfo.artist],
@ -177,4 +177,4 @@ function registerMPRIS(win) {
}
}
module.exports = registerMPRIS;
export default registerMPRIS;

View File

@ -1,7 +1,6 @@
import config from '../../config';
import type { ConfigType } from '../../config/dynamic';
const SkipSilencesOptionsObj = config.get('plugins.skip-silences');
type SkipSilencesOptions = typeof SkipSilencesOptionsObj;
type SkipSilencesOptions = ConfigType<'skip-silences'>;
export default (options: SkipSilencesOptions) => {
let isSilent = false;

View File

@ -1,26 +1,31 @@
const { ipcMain } = require('electron');
const is = require('electron-is');
import { BrowserWindow, ipcMain } from 'electron';
import is from 'electron-is';
const { sortSegments } = require('./segments');
import { sortSegments } from './segments';
const defaultConfig = require('../../config/defaults');
import { SkipSegment } from './types';
let videoID;
import defaultConfig from '../../config/defaults';
import { GetPlayerResponse } from '../../types/get-player-response';
module.exports = (win, options) => {
import type { ConfigType } from '../../config/dynamic';
let videoID: string;
export default (win: BrowserWindow, options: ConfigType<'sponsorblock'>) => {
const { apiURL, categories } = {
...defaultConfig.plugins.sponsorblock,
...options,
};
ipcMain.on('video-src-changed', async (_, data) => {
videoID = JSON.parse(data)?.videoDetails?.videoId;
ipcMain.on('video-src-changed', async (_, data: string) => {
videoID = (JSON.parse(data) as GetPlayerResponse)?.videoDetails?.videoId;
const segments = await fetchSegments(apiURL, categories);
win.webContents.send('sponsorblock-skip', segments);
});
};
const fetchSegments = async (apiURL, categories) => {
const fetchSegments = async (apiURL: string, categories: string[]) => {
const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoID}&categories=${JSON.stringify(
categories,
)}`;
@ -36,7 +41,7 @@ const fetchSegments = async (apiURL, categories) => {
return [];
}
const segments = await resp.json();
const segments = await resp.json() as SkipSegment[];
return sortSegments(
segments.map((submission) => submission.segment),
);

View File

@ -1,30 +0,0 @@
const { ipcRenderer } = require('electron');
const is = require('electron-is');
let currentSegments = [];
module.exports = () => {
ipcRenderer.on('sponsorblock-skip', (_, segments) => {
currentSegments = segments;
});
document.addEventListener('apiLoaded', () => {
const video = document.querySelector('video');
video.addEventListener('timeupdate', (e) => {
for (const segment of currentSegments) {
if (
e.target.currentTime >= segment[0]
&& e.target.currentTime < segment[1]
) {
e.target.currentTime = segment[1];
if (is.dev()) {
console.log('SponsorBlock: skipping segment', segment);
}
}
}
});
// Reset segments on song end
video.addEventListener('emptied', () => currentSegments = []);
}, { once: true, passive: true });
};

View File

@ -0,0 +1,35 @@
import { ipcRenderer } from 'electron';
import is from 'electron-is';
import { Segment } from './types';
let currentSegments: Segment[] = [];
export default () => {
ipcRenderer.on('sponsorblock-skip', (_, segments: Segment[]) => {
currentSegments = segments;
});
document.addEventListener('apiLoaded', () => {
const video = document.querySelector('video') as HTMLVideoElement | undefined;
if (!video) return;
video.addEventListener('timeupdate', (e) => {
const target = e.target as HTMLVideoElement;
for (const segment of currentSegments) {
if (
target.currentTime >= segment[0]
&& target.currentTime < segment[1]
) {
target.currentTime = segment[1];
if (is.dev()) {
console.log('SponsorBlock: skipping segment', segment);
}
}
}
});
// Reset segments on song end
video.addEventListener('emptied', () => currentSegments = []);
}, { once: true, passive: true });
};

View File

@ -1,13 +1,15 @@
// Segments are an array [ [start, end], … ]
module.exports.sortSegments = (segments) => {
import { Segment } from './types';
export const sortSegments = (segments: Segment[]) => {
segments.sort((segment1, segment2) =>
segment1[0] === segment2[0]
? segment1[1] - segment2[1]
: segment1[0] - segment2[0],
);
const compiledSegments = [];
let currentSegment;
const compiledSegments: Segment[] = [];
let currentSegment: Segment | undefined;
for (const segment of segments) {
if (!currentSegment) {
@ -24,7 +26,9 @@ module.exports.sortSegments = (segments) => {
currentSegment[1] = Math.max(currentSegment[1], segment[1]);
}
compiledSegments.push(currentSegment);
if (currentSegment) {
compiledSegments.push(currentSegment);
}
return compiledSegments;
};

View File

@ -0,0 +1,12 @@
export type Segment = [number, number];
export interface SkipSegment { // Array of this object
segment: Segment; //[0, 15.23] start and end time in seconds
UUID: string,
category: string, // [1]
videoDuration: number // Duration of video when submission occurred (to be used to determine when a submission is out of date). 0 when unknown. +- 1 second
actionType: string, // [3]
locked: number, // if submission is locked
votes: number, // Votes on segment
description: string, // title for chapters, empty string for other segments
}

View File

@ -61,7 +61,7 @@ const touchBar = new TouchBar({
],
});
module.exports = (win: BrowserWindow) => {
export default (win: BrowserWindow) => {
const { playPause, next, previous, dislike, like } = getSongControls(win);
// If the page is ready, register the callback

View File

@ -44,7 +44,7 @@ const post = (data: Data) => {
}).catch((error: { code: number, errno: number }) => console.log(`Error: '${error.code || error.errno}' - when trying to access obs-tuna webserver at port ${port}`));
};
module.exports = (win: BrowserWindow) => {
export default (win: BrowserWindow) => {
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', (_, t: number) => {
if (!data.title) {

View File

@ -29,7 +29,7 @@ export const triggerAction = <Parameters extends unknown[]>(channel: string, act
export const triggerActionSync = <Parameters extends unknown[]>(channel: string, action: ValueOf<typeof Actions>, ...args: Parameters): unknown => ipcRenderer.sendSync(channel, action, ...args);
export const listenAction = (channel: string, callback: <Parameters extends unknown[]>(event: Electron.IpcMainEvent, ...args: Parameters) => void) => ipcMain.on(channel, callback);
export const listenAction = (channel: string, callback: (event: Electron.IpcMainEvent, action: string) => void) => ipcMain.on(channel, callback);
export const fileExists = (
path: fs.PathLike,
@ -48,7 +48,7 @@ export const fileExists = (
};
const cssToInject = new Map();
export const injectCSS = (webContents: Electron.WebContents, filepath: unknown, cb = undefined) => {
export const injectCSS = (webContents: Electron.WebContents, filepath: unknown, cb: (() => void) | undefined = undefined) => {
if (cssToInject.size === 0) {
setupCssInjection(webContents);
}

View File

@ -1,11 +0,0 @@
const path = require('node:path');
const { injectCSS } = require('../utils');
module.exports = (win, options) => {
if (options.forceHide) {
injectCSS(win.webContents, path.join(__dirname, 'force-hide.css'));
} else if (!options.mode || options.mode === 'custom') {
injectCSS(win.webContents, path.join(__dirname, 'button-switcher.css'));
}
};

View File

@ -0,0 +1,14 @@
import path from 'node:path';
import { BrowserWindow } from 'electron';
import { injectCSS } from '../utils';
import type { ConfigType } from '../../config/dynamic';
export default (win: BrowserWindow, options: ConfigType<'video-toggle'>) => {
if (options.forceHide) {
injectCSS(win.webContents, path.join(__dirname, 'force-hide.css'));
} else if (!options.mode || options.mode === 'custom') {
injectCSS(win.webContents, path.join(__dirname, 'button-switcher.css'));
}
};

View File

@ -1,37 +1,41 @@
const { ElementFromFile, templatePath } = require('../utils');
const { setOptions, isEnabled } = require('../../config/plugins');
import { ElementFromFile, templatePath } from '../utils';
import { setOptions, isEnabled } from '../../config/plugins';
const moveVolumeHud = isEnabled('precise-volume') ? require('../precise-volume/front').moveVolumeHud : () => {
};
import { moveVolumeHud as preciseVolumeMoveVolumeHud } from '../precise-volume/front';
import type { ConfigType } from '../../config/dynamic';
import { YoutubePlayer } from '../../types/youtube-player';
import { ThumbnailElement } from '../../types/get-player-response';
function $(selector) {
const moveVolumeHud = isEnabled('precise-volume') ? preciseVolumeMoveVolumeHud : () => {};
function $(selector: string): HTMLElement | null {
return document.querySelector(selector);
}
let options;
let player;
let video;
let api;
let options: ConfigType<'video-toggle'>;
let player: HTMLElement & { videoMode_: boolean };
let video: HTMLVideoElement;
let api: YoutubePlayer;
const switchButtonDiv = ElementFromFile(
templatePath(__dirname, 'button_template.html'),
);
module.exports = (_options) => {
export default (_options: ConfigType<'video-toggle'>) => {
if (_options.forceHide) {
return;
}
switch (_options.mode) {
case 'native': {
$('ytmusic-player-page').setAttribute('has-av-switcher');
$('ytmusic-player').setAttribute('has-av-switcher');
$('ytmusic-player-page')?.setAttribute('has-av-switcher', '');
$('ytmusic-player')?.setAttribute('has-av-switcher', '');
return;
}
case 'disabled': {
$('ytmusic-player-page').removeAttribute('has-av-switcher');
$('ytmusic-player').removeAttribute('has-av-switcher');
$('ytmusic-player-page')?.removeAttribute('has-av-switcher');
$('ytmusic-player')?.removeAttribute('has-av-switcher');
return;
}
@ -43,15 +47,15 @@ module.exports = (_options) => {
}
};
function setup(e) {
function setup(e: CustomEvent<YoutubePlayer>) {
api = e.detail;
player = $('ytmusic-player');
video = $('video');
player = $('ytmusic-player') as typeof player;
video = $('video') as HTMLVideoElement;
$('#main-panel').append(switchButtonDiv);
($('#main-panel') as HTMLVideoElement).append(switchButtonDiv);
if (options.hideVideo) {
$('.video-switch-button-checkbox').checked = false;
($('.video-switch-button-checkbox') as HTMLInputElement).checked = false;
changeDisplay(false);
forcePlaybackMode();
// Fix black video
@ -60,8 +64,9 @@ function setup(e) {
// Button checked = show video
switchButtonDiv.addEventListener('change', (e) => {
options.hideVideo = !e.target.checked;
changeDisplay(e.target.checked);
const target = e.target as HTMLInputElement;
options.hideVideo = target.checked;
changeDisplay(target.checked);
setOptions('video-toggle', options);
});
@ -87,12 +92,12 @@ function setup(e) {
}
}
function changeDisplay(showVideo) {
function changeDisplay(showVideo: boolean) {
player.style.margin = showVideo ? '' : 'auto 0px';
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
$('#song-video.ytmusic-player').style.display = showVideo ? 'block' : 'none';
$('#song-image').style.display = showVideo ? 'none' : 'block';
$('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none';
$('#song-image')!.style.display = showVideo ? 'none' : 'block';
if (showVideo && !video.style.top) {
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
@ -108,12 +113,12 @@ function videoStarted() {
// Hide toggle button
switchButtonDiv.style.display = 'none';
} else {
// Switch to high res thumbnail
forceThumbnail($('#song-image img'));
// Switch to high-res thumbnail
forceThumbnail($('#song-image img') as HTMLImageElement);
// Show toggle button
switchButtonDiv.style.display = 'initial';
// Change display to video mode if video exist & video is hidden & option.hideVideo = false
if (!options.hideVideo && $('#song-video.ytmusic-player').style.display === 'none') {
if (!options.hideVideo && $('#song-video.ytmusic-player')?.style.display === 'none') {
changeDisplay(true);
} else {
moveVolumeHud(!options.hideVideo);
@ -126,9 +131,10 @@ function videoStarted() {
function forcePlaybackMode() {
const playbackModeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.target.getAttribute('playback-mode') !== 'ATV_PREFERRED') {
const target = mutation.target as HTMLElement;
if (target.getAttribute('playback-mode') !== 'ATV_PREFERRED') {
playbackModeObserver.disconnect();
mutation.target.setAttribute('playback-mode', 'ATV_PREFERRED');
target.setAttribute('playback-mode', 'ATV_PREFERRED');
}
}
});
@ -142,19 +148,21 @@ function observeThumbnail() {
}
for (const mutation of mutations) {
if (!mutation.target.src.startsWith('data:')) {
const target = mutation.target as HTMLImageElement;
if (!target.src.startsWith('data:')) {
continue;
}
forceThumbnail(mutation.target);
forceThumbnail(target);
}
});
playbackModeObserver.observe($('#song-image img'), { attributeFilter: ['src'] });
playbackModeObserver.observe($('#song-image img')!, { attributeFilter: ['src'] });
}
function forceThumbnail(img) {
const thumbnails = $('#movie_player').getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails;
function forceThumbnail(img: HTMLImageElement) {
const thumbnails: ThumbnailElement[] = ($('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? [];
if (thumbnails && thumbnails.length > 0) {
img.src = thumbnails.at(-1).url.split('?')[0];
const thumbnail = thumbnails.at(-1)?.url.split('?')[0];
if (typeof thumbnail === 'string') img.src = thumbnail;
}
}

View File

@ -1,6 +1,10 @@
const { setMenuOptions } = require('../../config/plugins');
import { BrowserWindow } from 'electron';
module.exports = (win, options) => [
import { setMenuOptions } from '../../config/plugins';
import type { ConfigType } from '../../config/dynamic';
import { MenuTemplate } from '../../menu';
export default (win: BrowserWindow, options: ConfigType<'video-toggle'>): MenuTemplate => [
{
label: 'Mode',
submenu: [

View File

@ -1,7 +0,0 @@
const path = require('node:path');
const { injectCSS } = require('../utils');
module.exports = (win) => {
injectCSS(win.webContents, path.join(__dirname, 'empty-player.css'));
};

View File

@ -0,0 +1,9 @@
import path from 'node:path';
import { BrowserWindow } from 'electron';
import { injectCSS } from '../utils';
export default (win: BrowserWindow) => {
injectCSS(win.webContents, path.join(__dirname, 'empty-player.css'));
};

View File

@ -1,19 +1,28 @@
const defaultConfig = require('../../config/defaults');
import { Visualizer } from './visualizers/visualizer';
module.exports = (options) => {
import vudio from './visualizers/vudio';
import wave from './visualizers/wave';
import type { ConfigType } from '../../config/dynamic';
import defaultConfig from '../../config/defaults';
export default (options: ConfigType<'visualizer'>) => {
const optionsWithDefaults = {
...defaultConfig.plugins.visualizer,
...options,
};
const VisualizerType = require(`./visualizers/${optionsWithDefaults.type}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let visualizerType: { new(...args: any[]): Visualizer<unknown> } = vudio;
if (optionsWithDefaults.type === 'wave') visualizerType = wave;
document.addEventListener(
'audioCanPlay',
(e) => {
const video = document.querySelector('video');
const visualizerContainer = document.querySelector('#player');
const video = document.querySelector('video') as (HTMLVideoElement & { captureStream(): MediaStream; });
const visualizerContainer = document.querySelector('#player') as HTMLElement;
let canvas = document.querySelector('#visualizer');
let canvas = document.querySelector('#visualizer') as HTMLCanvasElement;
if (!canvas) {
canvas = document.createElement('canvas');
canvas.id = 'visualizer';
@ -33,17 +42,17 @@ module.exports = (options) => {
gainNode.gain.value = 1.25;
e.detail.audioSource.connect(gainNode);
const visualizer = new VisualizerType(
const visualizer = new visualizerType(
e.detail.audioContext,
e.detail.audioSource,
visualizerContainer,
canvas,
gainNode,
video.captureStream(),
optionsWithDefaults[optionsWithDefaults.type],
optionsWithDefaults,
);
const resizeVisualizer = (width, height) => {
const resizeVisualizer = (width: number, height: number) => {
resizeCanvas();
visualizer.resize(width, height);
};

View File

@ -1,13 +1,19 @@
const { readdirSync } = require('node:fs');
const path = require('node:path');
import { readdirSync } from 'node:fs';
import path from 'node:path';
const { setMenuOptions } = require('../../config/plugins');
import { BrowserWindow } from 'electron';
import { setMenuOptions } from '../../config/plugins';
import { MenuTemplate } from '../../menu';
import type { ConfigType } from '../../config/dynamic';
const visualizerTypes = readdirSync(path.join(__dirname, 'visualizers')).map(
(filename) => path.parse(filename).name,
);
module.exports = (win, options) => [
export default (win: BrowserWindow, options: ConfigType<'visualizer'>): MenuTemplate => [
{
label: 'Type',
submenu: visualizerTypes.map((visualizerType) => ({

View File

@ -1,47 +0,0 @@
const butterchurn = require('butterchurn');
const butterchurnPresets = require('butterchurn-presets');
const presets = butterchurnPresets.getPresets();
class ButterchurnVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
) {
this.visualizer = butterchurn.default.createVisualizer(
audioContext,
canvas,
{
width: canvas.width,
height: canvas.height,
},
);
const preset = presets[options.preset];
this.visualizer.loadPreset(preset, options.blendTimeInSeconds);
this.visualizer.connectAudio(audioNode);
this.renderingFrequencyInMs = options.renderingFrequencyInMs;
}
resize(width, height) {
this.visualizer.setRendererSize(width, height);
}
render() {
const renderVisualizer = () => {
requestAnimationFrame(() => renderVisualizer());
this.visualizer.render();
};
setTimeout(renderVisualizer(), this.renderingFrequencyInMs);
}
}
module.exports = ButterchurnVisualizer;

View File

@ -0,0 +1,18 @@
import type { ConfigType } from '../../../config/dynamic';
export abstract class Visualizer<T> {
abstract visualizer: T;
protected constructor(
audioContext: AudioContext,
audioSource: MediaElementAudioSourceNode,
visualizerContainer: HTMLElement,
canvas: HTMLCanvasElement,
audioNode: GainNode,
stream: MediaStream,
options: ConfigType<'visualizer'>,
) {}
abstract resize(width: number, height: number): void;
abstract render(): void;
}

View File

@ -1,33 +0,0 @@
const Vudio = require('vudio/umd/vudio');
class VudioVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
) {
this.visualizer = new Vudio(stream, canvas, {
width: canvas.width,
height: canvas.height,
// Visualizer config
...options,
});
}
resize(width, height) {
this.visualizer.setOption({
width,
height,
});
}
render() {
this.visualizer.dance();
}
}
module.exports = VudioVisualizer;

View File

@ -0,0 +1,49 @@
import Vudio from 'vudio';
import { Visualizer } from './visualizer';
import type { ConfigType } from '../../../config/dynamic';
class VudioVisualizer extends Visualizer<Vudio> {
visualizer: Vudio;
constructor(
audioContext: AudioContext,
audioSource: MediaElementAudioSourceNode,
visualizerContainer: HTMLElement,
canvas: HTMLCanvasElement,
audioNode: GainNode,
stream: MediaStream,
options: ConfigType<'visualizer'>,
) {
super(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
);
this.visualizer = new Vudio(stream, canvas, {
width: canvas.width,
height: canvas.height,
// Visualizer config
...options,
});
}
resize(width: number, height: number) {
this.visualizer.setOptions({
width,
height,
});
}
render() {
this.visualizer.dance();
}
}
export default VudioVisualizer;

View File

@ -1,34 +0,0 @@
const { Wave } = require('@foobar404/wave');
class WaveVisualizer {
constructor(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
) {
this.visualizer = new Wave(
{ context: audioContext, source: audioSource },
canvas,
);
for (const animation of options.animations) {
this.visualizer.addAnimation(
eval(`new this.visualizer.animations.${animation.type}(
${JSON.stringify(animation.config)}
)`),
);
}
}
// eslint-disable-next-line no-unused-vars
resize(width, height) {
}
render() {
}
}
module.exports = WaveVisualizer;

View File

@ -0,0 +1,49 @@
import { Wave } from '@foobar404/wave';
import { Visualizer } from './visualizer';
import type { ConfigType } from '../../../config/dynamic';
class WaveVisualizer extends Visualizer<Wave> {
visualizer: Wave;
constructor(
audioContext: AudioContext,
audioSource: MediaElementAudioSourceNode,
visualizerContainer: HTMLElement,
canvas: HTMLCanvasElement,
audioNode: GainNode,
stream: MediaStream,
options: ConfigType<'visualizer'>,
) {
super(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
);
this.visualizer = new Wave(
{ context: audioContext, source: audioSource },
canvas,
);
for (const animation of options.wave.animations) {
const TargetVisualizer = this.visualizer.animations[animation.type as keyof typeof this.visualizer.animations];
this.visualizer.addAnimation(
new TargetVisualizer(animation.config as never), // Magic of Typescript
);
}
}
resize(_: number, __: number) {
}
render() {
}
}
export default WaveVisualizer;

34
plugins/visualizer/vudio.d.ts vendored Normal file
View File

@ -0,0 +1,34 @@
declare module 'vudio' {
interface NoneWaveformOptions {
maxHeight?: number;
minHeight?: number;
spacing?: number;
color?: string | string[];
shadowBlur?: number;
shadowColor?: string;
fadeSide?: boolean;
}
interface WaveformOptions extends NoneWaveformOptions{
horizontalAlign: 'left' | 'center' | 'right';
verticalAlign: 'top' | 'middle' | 'bottom';
}
interface VudioOptions {
effect?: 'waveform' | 'circlewave' | 'circlebar' | 'lighting';
accuracy?: number;
width?: number;
height?: number;
waveform?: WaveformOptions
}
class Vudio {
constructor(audio: HTMLAudioElement | MediaStream, canvas: HTMLCanvasElement, options: VudioOptions = {});
dance(): void;
pause(): void;
setOptions(options: VudioOptions): void;
}
export default Vudio;
}

View File

@ -43,7 +43,7 @@ export interface YoutubePlayer {
getVideoAspectRatio: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPreferredQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaybackQualityLabel: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setPlaybackQualityRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setPlaybackQualityRange: (quality: string) => void;
onAdUxClicked: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getFeedbackProductData: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getStoryboardFrame: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
@ -51,7 +51,7 @@ export interface YoutubePlayer {
getStoryboardLevel: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getNumberOfStoryboardLevels: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getCaptionWindowContainerId: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailableQualityLabels: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailableQualityLabels: () => string[];
addUtcCueRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
showAirplayPicker: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
dispatchReduxAction: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
@ -147,14 +147,14 @@ export interface YoutubePlayer {
unMute: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isMuted: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setVolume: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVolume: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getVolume: () => number;
seekTo: (seconds: number) => void;
getPlayerMode: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlayerState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailablePlaybackRates: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaybackQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setPlaybackQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailableQualityLevels: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaybackQuality: () => string;
setPlaybackQuality: (quality: string) => void;
getAvailableQualityLevels: () => string[];
getCurrentTime: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getDuration: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
addEventListener: <Parameters extends unknown[], Return>(...params: Parameters) => Return;