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

View File

@ -1,19 +1,29 @@
declare module 'custom-electron-prompt' { declare module 'custom-electron-prompt' {
import { BrowserWindow } from 'electron'; import { BrowserWindow } from 'electron';
export interface PromptCounterOptions { export type SelectOptions = Record<string, string>;
export interface CounterOptions {
minimum?: number; minimum?: number;
maximum?: number; maximum?: number;
multiFire?: boolean; multiFire?: boolean;
} }
export interface PromptKeybindOptions { export interface KeybindOptions {
value: string; value: string;
label: 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; width?: number;
height?: number; height?: number;
resizable?: boolean; resizable?: boolean;
@ -25,10 +35,6 @@ declare module 'custom-electron-prompt' {
}; };
alwaysOnTop?: boolean; alwaysOnTop?: boolean;
value?: unknown; value?: unknown;
type?: 'input' | 'select' | 'counter' | 'multiInput';
selectOptions?: Record<string, string>;
keybindOptions?: PromptKeybindOptions[];
counterOptions?: PromptCounterOptions;
icon?: string; icon?: string;
useHtmlLabel?: boolean; useHtmlLabel?: boolean;
customStylesheet?: string; customStylesheet?: string;
@ -38,15 +44,42 @@ declare module 'custom-electron-prompt' {
customScript?: string; customScript?: string;
enableRemoteModule?: boolean; enableRemoteModule?: boolean;
inputAttrs?: Partial<HTMLInputElement>; 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; 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[]; 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 { return {
label: pluginLabel, label: pluginLabel,
submenu: [ submenu: [
@ -274,7 +275,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
{ {
label: 'Proxy', label: 'Proxy',
type: 'checkbox', type: 'checkbox',
checked: Boolean(config.get('options.proxy')), checked: !!(config.get('options.proxy')),
click(item) { click(item) {
setProxy(item, win); setProxy(item, win);
}, },

250
package-lock.json generated
View File

@ -52,6 +52,7 @@
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0", "@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0", "auto-changelog": "2.4.0",
"copyfiles": "2.4.1",
"del-cli": "5.0.1", "del-cli": "5.0.1",
"electron": "27.0.0-alpha.5", "electron": "27.0.0-alpha.5",
"electron-builder": "24.6.3", "electron-builder": "24.6.3",
@ -2834,6 +2835,76 @@
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"dev": true "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": { "node_modules/core-js": {
"version": "2.6.12", "version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
@ -3162,6 +3233,21 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -3543,6 +3629,21 @@
"unzip-crx-3": "^0.2.0" "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": { "node_modules/electron-is": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/electron-is/-/electron-is-3.0.0.tgz", "resolved": "https://registry.npmjs.org/electron-is/-/electron-is-3.0.0.tgz",
@ -4507,6 +4608,21 @@
"node": ">=12.0.0" "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": { "node_modules/flatted": {
"version": "3.2.7", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
@ -6596,6 +6712,21 @@
"node": "^12.13 || ^14.13 || >=16" "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": { "node_modules/node-id3": {
"version": "0.2.6", "version": "0.2.6",
"resolved": "https://registry.npmjs.org/node-id3/-/node-id3-0.2.6.tgz", "resolved": "https://registry.npmjs.org/node-id3/-/node-id3-0.2.6.tgz",
@ -6615,6 +6746,40 @@
"node": ">=0.10.0" "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": { "node_modules/nopt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
@ -7617,21 +7782,6 @@
"node": ">=0.10.0" "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": { "node_modules/roarr": {
"version": "2.15.4", "version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "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", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" "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": { "node_modules/titleize": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz",
@ -8522,6 +8718,21 @@
"tmp": "^0.2.0" "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": { "node_modules/tmp-promise/node_modules/tmp": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
@ -9079,6 +9290,15 @@
"node": ">=8.0" "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": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -84,21 +84,22 @@
"scripts": { "scripts": {
"test": "playwright test", "test": "playwright test",
"test:debug": "DEBUG=pw:browser* 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", "start:debug": "ELECTRON_ENABLE_LOGGING=1 electron ./dist/index.js",
"generate:package": "node utils/generate-package-json.js", "generate:package": "node utils/generate-package-json.js",
"postinstall": "npm run plugins", "postinstall": "npm run plugins",
"clean": "del-cli dist && del-cli pack", "clean": "del-cli dist && del-cli pack",
"build": "npm run clean && tsc && electron-builder --win --mac --linux -p never", "copy-files": "copyfiles -u 1 plugins/**/*.html plugins/**/*.css plugins/**/*.bin plugins/**/*.js dist/plugins/",
"build:linux": "npm run clean && electron-builder --linux -p never", "build": "npm run clean && tsc && npm run copy-files && electron-builder --win --mac --linux -p never",
"build:mac": "npm run clean && electron-builder --mac dmg:x64 -p never", "build:linux": "npm run clean && tsc && npm run copy-files && electron-builder --linux -p never",
"build:mac:arm64": "npm run clean && electron-builder --mac dmg:arm64 -p never", "build:mac": "npm run clean && tsc && npm run copy-files && electron-builder --mac dmg:x64 -p never",
"build:win": "npm run clean && electron-builder --win -p never", "build:mac:arm64": "npm run clean && tsc && npm run copy-files && electron-builder --mac dmg:arm64 -p never",
"build:win:x64": "npm run clean && electron-builder --win nsis:x64 -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", "lint": "xo",
"changelog": "auto-changelog", "changelog": "auto-changelog",
"plugins": "npm run plugin:adblocker && npm run plugin:bypass-age-restrictions", "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", "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:linux": "npm run clean && electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "npm run clean && electron-builder --mac -p always", "release:mac": "npm run clean && electron-builder --mac -p always",
@ -157,6 +158,7 @@
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0", "@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0", "auto-changelog": "2.4.0",
"copyfiles": "2.4.1",
"del-cli": "5.0.1", "del-cli": "5.0.1",
"electron": "27.0.0-alpha.5", "electron": "27.0.0-alpha.5",
"electron-builder": "24.6.3", "electron-builder": "24.6.3",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,10 +4,9 @@ import md5 from 'md5';
import { setOptions } from '../../config/plugins'; import { setOptions } from '../../config/plugins';
import registerCallback, { SongInfo } from '../../providers/song-info'; import registerCallback, { SongInfo } from '../../providers/song-info';
import defaultConfig from '../../config/defaults'; import defaultConfig from '../../config/defaults';
import config from '../../config'; import type { ConfigType } from '../../config/dynamic';
const LastFMOptionsObj = config.get('plugins.last-fm'); type LastFMOptions = ConfigType<'last-fm'>;
type LastFMOptions = typeof LastFMOptionsObj;
interface LastFmData { interface LastFmData {
method: string, 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 { cleanupName, SongInfo } from '../../providers/song-info';
import { injectCSS } from '../utils'; 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; const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false; let revRomanized = false;
const LyricGeniusTypeObj = config.get('plugins.lyric-genius'); export type LyricGeniusType = ConfigType<'lyric-genius'>;
export type LyricGeniusType = typeof LyricGeniusTypeObj;
export default (win: BrowserWindow, options: LyricGeniusType) => { export default (win: BrowserWindow, options: LyricGeniusType) => {
if (options.romanizedLyrics) { if (options.romanizedLyrics) {

View File

@ -4,7 +4,7 @@ import { LyricGeniusType, toggleRomanized } from './back';
import { setOptions } from '../../config/plugins'; import { setOptions } from '../../config/plugins';
module.exports = (win: BrowserWindow, options: LyricGeniusType) => [ export default (_: BrowserWindow, options: LyricGeniusType) => [
{ {
label: 'Romanized Lyrics', label: 'Romanized Lyrics',
type: 'checkbox', 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'), () => { injectCSS(win.webContents, path.join(__dirname, 'style.css'), () => {
win.webContents.send('navigation-css-ready'); win.webContents.send('navigation-css-ready');
}); });
listenAction(CHANNEL, (event, action) => { listenAction(CHANNEL, (_, action) => {
switch (action) { switch (action) {
case ACTIONS.NEXT: { case ACTIONS.NEXT: {
if (win.webContents.canGoForward()) { 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', () => { ipcRenderer.on('navigation-css-ready', () => {
const forwardButton = ElementFromFile( const forwardButton = ElementFromFile(
templatePath(__dirname, 'forward.html'), 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( const menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer', '#items ytmusic-guide-entry-renderer',
); );
for (const item of menuEntries) { menuEntries.forEach((item) => {
const icon = item.querySelector('path'); const icon = item.querySelector('path');
if (icon) { if (icon) {
observer.disconnect(); observer.disconnect();
@ -26,7 +26,7 @@ function removeLoginElements() {
item.remove(); item.remove();
} }
} }
} });
}); });
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
childList: true, 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';
/** import getSongControls from '../../providers/song-controls';
* @type {PluginConfig} import registerCallback, { SongInfo } from '../../providers/song-info';
*/ import { changeProtocolHandler } from '../../providers/protocol-handler';
const config = require('./config'); import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray';
const getSongControls = require('../../providers/song-controls'); let songControls: ReturnType<typeof getSongControls>;
const registerCallback = require('../../providers/song-info'); let savedNotification: Notification | undefined;
const { changeProtocolHandler } = require('../../providers/protocol-handler');
const { setTrayOnClick, setTrayOnDoubleClick } = require('../../tray');
export default (win: BrowserWindow) => {
let songControls;
let savedNotification;
/** @param {Electron.BrowserWindow} win */
module.exports = (win) => {
songControls = getSongControls(win); songControls = getSongControls(win);
let currentSeconds = 0; let currentSeconds = 0;
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener')); ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', (_, t) => currentSeconds = t); ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t);
if (app.isPackaged) { if (app.isPackaged) {
saveTempIcon(); saveTempIcon();
} }
let savedSongInfo; let savedSongInfo: SongInfo;
let lastUrl; let lastUrl: string | undefined;
// Register songInfoCallback // Register songInfoCallback
registerCallback((songInfo) => { registerCallback((songInfo) => {
@ -78,7 +72,7 @@ module.exports = (win) => {
changeProtocolHandler( changeProtocolHandler(
(cmd) => { (cmd) => {
if (Object.keys(songControls).includes(cmd)) { if (Object.keys(songControls).includes(cmd)) {
songControls[cmd](); songControls[cmd as keyof typeof songControls]();
if (config.get('refreshOnPlayPause') && ( if (config.get('refreshOnPlayPause') && (
cmd === 'pause' cmd === 'pause'
|| (cmd === 'play' && !config.get('unpauseNotification')) || (cmd === 'play' && !config.get('unpauseNotification'))
@ -97,11 +91,18 @@ module.exports = (win) => {
); );
}; };
function sendNotification(songInfo) { function sendNotification(songInfo: SongInfo) {
const iconSrc = notificationImage(songInfo); const iconSrc = notificationImage(songInfo);
savedNotification?.close(); savedNotification?.close();
let icon: string;
if (typeof iconSrc === 'object') {
icon = iconSrc.toDataURL();
} else {
icon = iconSrc;
}
savedNotification = new Notification({ savedNotification = new Notification({
title: songInfo.title || 'Playing', title: songInfo.title || 'Playing',
body: songInfo.artist, 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/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/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype // https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
toastXml: getXml(songInfo, iconSrc), toastXml: getXml(songInfo, icon),
}); });
savedNotification.on('close', () => { savedNotification.on('close', () => {
@ -121,7 +122,7 @@ function sendNotification(songInfo) {
savedNotification.show(); savedNotification.show();
} }
const getXml = (songInfo, iconSrc) => { const getXml = (songInfo: SongInfo, iconSrc: string) => {
switch (config.get('toastStyle')) { switch (config.get('toastStyle')) {
default: default:
case ToastStyles.logo: case ToastStyles.logo:
@ -155,7 +156,7 @@ const iconLocation = app.isPackaged
? path.resolve(app.getPath('userData'), 'icons') ? path.resolve(app.getPath('userData'), 'icons')
: path.resolve(__dirname, '..', '..', 'assets/media-icons-black'); : path.resolve(__dirname, '..', '..', 'assets/media-icons-black');
const display = (kind) => { const display = (kind: keyof typeof icons) => {
if (config.get('toastStyle') === ToastStyles.legacy) { if (config.get('toastStyle') === ToastStyles.legacy) {
return `content="${icons[kind]}"`; 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}"/>`; `<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
const getButtons = (isPaused) => `\ const getButtons = (isPaused: boolean) => `\
<actions> <actions>
${getButton('previous')} ${getButton('previous')}
${isPaused ? getButton('play') : getButton('pause')} ${isPaused ? getButton('play') : getButton('pause')}
@ -177,7 +178,7 @@ const getButtons = (isPaused) => `\
</actions>\ </actions>\
`; `;
const toast = (content, isPaused) => `\ const toast = (content: string, isPaused: boolean) => `\
<toast> <toast>
<audio silent="true" /> <audio silent="true" />
<visual> <visual>
@ -189,19 +190,19 @@ const toast = (content, isPaused) => `\
${getButtons(isPaused)} ${getButtons(isPaused)}
</toast>`; </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}/> <image id="1" src="${imgSrc}" name="Image" ${placement}/>
<text id="1">${title}</text> <text id="1">${title}</text>
<text id="2">${artist}</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" /> <image id="1" src="${imgSrc}" name="Image" />
<text></text> <text></text>
<group> <group>
@ -211,17 +212,17 @@ const xmlBannerTopCustom = (songInfo, imgSrc) => toast(`\
</subgroup> </subgroup>
${xmlMoreData(songInfo)} ${xmlMoreData(songInfo)}
</group>\ </group>\
`, songInfo.isPaused); `, songInfo.isPaused ?? false);
const xmlMoreData = ({ album, elapsedSeconds, songDuration }) => `\ const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\
<subgroup hint-textStacking="bottom"> <subgroup hint-textStacking="bottom">
${album ${album
? `<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''} ? `<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>\ </subgroup>\
`; `;
const xmlBannerCenteredBottom = ({ title, artist, isPaused }, imgSrc) => toast(`\ const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
<text></text> <text></text>
<group> <group>
<subgroup hint-weight="1" hint-textStacking="center"> <subgroup hint-weight="1" hint-textStacking="center">
@ -230,9 +231,9 @@ const xmlBannerCenteredBottom = ({ title, artist, isPaused }, imgSrc) => toast(`
</subgroup> </subgroup>
</group> </group>
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\ <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" /> <image id="1" src="${imgSrc}" name="Image" />
<text></text> <text></text>
<group> <group>
@ -241,9 +242,9 @@ const xmlBannerCenteredTop = ({ title, artist, isPaused }, imgSrc) => toast(`\
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text> <text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
</subgroup> </subgroup>
</group>\ </group>\
`, isPaused); `, isPaused ?? false);
const titleFontPicker = (title) => { const titleFontPicker = (title: string) => {
if (title.length <= 13) { if (title.length <= 13) {
return 'Header'; return 'Header';
} }

View File

@ -1,9 +1,14 @@
const is = require('electron-is'); import is from 'electron-is';
const { urgencyLevels, ToastStyles, snakeToCamel } = require('./utils'); import {BrowserWindow, MenuItem} from 'electron';
const config = require('./config');
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() ...(is.linux()
? [ ? [
{ {
@ -24,7 +29,7 @@ module.exports = (_win, options) => [
type: 'checkbox', type: 'checkbox',
checked: options.interactive, checked: options.interactive,
// Doesn't update until restart // 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) // 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', label: 'Open/Close on tray click',
type: 'checkbox', type: 'checkbox',
checked: options.trayControls, checked: options.trayControls,
click: (item) => config.set('trayControls', item.checked), click: (item: MenuItem) => config.set('trayControls', item.checked),
}, },
{ {
label: 'Hide Button Text', label: 'Hide Button Text',
type: 'checkbox', type: 'checkbox',
checked: options.hideButtonText, checked: options.hideButtonText,
click: (item) => config.set('hideButtonText', item.checked), click: (item: MenuItem) => config.set('hideButtonText', item.checked),
}, },
{ {
label: 'Refresh on Play/Pause', label: 'Refresh on Play/Pause',
type: 'checkbox', type: 'checkbox',
checked: options.refreshOnPlayPause, 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', label: 'Show notification on unpause',
type: 'checkbox', type: 'checkbox',
checked: options.unpauseNotification, 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 }); const array = Array.from({ length: Object.keys(ToastStyles).length });
// ToastStyles index starts from 1 // 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 icon = 'assets/youtube-music.png';
const userData = app.getPath('userData'); const userData = app.getPath('userData');
const temporaryIcon = path.join(userData, 'tempIcon.png'); const temporaryIcon = path.join(userData, 'tempIcon.png');
const temporaryBanner = path.join(userData, 'tempBanner.png'); const temporaryBanner = path.join(userData, 'tempBanner.png');
const { cache } = require('../../providers/decorators');
module.exports.ToastStyles = { export const ToastStyles = {
logo: 1, logo: 1,
banner_centered_top: 2, banner_centered_top: 2,
hero: 3, hero: 3,
@ -23,20 +24,20 @@ module.exports.ToastStyles = {
legacy: 7, legacy: 7,
}; };
module.exports.icons = { export const icons = {
play: '\u{1405}', // ᐅ play: '\u{1405}', // ᐅ
pause: '\u{2016}', // ‖ pause: '\u{2016}', // ‖
next: '\u{1433}', // next: '\u{1433}', //
previous: '\u{1438}', // previous: '\u{1438}', //
}; };
module.exports.urgencyLevels = [ export const urgencyLevels = [
{ name: 'Low', value: 'low' }, { name: 'Low', value: 'low' },
{ name: 'Normal', value: 'normal' }, { name: 'Normal', value: 'normal' },
{ name: 'High', value: 'critical' }, { name: 'High', value: 'critical' },
]; ];
const nativeImageToLogo = cache((nativeImage) => { const nativeImageToLogo = cache((nativeImage: NativeImage) => {
const temporaryImage = nativeImage.resize({ height: 256 }); const temporaryImage = nativeImage.resize({ height: 256 });
const margin = Math.max(temporaryImage.getSize().width - 256, 0); 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) { if (!songInfo.image) {
return icon; return icon;
} }
@ -58,30 +59,30 @@ module.exports.notificationImage = (songInfo) => {
} }
switch (config.get('toastStyle')) { switch (config.get('toastStyle')) {
case module.exports.ToastStyles.logo: case ToastStyles.logo:
case module.exports.ToastStyles.legacy: { case ToastStyles.legacy: {
return this.saveImage(nativeImageToLogo(songInfo.image), temporaryIcon); return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon);
} }
default: { 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 { try {
fs.writeFileSync(savePath, img.toPNG()); fs.writeFileSync(savePath, img.toPNG());
} catch (error) { } catch (error: unknown) {
console.log(`Error writing song icon to disk:\n${error.toString()}`); console.log(`Error writing song icon to disk:\n${String(error)}`);
return icon; return icon;
} }
return savePath; return savePath;
}); });
module.exports.saveTempIcon = () => { export const saveTempIcon = () => {
for (const kind of Object.keys(module.exports.icons)) { for (const kind of Object.keys(icons)) {
const destinationPath = path.join(userData, 'icons', `${kind}.png`); const destinationPath = path.join(userData, 'icons', `${kind}.png`);
if (fs.existsSync(destinationPath)) { if (fs.existsSync(destinationPath)) {
continue; 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() group.toUpperCase()
.replace('-', ' ') .replace('-', ' ')
.replace('_', ' '), .replace('_', ' '),
); );
module.exports.secondsToMinutes = (seconds) => { export const secondsToMinutes = (seconds: number) => {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const secondsLeft = seconds % 60; const secondsLeft = seconds % 60;
return `${minutes}:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`; 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 { setOptions as setPluginOptions } from '../../config/plugins';
import { injectCSS } from '../utils'; import { injectCSS } from '../utils';
import config from '../../config'; import type { ConfigType } from '../../config/dynamic';
let isInPiP = false; let isInPiP = false;
let originalPosition: number[]; let originalPosition: number[];
@ -15,9 +15,7 @@ let originalMaximized: boolean;
let win: BrowserWindow; let win: BrowserWindow;
// Magic of TypeScript type PiPOptions = ConfigType<'picture-in-picture'>;
const PiPOptionsObj = config.get('plugins.picture-in-picture');
type PiPOptions = typeof PiPOptionsObj;
let options: Partial<PiPOptions>; let options: Partial<PiPOptions>;

View File

@ -2,31 +2,44 @@ import { ipcRenderer } from 'electron';
import { toKeyEvent } from 'keyboardevent-from-electron-accelerator'; import { toKeyEvent } from 'keyboardevent-from-electron-accelerator';
import keyEventAreEqual from 'keyboardevents-areequal'; import keyEventAreEqual from 'keyboardevents-areequal';
const { getSongMenu } = require('../../providers/dom-elements'); import { getSongMenu } from '../../providers/dom-elements';
const { ElementFromFile, templatePath } = require('../utils');
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); return document.querySelector(selector);
} }
let useNativePiP = false; let useNativePiP = false;
let menu = null; let menu: Element | null = null;
const pipButton = ElementFromFile( const pipButton = ElementFromFile(
templatePath(__dirname, 'picture-in-picture.html'), templatePath(__dirname, 'picture-in-picture.html'),
); );
// Will also clone // Will also clone
function replaceButton(query, button) { function replaceButton(query: string, button: Element) {
const svg = button.querySelector('#icon svg').cloneNode(true); const svg = button.querySelector('#icon svg')?.cloneNode(true);
button.replaceWith(button.cloneNode(true)); if (svg) {
button.remove(); button.replaceWith(button.cloneNode(true));
const newButton = $(query); button.remove();
newButton.querySelector('#icon').append(svg); const newButton = $(query);
return newButton; if (newButton) {
newButton.querySelector('#icon')?.append(svg);
}
return newButton;
}
return null;
} }
function cloneButton(query) { function cloneButton(query: string) {
replaceButton(query, $(query)); const button = $(query);
if (button) {
replaceButton(query, button);
}
return $(query); 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; return;
} }
const menuUrl = $( const menuUrl = ($(
'tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint', 'tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint',
)?.href; ) as HTMLAnchorElement)?.href;
if (menuUrl && !menuUrl.includes('watch?')) { if (menuUrl && !menuUrl.includes('watch?')) {
return; return;
} }
@ -55,15 +73,15 @@ const observer = new MutationObserver(() => {
const togglePictureInPicture = async () => { const togglePictureInPicture = async () => {
if (useNativePiP) { if (useNativePiP) {
const isInPiP = document.pictureInPictureElement !== null; const isInPiP = document.pictureInPictureElement !== null;
const video = $('video'); const video = $('video') as HTMLVideoElement | null;
const togglePiP = () => const togglePiP = () =>
isInPiP isInPiP
? document.exitPictureInPicture.call(document) ? document.exitPictureInPicture.call(document)
: video.requestPictureInPicture.call(video); : video?.requestPictureInPicture?.call(video);
try { try {
await togglePiP(); await togglePiP();
$('#icon').click(); // Close the menu ($('#icon') as HTMLButtonElement | null)?.click(); // Close the menu
return true; return true;
} catch { } catch {
} }
@ -72,24 +90,26 @@ const togglePictureInPicture = async () => {
ipcRenderer.send('picture-in-picture'); ipcRenderer.send('picture-in-picture');
return false; 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 listenForToggle = () => {
const originalExitButton = $('.exit-fullscreen-button'); const originalExitButton = $('.exit-fullscreen-button') as HTMLButtonElement;
const appLayout = $('ytmusic-app-layout'); const appLayout = $('ytmusic-app-layout') as HTMLElement;
const expandMenu = $('#expanding-menu'); const expandMenu = $('#expanding-menu') as HTMLElement;
const middleControls = $('.middle-controls'); const middleControls = $('.middle-controls') as HTMLButtonElement;
const playerPage = $('ytmusic-player-page'); const playerPage = $('ytmusic-player-page') as HTMLElement & { playerPageOpen_: boolean };
const togglePlayerPageButton = $('.toggle-player-page-button'); const togglePlayerPageButton = $('.toggle-player-page-button') as HTMLButtonElement;
const fullScreenButton = $('.fullscreen-button'); const fullScreenButton = $('.fullscreen-button') as HTMLButtonElement;
const player = $('#player'); const player = ($('#player') as (HTMLVideoElement & { onDoubleClick_: () => void | undefined }));
const onPlayerDblClick = player.onDoubleClick_; 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) { if (isPip) {
replaceButton('.exit-fullscreen-button', originalExitButton).addEventListener('click', () => togglePictureInPicture()); replaceButton('.exit-fullscreen-button', originalExitButton)?.addEventListener('click', () => togglePictureInPicture());
player.onDoubleClick_ = () => { player.onDoubleClick_ = () => {
}; };
@ -104,9 +124,9 @@ const listenForToggle = () => {
titlebar.style.display = 'none'; titlebar.style.display = 'none';
} }
} else { } else {
$('.exit-fullscreen-button').replaceWith(originalExitButton); $('.exit-fullscreen-button')?.replaceWith(originalExitButton);
player.onDoubleClick_ = onPlayerDblClick; player.onDoubleClick_ = onPlayerDblClick;
expandMenu.onmouseleave = undefined; expandMenu.onmouseleave = null;
originalExitButton.click(); originalExitButton.click();
appLayout.classList.remove('pip'); appLayout.classList.remove('pip');
if (titlebar) { if (titlebar) {
@ -116,22 +136,23 @@ const listenForToggle = () => {
}); });
}; };
function observeMenu(options) { function observeMenu(options: PiPOptions) {
useNativePiP = options.useNativePiP; useNativePiP = options.useNativePiP;
document.addEventListener( document.addEventListener(
'apiLoaded', 'apiLoaded',
() => { () => {
listenForToggle(); listenForToggle();
cloneButton('.player-minimize-button').addEventListener('click', async () => { cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
await togglePictureInPicture(); await togglePictureInPicture();
setTimeout(() => $('#player').click()); setTimeout(() => ($('#player') as HTMLButtonElement | undefined)?.click());
}); });
// Allows easily closing the menu by programmatically clicking outside of it // 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 // 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, childList: true,
subtree: true, subtree: true,
}); });
@ -140,7 +161,7 @@ function observeMenu(options) {
); );
} }
module.exports = (options) => { export default (options: PiPOptions) => {
observeMenu(options); observeMenu(options);
if (options.hotkey) { if (options.hotkey) {
@ -148,7 +169,7 @@ module.exports = (options) => {
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if ( if (
keyEventAreEqual(event, hotkeyEvent) keyEventAreEqual(event, hotkeyEvent)
&& !$('ytmusic-search-box').opened && !($('ytmusic-search-box') as (HTMLElement & { opened: boolean }) | undefined)?.opened
) { ) {
togglePictureInPicture(); 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', label: 'Always on top',
type: 'checkbox', type: 'checkbox',
@ -33,7 +37,7 @@ module.exports = (win, options) => [
{ {
label: 'Hotkey', label: 'Hotkey',
type: 'checkbox', type: 'checkbox',
checked: options.hotkey, checked: !!options.hotkey,
async click(item) { async click(item) {
const output = await prompt({ const output = await prompt({
title: 'Picture in Picture Hotkey', title: 'Picture in Picture Hotkey',
@ -51,7 +55,7 @@ module.exports = (win, options) => [
const { value, accelerator } = output[0]; const { value, accelerator } = output[0];
setOptions({ [value]: accelerator }); setOptions({ [value]: accelerator });
item.checked = Boolean(accelerator); item.checked = !!accelerator;
} else { } else {
// Reset checkbox if prompt was canceled // Reset checkbox if prompt was canceled
item.checked = !item.checked; item.checked = !item.checked;

View File

@ -1,14 +1,15 @@
const { getSongMenu } = require('../../providers/dom-elements'); import { getSongMenu } from '../../providers/dom-elements';
const { ElementFromFile, templatePath } = require('../utils'); import { ElementFromFile, templatePath } from '../utils';
const { singleton } = require('../../providers/decorators'); import { singleton } from '../../providers/decorators';
function $(selector) {
function $(selector: string) {
return document.querySelector(selector); return document.querySelector(selector);
} }
const slider = ElementFromFile(templatePath(__dirname, 'slider.html')); 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 MIN_PLAYBACK_SPEED = 0.07;
const MAX_PLAYBACK_SPEED = 16; const MAX_PLAYBACK_SPEED = 16;
@ -16,19 +17,19 @@ const MAX_PLAYBACK_SPEED = 16;
let playbackSpeed = 1; let playbackSpeed = 1;
const updatePlayBackSpeed = () => { const updatePlayBackSpeed = () => {
$('video').playbackRate = playbackSpeed; ($('video') as HTMLVideoElement).playbackRate = playbackSpeed;
const playbackSpeedElement = $('#playback-speed-value'); const playbackSpeedElement = $('#playback-speed-value');
if (playbackSpeedElement) { if (playbackSpeedElement) {
playbackSpeedElement.innerHTML = playbackSpeed; playbackSpeedElement.innerHTML = String(playbackSpeed);
} }
}; };
let menu; let menu: Element | null = null;
const setupSliderListener = singleton(() => { const setupSliderListener = singleton(() => {
$('#playback-speed-slider').addEventListener('immediate-value-changed', (e) => { $('#playback-speed-slider')?.addEventListener('immediate-value-changed', (e) => {
playbackSpeed = e.detail.value || MIN_PLAYBACK_SPEED; playbackSpeed = (e as CustomEvent<{ value: number; }>).detail.value || MIN_PLAYBACK_SPEED;
if (isNaN(playbackSpeed)) { if (isNaN(playbackSpeed)) {
playbackSpeed = 1; playbackSpeed = 1;
} }
@ -43,20 +44,28 @@ const observePopupContainer = () => {
menu = getSongMenu(); 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); menu.prepend(slider);
setupSliderListener(); setupSliderListener();
} }
}); });
observer.observe($('ytmusic-popup-container'), { const popupContainer = $('ytmusic-popup-container');
childList: true, if (popupContainer) {
subtree: true, observer.observe(popupContainer, {
}); childList: true,
subtree: true,
});
}
}; };
const observeVideo = () => { const observeVideo = () => {
const video = $('video');+ const video = $('video') as HTMLVideoElement;
video.addEventListener('ratechange', forcePlaybackRate); video.addEventListener('ratechange', forcePlaybackRate);
video.addEventListener('srcChanged', forcePlaybackRate); video.addEventListener('srcChanged', forcePlaybackRate);
}; };
@ -76,17 +85,18 @@ const setupWheelListener = () => {
updatePlayBackSpeed(); updatePlayBackSpeed();
// Update slider position // Update slider position
$('#playback-speed-slider').value = playbackSpeed; ($('#playback-speed-slider') as HTMLElement & { value: number }).value = playbackSpeed;
}); });
}; };
function forcePlaybackRate(e) { function forcePlaybackRate(e: Event) {
if (e.target.playbackRate !== playbackSpeed) { const videoElement = (e.target as HTMLVideoElement);
e.target.playbackRate = playbackSpeed; if (videoElement.playbackRate !== playbackSpeed) {
videoElement.playbackRate = playbackSpeed;
} }
} }
module.exports = () => { export default () => {
document.addEventListener('apiLoaded', () => { document.addEventListener('apiLoaded', () => {
observePopupContainer(); observePopupContainer();
observeVideo(); 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 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) => { export const enabled = () => isEnabled;
enabled = true;
export default (win: BrowserWindow, options: ConfigType<'precise-volume'>) => {
isEnabled = true;
injectCSS(win.webContents, path.join(__dirname, 'volume-hud.css')); injectCSS(win.webContents, path.join(__dirname, 'volume-hud.css'));
if (options.globalShortcuts?.volumeUp) { if (options.globalShortcuts?.volumeUp) {
@ -22,5 +25,3 @@ module.exports = (win, options) => {
globalShortcut.register((options.globalShortcuts.volumeDown), () => win.webContents.send('changeVolume', false)); 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); return document.querySelector(selector);
} }
const { debounce } = require('../../providers/decorators'); let api: YoutubePlayer;
let options: ConfigType<'precise-volume'>;
let api; export default (_options: ConfigType<'precise-volume'>) => {
let options;
module.exports = (_options) => {
options = _options; options = _options;
document.addEventListener('apiLoaded', (e) => { document.addEventListener('apiLoaded', (e) => {
api = e.detail; api = e.detail;
ipcRenderer.on('changeVolume', (_, toIncrease) => changeVolume(toIncrease)); ipcRenderer.on('changeVolume', (_, toIncrease: boolean) => changeVolume(toIncrease));
ipcRenderer.on('setVolume', (_, value) => setVolume(value)); ipcRenderer.on('setVolume', (_, value: number) => setVolume(value));
firstRun(); firstRun();
}, { once: true, passive: true }); }, { once: true, passive: true });
}; };
@ -26,23 +29,22 @@ const writeOptions = debounce(() => {
setOptions('precise-volume', options); setOptions('precise-volume', options);
}, 1000); }, 1000);
const moveVolumeHud = debounce((showVideo) => { export const moveVolumeHud = debounce((showVideo: boolean) => {
const volumeHud = $('#volumeHud'); const volumeHud = $('#volumeHud') as HTMLElement | undefined;
if (!volumeHud) { if (!volumeHud) {
return; return;
} }
volumeHud.style.top = showVideo volumeHud.style.top = showVideo
? `${($('ytmusic-player').clientHeight - $('video').clientHeight) / 2}px` ? `${($('ytmusic-player')!.clientHeight - $('video')!.clientHeight) / 2}px`
: 0; : '0';
}, 250); }, 250);
module.exports.moveVolumeHud = moveVolumeHud;
const hideVolumeHud = debounce((volumeHud) => { const hideVolumeHud = debounce((volumeHud: HTMLElement) => {
volumeHud.style.opacity = 0; volumeHud.style.opacity = '0';
}, 2000); }, 2000);
const hideVolumeSlider = debounce((slider) => { const hideVolumeSlider = debounce((slider: HTMLElement) => {
slider.classList.remove('on-hover'); slider.classList.remove('on-hover');
}, 2500); }, 2500);
@ -61,14 +63,15 @@ function firstRun() {
setupLocalArrowShortcuts(); 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); injectVolumeHud(noVid);
if (!noVid) { if (!noVid) {
setupVideoPlayerOnwheel(); setupVideoPlayerOnwheel();
if (!isEnabled('video-toggle')) { if (!isEnabled('video-toggle')) {
// Video-toggle handles hud positioning on its own // Video-toggle handles hud positioning on its own
const videoMode = () => api.getPlayerResponse().videoDetails?.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'; 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) { if (noVid) {
const position = 'top: 18px; right: 60px;'; const position = 'top: 18px; right: 60px;';
const mainStyle = 'font-size: xx-large;'; 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>`); `<span id="volumeHud" style="${position + mainStyle}"></span>`);
} else { } else {
const position = 'top: 10px; left: 10px;'; const position = 'top: 10px; left: 10px;';
const mainStyle = 'font-size: xxx-large; webkit-text-stroke: 1px black; font-weight: 600;'; 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>`); `<span id="volumeHud" style="${position + mainStyle}"></span>`);
} }
} }
function showVolumeHud(volume) { function showVolumeHud(volume: number) {
const volumeHud = $('#volumeHud'); const volumeHud = $('#volumeHud') as HTMLElement | undefined;
if (!volumeHud) { if (!volumeHud) {
return; return;
} }
volumeHud.textContent = `${volume}%`; volumeHud.textContent = `${volume}%`;
volumeHud.style.opacity = 1; volumeHud.style.opacity = '1';
hideVolumeHud(volumeHud); hideVolumeHud(volumeHud);
} }
/** Add onwheel event to video player */ /** Add onwheel event to video player */
function setupVideoPlayerOnwheel() { function setupVideoPlayerOnwheel() {
$('#main-panel').addEventListener('wheel', (event) => { const panel = $('#main-panel') as HTMLElement | undefined;
if (!panel) return;
panel.addEventListener('wheel', (event) => {
event.preventDefault(); event.preventDefault();
// Event.deltaY < 0 means wheel-up // Event.deltaY < 0 means wheel-up
changeVolume(event.deltaY < 0); changeVolume(event.deltaY < 0);
}); });
} }
function saveVolume(volume) { function saveVolume(volume: number) {
options.savedVolume = volume; options.savedVolume = volume;
writeOptions(); writeOptions();
} }
/** Add onwheel event to play bar and also track if play bar is hovered */ /** Add onwheel event to play bar and also track if play bar is hovered */
function setupPlaybar() { function setupPlaybar() {
const playerbar = $('ytmusic-player-bar'); const playerbar = $('ytmusic-player-bar') as HTMLElement | undefined;
if (!playerbar) return;
playerbar.addEventListener('wheel', (event) => { playerbar.addEventListener('wheel', (event) => {
event.preventDefault(); event.preventDefault();
@ -148,23 +155,28 @@ function setupSliderObserver() {
const sliderObserver = new MutationObserver((mutations) => { const sliderObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) { for (const mutation of mutations) {
// This checks that volume-slider was manually set // This checks that volume-slider was manually set
if (mutation.oldValue !== mutation.target.value const target = mutation.target as HTMLInputElement;
&& (typeof options.savedVolume !== 'number' || Math.abs(options.savedVolume - mutation.target.value) > 4)) { 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 // Diff>4 means it was manually set
setTooltip(mutation.target.value); setTooltip(targetValueNumeric);
saveVolume(mutation.target.value); saveVolume(targetValueNumeric);
} }
} }
}); });
const slider = $('#volume-slider');
if (!slider) return;
// Observing only changes in 'value' of volume-slider // Observing only changes in 'value' of volume-slider
sliderObserver.observe($('#volume-slider'), { sliderObserver.observe(slider, {
attributeFilter: ['value'], attributeFilter: ['value'],
attributeOldValue: true, attributeOldValue: true,
}); });
} }
function setVolume(value) { function setVolume(value: number) {
api.setVolume(value); api.setVolume(value);
// Save the new volume // Save the new volume
saveVolume(value); saveVolume(value);
@ -181,7 +193,7 @@ function setVolume(value) {
} }
/** If (toIncrease = false) then volume decrease */ /** If (toIncrease = false) then volume decrease */
function changeVolume(toIncrease) { function changeVolume(toIncrease: boolean) {
// Apply volume change if valid // Apply volume change if valid
const steps = Number(options.steps || 1); const steps = Number(options.steps || 1);
setVolume(toIncrease setVolume(toIncrease
@ -190,17 +202,20 @@ function changeVolume(toIncrease) {
} }
function updateVolumeSlider() { function updateVolumeSlider() {
const savedVolume = options.savedVolume ?? 0;
// Slider value automatically rounds to multiples of 5 // Slider value automatically rounds to multiples of 5
for (const slider of ['#volume-slider', '#expand-volume-slider']) { for (const slider of ['#volume-slider', '#expand-volume-slider']) {
$(slider).value ($(slider) as HTMLInputElement).value
= options.savedVolume > 0 && options.savedVolume < 5 = String(savedVolume > 0 && savedVolume < 5
? 5 ? 5
: options.savedVolume; : savedVolume);
} }
} }
function showVolumeSlider() { 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 // This class display the volume slider if not in minimized mode
slider.classList.add('on-hover'); slider.classList.add('on-hover');
@ -215,16 +230,16 @@ const tooltipTargets = [
'#expand-volume', '#expand-volume',
]; ];
function setTooltip(volume) { function setTooltip(volume: number) {
for (const target of tooltipTargets) { for (const target of tooltipTargets) {
$(target).title = `${volume}%`; ($(target) as HTMLElement).title = `${volume}%`;
} }
} }
function setupLocalArrowShortcuts() { function setupLocalArrowShortcuts() {
if (options.arrowsShortcut) { if (options.arrowsShortcut) {
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if ($('ytmusic-search-box').opened) { if (($('ytmusic-search-box') as (HTMLElement & { opened: boolean }) | null)?.opened) {
return; 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'); import { enabled } from './back';
const promptOptions = require('../../providers/prompt-options');
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) { 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 // Dynamically change setting if plugin is enabled
if (enabled()) { if (enabled()) {
win.webContents.send('setOptions', changedOptions); 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', label: 'Local Arrowkeys Controls',
type: 'checkbox', type: 'checkbox',
@ -40,9 +45,9 @@ module.exports = (win, options) => [
]; ];
// Helper function for globalShortcuts prompt // 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({ const output = await prompt({
title: 'Volume Steps', title: 'Volume Steps',
label: 'Choose Volume Increase/Decrease 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({ const output = await prompt({
title: 'Global Volume Keybinds', title: 'Global Volume Keybinds',
label: 'Choose Global Volume Keybinds:', label: 'Choose Global Volume Keybinds:',
@ -71,9 +76,12 @@ async function promptGlobalShortcuts(win, options, item) {
}, win); }, win);
if (output) { if (output) {
const newGlobalShortcuts = {}; const newGlobalShortcuts: {
volumeUp: string;
volumeDown: string;
} = { volumeUp: '', volumeDown: '' };
for (const { value, accelerator } of output) { for (const { value, accelerator } of output) {
newGlobalShortcuts[value] = accelerator; newGlobalShortcuts[value as keyof typeof newGlobalShortcuts] = accelerator;
} }
changeOptions({ globalShortcuts: newGlobalShortcuts }, options, win); 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 = () => { export default () => {
ipcMain.handle('qualityChanger', async (_, qualityLabels, currentIndex) => await dialog.showMessageBox({ ipcMain.handle('qualityChanger', async (_, qualityLabels: string[], currentIndex: number) => await dialog.showMessageBox({
type: 'question', type: 'question',
buttons: qualityLabels, buttons: qualityLabels,
defaultId: currentIndex, 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); return document.querySelector(selector);
} }
@ -10,32 +11,19 @@ const qualitySettingsButton = ElementFromFile(
templatePath(__dirname, 'qualitySettingsTemplate.html'), templatePath(__dirname, 'qualitySettingsTemplate.html'),
); );
module.exports = () => { function setup(event: CustomEvent<YoutubePlayer>) {
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,
* }}
*/
const api = event.detail; const api = event.detail;
$('.top-row-buttons.ytmusic-player').prepend(qualitySettingsButton); $('.top-row-buttons.ytmusic-player')?.prepend(qualitySettingsButton);
qualitySettingsButton.addEventListener('click', function chooseQuality() { qualitySettingsButton.addEventListener('click', function chooseQuality() {
setTimeout(() => $('#player').click()); setTimeout(() => $('#player')?.click());
const qualityLevels = api.getAvailableQualityLevels(); const qualityLevels = api.getAvailableQualityLevels();
const currentIndex = qualityLevels.indexOf(api.getPlaybackQuality()); 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) { if (promise.response === -1) {
return; 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'); import { BrowserWindow, globalShortcut } from 'electron';
const is = require('electron-is'); import is from 'electron-is';
const electronLocalshortcut = require('electron-localshortcut'); 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, () => { globalShortcut.register(shortcut, () => {
action(webContents); action(webContents);
}); });
} }
function _registerLocalShortcut(win, shortcut, action) { function _registerLocalShortcut(win: BrowserWindow, shortcut: string, action: (webContents: Electron.WebContents) => void) {
electronLocalshortcut.register(win, shortcut, () => { electronLocalshortcut.register(win, shortcut, () => {
action(win.webContents); action(win.webContents);
}); });
} }
function registerShortcuts(win, options) { function registerShortcuts(win: BrowserWindow, options: ConfigType<'shortcuts'>) {
const songControls = getSongControls(win); const songControls = getSongControls(win);
const { playPause, next, previous, search } = songControls; const { playPause, next, previous, search } = songControls;
@ -39,28 +41,29 @@ function registerShortcuts(win, options) {
const shortcutOptions = { global, local }; const shortcutOptions = { global, local };
for (const optionType in shortcutOptions) { 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) { for (const action in container) {
if (!container[action]) { if (!container[action]) {
continue; // Action accelerator is empty continue; // Action accelerator is empty
} }
console.debug(`Registering ${type} shortcut`, container[action], ':', action); 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); console.warn('Invalid action', action);
continue; continue;
} }
if (type === 'global') { if (type === 'global') {
_registerGlobalShortcut(win.webContents, container[action], songControls[action]); _registerGlobalShortcut(win.webContents, container[action], actionCallback);
} else { // Type === "local" } 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'); import { BrowserWindow } from 'electron';
const promptOptions = require('../../providers/prompt-options');
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', label: 'Set Global Song Controls',
click: () => promptKeybind(options, win), 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) { if (key && newValue !== null) {
options[key] = newValue; options[key] = newValue;
} }
@ -25,9 +34,9 @@ function setOption(options, key = null, newValue = null) {
} }
// Helper function for keybind prompt // 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({ const output = await prompt({
title: 'Global Keybinds', title: 'Global Keybinds',
label: 'Choose Global Keybinds for Songs Control:', 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'); import { BrowserWindow, ipcMain } from 'electron';
const mpris = require('mpris-service');
const registerCallback = require('../../providers/song-info'); import mpris, { Track } from 'mpris-service';
const getSongControls = require('../../providers/song-controls');
const config = require('../../config'); import registerCallback from '../../providers/song-info';
import getSongControls from '../../providers/song-controls';
import config from '../../config';
function setupMPRIS() { function setupMPRIS() {
return mpris({ const instance = new mpris({
name: 'youtube-music', name: 'youtube-music',
identity: 'YouTube Music', identity: 'YouTube Music',
canRaise: true,
supportedUriSchemes: ['https'],
supportedMimeTypes: ['audio/mpeg'], supportedMimeTypes: ['audio/mpeg'],
supportedInterfaces: ['player'], supportedInterfaces: ['player'],
desktopEntry: 'youtube-music',
}); });
instance.canRaise = true;
instance.supportedUriSchemes = ['https'];
instance.desktopEntry = 'youtube-music';
return instance;
} }
/** @param {Electron.BrowserWindow} win */ function registerMPRIS(win: BrowserWindow) {
function registerMPRIS(win) {
const songControls = getSongControls(win); const songControls = getSongControls(win);
const { playPause, next, previous, volumeMinus10, volumePlus10, shuffle } = songControls; const { playPause, next, previous, volumeMinus10, volumePlus10, shuffle } = songControls;
try { try {
const secToMicro = (n) => Math.round(Number(n) * 1e6); // TODO: "Typing" for this arguments
const microToSec = (n) => Math.round(Number(n) / 1e6); 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 seekTo = (e: { position: unknown }) => win.webContents.send('seekTo', microToSec(e.position));
const seekBy = (o) => win.webContents.send('seekBy', microToSec(o)); const seekBy = (o: unknown) => win.webContents.send('seekBy', microToSec(o));
const player = setupMPRIS(); const player = setupMPRIS();
@ -37,12 +39,12 @@ function registerMPRIS(win) {
win.webContents.send('setupVolumeChangedListener', 'mpris'); 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; 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) { switch (mode) {
case 'NONE': { case 'NONE': {
player.loopStatus = mpris.LOOP_STATUS_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 // SwitchRepeat cycles between states in that order
const switches = [mpris.LOOP_STATUS_NONE, mpris.LOOP_STATUS_PLAYLIST, mpris.LOOP_STATUS_TRACK]; const switches = [mpris.LOOP_STATUS_NONE, mpris.LOOP_STATUS_PLAYLIST, mpris.LOOP_STATUS_TRACK];
const currentIndex = switches.indexOf(player.loopStatus); const currentIndex = switches.indexOf(player.loopStatus);
@ -75,8 +77,6 @@ function registerMPRIS(win) {
songControls.switchRepeat(delta); songControls.switchRepeat(delta);
}); });
player.getPosition = () => secToMicro(currentSeconds);
player.on('raise', () => { player.on('raise', () => {
win.setSkipTaskbar(false); win.setSkipTaskbar(false);
win.show(); win.show();
@ -114,7 +114,7 @@ function registerMPRIS(win) {
let mprisVolNewer = false; let mprisVolNewer = false;
let autoUpdate = false; let autoUpdate = false;
ipcMain.on('volumeChanged', (_, newVol) => { ipcMain.on('volumeChanged', (_, newVol) => {
if (Number.parseInt(player.volume * 100) !== newVol) { if (~~(player.volume * 100) !== newVol) {
if (mprisVolNewer) { if (mprisVolNewer) {
mprisVolNewer = false; mprisVolNewer = false;
autoUpdate = false; autoUpdate = false;
@ -130,8 +130,8 @@ function registerMPRIS(win) {
player.on('volume', (newVolume) => { player.on('volume', (newVolume) => {
if (config.plugins.isEnabled('precise-volume')) { if (config.plugins.isEnabled('precise-volume')) {
// With precise volume we can set the volume to the exact value. // With precise volume we can set the volume to the exact value.
const newVol = Number.parseInt(newVolume * 100); const newVol = ~~(newVolume * 100);
if (Number.parseInt(player.volume * 100) !== newVol && !autoUpdate) { if (~~(player.volume * 100) !== newVol && !autoUpdate) {
mprisVolNewer = true; mprisVolNewer = true;
autoUpdate = false; autoUpdate = false;
win.webContents.send('setVolume', newVol); win.webContents.send('setVolume', newVol);
@ -155,9 +155,9 @@ function registerMPRIS(win) {
registerCallback((songInfo) => { registerCallback((songInfo) => {
if (player) { if (player) {
const data = { const data: Track = {
'mpris:length': secToMicro(songInfo.songDuration), 'mpris:length': secToMicro(songInfo.songDuration),
'mpris:artUrl': songInfo.imageSrc, 'mpris:artUrl': songInfo.imageSrc ?? undefined,
'xesam:title': songInfo.title, 'xesam:title': songInfo.title,
'xesam:url': songInfo.url, 'xesam:url': songInfo.url,
'xesam:artist': [songInfo.artist], '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 = ConfigType<'skip-silences'>;
type SkipSilencesOptions = typeof SkipSilencesOptionsObj;
export default (options: SkipSilencesOptions) => { export default (options: SkipSilencesOptions) => {
let isSilent = false; let isSilent = false;

View File

@ -1,26 +1,31 @@
const { ipcMain } = require('electron'); import { BrowserWindow, ipcMain } from 'electron';
const is = require('electron-is'); 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 } = { const { apiURL, categories } = {
...defaultConfig.plugins.sponsorblock, ...defaultConfig.plugins.sponsorblock,
...options, ...options,
}; };
ipcMain.on('video-src-changed', async (_, data) => { ipcMain.on('video-src-changed', async (_, data: string) => {
videoID = JSON.parse(data)?.videoDetails?.videoId; videoID = (JSON.parse(data) as GetPlayerResponse)?.videoDetails?.videoId;
const segments = await fetchSegments(apiURL, categories); const segments = await fetchSegments(apiURL, categories);
win.webContents.send('sponsorblock-skip', segments); 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( const sponsorBlockURL = `${apiURL}/api/skipSegments?videoID=${videoID}&categories=${JSON.stringify(
categories, categories,
)}`; )}`;
@ -36,7 +41,7 @@ const fetchSegments = async (apiURL, categories) => {
return []; return [];
} }
const segments = await resp.json(); const segments = await resp.json() as SkipSegment[];
return sortSegments( return sortSegments(
segments.map((submission) => submission.segment), 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], … ] // Segments are an array [ [start, end], … ]
module.exports.sortSegments = (segments) => { import { Segment } from './types';
export const sortSegments = (segments: Segment[]) => {
segments.sort((segment1, segment2) => segments.sort((segment1, segment2) =>
segment1[0] === segment2[0] segment1[0] === segment2[0]
? segment1[1] - segment2[1] ? segment1[1] - segment2[1]
: segment1[0] - segment2[0], : segment1[0] - segment2[0],
); );
const compiledSegments = []; const compiledSegments: Segment[] = [];
let currentSegment; let currentSegment: Segment | undefined;
for (const segment of segments) { for (const segment of segments) {
if (!currentSegment) { if (!currentSegment) {
@ -24,7 +26,9 @@ module.exports.sortSegments = (segments) => {
currentSegment[1] = Math.max(currentSegment[1], segment[1]); currentSegment[1] = Math.max(currentSegment[1], segment[1]);
} }
compiledSegments.push(currentSegment); if (currentSegment) {
compiledSegments.push(currentSegment);
}
return compiledSegments; 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); const { playPause, next, previous, dislike, like } = getSongControls(win);
// If the page is ready, register the callback // 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}`)); }).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('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
ipcMain.on('timeChanged', (_, t: number) => { ipcMain.on('timeChanged', (_, t: number) => {
if (!data.title) { 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 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 = ( export const fileExists = (
path: fs.PathLike, path: fs.PathLike,
@ -48,7 +48,7 @@ export const fileExists = (
}; };
const cssToInject = new Map(); 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) { if (cssToInject.size === 0) {
setupCssInjection(webContents); 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'); import { ElementFromFile, templatePath } from '../utils';
const { setOptions, isEnabled } = require('../../config/plugins'); 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); return document.querySelector(selector);
} }
let options; let options: ConfigType<'video-toggle'>;
let player; let player: HTMLElement & { videoMode_: boolean };
let video; let video: HTMLVideoElement;
let api; let api: YoutubePlayer;
const switchButtonDiv = ElementFromFile( const switchButtonDiv = ElementFromFile(
templatePath(__dirname, 'button_template.html'), templatePath(__dirname, 'button_template.html'),
); );
module.exports = (_options) => { export default (_options: ConfigType<'video-toggle'>) => {
if (_options.forceHide) { if (_options.forceHide) {
return; return;
} }
switch (_options.mode) { switch (_options.mode) {
case 'native': { case 'native': {
$('ytmusic-player-page').setAttribute('has-av-switcher'); $('ytmusic-player-page')?.setAttribute('has-av-switcher', '');
$('ytmusic-player').setAttribute('has-av-switcher'); $('ytmusic-player')?.setAttribute('has-av-switcher', '');
return; return;
} }
case 'disabled': { case 'disabled': {
$('ytmusic-player-page').removeAttribute('has-av-switcher'); $('ytmusic-player-page')?.removeAttribute('has-av-switcher');
$('ytmusic-player').removeAttribute('has-av-switcher'); $('ytmusic-player')?.removeAttribute('has-av-switcher');
return; return;
} }
@ -43,15 +47,15 @@ module.exports = (_options) => {
} }
}; };
function setup(e) { function setup(e: CustomEvent<YoutubePlayer>) {
api = e.detail; api = e.detail;
player = $('ytmusic-player'); player = $('ytmusic-player') as typeof player;
video = $('video'); video = $('video') as HTMLVideoElement;
$('#main-panel').append(switchButtonDiv); ($('#main-panel') as HTMLVideoElement).append(switchButtonDiv);
if (options.hideVideo) { if (options.hideVideo) {
$('.video-switch-button-checkbox').checked = false; ($('.video-switch-button-checkbox') as HTMLInputElement).checked = false;
changeDisplay(false); changeDisplay(false);
forcePlaybackMode(); forcePlaybackMode();
// Fix black video // Fix black video
@ -60,8 +64,9 @@ function setup(e) {
// Button checked = show video // Button checked = show video
switchButtonDiv.addEventListener('change', (e) => { switchButtonDiv.addEventListener('change', (e) => {
options.hideVideo = !e.target.checked; const target = e.target as HTMLInputElement;
changeDisplay(e.target.checked); options.hideVideo = target.checked;
changeDisplay(target.checked);
setOptions('video-toggle', options); 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.style.margin = showVideo ? '' : 'auto 0px';
player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED'); player.setAttribute('playback-mode', showVideo ? 'OMV_PREFERRED' : 'ATV_PREFERRED');
$('#song-video.ytmusic-player').style.display = showVideo ? 'block' : 'none'; $('#song-video.ytmusic-player')!.style.display = showVideo ? 'block' : 'none';
$('#song-image').style.display = showVideo ? 'none' : 'block'; $('#song-image')!.style.display = showVideo ? 'none' : 'block';
if (showVideo && !video.style.top) { if (showVideo && !video.style.top) {
video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`; video.style.top = `${(player.clientHeight - video.clientHeight) / 2}px`;
@ -108,12 +113,12 @@ function videoStarted() {
// Hide toggle button // Hide toggle button
switchButtonDiv.style.display = 'none'; switchButtonDiv.style.display = 'none';
} else { } else {
// Switch to high res thumbnail // Switch to high-res thumbnail
forceThumbnail($('#song-image img')); forceThumbnail($('#song-image img') as HTMLImageElement);
// Show toggle button // Show toggle button
switchButtonDiv.style.display = 'initial'; switchButtonDiv.style.display = 'initial';
// Change display to video mode if video exist & video is hidden & option.hideVideo = false // 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); changeDisplay(true);
} else { } else {
moveVolumeHud(!options.hideVideo); moveVolumeHud(!options.hideVideo);
@ -126,9 +131,10 @@ function videoStarted() {
function forcePlaybackMode() { function forcePlaybackMode() {
const playbackModeObserver = new MutationObserver((mutations) => { const playbackModeObserver = new MutationObserver((mutations) => {
for (const mutation of 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(); 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) { for (const mutation of mutations) {
if (!mutation.target.src.startsWith('data:')) { const target = mutation.target as HTMLImageElement;
if (!target.src.startsWith('data:')) {
continue; continue;
} }
forceThumbnail(mutation.target); forceThumbnail(target);
} }
}); });
playbackModeObserver.observe($('#song-image img'), { attributeFilter: ['src'] }); playbackModeObserver.observe($('#song-image img')!, { attributeFilter: ['src'] });
} }
function forceThumbnail(img) { function forceThumbnail(img: HTMLImageElement) {
const thumbnails = $('#movie_player').getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails; const thumbnails: ThumbnailElement[] = ($('#movie_player') as unknown as YoutubePlayer).getPlayerResponse()?.videoDetails?.thumbnail?.thumbnails ?? [];
if (thumbnails && thumbnails.length > 0) { 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', label: 'Mode',
submenu: [ 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 = { const optionsWithDefaults = {
...defaultConfig.plugins.visualizer, ...defaultConfig.plugins.visualizer,
...options, ...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( document.addEventListener(
'audioCanPlay', 'audioCanPlay',
(e) => { (e) => {
const video = document.querySelector('video'); const video = document.querySelector('video') as (HTMLVideoElement & { captureStream(): MediaStream; });
const visualizerContainer = document.querySelector('#player'); const visualizerContainer = document.querySelector('#player') as HTMLElement;
let canvas = document.querySelector('#visualizer'); let canvas = document.querySelector('#visualizer') as HTMLCanvasElement;
if (!canvas) { if (!canvas) {
canvas = document.createElement('canvas'); canvas = document.createElement('canvas');
canvas.id = 'visualizer'; canvas.id = 'visualizer';
@ -33,17 +42,17 @@ module.exports = (options) => {
gainNode.gain.value = 1.25; gainNode.gain.value = 1.25;
e.detail.audioSource.connect(gainNode); e.detail.audioSource.connect(gainNode);
const visualizer = new VisualizerType( const visualizer = new visualizerType(
e.detail.audioContext, e.detail.audioContext,
e.detail.audioSource, e.detail.audioSource,
visualizerContainer, visualizerContainer,
canvas, canvas,
gainNode, gainNode,
video.captureStream(), video.captureStream(),
optionsWithDefaults[optionsWithDefaults.type], optionsWithDefaults,
); );
const resizeVisualizer = (width, height) => { const resizeVisualizer = (width: number, height: number) => {
resizeCanvas(); resizeCanvas();
visualizer.resize(width, height); visualizer.resize(width, height);
}; };

View File

@ -1,13 +1,19 @@
const { readdirSync } = require('node:fs'); import { readdirSync } from 'node:fs';
const path = require('node:path'); 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( const visualizerTypes = readdirSync(path.join(__dirname, 'visualizers')).map(
(filename) => path.parse(filename).name, (filename) => path.parse(filename).name,
); );
module.exports = (win, options) => [ export default (win: BrowserWindow, options: ConfigType<'visualizer'>): MenuTemplate => [
{ {
label: 'Type', label: 'Type',
submenu: visualizerTypes.map((visualizerType) => ({ 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; getVideoAspectRatio: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPreferredQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getPreferredQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaybackQualityLabel: <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; onAdUxClicked: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getFeedbackProductData: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getFeedbackProductData: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getStoryboardFrame: <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; getStoryboardLevel: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getNumberOfStoryboardLevels: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getNumberOfStoryboardLevels: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getCaptionWindowContainerId: <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; addUtcCueRange: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
showAirplayPicker: <Parameters extends unknown[], Return>(...params: Parameters) => Return; showAirplayPicker: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
dispatchReduxAction: <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; unMute: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
isMuted: <Parameters extends unknown[], Return>(...params: Parameters) => Return; isMuted: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
setVolume: <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; seekTo: (seconds: number) => void;
getPlayerMode: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getPlayerMode: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlayerState: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getPlayerState: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getAvailablePlaybackRates: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getAvailablePlaybackRates: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getPlaybackQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getPlaybackQuality: () => string;
setPlaybackQuality: <Parameters extends unknown[], Return>(...params: Parameters) => Return; setPlaybackQuality: (quality: string) => void;
getAvailableQualityLevels: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getAvailableQualityLevels: () => string[];
getCurrentTime: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getCurrentTime: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
getDuration: <Parameters extends unknown[], Return>(...params: Parameters) => Return; getDuration: <Parameters extends unknown[], Return>(...params: Parameters) => Return;
addEventListener: <Parameters extends unknown[], Return>(...params: Parameters) => Return; addEventListener: <Parameters extends unknown[], Return>(...params: Parameters) => Return;