mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
Merge pull request #1401 from organization/feat/refactor-plugin-system
This commit is contained in:
12
.eslintrc.js
12
.eslintrc.js
@ -26,7 +26,7 @@ module.exports = {
|
|||||||
'import/newline-after-import': 'error',
|
'import/newline-after-import': 'error',
|
||||||
'import/no-default-export': 'off',
|
'import/no-default-export': 'off',
|
||||||
'import/no-duplicates': 'error',
|
'import/no-duplicates': 'error',
|
||||||
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack', '^youtubei.js$'] }],
|
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'] }],
|
||||||
'import/order': [
|
'import/order': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
@ -67,4 +67,14 @@ module.exports = {
|
|||||||
es6: true,
|
es6: true,
|
||||||
},
|
},
|
||||||
ignorePatterns: ['dist', 'node_modules'],
|
ignorePatterns: ['dist', 'node_modules'],
|
||||||
|
root: true,
|
||||||
|
settings: {
|
||||||
|
'import/parsers': {
|
||||||
|
'@typescript-eslint/parser': ['.ts']
|
||||||
|
},
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {},
|
||||||
|
exports: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,3 +12,4 @@ electron-builder.yml
|
|||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
.vite-inspect
|
||||||
|
|||||||
@ -1,18 +1,27 @@
|
|||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import { defineConfig, defineViteConfig } from 'electron-vite';
|
import { defineConfig, defineViteConfig } from 'electron-vite';
|
||||||
import builtinModules from 'builtin-modules';
|
import builtinModules from 'builtin-modules';
|
||||||
import viteResolve from 'vite-plugin-resolve';
|
import viteResolve from 'vite-plugin-resolve';
|
||||||
|
import Inspect from 'vite-plugin-inspect';
|
||||||
|
|
||||||
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-virtual-module-generator';
|
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer';
|
||||||
|
import pluginLoader from './vite-plugins/plugin-loader';
|
||||||
|
|
||||||
import type { UserConfig } from 'vite';
|
import type { UserConfig } from 'vite';
|
||||||
|
|
||||||
|
const resolveAlias = {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
'@assets': resolve(__dirname, './assets'),
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: defineViteConfig(({ mode }) => {
|
main: defineViteConfig(({ mode }) => {
|
||||||
const commonConfig: UserConfig = {
|
const commonConfig: UserConfig = {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
pluginLoader('backend'),
|
||||||
viteResolve({
|
viteResolve({
|
||||||
'virtual:MainPlugins': pluginVirtualModuleGenerator('main'),
|
'virtual:plugins': pluginVirtualModuleGenerator('main'),
|
||||||
'virtual:MenuPlugins': pluginVirtualModuleGenerator('menu'),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
publicDir: 'assets',
|
publicDir: 'assets',
|
||||||
@ -30,9 +39,15 @@ export default defineConfig({
|
|||||||
input: './src/index.ts',
|
input: './src/index.ts',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: resolveAlias,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'development') {
|
if (mode === 'development') {
|
||||||
|
commonConfig.plugins?.push(
|
||||||
|
Inspect({ build: true, outputDir: '.vite-inspect/backend' }),
|
||||||
|
);
|
||||||
return commonConfig;
|
return commonConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,8 +63,9 @@ export default defineConfig({
|
|||||||
preload: defineViteConfig(({ mode }) => {
|
preload: defineViteConfig(({ mode }) => {
|
||||||
const commonConfig: UserConfig = {
|
const commonConfig: UserConfig = {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
pluginLoader('preload'),
|
||||||
viteResolve({
|
viteResolve({
|
||||||
'virtual:PreloadPlugins': pluginVirtualModuleGenerator('preload'),
|
'virtual:plugins': pluginVirtualModuleGenerator('preload'),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
@ -64,11 +80,17 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
||||||
input: './src/preload.ts',
|
input: './src/preload.ts',
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: resolveAlias,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'development') {
|
if (mode === 'development') {
|
||||||
|
commonConfig.plugins?.push(
|
||||||
|
Inspect({ build: true, outputDir: '.vite-inspect/preload' }),
|
||||||
|
);
|
||||||
return commonConfig;
|
return commonConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,8 +106,9 @@ export default defineConfig({
|
|||||||
renderer: defineViteConfig(({ mode }) => {
|
renderer: defineViteConfig(({ mode }) => {
|
||||||
const commonConfig: UserConfig = {
|
const commonConfig: UserConfig = {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
pluginLoader('renderer'),
|
||||||
viteResolve({
|
viteResolve({
|
||||||
'virtual:RendererPlugins': pluginVirtualModuleGenerator('renderer'),
|
'virtual:plugins': pluginVirtualModuleGenerator('renderer'),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
root: './src/',
|
root: './src/',
|
||||||
@ -104,9 +127,15 @@ export default defineConfig({
|
|||||||
input: './src/index.html',
|
input: './src/index.html',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: resolveAlias,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'development') {
|
if (mode === 'development') {
|
||||||
|
commonConfig.plugins?.push(
|
||||||
|
Inspect({ build: true, outputDir: '.vite-inspect/renderer' }),
|
||||||
|
);
|
||||||
return commonConfig;
|
return commonConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
package.json
23
package.json
@ -94,11 +94,12 @@
|
|||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
|
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
|
"vite:inspect": "pnpm clean && electron-vite build --mode development && pnpm exec serve .vite-inspect",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
|
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
|
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
|
||||||
"clean": "del-cli dist && del-cli pack",
|
"clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
|
||||||
"dist": "pnpm clean && pnpm build && electron-builder --win --mac --linux -p never",
|
"dist": "pnpm clean && pnpm build && electron-builder --win --mac --linux -p never",
|
||||||
"dist:linux": "pnpm clean && pnpm build && electron-builder --linux -p never",
|
"dist:linux": "pnpm clean && pnpm build && electron-builder --linux -p never",
|
||||||
"dist:mac": "pnpm clean && pnpm build && electron-builder --mac dmg:x64 -p never",
|
"dist:mac": "pnpm clean && pnpm build && electron-builder --mac dmg:x64 -p never",
|
||||||
@ -117,7 +118,7 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rollup": "4.4.1",
|
"rollup": "4.6.0",
|
||||||
"node-gyp": "10.0.1",
|
"node-gyp": "10.0.1",
|
||||||
"xml2js": "0.6.2",
|
"xml2js": "0.6.2",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
@ -128,7 +129,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cliqz/adblocker-electron": "1.26.12",
|
"@cliqz/adblocker-electron": "1.26.12",
|
||||||
"@cliqz/adblocker-electron-preload": "1.26.12",
|
"@cliqz/adblocker-electron-preload": "1.26.12",
|
||||||
"@fastify/deepmerge": "1.3.0",
|
"@electron-toolkit/tsconfig": "1.0.1",
|
||||||
|
"@electron/remote": "2.1.0",
|
||||||
"@ffmpeg.wasm/core-mt": "0.12.0",
|
"@ffmpeg.wasm/core-mt": "0.12.0",
|
||||||
"@ffmpeg.wasm/main": "0.12.0",
|
"@ffmpeg.wasm/main": "0.12.0",
|
||||||
"@foobar404/wave": "2.0.4",
|
"@foobar404/wave": "2.0.4",
|
||||||
@ -138,9 +140,10 @@
|
|||||||
"async-mutex": "0.4.0",
|
"async-mutex": "0.4.0",
|
||||||
"butterchurn": "3.0.0-beta.4",
|
"butterchurn": "3.0.0-beta.4",
|
||||||
"butterchurn-presets": "3.0.0-beta.4",
|
"butterchurn-presets": "3.0.0-beta.4",
|
||||||
"conf": "12.0.0",
|
"conf": "10.2.0",
|
||||||
"custom-electron-prompt": "1.5.7",
|
"custom-electron-prompt": "1.5.7",
|
||||||
"dbus-next": "0.10.2",
|
"dbus-next": "0.10.2",
|
||||||
|
"deepmerge-ts": "5.1.0",
|
||||||
"electron-debug": "3.2.0",
|
"electron-debug": "3.2.0",
|
||||||
"electron-is": "3.0.0",
|
"electron-is": "3.0.0",
|
||||||
"electron-localshortcut": "3.2.1",
|
"electron-localshortcut": "3.2.1",
|
||||||
@ -148,6 +151,7 @@
|
|||||||
"electron-unhandled": "4.0.1",
|
"electron-unhandled": "4.0.1",
|
||||||
"electron-updater": "6.1.7",
|
"electron-updater": "6.1.7",
|
||||||
"fast-average-color": "9.4.0",
|
"fast-average-color": "9.4.0",
|
||||||
|
"fast-equals": "^5.0.1",
|
||||||
"filenamify": "6.0.0",
|
"filenamify": "6.0.0",
|
||||||
"howler": "2.2.4",
|
"howler": "2.2.4",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
@ -155,18 +159,20 @@
|
|||||||
"keyboardevents-areequal": "0.2.2",
|
"keyboardevents-areequal": "0.2.2",
|
||||||
"node-html-parser": "6.1.11",
|
"node-html-parser": "6.1.11",
|
||||||
"node-id3": "0.2.6",
|
"node-id3": "0.2.6",
|
||||||
|
"serve": "^14.2.1",
|
||||||
"simple-youtube-age-restriction-bypass": "git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8",
|
"simple-youtube-age-restriction-bypass": "git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8",
|
||||||
|
"ts-morph": "^20.0.0",
|
||||||
"vudio": "2.1.1",
|
"vudio": "2.1.1",
|
||||||
"x11": "2.3.0",
|
"x11": "2.3.0",
|
||||||
"youtubei.js": "7.0.0"
|
"youtubei.js": "7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.40.0",
|
"@playwright/test": "1.40.1",
|
||||||
"@total-typescript/ts-reset": "0.5.1",
|
"@total-typescript/ts-reset": "0.5.1",
|
||||||
"@types/electron-localshortcut": "3.1.3",
|
"@types/electron-localshortcut": "3.1.3",
|
||||||
"@types/howler": "2.2.11",
|
"@types/howler": "2.2.11",
|
||||||
"@types/html-to-text": "9.0.4",
|
"@types/html-to-text": "9.0.4",
|
||||||
"@typescript-eslint/eslint-plugin": "6.12.0",
|
"@typescript-eslint/eslint-plugin": "6.13.0",
|
||||||
"bufferutil": "4.0.8",
|
"bufferutil": "4.0.8",
|
||||||
"builtin-modules": "^3.3.0",
|
"builtin-modules": "^3.3.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
@ -176,15 +182,18 @@
|
|||||||
"electron-devtools-installer": "3.2.0",
|
"electron-devtools-installer": "3.2.0",
|
||||||
"electron-vite": "1.0.29",
|
"electron-vite": "1.0.29",
|
||||||
"eslint": "8.54.0",
|
"eslint": "8.54.0",
|
||||||
|
"eslint-import-resolver-exports": "1.0.0-beta.5",
|
||||||
|
"eslint-import-resolver-typescript": "3.6.1",
|
||||||
"eslint-plugin-import": "2.29.0",
|
"eslint-plugin-import": "2.29.0",
|
||||||
"eslint-plugin-prettier": "5.0.1",
|
"eslint-plugin-prettier": "5.0.1",
|
||||||
"glob": "10.3.10",
|
"glob": "10.3.10",
|
||||||
"node-gyp": "10.0.1",
|
"node-gyp": "10.0.1",
|
||||||
"playwright": "1.40.0",
|
"playwright": "1.40.1",
|
||||||
"rollup": "4.6.0",
|
"rollup": "4.6.0",
|
||||||
"typescript": "5.3.2",
|
"typescript": "5.3.2",
|
||||||
"utf-8-validate": "6.0.3",
|
"utf-8-validate": "6.0.3",
|
||||||
"vite": "4.5.0",
|
"vite": "4.5.0",
|
||||||
|
"vite-plugin-inspect": "^0.7.42",
|
||||||
"vite-plugin-resolve": "2.5.1",
|
"vite-plugin-resolve": "2.5.1",
|
||||||
"ws": "8.14.2"
|
"ws": "8.14.2"
|
||||||
},
|
},
|
||||||
|
|||||||
779
pnpm-lock.yaml
generated
779
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
160
readme.md
160
readme.md
@ -189,7 +189,7 @@ Some predefined themes are available in https://github.com/kerichdev/themes-for-
|
|||||||
git clone https://github.com/th-ch/youtube-music
|
git clone https://github.com/th-ch/youtube-music
|
||||||
cd youtube-music
|
cd youtube-music
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm start
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build your own plugins
|
## Build your own plugins
|
||||||
@ -203,61 +203,70 @@ Using plugins, you can:
|
|||||||
|
|
||||||
Create a folder in `plugins/YOUR-PLUGIN-NAME`:
|
Create a folder in `plugins/YOUR-PLUGIN-NAME`:
|
||||||
|
|
||||||
- if you need to manipulate the BrowserWindow, create a file with the following template:
|
- `index.ts`: the main file of the plugin
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// file: main.ts
|
import style from './style.css?inline'; // import style as inline
|
||||||
export default (win: Electron.BrowserWindow, config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
|
|
||||||
// something
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
then, register the plugin in `src/index.ts`:
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
```typescript
|
export default createPlugin({
|
||||||
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/back';
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // if value is true, ytmusic show restart dialog
|
||||||
// ...
|
config: {
|
||||||
|
enabled: false,
|
||||||
const mainPlugins = {
|
}, // your custom config
|
||||||
// ...
|
stylesheets: [style], // your custom style,
|
||||||
'YOUR-PLUGIN-NAME': yourPlugin,
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
};
|
// All *Config methods are wrapped Promise<T>
|
||||||
```
|
const config = await getConfig();
|
||||||
|
return [
|
||||||
- if you need to change the front, create a file with the following template:
|
{
|
||||||
|
label: 'menu',
|
||||||
```typescript
|
submenu: [1, 2, 3].map((value) => ({
|
||||||
// file: renderer.ts
|
label: `value ${value}`,
|
||||||
export default (config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
|
type: 'radio',
|
||||||
// This function will be called as a preload script
|
checked: config.value === value,
|
||||||
// So you can use front features like `document.querySelector`
|
click() {
|
||||||
};
|
setConfig({ value });
|
||||||
```
|
},
|
||||||
|
})),
|
||||||
then, register the plugin in `src/renderer.ts`:
|
},
|
||||||
|
];
|
||||||
```typescript
|
|
||||||
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/front';
|
|
||||||
|
|
||||||
const rendererPlugins: PluginMapper<'renderer'> = {
|
|
||||||
// ...
|
|
||||||
'YOUR-PLUGIN-NAME': yourPlugin,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Finally, add the plugin to the default config file `src/config/default.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export default {
|
|
||||||
// ...
|
|
||||||
'plugins': {
|
|
||||||
// ...
|
|
||||||
'YOUR-PLUGIN-NAME': {
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
backend: {
|
||||||
|
start({ window, ipc }) {
|
||||||
|
window.maximize();
|
||||||
|
|
||||||
|
// you can communicate with renderer plugin
|
||||||
|
ipc.handle('some-event', () => {
|
||||||
|
return 'hello';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// it fired when config changed
|
||||||
|
onConfigChange(newConfig) { /* ... */ },
|
||||||
|
// you can also clean up plugin
|
||||||
|
stop(context) { /* ... */ },
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
async start(context) {
|
||||||
|
console.log(await context.ipc.invoke('some-event'));
|
||||||
|
},
|
||||||
|
// Only renderer available hook
|
||||||
|
onPlayerApiReady(api: YoutubePlayer, context: RendererContext) {
|
||||||
|
// set plugin config easily
|
||||||
|
context.setConfig({ myConfig: api.getVolume() });
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) { /* ... */ },
|
||||||
|
stop(_context) { /* ... */ },
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
async start({ getConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {},
|
||||||
|
stop(_context) {},
|
||||||
|
},
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Common use cases
|
### Common use cases
|
||||||
@ -265,27 +274,42 @@ export default {
|
|||||||
- injecting custom CSS: create a `style.css` file in the same folder then:
|
- injecting custom CSS: create a `style.css` file in the same folder then:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import path from 'node:path';
|
// index.ts
|
||||||
import style from './style.css';
|
import style from './style.css?inline'; // import style as inline
|
||||||
|
|
||||||
// main.ts
|
import { createPlugin } from '@/utils';
|
||||||
export default (win: Electron.BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, style);
|
const builder = createPlugin({
|
||||||
};
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // if value is true, ytmusic show restart dialog
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // your custom config
|
||||||
|
stylesheets: [style], // your custom style
|
||||||
|
renderer() {} // define renderer hook
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- changing the HTML:
|
- If you want to change the HTML:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// renderer.ts
|
import { createPlugin } from '@/utils';
|
||||||
export default () => {
|
|
||||||
// Remove the login button
|
const builder = createPlugin({
|
||||||
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
|
name: 'Plugin Label',
|
||||||
};
|
restartNeeded: true, // if value is true, ytmusic show restart dialog
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // your custom config
|
||||||
|
renderer() {
|
||||||
|
// Remove the login button
|
||||||
|
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
|
||||||
|
} // define renderer hook
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- communicating between the front and back: can be done using the ipcMain module from electron. See `utils.js` file and
|
- communicating between the front and back: can be done using the ipcMain module from electron. See `utils.js` file and
|
||||||
example in `navigation` plugin.
|
example in `sponsorblock` plugin.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@ -301,6 +325,12 @@ export default () => {
|
|||||||
Builds the app for macOS, Linux, and Windows,
|
Builds the app for macOS, Linux, and Windows,
|
||||||
using [electron-builder](https://github.com/electron-userland/electron-builder).
|
using [electron-builder](https://github.com/electron-userland/electron-builder).
|
||||||
|
|
||||||
|
## Production Preview
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -1,22 +1,17 @@
|
|||||||
import { blockers } from '../plugins/adblocker/blocker-types';
|
|
||||||
|
|
||||||
import { DefaultPresetList } from '../plugins/downloader/types';
|
|
||||||
|
|
||||||
export interface WindowSizeConfig {
|
export interface WindowSizeConfig {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WindowPositionConfig {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DefaultConfig {
|
export interface DefaultConfig {
|
||||||
'window-size': {
|
'window-size': WindowSizeConfig;
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
'window-maximized': boolean;
|
'window-maximized': boolean;
|
||||||
'window-position': {
|
'window-position': WindowPositionConfig;
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
url: string;
|
url: string;
|
||||||
options: {
|
options: {
|
||||||
tray: boolean;
|
tray: boolean;
|
||||||
@ -37,10 +32,11 @@ export interface DefaultConfig {
|
|||||||
startingPage: string;
|
startingPage: string;
|
||||||
overrideUserAgent: boolean;
|
overrideUserAgent: boolean;
|
||||||
themes: string[];
|
themes: string[];
|
||||||
}
|
},
|
||||||
|
plugins: Record<string, unknown>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig = {
|
const defaultConfig: DefaultConfig = {
|
||||||
'window-size': {
|
'window-size': {
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 550,
|
height: 550,
|
||||||
@ -69,229 +65,9 @@ const defaultConfig = {
|
|||||||
proxy: '',
|
proxy: '',
|
||||||
startingPage: '',
|
startingPage: '',
|
||||||
overrideUserAgent: false,
|
overrideUserAgent: false,
|
||||||
themes: [] as string[],
|
themes: [],
|
||||||
},
|
|
||||||
/** please order alphabetically */
|
|
||||||
'plugins': {
|
|
||||||
'adblocker': {
|
|
||||||
enabled: true,
|
|
||||||
cache: true,
|
|
||||||
blocker: blockers.InPlayer as string,
|
|
||||||
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
|
|
||||||
disableDefaultLists: false,
|
|
||||||
},
|
|
||||||
'album-color-theme': {},
|
|
||||||
'ambient-mode': {
|
|
||||||
enabled: false,
|
|
||||||
quality: 50,
|
|
||||||
buffer: 30,
|
|
||||||
interpolationTime: 1500,
|
|
||||||
blur: 100,
|
|
||||||
size: 100,
|
|
||||||
opacity: 1,
|
|
||||||
fullscreen: false,
|
|
||||||
},
|
|
||||||
'audio-compressor': {},
|
|
||||||
'blur-nav-bar': {},
|
|
||||||
'bypass-age-restrictions': {},
|
|
||||||
'captions-selector': {
|
|
||||||
enabled: false,
|
|
||||||
disableCaptions: false,
|
|
||||||
autoload: false,
|
|
||||||
lastCaptionsCode: '',
|
|
||||||
},
|
|
||||||
'compact-sidebar': {},
|
|
||||||
'crossfade': {
|
|
||||||
enabled: false,
|
|
||||||
fadeInDuration: 1500, // Ms
|
|
||||||
fadeOutDuration: 5000, // Ms
|
|
||||||
secondsBeforeEnd: 10, // S
|
|
||||||
fadeScaling: 'linear', // 'linear', 'logarithmic' or a positive number in dB
|
|
||||||
},
|
|
||||||
'disable-autoplay': {
|
|
||||||
applyOnce: false,
|
|
||||||
},
|
|
||||||
'discord': {
|
|
||||||
enabled: false,
|
|
||||||
autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
|
|
||||||
activityTimoutEnabled: true, // If enabled, the discord rich presence gets cleared when music paused after the time specified below
|
|
||||||
activityTimoutTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
playOnYouTubeMusic: true, // Add a "Play on YouTube Music" button to rich presence
|
|
||||||
hideGitHubButton: false, // Disable the "View App On GitHub" button
|
|
||||||
hideDurationLeft: false, // Hides the start and end time of the song to rich presence
|
|
||||||
},
|
|
||||||
'downloader': {
|
|
||||||
enabled: false,
|
|
||||||
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
|
|
||||||
selectedPreset: 'mp3 (256kbps)', // Selected preset
|
|
||||||
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
|
|
||||||
skipExisting: false,
|
|
||||||
playlistMaxItems: undefined as number | undefined,
|
|
||||||
},
|
|
||||||
'exponential-volume': {},
|
|
||||||
'in-app-menu': {
|
|
||||||
/**
|
|
||||||
* true in Windows, false in Linux and macOS (see youtube-music/config/store.ts)
|
|
||||||
*/
|
|
||||||
enabled: false,
|
|
||||||
hideDOMWindowControls: false,
|
|
||||||
},
|
|
||||||
'last-fm': {
|
|
||||||
enabled: false,
|
|
||||||
token: undefined as string | undefined, // Token used for authentication
|
|
||||||
session_key: undefined as string | undefined, // Session key used for scrobbling
|
|
||||||
api_root: 'http://ws.audioscrobbler.com/2.0/',
|
|
||||||
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
|
|
||||||
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
|
||||||
},
|
|
||||||
'lumiastream': {},
|
|
||||||
'lyrics-genius': {
|
|
||||||
romanizedLyrics: false,
|
|
||||||
},
|
|
||||||
'navigation': {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
'no-google-login': {},
|
|
||||||
'notifications': {
|
|
||||||
enabled: false,
|
|
||||||
unpauseNotification: false,
|
|
||||||
urgency: 'normal', // Has effect only on Linux
|
|
||||||
// the following has effect only on Windows
|
|
||||||
interactive: true,
|
|
||||||
toastStyle: 1, // See plugins/notifications/utils for more info
|
|
||||||
refreshOnPlayPause: false,
|
|
||||||
trayControls: true,
|
|
||||||
hideButtonText: false,
|
|
||||||
},
|
|
||||||
'picture-in-picture': {
|
|
||||||
'enabled': false,
|
|
||||||
'alwaysOnTop': true,
|
|
||||||
'savePosition': true,
|
|
||||||
'saveSize': false,
|
|
||||||
'hotkey': 'P',
|
|
||||||
'pip-position': [10, 10],
|
|
||||||
'pip-size': [450, 275],
|
|
||||||
'isInPiP': false,
|
|
||||||
'useNativePiP': true,
|
|
||||||
},
|
|
||||||
'playback-speed': {},
|
|
||||||
'precise-volume': {
|
|
||||||
enabled: false,
|
|
||||||
steps: 1, // Percentage of volume to change
|
|
||||||
arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts
|
|
||||||
globalShortcuts: {
|
|
||||||
volumeUp: '',
|
|
||||||
volumeDown: '',
|
|
||||||
},
|
|
||||||
savedVolume: undefined as number | undefined, // Plugin save volume between session here
|
|
||||||
},
|
|
||||||
'quality-changer': {},
|
|
||||||
'shortcuts': {
|
|
||||||
enabled: false,
|
|
||||||
overrideMediaKeys: false,
|
|
||||||
global: {
|
|
||||||
previous: '',
|
|
||||||
playPause: '',
|
|
||||||
next: '',
|
|
||||||
} as Record<string, string>,
|
|
||||||
local: {
|
|
||||||
previous: '',
|
|
||||||
playPause: '',
|
|
||||||
next: '',
|
|
||||||
} as Record<string, string>,
|
|
||||||
},
|
|
||||||
'skip-silences': {
|
|
||||||
onlySkipBeginning: false,
|
|
||||||
},
|
|
||||||
'sponsorblock': {
|
|
||||||
enabled: false,
|
|
||||||
apiURL: 'https://sponsor.ajay.app',
|
|
||||||
categories: [
|
|
||||||
'sponsor',
|
|
||||||
'intro',
|
|
||||||
'outro',
|
|
||||||
'interaction',
|
|
||||||
'selfpromo',
|
|
||||||
'music_offtopic',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'taskbar-mediacontrol': {},
|
|
||||||
'touchbar': {},
|
|
||||||
'tuna-obs': {},
|
|
||||||
'video-toggle': {
|
|
||||||
enabled: false,
|
|
||||||
hideVideo: false,
|
|
||||||
mode: 'custom',
|
|
||||||
forceHide: false,
|
|
||||||
align: '',
|
|
||||||
},
|
|
||||||
'visualizer': {
|
|
||||||
enabled: false,
|
|
||||||
type: 'butterchurn',
|
|
||||||
// Config per visualizer
|
|
||||||
butterchurn: {
|
|
||||||
preset: 'martin [shadow harlequins shape code] - fata morgana',
|
|
||||||
renderingFrequencyInMs: 500,
|
|
||||||
blendTimeInSeconds: 2.7,
|
|
||||||
},
|
|
||||||
vudio: {
|
|
||||||
effect: 'lighting',
|
|
||||||
accuracy: 128,
|
|
||||||
lighting: {
|
|
||||||
maxHeight: 160,
|
|
||||||
maxSize: 12,
|
|
||||||
lineWidth: 1,
|
|
||||||
color: '#49f3f7',
|
|
||||||
shadowBlur: 2,
|
|
||||||
shadowColor: 'rgba(244,244,244,.5)',
|
|
||||||
fadeSide: true,
|
|
||||||
prettify: false,
|
|
||||||
horizontalAlign: 'center',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
dottify: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wave: {
|
|
||||||
animations: [
|
|
||||||
{
|
|
||||||
type: 'Cubes',
|
|
||||||
config: {
|
|
||||||
bottom: true,
|
|
||||||
count: 30,
|
|
||||||
cubeHeight: 5,
|
|
||||||
fillColor: { gradient: ['#FAD961', '#F76B1C'] },
|
|
||||||
lineColor: 'rgba(0,0,0,0)',
|
|
||||||
radius: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Cubes',
|
|
||||||
config: {
|
|
||||||
top: true,
|
|
||||||
count: 12,
|
|
||||||
cubeHeight: 5,
|
|
||||||
fillColor: { gradient: ['#FAD961', '#F76B1C'] },
|
|
||||||
lineColor: 'rgba(0,0,0,0)',
|
|
||||||
radius: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Circles',
|
|
||||||
config: {
|
|
||||||
lineColor: {
|
|
||||||
gradient: ['#FAD961', '#FAD961', '#F76B1C'],
|
|
||||||
rotate: 90,
|
|
||||||
},
|
|
||||||
lineWidth: 4,
|
|
||||||
diameter: 20,
|
|
||||||
count: 10,
|
|
||||||
frequencyBand: 'base',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
'plugins': {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defaultConfig;
|
export default defaultConfig;
|
||||||
|
|||||||
@ -1,182 +0,0 @@
|
|||||||
import defaultConfig from './defaults';
|
|
||||||
|
|
||||||
import { Entries } from '../utils/type-utils';
|
|
||||||
|
|
||||||
import type { OneOfDefaultConfigKey, ConfigType, PluginConfigOptions } from './dynamic';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};
|
|
||||||
|
|
||||||
export const getActivePlugins
|
|
||||||
= async () => await window.ipcRenderer.invoke('get-active-plugins') as Promise<typeof activePlugins>;
|
|
||||||
|
|
||||||
export const isActive
|
|
||||||
= async (plugin: string) => plugin in (await window.ipcRenderer.invoke('get-active-plugins'));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is used to create a dynamic synced config for plugins.
|
|
||||||
*
|
|
||||||
* @param {string} name - The name of the plugin.
|
|
||||||
* @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false.
|
|
||||||
* @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const { PluginConfig } = require("../../config/dynamic-renderer");
|
|
||||||
* const config = new PluginConfig("plugin-name", { enableFront: true });
|
|
||||||
* module.exports = { ...config };
|
|
||||||
*
|
|
||||||
* // or
|
|
||||||
*
|
|
||||||
* module.exports = (win, options) => {
|
|
||||||
* const config = new PluginConfig("plugin-name", {
|
|
||||||
* enableFront: true,
|
|
||||||
* initialOptions: options,
|
|
||||||
* });
|
|
||||||
* setupMyPlugin(win, config);
|
|
||||||
* };
|
|
||||||
*/
|
|
||||||
type ValueOf<T> = T[keyof T];
|
|
||||||
|
|
||||||
export class PluginConfig<T extends OneOfDefaultConfigKey> {
|
|
||||||
private readonly name: string;
|
|
||||||
private readonly config: ConfigType<T>;
|
|
||||||
private readonly defaultConfig: ConfigType<T>;
|
|
||||||
private readonly enableFront: boolean;
|
|
||||||
|
|
||||||
private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {};
|
|
||||||
private allSubscribers: ((config: ConfigType<T>) => void)[] = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
name: T,
|
|
||||||
options: PluginConfigOptions = {
|
|
||||||
enableFront: false,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const pluginDefaultConfig = defaultConfig.plugins[name] ?? {};
|
|
||||||
const pluginConfig = options.initialOptions || window.mainConfig.plugins.getOptions(name) || {};
|
|
||||||
|
|
||||||
this.name = name;
|
|
||||||
this.enableFront = options.enableFront;
|
|
||||||
this.defaultConfig = pluginDefaultConfig;
|
|
||||||
this.config = { ...pluginDefaultConfig, ...pluginConfig };
|
|
||||||
|
|
||||||
if (this.enableFront) {
|
|
||||||
this.setupFront();
|
|
||||||
}
|
|
||||||
|
|
||||||
activePlugins[name] = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
get<Key extends keyof ConfigType<T> = keyof ConfigType<T>>(key: Key): ConfigType<T>[Key] {
|
|
||||||
return this.config?.[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
|
|
||||||
this.config[key] = value;
|
|
||||||
this.onChange(key);
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(): ConfigType<T> {
|
|
||||||
return { ...this.config };
|
|
||||||
}
|
|
||||||
|
|
||||||
setAll(options: Partial<ConfigType<T>>) {
|
|
||||||
if (!options || typeof options !== 'object') {
|
|
||||||
throw new Error('Options must be an object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
for (const [key, value] of Object.entries(options) as Entries<typeof options>) {
|
|
||||||
if (this.config[key] !== value) {
|
|
||||||
if (value !== undefined) this.config[key] = value;
|
|
||||||
this.onChange(key, false);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
for (const fn of this.allSubscribers) {
|
|
||||||
fn(this.config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultConfig() {
|
|
||||||
return this.defaultConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true`
|
|
||||||
*
|
|
||||||
* Used for options that require a restart to take effect.
|
|
||||||
*/
|
|
||||||
setAndMaybeRestart(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
|
|
||||||
this.config[key] = value;
|
|
||||||
window.mainConfig.plugins.setMenuOptions(this.name, this.config);
|
|
||||||
this.onChange(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(valueName: keyof ConfigType<T>, fn: (config: ConfigType<T>) => void) {
|
|
||||||
this.subscribers[valueName] = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeAll(fn: (config: ConfigType<T>) => void) {
|
|
||||||
this.allSubscribers.push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called only from back */
|
|
||||||
private save() {
|
|
||||||
window.mainConfig.plugins.setOptions(this.name, this.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onChange(valueName: keyof ConfigType<T>, single: boolean = true) {
|
|
||||||
this.subscribers[valueName]?.(this.config[valueName] as ConfigType<T>);
|
|
||||||
if (single) {
|
|
||||||
for (const fn of this.allSubscribers) {
|
|
||||||
fn(this.config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupFront() {
|
|
||||||
const ignoredMethods = ['subscribe', 'subscribeAll'];
|
|
||||||
|
|
||||||
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
|
|
||||||
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-return
|
|
||||||
this[fnName] = (async (...args: any) => await window.ipcRenderer.invoke(
|
|
||||||
`${this.name}-config-${String(fnName)}`,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
...args,
|
|
||||||
)) as typeof this[keyof this];
|
|
||||||
|
|
||||||
this.subscribe = (valueName, fn: (config: ConfigType<T>) => void) => {
|
|
||||||
if (valueName in this.subscribers) {
|
|
||||||
console.error(`Already subscribed to ${String(valueName)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subscribers[valueName] = fn;
|
|
||||||
window.ipcRenderer.on(
|
|
||||||
`${this.name}-config-changed-${String(valueName)}`,
|
|
||||||
(_, value: ConfigType<T>) => {
|
|
||||||
fn(value);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
window.ipcRenderer.send(`${this.name}-config-subscribe`, valueName);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.subscribeAll = (fn: (config: ConfigType<T>) => void) => {
|
|
||||||
window.ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType<T>) => {
|
|
||||||
fn(value);
|
|
||||||
});
|
|
||||||
window.ipcRenderer.send(`${this.name}-config-subscribe-all`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import { ipcMain } from 'electron';
|
|
||||||
|
|
||||||
import defaultConfig from './defaults';
|
|
||||||
|
|
||||||
import { getOptions, setMenuOptions, setOptions } from './plugins';
|
|
||||||
|
|
||||||
import { sendToFront } from '../providers/app-controls';
|
|
||||||
import { Entries } from '../utils/type-utils';
|
|
||||||
|
|
||||||
export type DefaultPluginsConfig = typeof defaultConfig.plugins;
|
|
||||||
export type OneOfDefaultConfigKey = keyof DefaultPluginsConfig;
|
|
||||||
export type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey];
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};
|
|
||||||
|
|
||||||
export const getActivePlugins = () => activePlugins;
|
|
||||||
|
|
||||||
if (process.type === 'browser') {
|
|
||||||
ipcMain.handle('get-active-plugins', getActivePlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isActive = (plugin: string): boolean => plugin in activePlugins;
|
|
||||||
|
|
||||||
export interface PluginConfigOptions {
|
|
||||||
enableFront: boolean;
|
|
||||||
initialOptions?: OneOfDefaultConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is used to create a dynamic synced config for plugins.
|
|
||||||
*
|
|
||||||
* @param {string} name - The name of the plugin.
|
|
||||||
* @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false.
|
|
||||||
* @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const { PluginConfig } = require("../../config/dynamic");
|
|
||||||
* const config = new PluginConfig("plugin-name", { enableFront: true });
|
|
||||||
* module.exports = { ...config };
|
|
||||||
*
|
|
||||||
* // or
|
|
||||||
*
|
|
||||||
* module.exports = (win, options) => {
|
|
||||||
* const config = new PluginConfig("plugin-name", {
|
|
||||||
* enableFront: true,
|
|
||||||
* initialOptions: options,
|
|
||||||
* });
|
|
||||||
* setupMyPlugin(win, config);
|
|
||||||
* };
|
|
||||||
*/
|
|
||||||
export type ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T];
|
|
||||||
type ValueOf<T> = T[keyof T];
|
|
||||||
|
|
||||||
export class PluginConfig<T extends OneOfDefaultConfigKey> {
|
|
||||||
private readonly name: string;
|
|
||||||
private readonly config: ConfigType<T>;
|
|
||||||
private readonly defaultConfig: ConfigType<T>;
|
|
||||||
private readonly enableFront: boolean;
|
|
||||||
|
|
||||||
private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {};
|
|
||||||
private allSubscribers: ((config: ConfigType<T>) => void)[] = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
name: T,
|
|
||||||
options: PluginConfigOptions = {
|
|
||||||
enableFront: false,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const pluginDefaultConfig = defaultConfig.plugins[name] ?? {};
|
|
||||||
const pluginConfig = options.initialOptions || getOptions(name) || {};
|
|
||||||
|
|
||||||
this.name = name;
|
|
||||||
this.enableFront = options.enableFront;
|
|
||||||
this.defaultConfig = pluginDefaultConfig;
|
|
||||||
this.config = { ...pluginDefaultConfig, ...pluginConfig };
|
|
||||||
|
|
||||||
if (this.enableFront) {
|
|
||||||
this.setupFront();
|
|
||||||
}
|
|
||||||
|
|
||||||
activePlugins[name] = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
get<Key extends keyof ConfigType<T> = keyof ConfigType<T>>(key: Key): ConfigType<T>[Key] {
|
|
||||||
return this.config?.[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
|
|
||||||
this.config[key] = value;
|
|
||||||
this.onChange(key);
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(): ConfigType<T> {
|
|
||||||
return { ...this.config };
|
|
||||||
}
|
|
||||||
|
|
||||||
setAll(options: Partial<ConfigType<T>>) {
|
|
||||||
if (!options || typeof options !== 'object') {
|
|
||||||
throw new Error('Options must be an object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
for (const [key, value] of Object.entries(options) as Entries<typeof options>) {
|
|
||||||
if (this.config[key] !== value) {
|
|
||||||
if (value !== undefined) this.config[key] = value;
|
|
||||||
this.onChange(key, false);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
for (const fn of this.allSubscribers) {
|
|
||||||
fn(this.config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultConfig() {
|
|
||||||
return this.defaultConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true`
|
|
||||||
*
|
|
||||||
* Used for options that require a restart to take effect.
|
|
||||||
*/
|
|
||||||
setAndMaybeRestart(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
|
|
||||||
this.config[key] = value;
|
|
||||||
setMenuOptions(this.name, this.config);
|
|
||||||
this.onChange(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(valueName: keyof ConfigType<T>, fn: (config: ConfigType<T>) => void) {
|
|
||||||
this.subscribers[valueName] = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeAll(fn: (config: ConfigType<T>) => void) {
|
|
||||||
this.allSubscribers.push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called only from back */
|
|
||||||
private save() {
|
|
||||||
setOptions(this.name, this.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onChange(valueName: keyof ConfigType<T>, single: boolean = true) {
|
|
||||||
this.subscribers[valueName]?.(this.config[valueName] as ConfigType<T>);
|
|
||||||
if (single) {
|
|
||||||
for (const fn of this.allSubscribers) {
|
|
||||||
fn(this.config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupFront() {
|
|
||||||
const ignoredMethods = ['subscribe', 'subscribeAll'];
|
|
||||||
|
|
||||||
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
|
|
||||||
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-return
|
|
||||||
ipcMain.handle(`${this.name}-config-${String(fnName)}`, (_, ...args) => fn(...args));
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.on(`${this.name}-config-subscribe`, (_, valueName: keyof ConfigType<T>) => {
|
|
||||||
this.subscribe(valueName, (value) => {
|
|
||||||
sendToFront(`${this.name}-config-changed-${String(valueName)}`, value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on(`${this.name}-config-subscribe-all`, () => {
|
|
||||||
this.subscribeAll((value) => {
|
|
||||||
sendToFront(`${this.name}-config-changed`, value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +1,20 @@
|
|||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
|
||||||
import defaultConfig from './defaults';
|
import defaultConfig from './defaults';
|
||||||
import plugins from './plugins';
|
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
import plugins from './plugins';
|
||||||
|
|
||||||
import { restart } from '../providers/app-controls';
|
import { restart } from '@/providers/app-controls';
|
||||||
|
|
||||||
|
|
||||||
const set = (key: string, value: unknown) => {
|
const set = (key: string, value: unknown) => {
|
||||||
store.set(key, value);
|
store.set(key, value);
|
||||||
};
|
};
|
||||||
|
const setPartial = (key: string, value: object, defaultValue?: object) => {
|
||||||
|
const newValue = deepmerge(defaultValue ?? {}, store.get(key) ?? {}, value);
|
||||||
|
store.set(key, newValue);
|
||||||
|
};
|
||||||
|
|
||||||
function setMenuOption(key: string, value: unknown) {
|
function setMenuOption(key: string, value: unknown) {
|
||||||
set(key, value);
|
set(key, value);
|
||||||
@ -20,34 +25,65 @@ function setMenuOption(key: string, value: unknown) {
|
|||||||
|
|
||||||
// MAGIC OF TYPESCRIPT
|
// MAGIC OF TYPESCRIPT
|
||||||
|
|
||||||
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
|
type Prev = [
|
||||||
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
|
never,
|
||||||
type Join<K, P> = K extends string | number ?
|
0,
|
||||||
P extends string | number ?
|
1,
|
||||||
`${K}${'' extends P ? '' : '.'}${P}`
|
2,
|
||||||
: never : never;
|
3,
|
||||||
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
|
4,
|
||||||
{ [K in keyof T]-?: K extends string | number ?
|
5,
|
||||||
`${K}` | Join<K, Paths<T[K], Prev[D]>>
|
6,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
15,
|
||||||
|
16,
|
||||||
|
17,
|
||||||
|
18,
|
||||||
|
19,
|
||||||
|
20,
|
||||||
|
...0[],
|
||||||
|
];
|
||||||
|
type Join<K, P> = K extends string | number
|
||||||
|
? P extends string | number
|
||||||
|
? `${K}${'' extends P ? '' : '.'}${P}`
|
||||||
: never
|
: never
|
||||||
}[keyof T] : ''
|
: never;
|
||||||
|
type Paths<T, D extends number = 10> = [D] extends [never]
|
||||||
|
? never
|
||||||
|
: T extends object
|
||||||
|
? {
|
||||||
|
[K in keyof T]-?: K extends string | number
|
||||||
|
? `${K}` | Join<K, Paths<T[K], Prev[D]>>
|
||||||
|
: never;
|
||||||
|
}[keyof T]
|
||||||
|
: '';
|
||||||
|
|
||||||
type SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
|
type SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
|
||||||
type PathValue<T, K extends string> =
|
type PathValue<T, K extends string> = SplitKey<K> extends [
|
||||||
SplitKey<K> extends [infer A extends keyof T, infer B extends string]
|
infer A extends keyof T,
|
||||||
? PathValue<T[A], B>
|
infer B extends string,
|
||||||
: T;
|
]
|
||||||
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) => store.get(key) as PathValue<typeof defaultConfig, typeof key>;
|
? PathValue<T[A], B>
|
||||||
|
: T;
|
||||||
|
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) =>
|
||||||
|
store.get(key) as PathValue<typeof defaultConfig, typeof key>;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
defaultConfig,
|
defaultConfig,
|
||||||
get,
|
get,
|
||||||
set,
|
set,
|
||||||
|
setPartial,
|
||||||
setMenuOption,
|
setMenuOption,
|
||||||
edit: () => store.openInEditor(),
|
edit: () => store.openInEditor(),
|
||||||
watch(cb: Parameters<Store['onDidChange']>[1]) {
|
watch(cb: Parameters<Store['onDidAnyChange']>[0]) {
|
||||||
store.onDidChange('options', cb);
|
store.onDidAnyChange(cb);
|
||||||
store.onDidChange('plugins', cb);
|
|
||||||
},
|
},
|
||||||
plugins,
|
plugins,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,27 +1,18 @@
|
|||||||
import { deepmerge } from '@fastify/deepmerge';
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { allPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
import store from './store';
|
import store from './store';
|
||||||
import defaultConfig from './defaults';
|
|
||||||
|
|
||||||
import { restart } from '../providers/app-controls';
|
import { restart } from '@/providers/app-controls';
|
||||||
import { Entries } from '../utils/type-utils';
|
|
||||||
|
|
||||||
interface Plugin {
|
import type { PluginConfig } from '@/types/plugins';
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DefaultPluginsConfig = typeof defaultConfig.plugins;
|
export function getPlugins() {
|
||||||
const deepmergeFn = deepmerge();
|
return store.get('plugins') as Record<string, PluginConfig>;
|
||||||
|
|
||||||
export function getEnabled() {
|
|
||||||
const plugins = deepmergeFn(defaultConfig.plugins, (store.get('plugins') as DefaultPluginsConfig));
|
|
||||||
return (Object.entries(plugins) as Entries<DefaultPluginsConfig>).filter(([, options]) =>
|
|
||||||
(options as Plugin).enabled,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEnabled(plugin: string) {
|
export function isEnabled(plugin: string) {
|
||||||
const pluginConfig = (store.get('plugins') as Record<string, Plugin>)[plugin];
|
const pluginConfig = deepmerge(allPlugins[plugin].config ?? { enabled: false }, (store.get('plugins') as Record<string, PluginConfig>)[plugin] ?? {});
|
||||||
return pluginConfig !== undefined && pluginConfig.enabled;
|
return pluginConfig !== undefined && pluginConfig.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +60,7 @@ export function disable(plugin: string) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
getEnabled,
|
getPlugins,
|
||||||
enable,
|
enable,
|
||||||
disable,
|
disable,
|
||||||
setOptions,
|
setOptions,
|
||||||
|
|||||||
@ -1,25 +1,26 @@
|
|||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
import Conf from 'conf';
|
import Conf from 'conf';
|
||||||
import is from 'electron-is';
|
|
||||||
|
|
||||||
import defaults from './defaults';
|
import defaults from './defaults';
|
||||||
|
|
||||||
import { DefaultPresetList, type Preset } from '../plugins/downloader/types';
|
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
|
||||||
|
|
||||||
const getDefaults = () => {
|
|
||||||
if (is.windows()) {
|
|
||||||
defaults.plugins['in-app-menu'].enabled = true;
|
|
||||||
}
|
|
||||||
return defaults;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setDefaultPluginOptions = (store: Conf<Record<string, unknown>>, plugin: keyof typeof defaults.plugins) => {
|
|
||||||
if (!store.get(`plugins.${plugin}`)) {
|
|
||||||
store.set(`plugins.${plugin}`, defaults.plugins[plugin]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrations = {
|
const migrations = {
|
||||||
|
'>=3.0.0'(store: Conf<Record<string, unknown>>) {
|
||||||
|
const discordConfig = store.get('plugins.discord') as Record<string, unknown>;
|
||||||
|
if (discordConfig) {
|
||||||
|
const oldActivityTimoutEnabled = store.get('plugins.discord.activityTimoutEnabled') as boolean | undefined;
|
||||||
|
const oldActivityTimoutTime = store.get('plugins.discord.activityTimoutTime') as number | undefined;
|
||||||
|
if (oldActivityTimoutEnabled !== undefined) {
|
||||||
|
discordConfig.activityTimeoutEnabled = oldActivityTimoutEnabled;
|
||||||
|
store.set('plugins.discord', discordConfig);
|
||||||
|
}
|
||||||
|
if (oldActivityTimoutTime !== undefined) {
|
||||||
|
discordConfig.activityTimeoutTime = oldActivityTimoutTime;
|
||||||
|
store.set('plugins.discord', discordConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
'>=2.1.3'(store: Conf<Record<string, unknown>>) {
|
'>=2.1.3'(store: Conf<Record<string, unknown>>) {
|
||||||
const listenAlong = store.get('plugins.discord.listenAlong');
|
const listenAlong = store.get('plugins.discord.listenAlong');
|
||||||
if (listenAlong !== undefined) {
|
if (listenAlong !== undefined) {
|
||||||
@ -28,19 +29,24 @@ const migrations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=2.1.0'(store: Conf<Record<string, unknown>>) {
|
'>=2.1.0'(store: Conf<Record<string, unknown>>) {
|
||||||
const originalPreset = store.get('plugins.downloader.preset') as string | undefined;
|
const originalPreset = store.get('plugins.downloader.preset') as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
if (originalPreset) {
|
if (originalPreset) {
|
||||||
if (originalPreset !== 'opus') {
|
if (originalPreset !== 'opus') {
|
||||||
store.set('plugins.downloader.selectedPreset', 'Custom');
|
store.set('plugins.downloader.selectedPreset', 'Custom');
|
||||||
store.set('plugins.downloader.customPresetSetting', {
|
store.set('plugins.downloader.customPresetSetting', {
|
||||||
extension: 'mp3',
|
extension: 'mp3',
|
||||||
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? DefaultPresetList['mp3 (256kbps)'].ffmpegArgs,
|
ffmpegArgs:
|
||||||
|
(store.get('plugins.downloader.ffmpegArgs') as string[]) ??
|
||||||
|
DefaultPresetList['mp3 (256kbps)'].ffmpegArgs,
|
||||||
} satisfies Preset);
|
} satisfies Preset);
|
||||||
} else {
|
} else {
|
||||||
store.set('plugins.downloader.selectedPreset', 'Source');
|
store.set('plugins.downloader.selectedPreset', 'Source');
|
||||||
store.set('plugins.downloader.customPresetSetting', {
|
store.set('plugins.downloader.customPresetSetting', {
|
||||||
extension: null,
|
extension: null,
|
||||||
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [],
|
ffmpegArgs:
|
||||||
|
(store.get('plugins.downloader.ffmpegArgs') as string[]) ?? [],
|
||||||
} satisfies Preset);
|
} satisfies Preset);
|
||||||
}
|
}
|
||||||
store.delete('plugins.downloader.preset');
|
store.delete('plugins.downloader.preset');
|
||||||
@ -48,12 +54,11 @@ const migrations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=1.20.0'(store: Conf<Record<string, unknown>>) {
|
'>=1.20.0'(store: Conf<Record<string, unknown>>) {
|
||||||
setDefaultPluginOptions(store, 'visualizer');
|
store.delete('plugins.visualizer'); // default value is now in the plugin
|
||||||
|
|
||||||
if (store.get('plugins.notifications.toastStyle') === undefined) {
|
if (store.get('plugins.notifications.toastStyle') === undefined) {
|
||||||
const pluginOptions = store.get('plugins.notifications') || {};
|
const pluginOptions = store.get('plugins.notifications') || {};
|
||||||
store.set('plugins.notifications', {
|
store.set('plugins.notifications', {
|
||||||
...defaults.plugins.notifications,
|
|
||||||
...pluginOptions,
|
...pluginOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -64,7 +69,7 @@ const migrations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=1.17.0'(store: Conf<Record<string, unknown>>) {
|
'>=1.17.0'(store: Conf<Record<string, unknown>>) {
|
||||||
setDefaultPluginOptions(store, 'picture-in-picture');
|
store.delete('plugins.picture-in-picture'); // default value is now in the plugin
|
||||||
|
|
||||||
if (store.get('plugins.video-toggle.mode') === undefined) {
|
if (store.get('plugins.video-toggle.mode') === undefined) {
|
||||||
store.set('plugins.video-toggle.mode', 'custom');
|
store.set('plugins.video-toggle.mode', 'custom');
|
||||||
@ -88,31 +93,36 @@ const migrations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
|
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
|
||||||
const options = store.get('plugins.shortcuts') as Record<string, {
|
const options = store.get('plugins.shortcuts') as Record<
|
||||||
action: string;
|
string,
|
||||||
shortcut: unknown;
|
| {
|
||||||
}[] | Record<string, unknown>>;
|
|
||||||
let updated = false;
|
|
||||||
for (const optionType of ['global', 'local']) {
|
|
||||||
if (Array.isArray(options[optionType])) {
|
|
||||||
const optionsArray = options[optionType] as {
|
|
||||||
action: string;
|
action: string;
|
||||||
shortcut: unknown;
|
shortcut: unknown;
|
||||||
}[];
|
}[]
|
||||||
const updatedOptions: Record<string, unknown> = {};
|
| Record<string, unknown>
|
||||||
for (const optionObject of optionsArray) {
|
> | undefined;
|
||||||
if (optionObject.action && optionObject.shortcut) {
|
if (options) {
|
||||||
updatedOptions[optionObject.action] = optionObject.shortcut;
|
let updated = false;
|
||||||
|
for (const optionType of ['global', 'local']) {
|
||||||
|
if (Object.hasOwn(options, optionType) && Array.isArray(options[optionType])) {
|
||||||
|
const optionsArray = options[optionType] as {
|
||||||
|
action: string;
|
||||||
|
shortcut: unknown;
|
||||||
|
}[];
|
||||||
|
const updatedOptions: Record<string, unknown> = {};
|
||||||
|
for (const optionObject of optionsArray) {
|
||||||
|
if (optionObject.action && optionObject.shortcut) {
|
||||||
|
updatedOptions[optionObject.action] = optionObject.shortcut;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options[optionType] = updatedOptions;
|
||||||
|
updated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
options[optionType] = updatedOptions;
|
|
||||||
updated = true;
|
|
||||||
}
|
}
|
||||||
}
|
if (updated) {
|
||||||
|
store.set('plugins.shortcuts', options);
|
||||||
if (updated) {
|
}
|
||||||
store.set('plugins.shortcuts', options);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=1.11.0'(store: Conf<Record<string, unknown>>) {
|
'>=1.11.0'(store: Conf<Record<string, unknown>>) {
|
||||||
@ -155,7 +165,10 @@ const migrations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default new Store({
|
export default new Store({
|
||||||
defaults: getDefaults(),
|
defaults: {
|
||||||
|
...defaults,
|
||||||
|
// README: 'plugin' uses deepmerge to populate the default values, so it is not necessary to include it here
|
||||||
|
},
|
||||||
clearInvalidConfig: false,
|
clearInvalidConfig: false,
|
||||||
migrations,
|
migrations,
|
||||||
});
|
});
|
||||||
|
|||||||
466
src/index.ts
466
src/index.ts
@ -2,30 +2,55 @@ import path from 'node:path';
|
|||||||
import url from 'node:url';
|
import url from 'node:url';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
|
import {
|
||||||
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request';
|
BrowserWindow,
|
||||||
|
app,
|
||||||
|
screen,
|
||||||
|
globalShortcut,
|
||||||
|
session,
|
||||||
|
shell,
|
||||||
|
dialog,
|
||||||
|
ipcMain,
|
||||||
|
} from 'electron';
|
||||||
|
import enhanceWebRequest, {
|
||||||
|
BetterSession,
|
||||||
|
} from '@jellybrick/electron-better-web-request';
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
import unhandled from 'electron-unhandled';
|
import unhandled from 'electron-unhandled';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
import electronDebug from 'electron-debug';
|
import electronDebug from 'electron-debug';
|
||||||
import { parse } from 'node-html-parser';
|
import { parse } from 'node-html-parser';
|
||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { deepEqual } from 'fast-equals';
|
||||||
|
|
||||||
import config from './config';
|
import { allPlugins, mainPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
import { refreshMenu, setApplicationMenu } from './menu';
|
import config from '@/config';
|
||||||
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils/main';
|
|
||||||
import { isTesting } from './utils/testing';
|
|
||||||
import { setUpTray } from './tray';
|
|
||||||
import { setupSongInfo } from './providers/song-info';
|
|
||||||
import { restart, setupAppControls } from './providers/app-controls';
|
|
||||||
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler';
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/order
|
import { refreshMenu, setApplicationMenu } from '@/menu';
|
||||||
import { mainPlugins } from 'virtual:MainPlugins';
|
import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main';
|
||||||
|
import { isTesting } from '@/utils/testing';
|
||||||
|
import { setUpTray } from '@/tray';
|
||||||
|
import { setupSongInfo } from '@/providers/song-info';
|
||||||
|
import { restart, setupAppControls } from '@/providers/app-controls';
|
||||||
|
import {
|
||||||
|
APP_PROTOCOL,
|
||||||
|
handleProtocol,
|
||||||
|
setupProtocolHandler,
|
||||||
|
} from '@/providers/protocol-handler';
|
||||||
|
|
||||||
import { setOptions as pipSetOptions } from './plugins/picture-in-picture/main';
|
import youtubeMusicCSS from '@/youtube-music.css?inline';
|
||||||
|
|
||||||
import youtubeMusicCSS from './youtube-music.css';
|
import {
|
||||||
|
forceLoadMainPlugin,
|
||||||
|
forceUnloadMainPlugin,
|
||||||
|
getAllLoadedMainPlugins,
|
||||||
|
loadAllMainPlugins,
|
||||||
|
} from '@/loader/main';
|
||||||
|
|
||||||
|
import { LoggerPrefix } from '@/utils';
|
||||||
|
|
||||||
|
import type { PluginConfig } from '@/types/plugins';
|
||||||
|
|
||||||
// Catch errors and log them
|
// Catch errors and log them
|
||||||
unhandled({
|
unhandled({
|
||||||
@ -47,7 +72,10 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
|
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
|
||||||
// OverlayScrollbar: Required for overlay scrollbars
|
// OverlayScrollbar: Required for overlay scrollbars
|
||||||
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer');
|
app.commandLine.appendSwitch(
|
||||||
|
'enable-features',
|
||||||
|
'OverlayScrollbar,SharedArrayBuffer',
|
||||||
|
);
|
||||||
if (config.get('options.disableHardwareAcceleration')) {
|
if (config.get('options.disableHardwareAcceleration')) {
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log('Disabling hardware acceleration');
|
console.log('Disabling hardware acceleration');
|
||||||
@ -83,20 +111,106 @@ function onClosed() {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mainPluginNames = Object.keys(mainPlugins);
|
|
||||||
|
|
||||||
if (is.windows()) {
|
|
||||||
delete mainPlugins['touchbar'];
|
|
||||||
} else if (is.macOS()) {
|
|
||||||
delete mainPlugins['taskbar-mediacontrol'];
|
|
||||||
} else {
|
|
||||||
delete mainPlugins['touchbar'];
|
|
||||||
delete mainPlugins['taskbar-mediacontrol'];
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
|
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
|
||||||
|
|
||||||
async function loadPlugins(win: BrowserWindow) {
|
const initHook = (win: BrowserWindow) => {
|
||||||
|
ipcMain.handle(
|
||||||
|
'get-config',
|
||||||
|
(_, id: string) =>
|
||||||
|
deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
config.get(`plugins.${id}`) ?? {},
|
||||||
|
) as PluginConfig,
|
||||||
|
);
|
||||||
|
ipcMain.handle('set-config', (_, name: string, obj: object) =>
|
||||||
|
config.setPartial(`plugins.${name}`, obj, allPlugins[name].config),
|
||||||
|
);
|
||||||
|
|
||||||
|
config.watch((newValue, oldValue) => {
|
||||||
|
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => {
|
||||||
|
const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig);
|
||||||
|
|
||||||
|
if (!isEqual) {
|
||||||
|
const oldConfig = oldPluginConfigList[id] as PluginConfig;
|
||||||
|
const config = deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
newPluginConfig ?? {},
|
||||||
|
) as PluginConfig;
|
||||||
|
|
||||||
|
if (config.enabled !== oldConfig?.enabled) {
|
||||||
|
if (config.enabled) {
|
||||||
|
win.webContents.send('plugin:enable', id);
|
||||||
|
ipcMain.emit('plugin:enable', id);
|
||||||
|
forceLoadMainPlugin(id, win);
|
||||||
|
} else {
|
||||||
|
win.webContents.send('plugin:unload', id);
|
||||||
|
ipcMain.emit('plugin:unload', id);
|
||||||
|
forceUnloadMainPlugin(id, win);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPlugins[id]?.restartNeeded) {
|
||||||
|
showNeedToRestartDialog(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainPlugin = getAllLoadedMainPlugins()[id];
|
||||||
|
if (mainPlugin) {
|
||||||
|
if (config.enabled && typeof mainPlugin.backend !== 'function') {
|
||||||
|
mainPlugin.backend?.onConfigChange?.call(mainPlugin.backend, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
win.webContents.send('config-changed', id, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNeedToRestartDialog = (id: string) => {
|
||||||
|
const plugin = allPlugins[id];
|
||||||
|
|
||||||
|
const dialogOptions: Electron.MessageBoxOptions = {
|
||||||
|
type: 'info',
|
||||||
|
buttons: ['Restart Now', 'Later'],
|
||||||
|
title: 'Restart Required',
|
||||||
|
message: `"${plugin?.name ?? id}" needs to restart`,
|
||||||
|
detail: `"${plugin?.name ?? id}" plugin requires a restart to take effect`,
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dialogPromise: Promise<Electron.MessageBoxReturnValue>;
|
||||||
|
if (mainWindow) {
|
||||||
|
dialogPromise = dialog.showMessageBox(mainWindow, dialogOptions);
|
||||||
|
} else {
|
||||||
|
dialogPromise = dialog.showMessageBox(dialogOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogPromise.then((dialogOutput) => {
|
||||||
|
switch (dialogOutput.response) {
|
||||||
|
case 0: {
|
||||||
|
restart();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore
|
||||||
|
default: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function initTheme(win: BrowserWindow) {
|
||||||
injectCSS(win.webContents, youtubeMusicCSS);
|
injectCSS(win.webContents, youtubeMusicCSS);
|
||||||
// Load user CSS
|
// Load user CSS
|
||||||
const themes: string[] = config.get('options.themes');
|
const themes: string[] = config.get('options.themes');
|
||||||
@ -108,7 +222,10 @@ async function loadPlugins(win: BrowserWindow) {
|
|||||||
injectCSSAsFile(win.webContents, cssFile);
|
injectCSSAsFile(win.webContents, cssFile);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
console.warn(`CSS file "${cssFile}" does not exist, ignoring`);
|
console.warn(
|
||||||
|
LoggerPrefix,
|
||||||
|
`CSS file "${cssFile}" does not exist, ignoring`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -116,24 +233,10 @@ async function loadPlugins(win: BrowserWindow) {
|
|||||||
|
|
||||||
win.webContents.once('did-finish-load', () => {
|
win.webContents.once('did-finish-load', () => {
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log('did finish load');
|
console.log(LoggerPrefix, 'did finish load');
|
||||||
win.webContents.openDevTools();
|
win.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [plugin, options] of config.plugins.getEnabled()) {
|
|
||||||
try {
|
|
||||||
if (Object.hasOwn(mainPlugins, plugin)) {
|
|
||||||
console.log('Loaded plugin - ' + plugin);
|
|
||||||
const handler = mainPlugins[plugin as keyof typeof mainPlugins];
|
|
||||||
if (handler) {
|
|
||||||
await handler(win, options as never);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to load plugin "${plugin}"`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createMainWindow() {
|
async function createMainWindow() {
|
||||||
@ -160,21 +263,24 @@ async function createMainWindow() {
|
|||||||
...(isTesting()
|
...(isTesting()
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
// Sandbox is only enabled in tests for now
|
// Sandbox is only enabled in tests for now
|
||||||
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
frame: !is.macOS() && !useInlineMenu,
|
frame: !is.macOS() && !useInlineMenu,
|
||||||
titleBarOverlay: defaultTitleBarOverlayOptions,
|
titleBarOverlay: defaultTitleBarOverlayOptions,
|
||||||
titleBarStyle: useInlineMenu
|
titleBarStyle: useInlineMenu
|
||||||
? 'hidden'
|
? 'hidden'
|
||||||
: (is.macOS()
|
: is.macOS()
|
||||||
? 'hiddenInset'
|
? 'hiddenInset'
|
||||||
: 'default'),
|
: 'default',
|
||||||
autoHideMenuBar: config.get('options.hideMenu'),
|
autoHideMenuBar: config.get('options.hideMenu'),
|
||||||
});
|
});
|
||||||
await loadPlugins(win);
|
initHook(win);
|
||||||
|
initTheme(win);
|
||||||
|
|
||||||
|
await loadAllMainPlugins(win);
|
||||||
|
|
||||||
|
|
||||||
if (windowPosition) {
|
if (windowPosition) {
|
||||||
@ -198,7 +304,11 @@ async function createMainWindow() {
|
|||||||
// Window is offscreen
|
// Window is offscreen
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log(
|
console.log(
|
||||||
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(display.bounds)}, position=${String(windowPosition)}`,
|
`Window tried to render offscreen, windowSize=${String(
|
||||||
|
winSize,
|
||||||
|
)}, displaySize=${String(display.bounds)}, position=${String(
|
||||||
|
windowPosition,
|
||||||
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -221,39 +331,22 @@ async function createMainWindow() {
|
|||||||
: config.defaultConfig.url;
|
: config.defaultConfig.url;
|
||||||
win.on('closed', onClosed);
|
win.on('closed', onClosed);
|
||||||
|
|
||||||
type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture'];
|
|
||||||
const setPiPOptions = config.plugins.isEnabled('picture-in-picture')
|
|
||||||
? (key: string, value: unknown) => pipSetOptions({ [key]: value })
|
|
||||||
: () => {};
|
|
||||||
|
|
||||||
win.on('move', () => {
|
win.on('move', () => {
|
||||||
if (win.isMaximized()) {
|
if (win.isMaximized()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = win.getPosition();
|
const [x, y] = win.getPosition();
|
||||||
const isPiPEnabled: boolean
|
lateSave('window-position', { x, y });
|
||||||
= config.plugins.isEnabled('picture-in-picture')
|
|
||||||
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
|
|
||||||
if (!isPiPEnabled) {
|
|
||||||
|
|
||||||
lateSave('window-position', { x: position[0], y: position[1] });
|
|
||||||
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').savePosition) {
|
|
||||||
lateSave('pip-position', position, setPiPOptions);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let winWasMaximized: boolean;
|
let winWasMaximized: boolean;
|
||||||
|
|
||||||
win.on('resize', () => {
|
win.on('resize', () => {
|
||||||
const windowSize = win.getSize();
|
const [width, height] = win.getSize();
|
||||||
const isMaximized = win.isMaximized();
|
const isMaximized = win.isMaximized();
|
||||||
|
|
||||||
const isPiPEnabled
|
if (winWasMaximized !== isMaximized) {
|
||||||
= config.plugins.isEnabled('picture-in-picture')
|
|
||||||
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
|
|
||||||
|
|
||||||
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
|
|
||||||
winWasMaximized = isMaximized;
|
winWasMaximized = isMaximized;
|
||||||
config.set('window-maximized', isMaximized);
|
config.set('window-maximized', isMaximized);
|
||||||
}
|
}
|
||||||
@ -262,19 +355,19 @@ async function createMainWindow() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPiPEnabled) {
|
lateSave('window-size', {
|
||||||
lateSave('window-size', {
|
width,
|
||||||
width: windowSize[0],
|
height,
|
||||||
height: windowSize[1],
|
});
|
||||||
});
|
|
||||||
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').saveSize) {
|
|
||||||
lateSave('pip-size', windowSize, setPiPOptions);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
|
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
|
||||||
|
|
||||||
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) {
|
function lateSave(
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
fn: (key: string, value: unknown) => void = config.set,
|
||||||
|
) {
|
||||||
if (savedTimeouts[key]) {
|
if (savedTimeouts[key]) {
|
||||||
clearTimeout(savedTimeouts[key]);
|
clearTimeout(savedTimeouts[key]);
|
||||||
}
|
}
|
||||||
@ -285,7 +378,7 @@ async function createMainWindow() {
|
|||||||
}, 600);
|
}, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on('render-process-gone', (event, webContents, details) => {
|
app.on('render-process-gone', (_event, _webContents, details) => {
|
||||||
showUnresponsiveDialog(win, details);
|
showUnresponsiveDialog(win, details);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -301,7 +394,10 @@ async function createMainWindow() {
|
|||||||
if (useInlineMenu) {
|
if (useInlineMenu) {
|
||||||
win.setTitleBarOverlay({
|
win.setTitleBarOverlay({
|
||||||
...defaultTitleBarOverlayOptions,
|
...defaultTitleBarOverlayOptions,
|
||||||
height: Math.floor(defaultTitleBarOverlayOptions.height! * win.webContents.getZoomFactor()),
|
height: Math.floor(
|
||||||
|
defaultTitleBarOverlayOptions.height! *
|
||||||
|
win.webContents.getZoomFactor(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,14 +419,25 @@ async function createMainWindow() {
|
|||||||
`);
|
`);
|
||||||
} else {
|
} else {
|
||||||
const rendererPath = path.join(__dirname, '..', 'renderer');
|
const rendererPath = path.join(__dirname, '..', 'renderer');
|
||||||
const indexHTML = parse(fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'));
|
const indexHTML = parse(
|
||||||
|
fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'),
|
||||||
|
);
|
||||||
const scriptSrc = indexHTML.querySelector('script')!;
|
const scriptSrc = indexHTML.querySelector('script')!;
|
||||||
const scriptPath = path.join(rendererPath, scriptSrc.getAttribute('src')!);
|
const scriptPath = path.join(
|
||||||
|
rendererPath,
|
||||||
|
scriptSrc.getAttribute('src')!,
|
||||||
|
);
|
||||||
const scriptString = fs.readFileSync(scriptPath, 'utf-8');
|
const scriptString = fs.readFileSync(scriptPath, 'utf-8');
|
||||||
await win.webContents.executeJavaScriptInIsolatedWorld(0, [{
|
await win.webContents.executeJavaScriptInIsolatedWorld(
|
||||||
code: scriptString + ';0',
|
0,
|
||||||
url: url.pathToFileURL(scriptPath).toString(),
|
[
|
||||||
}], true);
|
{
|
||||||
|
code: scriptString + ';0',
|
||||||
|
url: url.pathToFileURL(scriptPath).toString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -339,27 +446,32 @@ async function createMainWindow() {
|
|||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.once('browser-window-created', (event, win) => {
|
app.once('browser-window-created', (_event, win) => {
|
||||||
if (config.get('options.overrideUserAgent')) {
|
if (config.get('options.overrideUserAgent')) {
|
||||||
// User agents are from https://developers.whatismybrowser.com/useragents/explore/
|
// User agents are from https://developers.whatismybrowser.com/useragents/explore/
|
||||||
const originalUserAgent = win.webContents.userAgent;
|
const originalUserAgent = win.webContents.userAgent;
|
||||||
const userAgents = {
|
const userAgents = {
|
||||||
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0',
|
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||||
windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
windows:
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||||
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedUserAgent
|
const updatedUserAgent = is.macOS()
|
||||||
= is.macOS() ? userAgents.mac
|
? userAgents.mac
|
||||||
: (is.windows() ? userAgents.windows
|
: is.windows()
|
||||||
: userAgents.linux);
|
? userAgents.windows
|
||||||
|
: userAgents.linux;
|
||||||
|
|
||||||
win.webContents.userAgent = updatedUserAgent;
|
win.webContents.userAgent = updatedUserAgent;
|
||||||
app.userAgentFallback = updatedUserAgent;
|
app.userAgentFallback = updatedUserAgent;
|
||||||
|
|
||||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
|
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
|
||||||
// This will only happen if login failed, and "retry" was pressed
|
// This will only happen if login failed, and "retry" was pressed
|
||||||
if (win.webContents.getURL().startsWith('https://accounts.google.com') && details.url.startsWith('https://accounts.google.com')) {
|
if (
|
||||||
|
win.webContents.getURL().startsWith('https://accounts.google.com') &&
|
||||||
|
details.url.startsWith('https://accounts.google.com')
|
||||||
|
) {
|
||||||
details.requestHeaders['User-Agent'] = originalUserAgent;
|
details.requestHeaders['User-Agent'] = originalUserAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -370,33 +482,41 @@ app.once('browser-window-created', (event, win) => {
|
|||||||
setupSongInfo(win);
|
setupSongInfo(win);
|
||||||
setupAppControls();
|
setupAppControls();
|
||||||
|
|
||||||
win.webContents.on('did-fail-load', (
|
win.webContents.on(
|
||||||
_event,
|
'did-fail-load',
|
||||||
errorCode,
|
(
|
||||||
errorDescription,
|
_event,
|
||||||
validatedURL,
|
|
||||||
isMainFrame,
|
|
||||||
frameProcessId,
|
|
||||||
frameRoutingId,
|
|
||||||
) => {
|
|
||||||
const log = JSON.stringify({
|
|
||||||
error: 'did-fail-load',
|
|
||||||
errorCode,
|
errorCode,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
validatedURL,
|
validatedURL,
|
||||||
isMainFrame,
|
isMainFrame,
|
||||||
frameProcessId,
|
frameProcessId,
|
||||||
frameRoutingId,
|
frameRoutingId,
|
||||||
}, null, '\t');
|
) => {
|
||||||
if (is.dev()) {
|
const log = JSON.stringify(
|
||||||
console.log(log);
|
{
|
||||||
}
|
error: 'did-fail-load',
|
||||||
|
errorCode,
|
||||||
|
errorDescription,
|
||||||
|
validatedURL,
|
||||||
|
isMainFrame,
|
||||||
|
frameProcessId,
|
||||||
|
frameRoutingId,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
'\t',
|
||||||
|
);
|
||||||
|
if (is.dev()) {
|
||||||
|
console.log(log);
|
||||||
|
}
|
||||||
|
|
||||||
if (errorCode !== -3) { // -3 is a false positive
|
if (errorCode !== -3) {
|
||||||
win.webContents.send('log', log);
|
// -3 is a false positive
|
||||||
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
win.webContents.send('log', log);
|
||||||
}
|
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
win.webContents.on('will-prevent-unload', (event) => {
|
win.webContents.on('will-prevent-unload', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -422,7 +542,7 @@ app.on('activate', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('ready', async () => {
|
app.whenReady().then(async () => {
|
||||||
if (config.get('options.autoResetAppCache')) {
|
if (config.get('options.autoResetAppCache')) {
|
||||||
// Clear cache after 20s
|
// Clear cache after 20s
|
||||||
const clearCacheTimeout = setTimeout(() => {
|
const clearCacheTimeout = setTimeout(() => {
|
||||||
@ -442,17 +562,29 @@ app.on('ready', async () => {
|
|||||||
const appLocation = process.execPath;
|
const appLocation = process.execPath;
|
||||||
const appData = app.getPath('appData');
|
const appData = app.getPath('appData');
|
||||||
// Check shortcut validity if not in dev mode / running portable app
|
// Check shortcut validity if not in dev mode / running portable app
|
||||||
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) {
|
if (
|
||||||
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk');
|
!is.dev() &&
|
||||||
try { // Check if shortcut is registered and valid
|
!appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))
|
||||||
|
) {
|
||||||
|
const shortcutPath = path.join(
|
||||||
|
appData,
|
||||||
|
'Microsoft',
|
||||||
|
'Windows',
|
||||||
|
'Start Menu',
|
||||||
|
'Programs',
|
||||||
|
'YouTube Music.lnk',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// Check if shortcut is registered and valid
|
||||||
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
|
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
|
||||||
if (
|
if (
|
||||||
shortcutDetails.target !== appLocation
|
shortcutDetails.target !== appLocation ||
|
||||||
|| shortcutDetails.appUserModelId !== appID
|
shortcutDetails.appUserModelId !== appID
|
||||||
) {
|
) {
|
||||||
throw 'needUpdate';
|
throw 'needUpdate';
|
||||||
}
|
}
|
||||||
} catch (error) { // If not valid -> Register shortcut
|
} catch (error) {
|
||||||
|
// If not valid -> Register shortcut
|
||||||
shell.writeShortcutLink(
|
shell.writeShortcutLink(
|
||||||
shortcutPath,
|
shortcutPath,
|
||||||
error === 'needUpdate' ? 'update' : 'create',
|
error === 'needUpdate' ? 'update' : 'create',
|
||||||
@ -468,8 +600,8 @@ app.on('ready', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mainWindow = await createMainWindow();
|
mainWindow = await createMainWindow();
|
||||||
setApplicationMenu(mainWindow);
|
await setApplicationMenu(mainWindow);
|
||||||
refreshMenu(mainWindow);
|
await refreshMenu(mainWindow);
|
||||||
setUpTray(app, mainWindow);
|
setUpTray(app, mainWindow);
|
||||||
|
|
||||||
setupProtocolHandler(mainWindow);
|
setupProtocolHandler(mainWindow);
|
||||||
@ -514,8 +646,8 @@ app.on('ready', async () => {
|
|||||||
clearTimeout(updateTimeout);
|
clearTimeout(updateTimeout);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
autoUpdater.on('update-available', () => {
|
autoUpdater.on('update-available', () => {
|
||||||
const downloadLink
|
const downloadLink =
|
||||||
= 'https://github.com/th-ch/youtube-music/releases/latest';
|
'https://github.com/th-ch/youtube-music/releases/latest';
|
||||||
const dialogOptions: Electron.MessageBoxOptions = {
|
const dialogOptions: Electron.MessageBoxOptions = {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
buttons: ['OK', 'Download', 'Disable updates'],
|
buttons: ['OK', 'Download', 'Disable updates'],
|
||||||
@ -555,8 +687,10 @@ app.on('ready', async () => {
|
|||||||
|
|
||||||
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
|
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
|
||||||
dialog.showMessageBox(mainWindow, {
|
dialog.showMessageBox(mainWindow, {
|
||||||
type: 'info', title: 'Hide Menu Enabled',
|
type: 'info',
|
||||||
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
|
title: 'Hide Menu Enabled',
|
||||||
|
message:
|
||||||
|
"Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
|
||||||
});
|
});
|
||||||
config.set('options.hideMenuWarned', true);
|
config.set('options.hideMenuWarned', true);
|
||||||
}
|
}
|
||||||
@ -582,31 +716,36 @@ app.on('ready', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) {
|
function showUnresponsiveDialog(
|
||||||
|
win: BrowserWindow,
|
||||||
|
details: Electron.RenderProcessGoneDetails,
|
||||||
|
) {
|
||||||
if (details) {
|
if (details) {
|
||||||
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
|
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.showMessageBox(win, {
|
dialog
|
||||||
type: 'error',
|
.showMessageBox(win, {
|
||||||
title: 'Window Unresponsive',
|
type: 'error',
|
||||||
message: 'The Application is Unresponsive',
|
title: 'Window Unresponsive',
|
||||||
detail: 'We are sorry for the inconvenience! please choose what to do:',
|
message: 'The Application is Unresponsive',
|
||||||
buttons: ['Wait', 'Relaunch', 'Quit'],
|
detail: 'We are sorry for the inconvenience! please choose what to do:',
|
||||||
cancelId: 0,
|
buttons: ['Wait', 'Relaunch', 'Quit'],
|
||||||
}).then((result) => {
|
cancelId: 0,
|
||||||
switch (result.response) {
|
})
|
||||||
case 1: {
|
.then((result) => {
|
||||||
restart();
|
switch (result.response) {
|
||||||
break;
|
case 1: {
|
||||||
}
|
restart();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 2: {
|
case 2: {
|
||||||
app.quit();
|
app.quit();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeContentSecurityPolicy(
|
function removeContentSecurityPolicy(
|
||||||
@ -629,18 +768,21 @@ function removeContentSecurityPolicy(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When multiple listeners are defined, apply them all
|
// When multiple listeners are defined, apply them all
|
||||||
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => {
|
betterSession.webRequest.setResolver(
|
||||||
return listeners.reduce(
|
'onHeadersReceived',
|
||||||
async (accumulator, listener) => {
|
async (listeners) => {
|
||||||
const acc = await accumulator;
|
return listeners.reduce(
|
||||||
if (acc.cancel) {
|
async (accumulator, listener) => {
|
||||||
return acc;
|
const acc = await accumulator;
|
||||||
}
|
if (acc.cancel) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await listener.apply();
|
const result = await listener.apply();
|
||||||
return { ...accumulator, ...result };
|
return { ...accumulator, ...result };
|
||||||
},
|
},
|
||||||
Promise.resolve({ cancel: false }),
|
Promise.resolve({ cancel: false }),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
144
src/loader/main.ts
Normal file
144
src/loader/main.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { allPlugins, mainPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import config from '@/config';
|
||||||
|
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||||
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
|
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
||||||
|
|
||||||
|
const createContext = (id: string, win: BrowserWindow): BackendContext<PluginConfig> => ({
|
||||||
|
getConfig: () =>
|
||||||
|
deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
config.get(`plugins.${id}`) ?? {},
|
||||||
|
) as PluginConfig,
|
||||||
|
setConfig: (newConfig) => {
|
||||||
|
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
|
||||||
|
},
|
||||||
|
|
||||||
|
ipc: {
|
||||||
|
send: (event: string, ...args: unknown[]) => {
|
||||||
|
win.webContents.send(event, ...args);
|
||||||
|
},
|
||||||
|
handle: (event: string, listener: CallableFunction) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
ipcMain.handle(event, (_, ...args: unknown[]) => listener(...args));
|
||||||
|
},
|
||||||
|
on: (event: string, listener: CallableFunction) => {
|
||||||
|
ipcMain.on(event, (_, ...args: unknown[]) => {
|
||||||
|
listener(...args);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeHandler: (event: string) => {
|
||||||
|
ipcMain.removeHandler(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
window: win,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forceUnloadMainPlugin = async (
|
||||||
|
id: string,
|
||||||
|
win: BrowserWindow,
|
||||||
|
): Promise<void> => {
|
||||||
|
const plugin = loadedPluginMap[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasStopped = await stopPlugin(id, plugin, {
|
||||||
|
ctx: 'backend',
|
||||||
|
context: createContext(id, win),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
hasStopped ||
|
||||||
|
(
|
||||||
|
hasStopped === null &&
|
||||||
|
typeof plugin.backend !== 'function' && plugin.backend
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
delete loadedPluginMap[id];
|
||||||
|
console.log(LoggerPrefix, `"${id}" plugin is unloaded`);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
`Cannot unload "${id}" plugin`,
|
||||||
|
);
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(LoggerPrefix, `Cannot unload "${id}" plugin`);
|
||||||
|
console.trace(err);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forceLoadMainPlugin = async (
|
||||||
|
id: string,
|
||||||
|
win: BrowserWindow,
|
||||||
|
): Promise<void> => {
|
||||||
|
const plugin = mainPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasStarted = await startPlugin(id, plugin, {
|
||||||
|
ctx: 'backend',
|
||||||
|
context: createContext(id, win),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
hasStarted ||
|
||||||
|
(
|
||||||
|
hasStarted === null &&
|
||||||
|
typeof plugin.backend !== 'function' && plugin.backend
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
loadedPluginMap[id] = plugin;
|
||||||
|
} else {
|
||||||
|
console.log(LoggerPrefix, `Cannot load "${id}" plugin`);
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
`Cannot initialize "${id}" plugin: `,
|
||||||
|
);
|
||||||
|
console.trace(err);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAllMainPlugins = async (win: BrowserWindow) => {
|
||||||
|
console.log(LoggerPrefix, 'Loading all plugins');
|
||||||
|
const pluginConfigs = config.plugins.getPlugins();
|
||||||
|
const queue: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const [plugin, pluginDef] of Object.entries(mainPlugins)) {
|
||||||
|
const config = deepmerge(pluginDef.config, pluginConfigs[plugin] ?? {});
|
||||||
|
if (config.enabled) {
|
||||||
|
queue.push(forceLoadMainPlugin(plugin, win));
|
||||||
|
} else if (loadedPluginMap[plugin]) {
|
||||||
|
queue.push(forceUnloadMainPlugin(plugin, win));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(queue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unloadAllMainPlugins = async (win: BrowserWindow) => {
|
||||||
|
for (const id of Object.keys(loadedPluginMap)) {
|
||||||
|
await forceUnloadMainPlugin(id, win);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoadedMainPlugin = (id: string): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||||
|
return loadedPluginMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoadedMainPlugins = () => {
|
||||||
|
return loadedPluginMap;
|
||||||
|
};
|
||||||
76
src/loader/menu.ts
Normal file
76
src/loader/menu.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { allPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import config from '@/config';
|
||||||
|
import { setApplicationMenu } from '@/menu';
|
||||||
|
|
||||||
|
import { LoggerPrefix } from '@/utils';
|
||||||
|
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
|
||||||
|
import type { PluginConfig } from '@/types/plugins';
|
||||||
|
|
||||||
|
const menuTemplateMap: Record<string, MenuItemConstructorOptions[]> = {};
|
||||||
|
const createContext = (id: string, win: BrowserWindow): MenuContext<PluginConfig> => ({
|
||||||
|
getConfig: () =>
|
||||||
|
deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
config.get(`plugins.${id}`) ?? {},
|
||||||
|
) as PluginConfig,
|
||||||
|
setConfig: (newConfig) => {
|
||||||
|
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
|
||||||
|
},
|
||||||
|
window: win,
|
||||||
|
refresh: async () => {
|
||||||
|
await setApplicationMenu(win);
|
||||||
|
|
||||||
|
if (config.plugins.isEnabled('in-app-menu')) {
|
||||||
|
win.webContents.send('refresh-in-app-menu');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forceLoadMenuPlugin = async (id: string, win: BrowserWindow) => {
|
||||||
|
try {
|
||||||
|
const plugin = allPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const menu = plugin.menu?.(createContext(id, win));
|
||||||
|
if (menu) {
|
||||||
|
const result = await menu;
|
||||||
|
if (result.length > 0) {
|
||||||
|
menuTemplateMap[id] = result;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else return;
|
||||||
|
|
||||||
|
console.log(LoggerPrefix, `Successfully loaded '${id}::menu'`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(LoggerPrefix, `Cannot initialize '${id}::menu': `);
|
||||||
|
console.trace(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAllMenuPlugins = async (win: BrowserWindow) => {
|
||||||
|
const pluginConfigs = config.plugins.getPlugins();
|
||||||
|
|
||||||
|
for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
|
||||||
|
const config = deepmerge(pluginDef.config ?? { enabled: false }, pluginConfigs[pluginId] ?? {});
|
||||||
|
|
||||||
|
if (config.enabled) {
|
||||||
|
await forceLoadMenuPlugin(pluginId, win);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMenuTemplate = (
|
||||||
|
id: string,
|
||||||
|
): MenuItemConstructorOptions[] | undefined => {
|
||||||
|
return menuTemplateMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllMenuTemplate = () => {
|
||||||
|
return menuTemplateMap;
|
||||||
|
};
|
||||||
102
src/loader/preload.ts
Normal file
102
src/loader/preload.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { allPlugins, preloadPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
|
import type { PreloadContext } from '@/types/contexts';
|
||||||
|
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||||
|
|
||||||
|
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
||||||
|
const createContext = (id: string): PreloadContext<PluginConfig> => ({
|
||||||
|
getConfig: () =>
|
||||||
|
deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
config.get(`plugins.${id}`) ?? {},
|
||||||
|
) as PluginConfig,
|
||||||
|
setConfig: (newConfig) => {
|
||||||
|
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forceUnloadPreloadPlugin = async (id: string) => {
|
||||||
|
if (!loadedPluginMap[id]) return;
|
||||||
|
|
||||||
|
const hasStopped = await stopPlugin(id, loadedPluginMap[id], {
|
||||||
|
ctx: 'preload',
|
||||||
|
context: createContext(id),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
hasStopped ||
|
||||||
|
(
|
||||||
|
hasStopped === null &&
|
||||||
|
loadedPluginMap[id].preload
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
console.log(LoggerPrefix, `"${id}" plugin is unloaded`);
|
||||||
|
delete loadedPluginMap[id];
|
||||||
|
} else {
|
||||||
|
console.error(LoggerPrefix, `Cannot stop "${id}" plugin`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forceLoadPreloadPlugin = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const plugin = preloadPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const hasStarted = await startPlugin(id, plugin, {
|
||||||
|
ctx: 'preload',
|
||||||
|
context: createContext(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasStarted ||
|
||||||
|
(
|
||||||
|
hasStarted === null &&
|
||||||
|
typeof plugin.preload !== 'function' && plugin.preload
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
loadedPluginMap[id] = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(LoggerPrefix, `"${id}" plugin is loaded`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
`Cannot initialize "${id}" plugin: `,
|
||||||
|
);
|
||||||
|
console.trace(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAllPreloadPlugins = () => {
|
||||||
|
const pluginConfigs = config.plugins.getPlugins();
|
||||||
|
|
||||||
|
for (const [pluginId, pluginDef] of Object.entries(preloadPlugins)) {
|
||||||
|
const config = deepmerge(pluginDef.config ?? { enable: false }, pluginConfigs[pluginId] ?? {});
|
||||||
|
|
||||||
|
if (config.enabled) {
|
||||||
|
forceLoadPreloadPlugin(pluginId);
|
||||||
|
} else {
|
||||||
|
if (loadedPluginMap[pluginId]) {
|
||||||
|
forceUnloadPreloadPlugin(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unloadAllPreloadPlugins = async () => {
|
||||||
|
for (const id of Object.keys(loadedPluginMap)) {
|
||||||
|
await forceUnloadPreloadPlugin(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoadedPreloadPlugin = (id: string): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||||
|
return loadedPluginMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoadedPreloadPlugins = () => {
|
||||||
|
return loadedPluginMap;
|
||||||
|
};
|
||||||
124
src/loader/renderer.ts
Normal file
124
src/loader/renderer.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
|
||||||
|
import { rendererPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||||
|
|
||||||
|
const unregisterStyleMap: Record<string, (() => void)[]> = {};
|
||||||
|
const loadedPluginMap: Record<string, PluginDef<unknown, unknown, unknown>> = {};
|
||||||
|
|
||||||
|
export const createContext = <Config extends PluginConfig>(id: string): RendererContext<Config> => ({
|
||||||
|
getConfig: async () => window.ipcRenderer.invoke('get-config', id),
|
||||||
|
setConfig: async (newConfig) => {
|
||||||
|
await window.ipcRenderer.invoke('set-config', id, newConfig);
|
||||||
|
},
|
||||||
|
ipc: {
|
||||||
|
send: (event: string, ...args: unknown[]) => {
|
||||||
|
window.ipcRenderer.send(event, ...args);
|
||||||
|
},
|
||||||
|
invoke: (event: string, ...args: unknown[]) => window.ipcRenderer.invoke(event, ...args),
|
||||||
|
on: (event: string, listener: CallableFunction) => {
|
||||||
|
window.ipcRenderer.on(event, (_, ...args: unknown[]) => {
|
||||||
|
listener(...args);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeAllListeners: (event: string) => {
|
||||||
|
window.ipcRenderer.removeAllListeners(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forceUnloadRendererPlugin = async (id: string) => {
|
||||||
|
unregisterStyleMap[id]?.forEach((unregister) => unregister());
|
||||||
|
|
||||||
|
delete unregisterStyleMap[id];
|
||||||
|
delete loadedPluginMap[id];
|
||||||
|
|
||||||
|
const plugin = rendererPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const hasStopped = await stopPlugin(id, plugin, { ctx: 'renderer', context: createContext(id) });
|
||||||
|
if (plugin?.stylesheets) {
|
||||||
|
document.querySelector(`style#plugin-${id}`)?.remove();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
hasStopped ||
|
||||||
|
(
|
||||||
|
hasStopped === null &&
|
||||||
|
plugin?.renderer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
console.log(LoggerPrefix, `"${id}" plugin is unloaded`);
|
||||||
|
} else {
|
||||||
|
console.error(LoggerPrefix, `Cannot stop "${id}" plugin`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forceLoadRendererPlugin = async (id: string) => {
|
||||||
|
const plugin = rendererPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const hasEvaled = await startPlugin(id, plugin, {
|
||||||
|
ctx: 'renderer',
|
||||||
|
context: createContext(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasEvaled ||
|
||||||
|
plugin?.stylesheets ||
|
||||||
|
(
|
||||||
|
hasEvaled === null &&
|
||||||
|
typeof plugin?.renderer !== 'function' && plugin?.renderer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
loadedPluginMap[id] = plugin;
|
||||||
|
|
||||||
|
if (plugin?.stylesheets) {
|
||||||
|
const styleSheetList = plugin.stylesheets.map((style) => {
|
||||||
|
const styleSheet = new CSSStyleSheet();
|
||||||
|
styleSheet.replaceSync(style);
|
||||||
|
|
||||||
|
return styleSheet;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.adoptedStyleSheets = [...document.adoptedStyleSheets, ...styleSheetList];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(LoggerPrefix, `"${id}" plugin is loaded`);
|
||||||
|
} else {
|
||||||
|
console.log(LoggerPrefix, `Cannot initialize "${id}" plugin`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAllRendererPlugins = async () => {
|
||||||
|
const pluginConfigs = window.mainConfig.plugins.getPlugins();
|
||||||
|
|
||||||
|
for (const [pluginId, pluginDef] of Object.entries(rendererPlugins)) {
|
||||||
|
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {}) ;
|
||||||
|
|
||||||
|
if (config.enabled) {
|
||||||
|
await forceLoadRendererPlugin(pluginId);
|
||||||
|
} else {
|
||||||
|
if (loadedPluginMap[pluginId]) {
|
||||||
|
await forceUnloadRendererPlugin(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unloadAllRendererPlugins = async () => {
|
||||||
|
for (const id of Object.keys(loadedPluginMap)) {
|
||||||
|
await forceUnloadRendererPlugin(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoadedRendererPlugin = (id: string): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||||
|
return loadedPluginMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoadedRendererPlugins = () => {
|
||||||
|
return loadedPluginMap;
|
||||||
|
};
|
||||||
298
src/menu.ts
298
src/menu.ts
@ -1,25 +1,35 @@
|
|||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
clipboard,
|
||||||
|
dialog,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
} from 'electron';
|
||||||
import prompt from 'custom-electron-prompt';
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
import { restart } from './providers/app-controls';
|
import { allPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
import config from './config';
|
import config from './config';
|
||||||
|
|
||||||
|
import { restart } from './providers/app-controls';
|
||||||
import { startingPages } from './providers/extracted-data';
|
import { startingPages } from './providers/extracted-data';
|
||||||
import promptOptions from './providers/prompt-options';
|
import promptOptions from './providers/prompt-options';
|
||||||
|
|
||||||
// eslint-disable-next-line import/order
|
import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
|
||||||
import { menuPlugins as menuList } from 'virtual:MenuPlugins';
|
|
||||||
|
|
||||||
import { getAvailablePluginNames } from './plugins/utils/main';
|
|
||||||
|
|
||||||
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
|
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
|
||||||
|
|
||||||
// True only if in-app-menu was loaded on launch
|
// True only if in-app-menu was loaded on launch
|
||||||
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
|
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
|
||||||
|
|
||||||
const betaPlugins = ['crossfade', 'lumiastream'];
|
const pluginEnabledMenu = (
|
||||||
|
plugin: string,
|
||||||
const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
|
label = '',
|
||||||
|
hasSubmenu = false,
|
||||||
|
refreshMenu: (() => void) | undefined = undefined,
|
||||||
|
): Electron.MenuItemConstructorOptions => ({
|
||||||
label: label || plugin,
|
label: label || plugin,
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.plugins.isEnabled(plugin),
|
checked: config.plugins.isEnabled(plugin),
|
||||||
@ -36,45 +46,64 @@ const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refre
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const refreshMenu = (win: BrowserWindow) => {
|
export const refreshMenu = async (win: BrowserWindow) => {
|
||||||
setApplicationMenu(win);
|
await setApplicationMenu(win);
|
||||||
if (inAppMenuActive) {
|
if (inAppMenuActive) {
|
||||||
win.webContents.send('refresh-in-app-menu');
|
win.webContents.send('refresh-in-app-menu');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
export const mainMenuTemplate = async (win: BrowserWindow): Promise<MenuTemplate> => {
|
||||||
const innerRefreshMenu = () => refreshMenu(win);
|
const innerRefreshMenu = () => refreshMenu(win);
|
||||||
|
|
||||||
|
await loadAllMenuPlugins(win);
|
||||||
|
|
||||||
|
const menuResult = Object.entries(getAllMenuTemplate()).map(
|
||||||
|
([id, template]) => {
|
||||||
|
const pluginLabel = allPlugins[id]?.name ?? id;
|
||||||
|
|
||||||
|
if (!config.plugins.isEnabled(id)) {
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu),
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
label: pluginLabel,
|
||||||
|
submenu: [
|
||||||
|
pluginEnabledMenu(id, 'Enabled', true, innerRefreshMenu),
|
||||||
|
{ type: 'separator' },
|
||||||
|
...template,
|
||||||
|
],
|
||||||
|
} satisfies Electron.MenuItemConstructorOptions,
|
||||||
|
] as const;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const availablePlugins = Object.keys(allPlugins);
|
||||||
|
const pluginMenus = availablePlugins
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aPluginLabel = allPlugins[a]?.name ?? a;
|
||||||
|
const bPluginLabel = allPlugins[b]?.name ?? b;
|
||||||
|
|
||||||
|
return aPluginLabel.localeCompare(bPluginLabel);
|
||||||
|
})
|
||||||
|
.map((id) => {
|
||||||
|
const predefinedTemplate = menuResult.find((it) => it[0] === id);
|
||||||
|
if (predefinedTemplate) return predefinedTemplate[1];
|
||||||
|
|
||||||
|
const pluginLabel = allPlugins[id]?.name ?? id;
|
||||||
|
|
||||||
|
return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu);
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Plugins',
|
label: 'Plugins',
|
||||||
submenu:
|
submenu: pluginMenus,
|
||||||
getAvailablePluginNames().map((pluginName) => {
|
|
||||||
let pluginLabel = pluginName;
|
|
||||||
if (betaPlugins.includes(pluginLabel)) {
|
|
||||||
pluginLabel += ' [beta]';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.hasOwn(menuList, pluginName)) {
|
|
||||||
const getPluginMenu = menuList[pluginName];
|
|
||||||
|
|
||||||
if (!config.plugins.isEnabled(pluginName)) {
|
|
||||||
return pluginEnabledMenu(pluginName, pluginLabel, true, innerRefreshMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: pluginLabel,
|
|
||||||
submenu: [
|
|
||||||
pluginEnabledMenu(pluginName, 'Enabled', true, innerRefreshMenu),
|
|
||||||
{ type: 'separator' },
|
|
||||||
...(getPluginMenu(win, config.plugins.getOptions(pluginName), innerRefreshMenu) as MenuTemplate),
|
|
||||||
],
|
|
||||||
} satisfies Electron.MenuItemConstructorOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pluginEnabledMenu(pluginName, pluginLabel);
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Options',
|
label: 'Options',
|
||||||
@ -83,7 +112,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
label: 'Auto-update',
|
label: 'Auto-update',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.autoUpdates'),
|
checked: config.get('options.autoUpdates'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.autoUpdates', item.checked);
|
config.setMenuOption('options.autoUpdates', item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -91,21 +120,22 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
label: 'Resume last song when app starts',
|
label: 'Resume last song when app starts',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.resumeOnStart'),
|
checked: config.get('options.resumeOnStart'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.resumeOnStart', item.checked);
|
config.setMenuOption('options.resumeOnStart', item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Starting page',
|
label: 'Starting page',
|
||||||
submenu: (() => {
|
submenu: (() => {
|
||||||
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({
|
const subMenuArray: Electron.MenuItemConstructorOptions[] =
|
||||||
label: name,
|
Object.keys(startingPages).map((name) => ({
|
||||||
type: 'radio',
|
label: name,
|
||||||
checked: config.get('options.startingPage') === name,
|
type: 'radio',
|
||||||
click() {
|
checked: config.get('options.startingPage') === name,
|
||||||
config.set('options.startingPage', name);
|
click() {
|
||||||
},
|
config.set('options.startingPage', name);
|
||||||
}));
|
},
|
||||||
|
}));
|
||||||
subMenuArray.unshift({
|
subMenuArray.unshift({
|
||||||
label: 'Unset',
|
label: 'Unset',
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
@ -124,8 +154,11 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
label: 'Remove upgrade button',
|
label: 'Remove upgrade button',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.removeUpgradeButton'),
|
checked: config.get('options.removeUpgradeButton'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.removeUpgradeButton', item.checked);
|
config.setMenuOption(
|
||||||
|
'options.removeUpgradeButton',
|
||||||
|
item.checked,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -190,7 +223,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
label: 'Single instance lock',
|
label: 'Single instance lock',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: true,
|
checked: true,
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
if (!item.checked && app.hasSingleInstanceLock()) {
|
if (!item.checked && app.hasSingleInstanceLock()) {
|
||||||
app.releaseSingleInstanceLock();
|
app.releaseSingleInstanceLock();
|
||||||
} else if (item.checked && !app.hasSingleInstanceLock()) {
|
} else if (item.checked && !app.hasSingleInstanceLock()) {
|
||||||
@ -202,43 +235,45 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
label: 'Always on top',
|
label: 'Always on top',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.alwaysOnTop'),
|
checked: config.get('options.alwaysOnTop'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.alwaysOnTop', item.checked);
|
config.setMenuOption('options.alwaysOnTop', item.checked);
|
||||||
win.setAlwaysOnTop(item.checked);
|
win.setAlwaysOnTop(item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(is.windows() || is.linux()
|
...((is.windows() || is.linux()
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: 'Hide menu',
|
label: 'Hide menu',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.hideMenu'),
|
checked: config.get('options.hideMenu'),
|
||||||
click(item) {
|
click(item) {
|
||||||
config.setMenuOption('options.hideMenu', item.checked);
|
config.setMenuOption('options.hideMenu', item.checked);
|
||||||
if (item.checked && !config.get('options.hideMenuWarned')) {
|
if (item.checked && !config.get('options.hideMenuWarned')) {
|
||||||
dialog.showMessageBox(win, {
|
dialog.showMessageBox(win, {
|
||||||
type: 'info', title: 'Hide Menu Enabled',
|
type: 'info',
|
||||||
message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)',
|
title: 'Hide Menu Enabled',
|
||||||
});
|
message:
|
||||||
}
|
'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
]
|
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||||
: []) satisfies Electron.MenuItemConstructorOptions[],
|
...((is.windows() || is.macOS()
|
||||||
...(is.windows() || is.macOS()
|
|
||||||
? // Only works on Win/Mac
|
? // Only works on Win/Mac
|
||||||
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: 'Start at login',
|
label: 'Start at login',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.startAtLogin'),
|
checked: config.get('options.startAtLogin'),
|
||||||
click(item) {
|
click(item) {
|
||||||
config.setMenuOption('options.startAtLogin', item.checked);
|
config.setMenuOption('options.startAtLogin', item.checked);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
]
|
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||||
: []) satisfies Electron.MenuItemConstructorOptions[],
|
|
||||||
{
|
{
|
||||||
label: 'Tray',
|
label: 'Tray',
|
||||||
submenu: [
|
submenu: [
|
||||||
@ -254,7 +289,8 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
{
|
{
|
||||||
label: 'Enabled + app visible',
|
label: 'Enabled + app visible',
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: config.get('options.tray') && config.get('options.appVisible'),
|
checked:
|
||||||
|
config.get('options.tray') && config.get('options.appVisible'),
|
||||||
click() {
|
click() {
|
||||||
config.setMenuOption('options.tray', true);
|
config.setMenuOption('options.tray', true);
|
||||||
config.setMenuOption('options.appVisible', true);
|
config.setMenuOption('options.appVisible', true);
|
||||||
@ -263,7 +299,8 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
{
|
{
|
||||||
label: 'Enabled + app hidden',
|
label: 'Enabled + app hidden',
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: config.get('options.tray') && !config.get('options.appVisible'),
|
checked:
|
||||||
|
config.get('options.tray') && !config.get('options.appVisible'),
|
||||||
click() {
|
click() {
|
||||||
config.setMenuOption('options.tray', true);
|
config.setMenuOption('options.tray', true);
|
||||||
config.setMenuOption('options.appVisible', false);
|
config.setMenuOption('options.appVisible', false);
|
||||||
@ -274,8 +311,11 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
label: 'Play/Pause on click',
|
label: 'Play/Pause on click',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.trayClickPlayPause'),
|
checked: config.get('options.trayClickPlayPause'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.trayClickPlayPause', item.checked);
|
config.setMenuOption(
|
||||||
|
'options.trayClickPlayPause',
|
||||||
|
item.checked,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -287,7 +327,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
{
|
{
|
||||||
label: 'Set Proxy',
|
label: 'Set Proxy',
|
||||||
type: 'normal',
|
type: 'normal',
|
||||||
async click(item) {
|
async click(item: MenuItem) {
|
||||||
await setProxy(item, win);
|
await setProxy(item, win);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -295,7 +335,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
label: 'Override useragent',
|
label: 'Override useragent',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.overrideUserAgent'),
|
checked: config.get('options.overrideUserAgent'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.overrideUserAgent', item.checked);
|
config.setMenuOption('options.overrideUserAgent', item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -303,40 +343,46 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
label: 'Disable hardware acceleration',
|
label: 'Disable hardware acceleration',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.disableHardwareAcceleration'),
|
checked: config.get('options.disableHardwareAcceleration'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.disableHardwareAcceleration', item.checked);
|
config.setMenuOption(
|
||||||
|
'options.disableHardwareAcceleration',
|
||||||
|
item.checked,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Restart on config changes',
|
label: 'Restart on config changes',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.restartOnConfigChanges'),
|
checked: config.get('options.restartOnConfigChanges'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.restartOnConfigChanges', item.checked);
|
config.setMenuOption(
|
||||||
|
'options.restartOnConfigChanges',
|
||||||
|
item.checked,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Reset App cache when app starts',
|
label: 'Reset App cache when app starts',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.autoResetAppCache'),
|
checked: config.get('options.autoResetAppCache'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.autoResetAppCache', item.checked);
|
config.setMenuOption('options.autoResetAppCache', item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
is.macOS()
|
is.macOS()
|
||||||
? {
|
? {
|
||||||
label: 'Toggle DevTools',
|
label: 'Toggle DevTools',
|
||||||
// Cannot use "toggleDevTools" role in macOS
|
// Cannot use "toggleDevTools" role in macOS
|
||||||
click() {
|
click() {
|
||||||
const { webContents } = win;
|
const { webContents } = win;
|
||||||
if (webContents.isDevToolsOpened()) {
|
if (webContents.isDevToolsOpened()) {
|
||||||
webContents.closeDevTools();
|
webContents.closeDevTools();
|
||||||
} else {
|
} else {
|
||||||
webContents.openDevTools();
|
webContents.openDevTools();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: { role: 'toggleDevTools' },
|
: { role: 'toggleDevTools' },
|
||||||
{
|
{
|
||||||
label: 'Edit config.json',
|
label: 'Edit config.json',
|
||||||
@ -354,8 +400,14 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
{ role: 'reload' },
|
{ role: 'reload' },
|
||||||
{ role: 'forceReload' },
|
{ role: 'forceReload' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'zoomIn', accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I' },
|
{
|
||||||
{ role: 'zoomOut', accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O' },
|
role: 'zoomIn',
|
||||||
|
accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'zoomOut',
|
||||||
|
accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O',
|
||||||
|
},
|
||||||
{ role: 'resetZoom' },
|
{ role: 'resetZoom' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'togglefullscreen' },
|
{ role: 'togglefullscreen' },
|
||||||
@ -396,14 +448,12 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'About',
|
label: 'About',
|
||||||
submenu: [
|
submenu: [{ role: 'about' }],
|
||||||
{ role: 'about' },
|
},
|
||||||
],
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
|
export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
|
||||||
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
|
const menuTemplate: MenuTemplate = [...await mainMenuTemplate(win)];
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
const { name } = app;
|
const { name } = app;
|
||||||
menuTemplate.unshift({
|
menuTemplate.unshift({
|
||||||
@ -432,23 +482,27 @@ export const setApplicationMenu = (win: Electron.BrowserWindow) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
|
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
|
||||||
const output = await prompt({
|
const output = await prompt(
|
||||||
title: 'Set Proxy',
|
{
|
||||||
label: 'Enter Proxy Address: (leave empty to disable)',
|
title: 'Set Proxy',
|
||||||
value: config.get('options.proxy'),
|
label: 'Enter Proxy Address: (leave empty to disable)',
|
||||||
type: 'input',
|
value: config.get('options.proxy'),
|
||||||
inputAttrs: {
|
type: 'input',
|
||||||
type: 'url',
|
inputAttrs: {
|
||||||
placeholder: "Example: 'socks5://127.0.0.1:9999",
|
type: 'url',
|
||||||
|
placeholder: "Example: 'socks5://127.0.0.1:9999",
|
||||||
|
},
|
||||||
|
width: 450,
|
||||||
|
...promptOptions(),
|
||||||
},
|
},
|
||||||
width: 450,
|
win,
|
||||||
...promptOptions(),
|
);
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (typeof output === 'string') {
|
if (typeof output === 'string') {
|
||||||
config.setMenuOption('options.proxy', output);
|
config.setMenuOption('options.proxy', output);
|
||||||
item.checked = output !== '';
|
item.checked = output !== '';
|
||||||
} else { // User pressed cancel
|
} else {
|
||||||
|
// User pressed cancel
|
||||||
item.checked = !item.checked; // Reset checkbox
|
item.checked = !item.checked; // Reset checkbox
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,10 +17,12 @@ const SOURCES = [
|
|||||||
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
|
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let blocker: ElectronBlocker | undefined;
|
||||||
|
|
||||||
export const loadAdBlockerEngine = async (
|
export const loadAdBlockerEngine = async (
|
||||||
session: Electron.Session | undefined = undefined,
|
session: Electron.Session | undefined = undefined,
|
||||||
cache = true,
|
cache: boolean = true,
|
||||||
additionalBlockLists = [],
|
additionalBlockLists: string[] = [],
|
||||||
disableDefaultLists: boolean | unknown[] = false,
|
disableDefaultLists: boolean | unknown[] = false,
|
||||||
) => {
|
) => {
|
||||||
// Only use cache if no additional blocklists are passed
|
// Only use cache if no additional blocklists are passed
|
||||||
@ -45,7 +47,7 @@ export const loadAdBlockerEngine = async (
|
|||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blocker = await ElectronBlocker.fromLists(
|
blocker = await ElectronBlocker.fromLists(
|
||||||
(url: string) => net.fetch(url),
|
(url: string) => net.fetch(url),
|
||||||
lists,
|
lists,
|
||||||
{
|
{
|
||||||
@ -64,4 +66,10 @@ export const loadAdBlockerEngine = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { loadAdBlockerEngine };
|
export const unloadAdBlockerEngine = (session: Electron.Session) => {
|
||||||
|
if (blocker) {
|
||||||
|
blocker.disableBlockingInSession(session);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isBlockerEnabled = (session: Electron.Session) => blocker !== undefined && blocker.isBlockingEnabled(session);
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
/* renderer */
|
|
||||||
|
|
||||||
import { blockers } from './blocker-types';
|
|
||||||
|
|
||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('adblocker', { enableFront: true });
|
|
||||||
|
|
||||||
export const shouldUseBlocklists = () => config.get('blocker') !== blockers.InPlayer;
|
|
||||||
|
|
||||||
export default Object.assign(config, {
|
|
||||||
shouldUseBlocklists,
|
|
||||||
blockers,
|
|
||||||
});
|
|
||||||
120
src/plugins/adblocker/index.ts
Normal file
120
src/plugins/adblocker/index.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { blockers } from './types';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { isBlockerEnabled, loadAdBlockerEngine, unloadAdBlockerEngine } from './blocker';
|
||||||
|
|
||||||
|
import injectCliqzPreload from './injectors/inject-cliqz-preload';
|
||||||
|
import { inject, isInjected } from './injectors/inject';
|
||||||
|
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
interface AdblockerConfig {
|
||||||
|
/**
|
||||||
|
* Whether to enable the adblocker.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* When enabled, the adblocker will cache the blocklists.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
cache: boolean;
|
||||||
|
/**
|
||||||
|
* Which adblocker to use.
|
||||||
|
* @default blockers.InPlayer
|
||||||
|
*/
|
||||||
|
blocker: typeof blockers[keyof typeof blockers];
|
||||||
|
/**
|
||||||
|
* Additional list of filters to use.
|
||||||
|
* @example ["https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"]
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
|
additionalBlockLists: string[];
|
||||||
|
/**
|
||||||
|
* Disable the default blocklists.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
disableDefaultLists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Adblocker',
|
||||||
|
restartNeeded: false,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
cache: true,
|
||||||
|
blocker: blockers.InPlayer,
|
||||||
|
additionalBlockLists: [],
|
||||||
|
disableDefaultLists: false,
|
||||||
|
} as AdblockerConfig,
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Blocker',
|
||||||
|
submenu: Object.values(blockers).map((blocker) => ({
|
||||||
|
label: blocker,
|
||||||
|
type: 'radio',
|
||||||
|
checked: (config.blocker || blockers.WithBlocklists) === blocker,
|
||||||
|
click() {
|
||||||
|
setConfig({ blocker });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
backend: {
|
||||||
|
mainWindow: null as BrowserWindow | null,
|
||||||
|
async start({ getConfig, window }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
this.mainWindow = window;
|
||||||
|
|
||||||
|
if (config.blocker === blockers.WithBlocklists) {
|
||||||
|
await loadAdBlockerEngine(
|
||||||
|
window.webContents.session,
|
||||||
|
config.cache,
|
||||||
|
config.additionalBlockLists,
|
||||||
|
config.disableDefaultLists,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop({ window }) {
|
||||||
|
if (isBlockerEnabled(window.webContents.session)) {
|
||||||
|
unloadAdBlockerEngine(window.webContents.session);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onConfigChange(newConfig) {
|
||||||
|
if (this.mainWindow) {
|
||||||
|
if (newConfig.blocker === blockers.WithBlocklists && !isBlockerEnabled(this.mainWindow.webContents.session)) {
|
||||||
|
await loadAdBlockerEngine(
|
||||||
|
this.mainWindow.webContents.session,
|
||||||
|
newConfig.cache,
|
||||||
|
newConfig.additionalBlockLists,
|
||||||
|
newConfig.disableDefaultLists,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
async start({ getConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
if (config.blocker === blockers.WithBlocklists) {
|
||||||
|
// Preload adblocker to inject scripts/styles
|
||||||
|
await injectCliqzPreload();
|
||||||
|
} else if (config.blocker === blockers.InPlayer) {
|
||||||
|
inject();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onConfigChange(newConfig) {
|
||||||
|
if (newConfig.blocker === blockers.WithBlocklists) {
|
||||||
|
await injectCliqzPreload();
|
||||||
|
} else if (newConfig.blocker === blockers.InPlayer) {
|
||||||
|
if (!isInjected()) {
|
||||||
|
inject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
1
src/plugins/adblocker/inject.d.ts
vendored
1
src/plugins/adblocker/inject.d.ts
vendored
@ -1 +0,0 @@
|
|||||||
export const inject: () => void;
|
|
||||||
3
src/plugins/adblocker/injectors/inject.d.ts
vendored
Normal file
3
src/plugins/adblocker/injectors/inject.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const inject: () => void;
|
||||||
|
|
||||||
|
export const isInjected: () => boolean;
|
||||||
@ -7,7 +7,13 @@
|
|||||||
Parts of this code is derived from set-constant.js:
|
Parts of this code is derived from set-constant.js:
|
||||||
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
|
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
let injected = false;
|
||||||
|
|
||||||
|
export const isInjected = () => isInjected;
|
||||||
|
|
||||||
export const inject = () => {
|
export const inject = () => {
|
||||||
|
injected = true;
|
||||||
{
|
{
|
||||||
const pruner = function (o) {
|
const pruner = function (o) {
|
||||||
delete o.playerAds;
|
delete o.playerAds;
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { loadAdBlockerEngine } from './blocker';
|
|
||||||
import { shouldUseBlocklists } from './config';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
type AdBlockOptions = ConfigType<'adblocker'>;
|
|
||||||
|
|
||||||
export default async (win: BrowserWindow, options: AdBlockOptions) => {
|
|
||||||
if (shouldUseBlocklists()) {
|
|
||||||
await loadAdBlockerEngine(
|
|
||||||
win.webContents.session,
|
|
||||||
options.cache,
|
|
||||||
options.additionalBlockLists,
|
|
||||||
options.disableDefaultLists,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import config from './config';
|
|
||||||
|
|
||||||
import { blockers } from './blocker-types';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
export default (): MenuTemplate => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Blocker',
|
|
||||||
submenu: Object.values(blockers).map((blocker: string) => ({
|
|
||||||
label: blocker,
|
|
||||||
type: 'radio',
|
|
||||||
checked: (config.get('blocker') || blockers.WithBlocklists) === blocker,
|
|
||||||
click() {
|
|
||||||
config.set('blocker', blocker);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import config, { shouldUseBlocklists } from './config';
|
|
||||||
import { inject } from './inject';
|
|
||||||
import injectCliqzPreload from './inject-cliqz-preload';
|
|
||||||
|
|
||||||
import { blockers } from './blocker-types';
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
if (shouldUseBlocklists()) {
|
|
||||||
// Preload adblocker to inject scripts/styles
|
|
||||||
await injectCliqzPreload();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
||||||
} else if ((config.get('blocker')) === blockers.InPlayer) {
|
|
||||||
inject();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
146
src/plugins/album-color-theme/index.ts
Normal file
146
src/plugins/album-color-theme/index.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { FastAverageColor } from 'fast-average-color';
|
||||||
|
|
||||||
|
import style from './style.css?inline';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import type { VideoDataChanged } from '@/types/video-data-changed';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Album Color Theme',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
stylesheets: [style],
|
||||||
|
renderer: {
|
||||||
|
hexToHSL: (H: string) => {
|
||||||
|
// Convert hex to RGB first
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (H.length == 4) {
|
||||||
|
r = Number('0x' + H[1] + H[1]);
|
||||||
|
g = Number('0x' + H[2] + H[2]);
|
||||||
|
b = Number('0x' + H[3] + H[3]);
|
||||||
|
} else if (H.length == 7) {
|
||||||
|
r = Number('0x' + H[1] + H[2]);
|
||||||
|
g = Number('0x' + H[3] + H[4]);
|
||||||
|
b = Number('0x' + H[5] + H[6]);
|
||||||
|
}
|
||||||
|
// Then to HSL
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const cmin = Math.min(r, g, b);
|
||||||
|
const cmax = Math.max(r, g, b);
|
||||||
|
const delta = cmax - cmin;
|
||||||
|
let h: number;
|
||||||
|
let s: number;
|
||||||
|
let l: number;
|
||||||
|
|
||||||
|
if (delta == 0) {
|
||||||
|
h = 0;
|
||||||
|
} else if (cmax == r) {
|
||||||
|
h = ((g - b) / delta) % 6;
|
||||||
|
} else if (cmax == g) {
|
||||||
|
h = ((b - r) / delta) + 2;
|
||||||
|
} else {
|
||||||
|
h = ((r - g) / delta) + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
|
||||||
|
if (h < 0) {
|
||||||
|
h += 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
l = (cmax + cmin) / 2;
|
||||||
|
s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1));
|
||||||
|
s = +(s * 100).toFixed(1);
|
||||||
|
l = +(l * 100).toFixed(1);
|
||||||
|
|
||||||
|
//return "hsl(" + h + "," + s + "%," + l + "%)";
|
||||||
|
return [h,s,l];
|
||||||
|
},
|
||||||
|
hue: 0,
|
||||||
|
saturation: 0,
|
||||||
|
lightness: 0,
|
||||||
|
|
||||||
|
changeElementColor: (element: HTMLElement | null, hue: number, saturation: number, lightness: number) => {
|
||||||
|
if (element) {
|
||||||
|
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
playerPage: null as HTMLElement | null,
|
||||||
|
navBarBackground: null as HTMLElement | null,
|
||||||
|
ytmusicPlayerBar: null as HTMLElement | null,
|
||||||
|
playerBarBackground: null as HTMLElement | null,
|
||||||
|
sidebarBig: null as HTMLElement | null,
|
||||||
|
sidebarSmall: null as HTMLElement | null,
|
||||||
|
ytmusicAppLayout: null as HTMLElement | null,
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.playerPage = document.querySelector<HTMLElement>('#player-page');
|
||||||
|
this.navBarBackground = document.querySelector<HTMLElement>('#nav-bar-background');
|
||||||
|
this.ytmusicPlayerBar = document.querySelector<HTMLElement>('ytmusic-player-bar');
|
||||||
|
this.playerBarBackground = document.querySelector<HTMLElement>('#player-bar-background');
|
||||||
|
this.sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
|
||||||
|
this.sidebarSmall = document.querySelector<HTMLElement>('#mini-guide-background');
|
||||||
|
this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutationsList) => {
|
||||||
|
for (const mutation of mutationsList) {
|
||||||
|
if (mutation.type === 'attributes') {
|
||||||
|
const isPageOpen = this.ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||||
|
if (isPageOpen) {
|
||||||
|
this.changeElementColor(this.sidebarSmall, this.hue, this.saturation, this.lightness - 30);
|
||||||
|
} else {
|
||||||
|
if (this.sidebarSmall) {
|
||||||
|
this.sidebarSmall.style.backgroundColor = 'black';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.playerPage) {
|
||||||
|
observer.observe(this.playerPage, { attributes: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPlayerApiReady(playerApi) {
|
||||||
|
const fastAverageColor = new FastAverageColor();
|
||||||
|
|
||||||
|
document.addEventListener('videodatachange', (event: CustomEvent<VideoDataChanged>) => {
|
||||||
|
if (event.detail.name === 'dataloaded') {
|
||||||
|
const playerResponse = playerApi.getPlayerResponse();
|
||||||
|
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
|
||||||
|
if (thumbnail) {
|
||||||
|
fastAverageColor.getColorAsync(thumbnail.url)
|
||||||
|
.then((albumColor) => {
|
||||||
|
if (albumColor) {
|
||||||
|
const [hue, saturation, lightness] = [this.hue, this.saturation, this.lightness] = this.hexToHSL(albumColor.hex);
|
||||||
|
this.changeElementColor(this.playerPage, hue, saturation, lightness - 30);
|
||||||
|
this.changeElementColor(this.navBarBackground, hue, saturation, lightness - 15);
|
||||||
|
this.changeElementColor(this.ytmusicPlayerBar, hue, saturation, lightness - 15);
|
||||||
|
this.changeElementColor(this.playerBarBackground, hue, saturation, lightness - 15);
|
||||||
|
this.changeElementColor(this.sidebarBig, hue, saturation, lightness - 15);
|
||||||
|
if (this.ytmusicAppLayout?.hasAttribute('player-page-open')) {
|
||||||
|
this.changeElementColor(this.sidebarSmall, hue, saturation, lightness - 30);
|
||||||
|
}
|
||||||
|
const ytRightClickList = document.querySelector<HTMLElement>('tp-yt-paper-listbox');
|
||||||
|
this.changeElementColor(ytRightClickList, hue, saturation, lightness - 15);
|
||||||
|
} else {
|
||||||
|
if (this.playerPage) {
|
||||||
|
this.playerPage.style.backgroundColor = '#000000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils/main';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
};
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import { FastAverageColor } from 'fast-average-color';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
function hexToHSL(H: string) {
|
|
||||||
// Convert hex to RGB first
|
|
||||||
let r = 0;
|
|
||||||
let g = 0;
|
|
||||||
let b = 0;
|
|
||||||
if (H.length == 4) {
|
|
||||||
r = Number('0x' + H[1] + H[1]);
|
|
||||||
g = Number('0x' + H[2] + H[2]);
|
|
||||||
b = Number('0x' + H[3] + H[3]);
|
|
||||||
} else if (H.length == 7) {
|
|
||||||
r = Number('0x' + H[1] + H[2]);
|
|
||||||
g = Number('0x' + H[3] + H[4]);
|
|
||||||
b = Number('0x' + H[5] + H[6]);
|
|
||||||
}
|
|
||||||
// Then to HSL
|
|
||||||
r /= 255;
|
|
||||||
g /= 255;
|
|
||||||
b /= 255;
|
|
||||||
const cmin = Math.min(r, g, b);
|
|
||||||
const cmax = Math.max(r, g, b);
|
|
||||||
const delta = cmax - cmin;
|
|
||||||
let h: number;
|
|
||||||
let s: number;
|
|
||||||
let l: number;
|
|
||||||
|
|
||||||
if (delta == 0) {
|
|
||||||
h = 0;
|
|
||||||
} else if (cmax == r) {
|
|
||||||
h = ((g - b) / delta) % 6;
|
|
||||||
} else if (cmax == g) {
|
|
||||||
h = ((b - r) / delta) + 2;
|
|
||||||
} else {
|
|
||||||
h = ((r - g) / delta) + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
h = Math.round(h * 60);
|
|
||||||
|
|
||||||
if (h < 0) {
|
|
||||||
h += 360;
|
|
||||||
}
|
|
||||||
|
|
||||||
l = (cmax + cmin) / 2;
|
|
||||||
s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1));
|
|
||||||
s = +(s * 100).toFixed(1);
|
|
||||||
l = +(l * 100).toFixed(1);
|
|
||||||
|
|
||||||
//return "hsl(" + h + "," + s + "%," + l + "%)";
|
|
||||||
return [h,s,l];
|
|
||||||
}
|
|
||||||
|
|
||||||
let hue = 0;
|
|
||||||
let saturation = 0;
|
|
||||||
let lightness = 0;
|
|
||||||
|
|
||||||
function changeElementColor(element: HTMLElement | null, hue: number, saturation: number, lightness: number){
|
|
||||||
if (element) {
|
|
||||||
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (_: ConfigType<'album-color-theme'>) => {
|
|
||||||
// updated elements
|
|
||||||
const playerPage = document.querySelector<HTMLElement>('#player-page');
|
|
||||||
const navBarBackground = document.querySelector<HTMLElement>('#nav-bar-background');
|
|
||||||
const ytmusicPlayerBar = document.querySelector<HTMLElement>('ytmusic-player-bar');
|
|
||||||
const playerBarBackground = document.querySelector<HTMLElement>('#player-bar-background');
|
|
||||||
const sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
|
|
||||||
const sidebarSmall = document.querySelector<HTMLElement>('#mini-guide-background');
|
|
||||||
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutationsList) => {
|
|
||||||
for (const mutation of mutationsList) {
|
|
||||||
if (mutation.type === 'attributes') {
|
|
||||||
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
|
||||||
if (isPageOpen) {
|
|
||||||
changeElementColor(sidebarSmall, hue, saturation, lightness - 30);
|
|
||||||
} else {
|
|
||||||
if (sidebarSmall) {
|
|
||||||
sidebarSmall.style.backgroundColor = 'black';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (playerPage) {
|
|
||||||
observer.observe(playerPage, { attributes: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('apiLoaded', (apiEvent) => {
|
|
||||||
const fastAverageColor = new FastAverageColor();
|
|
||||||
|
|
||||||
apiEvent.detail.addEventListener('videodatachange', (name: string) => {
|
|
||||||
if (name === 'dataloaded') {
|
|
||||||
const playerResponse = apiEvent.detail.getPlayerResponse();
|
|
||||||
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
|
|
||||||
if (thumbnail) {
|
|
||||||
fastAverageColor.getColorAsync(thumbnail.url)
|
|
||||||
.then((albumColor) => {
|
|
||||||
if (albumColor) {
|
|
||||||
[hue, saturation, lightness] = hexToHSL(albumColor.hex);
|
|
||||||
changeElementColor(playerPage, hue, saturation, lightness - 30);
|
|
||||||
changeElementColor(navBarBackground, hue, saturation, lightness - 15);
|
|
||||||
changeElementColor(ytmusicPlayerBar, hue, saturation, lightness - 15);
|
|
||||||
changeElementColor(playerBarBackground, hue, saturation, lightness - 15);
|
|
||||||
changeElementColor(sidebarBig, hue, saturation, lightness - 15);
|
|
||||||
if (ytmusicAppLayout?.hasAttribute('player-page-open')) {
|
|
||||||
changeElementColor(sidebarSmall, hue, saturation, lightness - 30);
|
|
||||||
}
|
|
||||||
const ytRightClickList = document.querySelector<HTMLElement>('tp-yt-paper-listbox');
|
|
||||||
changeElementColor(ytRightClickList, hue, saturation, lightness - 15);
|
|
||||||
} else {
|
|
||||||
if (playerPage) {
|
|
||||||
playerPage.style.backgroundColor = '#000000';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => console.error(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('ambient-mode');
|
|
||||||
export default config;
|
|
||||||
297
src/plugins/ambient-mode/index.ts
Normal file
297
src/plugins/ambient-mode/index.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import style from './style.css?inline';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
export type AmbientModePluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
quality: number;
|
||||||
|
buffer: number;
|
||||||
|
interpolationTime: number;
|
||||||
|
blur: number;
|
||||||
|
size: number;
|
||||||
|
opacity: number;
|
||||||
|
fullscreen: boolean;
|
||||||
|
};
|
||||||
|
const defaultConfig: AmbientModePluginConfig = {
|
||||||
|
enabled: false,
|
||||||
|
quality: 50,
|
||||||
|
buffer: 30,
|
||||||
|
interpolationTime: 1500,
|
||||||
|
blur: 100,
|
||||||
|
size: 100,
|
||||||
|
opacity: 1,
|
||||||
|
fullscreen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Ambient Mode',
|
||||||
|
restartNeeded: false,
|
||||||
|
config: defaultConfig,
|
||||||
|
stylesheets: [style],
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
|
||||||
|
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
|
||||||
|
const sizeList = [100, 110, 125, 150, 175, 200, 300];
|
||||||
|
const bufferList = [1, 5, 10, 20, 30];
|
||||||
|
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
|
||||||
|
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Smoothness transition',
|
||||||
|
submenu: interpolationTimeList.map((interpolationTime) => ({
|
||||||
|
label: `During ${interpolationTime / 1000}s`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.interpolationTime === interpolationTime,
|
||||||
|
click() {
|
||||||
|
setConfig({ interpolationTime });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Quality',
|
||||||
|
submenu: qualityList.map((quality) => ({
|
||||||
|
label: `${quality} pixels`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.quality === quality,
|
||||||
|
click() {
|
||||||
|
setConfig({ quality });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Size',
|
||||||
|
submenu: sizeList.map((size) => ({
|
||||||
|
label: `${size}%`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.size === size,
|
||||||
|
click() {
|
||||||
|
setConfig({ size });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Buffer',
|
||||||
|
submenu: bufferList.map((buffer) => ({
|
||||||
|
label: `${buffer}`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.buffer === buffer,
|
||||||
|
click() {
|
||||||
|
setConfig({ buffer });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Opacity',
|
||||||
|
submenu: opacityList.map((opacity) => ({
|
||||||
|
label: `${opacity * 100}%`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.opacity === opacity,
|
||||||
|
click() {
|
||||||
|
setConfig({ opacity });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Blur amount',
|
||||||
|
submenu: blurAmountList.map((blur) => ({
|
||||||
|
label: `${blur} pixels`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.blur === blur,
|
||||||
|
click() {
|
||||||
|
setConfig({ blur });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Using fullscreen',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.fullscreen,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ fullscreen: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
interpolationTime: defaultConfig.interpolationTime,
|
||||||
|
buffer: defaultConfig.buffer,
|
||||||
|
qualityRatio: defaultConfig.quality,
|
||||||
|
sizeRatio: defaultConfig.size / 100,
|
||||||
|
blur: defaultConfig.blur,
|
||||||
|
opacity: defaultConfig.opacity,
|
||||||
|
isFullscreen: defaultConfig.fullscreen,
|
||||||
|
|
||||||
|
unregister: null as (() => void) | null,
|
||||||
|
update: null as (() => void) | null,
|
||||||
|
observer: null as MutationObserver | null,
|
||||||
|
|
||||||
|
start() {
|
||||||
|
const injectBlurVideo = (): (() => void) | null => {
|
||||||
|
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
|
||||||
|
const video = document.querySelector<HTMLVideoElement>('#song-video .html5-video-container > video');
|
||||||
|
const wrapper = document.querySelector('#song-video > .player-wrapper');
|
||||||
|
|
||||||
|
if (!songVideo) return null;
|
||||||
|
if (!video) return null;
|
||||||
|
if (!wrapper) return null;
|
||||||
|
|
||||||
|
const blurCanvas = document.createElement('canvas');
|
||||||
|
blurCanvas.classList.add('html5-blur-canvas');
|
||||||
|
|
||||||
|
const context = blurCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
/* effect */
|
||||||
|
let lastEffectWorkId: number | null = null;
|
||||||
|
let lastImageData: ImageData | null = null;
|
||||||
|
|
||||||
|
const onSync = () => {
|
||||||
|
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
|
||||||
|
|
||||||
|
lastEffectWorkId = requestAnimationFrame(() => {
|
||||||
|
// console.log('context', context);
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const width = this.qualityRatio;
|
||||||
|
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
|
||||||
|
if (!Number.isFinite(height)) height = width;
|
||||||
|
if (!height) return;
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
if (lastImageData) {
|
||||||
|
const frameOffset = (1 / this.buffer) * (1000 / this.interpolationTime);
|
||||||
|
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1
|
||||||
|
context.putImageData(lastImageData, 0, 0);
|
||||||
|
context.globalAlpha = frameOffset;
|
||||||
|
}
|
||||||
|
context.drawImage(video, 0, 0, width, height);
|
||||||
|
|
||||||
|
lastImageData = context.getImageData(0, 0, width, height); // current image data
|
||||||
|
|
||||||
|
lastEffectWorkId = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyVideoAttributes = () => {
|
||||||
|
const rect = video.getBoundingClientRect();
|
||||||
|
|
||||||
|
const newWidth = Math.floor(video.width || rect.width);
|
||||||
|
const newHeight = Math.floor(video.height || rect.height);
|
||||||
|
|
||||||
|
if (newWidth === 0 || newHeight === 0) return;
|
||||||
|
|
||||||
|
blurCanvas.width = this.qualityRatio;
|
||||||
|
blurCanvas.height = Math.floor(newHeight / newWidth * this.qualityRatio);
|
||||||
|
blurCanvas.style.width = `${newWidth * this.sizeRatio}px`;
|
||||||
|
blurCanvas.style.height = `${newHeight * this.sizeRatio}px`;
|
||||||
|
|
||||||
|
if (this.isFullscreen) blurCanvas.classList.add('fullscreen');
|
||||||
|
else blurCanvas.classList.remove('fullscreen');
|
||||||
|
|
||||||
|
const leftOffset = newWidth * (this.sizeRatio - 1) / 2;
|
||||||
|
const topOffset = newHeight * (this.sizeRatio - 1) / 2;
|
||||||
|
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
|
||||||
|
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`);
|
||||||
|
blurCanvas.style.setProperty('--blur', `${this.blur}px`);
|
||||||
|
blurCanvas.style.setProperty('--opacity', `${this.opacity}`);
|
||||||
|
};
|
||||||
|
this.update = applyVideoAttributes;
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === 'attributes') {
|
||||||
|
applyVideoAttributes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
applyVideoAttributes();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* hooking */
|
||||||
|
let canvasInterval: NodeJS.Timeout | null = null;
|
||||||
|
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / this.buffer)));
|
||||||
|
applyVideoAttributes();
|
||||||
|
observer.observe(songVideo, { attributes: true });
|
||||||
|
resizeObserver.observe(songVideo);
|
||||||
|
window.addEventListener('resize', applyVideoAttributes);
|
||||||
|
|
||||||
|
const onPause = () => {
|
||||||
|
if (canvasInterval) clearInterval(canvasInterval);
|
||||||
|
canvasInterval = null;
|
||||||
|
};
|
||||||
|
const onPlay = () => {
|
||||||
|
if (canvasInterval) clearInterval(canvasInterval);
|
||||||
|
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / this.buffer)));
|
||||||
|
};
|
||||||
|
songVideo.addEventListener('pause', onPause);
|
||||||
|
songVideo.addEventListener('play', onPlay);
|
||||||
|
|
||||||
|
/* injecting */
|
||||||
|
wrapper.prepend(blurCanvas);
|
||||||
|
|
||||||
|
/* cleanup */
|
||||||
|
return () => {
|
||||||
|
if (canvasInterval) clearInterval(canvasInterval);
|
||||||
|
|
||||||
|
songVideo.removeEventListener('pause', onPause);
|
||||||
|
songVideo.removeEventListener('play', onPlay);
|
||||||
|
|
||||||
|
observer.disconnect();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
window.removeEventListener('resize', applyVideoAttributes);
|
||||||
|
|
||||||
|
if (blurCanvas.isConnected) blurCanvas.remove();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const playerPage = document.querySelector<HTMLElement>('#player-page');
|
||||||
|
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
||||||
|
|
||||||
|
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||||
|
if (isPageOpen) {
|
||||||
|
this.unregister?.();
|
||||||
|
this.unregister = injectBlurVideo() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutationsList) => {
|
||||||
|
for (const mutation of mutationsList) {
|
||||||
|
if (mutation.type === 'attributes') {
|
||||||
|
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||||
|
if (isPageOpen) {
|
||||||
|
this.unregister?.();
|
||||||
|
this.unregister = injectBlurVideo() ?? null;
|
||||||
|
} else {
|
||||||
|
this.unregister?.();
|
||||||
|
this.unregister = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (playerPage) {
|
||||||
|
observer.observe(playerPage, { attributes: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.interpolationTime = newConfig.interpolationTime;
|
||||||
|
this.buffer = newConfig.buffer;
|
||||||
|
this.qualityRatio = newConfig.quality;
|
||||||
|
this.sizeRatio = newConfig.size / 100;
|
||||||
|
this.blur = newConfig.blur;
|
||||||
|
this.opacity = newConfig.opacity;
|
||||||
|
this.isFullscreen = newConfig.fullscreen;
|
||||||
|
|
||||||
|
this.update?.();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.update = null;
|
||||||
|
this.unregister?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import config from './config';
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils/main';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
config.subscribeAll((newConfig) => {
|
|
||||||
win.webContents.send('ambient-mode:config-change', newConfig);
|
|
||||||
});
|
|
||||||
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
};
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import config from './config';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
|
|
||||||
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
|
|
||||||
const sizeList = [100, 110, 125, 150, 175, 200, 300];
|
|
||||||
const bufferList = [1, 5, 10, 20, 30];
|
|
||||||
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
|
|
||||||
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
|
|
||||||
|
|
||||||
export default (): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Smoothness transition',
|
|
||||||
submenu: interpolationTimeList.map((interpolationTime) => ({
|
|
||||||
label: `During ${interpolationTime / 1000}s`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('interpolationTime') === interpolationTime,
|
|
||||||
click() {
|
|
||||||
config.set('interpolationTime', interpolationTime);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Quality',
|
|
||||||
submenu: qualityList.map((quality) => ({
|
|
||||||
label: `${quality} pixels`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('quality') === quality,
|
|
||||||
click() {
|
|
||||||
config.set('quality', quality);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Size',
|
|
||||||
submenu: sizeList.map((size) => ({
|
|
||||||
label: `${size}%`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('size') === size,
|
|
||||||
click() {
|
|
||||||
config.set('size', size);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Buffer',
|
|
||||||
submenu: bufferList.map((buffer) => ({
|
|
||||||
label: `${buffer}`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('buffer') === buffer,
|
|
||||||
click() {
|
|
||||||
config.set('buffer', buffer);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Opacity',
|
|
||||||
submenu: opacityList.map((opacity) => ({
|
|
||||||
label: `${opacity * 100}%`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('opacity') === opacity,
|
|
||||||
click() {
|
|
||||||
config.set('opacity', opacity);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Blur amount',
|
|
||||||
submenu: blurAmountList.map((blur) => ({
|
|
||||||
label: `${blur} pixels`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('blur') === blur,
|
|
||||||
click() {
|
|
||||||
config.set('blur', blur);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Using fullscreen',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('fullscreen'),
|
|
||||||
click(item) {
|
|
||||||
config.set('fullscreen', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (config: ConfigType<'ambient-mode'>) => {
|
|
||||||
let interpolationTime = config.interpolationTime; // interpolation time (ms)
|
|
||||||
let buffer = config.buffer; // frame
|
|
||||||
let qualityRatio = config.quality; // width size (pixel)
|
|
||||||
let sizeRatio = config.size / 100; // size ratio (percent)
|
|
||||||
let blur = config.blur; // blur (pixel)
|
|
||||||
let opacity = config.opacity; // opacity (percent)
|
|
||||||
let isFullscreen = config.fullscreen; // fullscreen (boolean)
|
|
||||||
|
|
||||||
let unregister: (() => void) | null = null;
|
|
||||||
|
|
||||||
const injectBlurVideo = (): (() => void) | null => {
|
|
||||||
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
|
|
||||||
const video = document.querySelector<HTMLVideoElement>('#song-video .html5-video-container > video');
|
|
||||||
const wrapper = document.querySelector('#song-video > .player-wrapper');
|
|
||||||
|
|
||||||
if (!songVideo) return null;
|
|
||||||
if (!video) return null;
|
|
||||||
if (!wrapper) return null;
|
|
||||||
|
|
||||||
const blurCanvas = document.createElement('canvas');
|
|
||||||
blurCanvas.classList.add('html5-blur-canvas');
|
|
||||||
|
|
||||||
const context = blurCanvas.getContext('2d', { willReadFrequently: true });
|
|
||||||
|
|
||||||
/* effect */
|
|
||||||
let lastEffectWorkId: number | null = null;
|
|
||||||
let lastImageData: ImageData | null = null;
|
|
||||||
|
|
||||||
const onSync = () => {
|
|
||||||
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
|
|
||||||
|
|
||||||
lastEffectWorkId = requestAnimationFrame(() => {
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
const width = qualityRatio;
|
|
||||||
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
|
|
||||||
if (!Number.isFinite(height)) height = width;
|
|
||||||
if (!height) return;
|
|
||||||
|
|
||||||
context.globalAlpha = 1;
|
|
||||||
if (lastImageData) {
|
|
||||||
const frameOffset = (1 / buffer) * (1000 / interpolationTime);
|
|
||||||
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1
|
|
||||||
context.putImageData(lastImageData, 0, 0);
|
|
||||||
context.globalAlpha = frameOffset;
|
|
||||||
}
|
|
||||||
context.drawImage(video, 0, 0, width, height);
|
|
||||||
|
|
||||||
lastImageData = context.getImageData(0, 0, width, height); // current image data
|
|
||||||
|
|
||||||
lastEffectWorkId = null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyVideoAttributes = () => {
|
|
||||||
const rect = video.getBoundingClientRect();
|
|
||||||
|
|
||||||
const newWidth = Math.floor(video.width || rect.width);
|
|
||||||
const newHeight = Math.floor(video.height || rect.height);
|
|
||||||
|
|
||||||
if (newWidth === 0 || newHeight === 0) return;
|
|
||||||
|
|
||||||
blurCanvas.width = qualityRatio;
|
|
||||||
blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio);
|
|
||||||
blurCanvas.style.width = `${newWidth * sizeRatio}px`;
|
|
||||||
blurCanvas.style.height = `${newHeight * sizeRatio}px`;
|
|
||||||
|
|
||||||
if (isFullscreen) blurCanvas.classList.add('fullscreen');
|
|
||||||
else blurCanvas.classList.remove('fullscreen');
|
|
||||||
|
|
||||||
const leftOffset = newWidth * (sizeRatio - 1) / 2;
|
|
||||||
const topOffset = newHeight * (sizeRatio - 1) / 2;
|
|
||||||
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
|
|
||||||
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`);
|
|
||||||
blurCanvas.style.setProperty('--blur', `${blur}px`);
|
|
||||||
blurCanvas.style.setProperty('--opacity', `${opacity}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.type === 'attributes') {
|
|
||||||
applyVideoAttributes();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
applyVideoAttributes();
|
|
||||||
});
|
|
||||||
const onConfigSync = (_: Electron.IpcRendererEvent, newConfig: ConfigType<'ambient-mode'>) => {
|
|
||||||
if (typeof newConfig.interpolationTime === 'number') interpolationTime = newConfig.interpolationTime;
|
|
||||||
if (typeof newConfig.buffer === 'number') buffer = newConfig.buffer;
|
|
||||||
if (typeof newConfig.quality === 'number') qualityRatio = newConfig.quality;
|
|
||||||
if (typeof newConfig.size === 'number') sizeRatio = newConfig.size / 100;
|
|
||||||
if (typeof newConfig.blur === 'number') blur = newConfig.blur;
|
|
||||||
if (typeof newConfig.opacity === 'number') opacity = newConfig.opacity;
|
|
||||||
if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen;
|
|
||||||
|
|
||||||
applyVideoAttributes();
|
|
||||||
};
|
|
||||||
window.ipcRenderer.on('ambient-mode:config-change', onConfigSync);
|
|
||||||
|
|
||||||
/* hooking */
|
|
||||||
let canvasInterval: NodeJS.Timeout | null = null;
|
|
||||||
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer)));
|
|
||||||
applyVideoAttributes();
|
|
||||||
observer.observe(songVideo, { attributes: true });
|
|
||||||
resizeObserver.observe(songVideo);
|
|
||||||
window.addEventListener('resize', applyVideoAttributes);
|
|
||||||
|
|
||||||
const onPause = () => {
|
|
||||||
if (canvasInterval) clearInterval(canvasInterval);
|
|
||||||
canvasInterval = null;
|
|
||||||
};
|
|
||||||
const onPlay = () => {
|
|
||||||
if (canvasInterval) clearInterval(canvasInterval);
|
|
||||||
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer)));
|
|
||||||
};
|
|
||||||
songVideo.addEventListener('pause', onPause);
|
|
||||||
songVideo.addEventListener('play', onPlay);
|
|
||||||
|
|
||||||
/* injecting */
|
|
||||||
wrapper.prepend(blurCanvas);
|
|
||||||
|
|
||||||
/* cleanup */
|
|
||||||
return () => {
|
|
||||||
if (canvasInterval) clearInterval(canvasInterval);
|
|
||||||
|
|
||||||
songVideo.removeEventListener('pause', onPause);
|
|
||||||
songVideo.removeEventListener('play', onPlay);
|
|
||||||
|
|
||||||
observer.disconnect();
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
window.ipcRenderer.removeListener('ambient-mode:config-change', onConfigSync);
|
|
||||||
window.removeEventListener('resize', applyVideoAttributes);
|
|
||||||
|
|
||||||
wrapper.removeChild(blurCanvas);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const playerPage = document.querySelector<HTMLElement>('#player-page');
|
|
||||||
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutationsList) => {
|
|
||||||
for (const mutation of mutationsList) {
|
|
||||||
if (mutation.type === 'attributes') {
|
|
||||||
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
|
||||||
if (isPageOpen) {
|
|
||||||
unregister?.();
|
|
||||||
unregister = injectBlurVideo() ?? null;
|
|
||||||
} else {
|
|
||||||
unregister?.();
|
|
||||||
unregister = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (playerPage) {
|
|
||||||
observer.observe(playerPage, { attributes: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
25
src/plugins/audio-compressor.ts
Normal file
25
src/plugins/audio-compressor.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Audio Compressor',
|
||||||
|
description: '',
|
||||||
|
|
||||||
|
renderer() {
|
||||||
|
document.addEventListener(
|
||||||
|
'audioCanPlay',
|
||||||
|
({ detail: { audioSource, audioContext } }) => {
|
||||||
|
const compressor = audioContext.createDynamicsCompressor();
|
||||||
|
|
||||||
|
compressor.threshold.value = -50;
|
||||||
|
compressor.ratio.value = 12;
|
||||||
|
compressor.knee.value = 40;
|
||||||
|
compressor.attack.value = 0;
|
||||||
|
compressor.release.value = 0.25;
|
||||||
|
|
||||||
|
audioSource.connect(compressor);
|
||||||
|
compressor.connect(audioContext.destination);
|
||||||
|
},
|
||||||
|
{ once: true, passive: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,17 +0,0 @@
|
|||||||
export default () =>
|
|
||||||
document.addEventListener('audioCanPlay', (e) => {
|
|
||||||
const { audioContext } = e.detail;
|
|
||||||
|
|
||||||
const compressor = audioContext.createDynamicsCompressor();
|
|
||||||
compressor.threshold.value = -50;
|
|
||||||
compressor.ratio.value = 12;
|
|
||||||
compressor.knee.value = 40;
|
|
||||||
compressor.attack.value = 0;
|
|
||||||
compressor.release.value = 0.25;
|
|
||||||
|
|
||||||
e.detail.audioSource.connect(compressor);
|
|
||||||
compressor.connect(audioContext.destination);
|
|
||||||
}, {
|
|
||||||
once: true, // Only create the audio compressor once, not on each video
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
9
src/plugins/blur-nav-bar/index.ts
Normal file
9
src/plugins/blur-nav-bar/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import style from './style.css?inline';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Blur Navigation Bar',
|
||||||
|
restartNeeded: true,
|
||||||
|
stylesheets: [style],
|
||||||
|
renderer() {},
|
||||||
|
});
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils/main';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
};
|
|
||||||
9
src/plugins/bypass-age-restrictions/index.ts
Normal file
9
src/plugins/bypass-age-restrictions/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Bypass Age Restrictions',
|
||||||
|
restartNeeded: true,
|
||||||
|
|
||||||
|
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
|
||||||
|
renderer: () => import('simple-youtube-age-restriction-bypass'),
|
||||||
|
});
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export default async () => {
|
|
||||||
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
|
|
||||||
await import('simple-youtube-age-restriction-bypass');
|
|
||||||
};
|
|
||||||
28
src/plugins/captions-selector/back.ts
Normal file
28
src/plugins/captions-selector/back.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
import { createBackend } from '@/utils';
|
||||||
|
|
||||||
|
export default createBackend({
|
||||||
|
start({ ipc: { handle }, window }) {
|
||||||
|
handle(
|
||||||
|
'captionsSelector',
|
||||||
|
async (captionLabels: Record<string, string>, currentIndex: string) =>
|
||||||
|
await prompt(
|
||||||
|
{
|
||||||
|
title: 'Choose Caption',
|
||||||
|
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
|
||||||
|
type: 'select',
|
||||||
|
value: currentIndex,
|
||||||
|
selectOptions: captionLabels,
|
||||||
|
resizable: true,
|
||||||
|
...promptOptions(),
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
stop({ ipc: { removeHandler } }) {
|
||||||
|
removeHandler('captionsSelector');
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic-renderer';
|
|
||||||
|
|
||||||
const configRenderer = new PluginConfig('captions-selector', { enableFront: true });
|
|
||||||
export default configRenderer;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('captions-selector', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
53
src/plugins/captions-selector/index.ts
Normal file
53
src/plugins/captions-selector/index.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { YoutubePlayer } from '@/types/youtube-player';
|
||||||
|
|
||||||
|
import backend from './back';
|
||||||
|
import renderer, { CaptionsSelectorConfig, LanguageOptions } from './renderer';
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
captionsSettingsButton: HTMLElement;
|
||||||
|
captionTrackList: LanguageOptions[] | null;
|
||||||
|
api: YoutubePlayer | null;
|
||||||
|
config: CaptionsSelectorConfig | null;
|
||||||
|
setConfig: (config: Partial<CaptionsSelectorConfig>) => void;
|
||||||
|
videoChangeListener: () => void;
|
||||||
|
captionsButtonClickListener: () => void;
|
||||||
|
},
|
||||||
|
CaptionsSelectorConfig
|
||||||
|
>({
|
||||||
|
name: 'Captions Selector',
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
disableCaptions: false,
|
||||||
|
autoload: false,
|
||||||
|
lastCaptionsCode: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
async menu({ getConfig, setConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Automatically select last used caption',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.autoload as boolean,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ autoload: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'No captions by default',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.disableCaptions as boolean,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ disableCaptions: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
backend,
|
||||||
|
renderer,
|
||||||
|
});
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { BrowserWindow, ipcMain } from 'electron';
|
|
||||||
import prompt from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
ipcMain.handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
|
|
||||||
{
|
|
||||||
title: 'Choose Caption',
|
|
||||||
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
|
|
||||||
type: 'select',
|
|
||||||
value: currentIndex,
|
|
||||||
selectOptions: captionLabels,
|
|
||||||
resizable: true,
|
|
||||||
...promptOptions(),
|
|
||||||
},
|
|
||||||
win,
|
|
||||||
));
|
|
||||||
};
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import config from './config';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
export default (): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Automatically select last used caption',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('autoload'),
|
|
||||||
click(item) {
|
|
||||||
config.set('autoload', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'No captions by default',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('disableCaptions'),
|
|
||||||
click(item) {
|
|
||||||
config.set('disableCaptions', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,13 +1,11 @@
|
|||||||
import configProvider from './config-renderer';
|
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||||
|
import { createRenderer } from '@/utils';
|
||||||
|
|
||||||
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
|
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils/renderer';
|
import { YoutubePlayer } from '@/types/youtube-player';
|
||||||
import { YoutubePlayer } from '../../types/youtube-player';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
export interface LanguageOptions {
|
||||||
|
|
||||||
interface LanguageOptions {
|
|
||||||
displayName: string;
|
displayName: string;
|
||||||
id: string | null;
|
id: string | null;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
@ -20,76 +18,134 @@ interface LanguageOptions {
|
|||||||
vss_id: string;
|
vss_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let captionsSelectorConfig: ConfigType<'captions-selector'>;
|
export interface CaptionsSelectorConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
disableCaptions: boolean;
|
||||||
|
autoload: boolean;
|
||||||
|
lastCaptionsCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
|
export default createRenderer<
|
||||||
|
{
|
||||||
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
|
captionsSettingsButton: HTMLElement;
|
||||||
|
captionTrackList: LanguageOptions[] | null;
|
||||||
export default () => {
|
api: YoutubePlayer | null;
|
||||||
captionsSelectorConfig = configProvider.getAll();
|
config: CaptionsSelectorConfig | null;
|
||||||
|
setConfig: (config: Partial<CaptionsSelectorConfig>) => void;
|
||||||
configProvider.subscribeAll((newConfig) => {
|
videoChangeListener: () => void;
|
||||||
captionsSelectorConfig = newConfig;
|
captionsButtonClickListener: () => void;
|
||||||
});
|
},
|
||||||
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
|
CaptionsSelectorConfig
|
||||||
};
|
>({
|
||||||
|
captionsSettingsButton: ElementFromHtml(CaptionsSettingsButtonHTML),
|
||||||
function setup(api: YoutubePlayer) {
|
captionTrackList: null,
|
||||||
$('.right-controls-buttons').append(captionsSettingsButton);
|
api: null,
|
||||||
|
config: null,
|
||||||
let captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
|
setConfig: () => {},
|
||||||
|
async captionsButtonClickListener() {
|
||||||
$('video').addEventListener('srcChanged', () => {
|
if (this.captionTrackList?.length) {
|
||||||
if (captionsSelectorConfig.disableCaptions) {
|
const currentCaptionTrack = this.api!.getOption<LanguageOptions>(
|
||||||
setTimeout(() => api.unloadModule('captions'), 100);
|
'captions',
|
||||||
captionsSettingsButton.style.display = 'none';
|
'track',
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
api.loadModule('captions');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
captionTrackList = api.getOption('captions', 'tracklist') ?? [];
|
|
||||||
|
|
||||||
if (captionsSelectorConfig.autoload && captionsSelectorConfig.lastCaptionsCode) {
|
|
||||||
api.setOption('captions', 'track', {
|
|
||||||
languageCode: captionsSelectorConfig.lastCaptionsCode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
captionsSettingsButton.style.display = captionTrackList?.length
|
|
||||||
? 'inline-block'
|
|
||||||
: 'none';
|
|
||||||
}, 250);
|
|
||||||
});
|
|
||||||
|
|
||||||
captionsSettingsButton.addEventListener('click', async () => {
|
|
||||||
if (captionTrackList?.length) {
|
|
||||||
const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
|
|
||||||
let currentIndex = currentCaptionTrack
|
let currentIndex = currentCaptionTrack
|
||||||
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!)
|
? this.captionTrackList.indexOf(
|
||||||
|
this.captionTrackList.find(
|
||||||
|
(track) =>
|
||||||
|
track.languageCode === currentCaptionTrack.languageCode,
|
||||||
|
)!,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const captionLabels = [
|
const captionLabels = [
|
||||||
...captionTrackList.map((track) => track.displayName),
|
...this.captionTrackList.map((track) => track.displayName),
|
||||||
'None',
|
'None',
|
||||||
];
|
];
|
||||||
|
|
||||||
currentIndex = await window.ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
|
currentIndex = (await window.ipcRenderer.invoke(
|
||||||
|
'captionsSelector',
|
||||||
|
captionLabels,
|
||||||
|
currentIndex,
|
||||||
|
)) as number;
|
||||||
if (currentIndex === null) {
|
if (currentIndex === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newCaptions = captionTrackList[currentIndex];
|
const newCaptions = this.captionTrackList[currentIndex];
|
||||||
configProvider.set('lastCaptionsCode', newCaptions?.languageCode);
|
this.setConfig({ lastCaptionsCode: newCaptions?.languageCode });
|
||||||
if (newCaptions) {
|
if (newCaptions) {
|
||||||
api.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
|
this.api?.setOption('captions', 'track', {
|
||||||
|
languageCode: newCaptions.languageCode,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
api.setOption('captions', 'track', {});
|
this.api?.setOption('captions', 'track', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => api.playVideo());
|
setTimeout(() => this.api?.playVideo());
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
videoChangeListener() {
|
||||||
|
if (this.config?.disableCaptions) {
|
||||||
|
setTimeout(() => this.api!.unloadModule('captions'), 100);
|
||||||
|
this.captionsSettingsButton.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.api!.loadModule('captions');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.captionTrackList =
|
||||||
|
this.api!.getOption('captions', 'tracklist') ?? [];
|
||||||
|
|
||||||
|
if (this.config!.autoload && this.config!.lastCaptionsCode) {
|
||||||
|
this.api?.setOption('captions', 'track', {
|
||||||
|
languageCode: this.config!.lastCaptionsCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.captionsSettingsButton.style.display = this.captionTrackList?.length
|
||||||
|
? 'inline-block'
|
||||||
|
: 'none';
|
||||||
|
}, 250);
|
||||||
|
},
|
||||||
|
async start({ getConfig, setConfig }) {
|
||||||
|
this.config = await getConfig();
|
||||||
|
this.setConfig = setConfig;
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
document
|
||||||
|
.querySelector('.right-controls-buttons')
|
||||||
|
?.removeChild(this.captionsSettingsButton);
|
||||||
|
document
|
||||||
|
.querySelector<YoutubePlayer & HTMLElement>('#movie_player')
|
||||||
|
?.unloadModule('captions');
|
||||||
|
document
|
||||||
|
.querySelector('video')
|
||||||
|
?.removeEventListener('srcChanged', this.videoChangeListener);
|
||||||
|
this.captionsSettingsButton.removeEventListener(
|
||||||
|
'click',
|
||||||
|
this.captionsButtonClickListener,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPlayerApiReady(playerApi) {
|
||||||
|
this.api = playerApi;
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector('.right-controls-buttons')
|
||||||
|
?.append(this.captionsSettingsButton);
|
||||||
|
|
||||||
|
this.captionTrackList =
|
||||||
|
this.api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector('video')
|
||||||
|
?.addEventListener('srcChanged', this.videoChangeListener);
|
||||||
|
this.captionsSettingsButton.addEventListener(
|
||||||
|
'click',
|
||||||
|
this.captionsButtonClickListener,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
38
src/plugins/compact-sidebar/index.ts
Normal file
38
src/plugins/compact-sidebar/index.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
getCompactSidebar: () => HTMLElement | null;
|
||||||
|
isCompactSidebarDisabled: () => boolean;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
name: 'Compact Sidebar',
|
||||||
|
restartNeeded: false,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
getCompactSidebar: () => document.querySelector('#mini-guide'),
|
||||||
|
isCompactSidebarDisabled() {
|
||||||
|
const compactSidebar = this.getCompactSidebar();
|
||||||
|
return compactSidebar === null || window.getComputedStyle(compactSidebar).display === 'none';
|
||||||
|
},
|
||||||
|
start() {
|
||||||
|
if (this.isCompactSidebarDisabled()) {
|
||||||
|
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (this.isCompactSidebarDisabled()) {
|
||||||
|
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfigChange() {
|
||||||
|
if (this.isCompactSidebarDisabled()) {
|
||||||
|
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,10 +0,0 @@
|
|||||||
export default () => {
|
|
||||||
const compactSidebar = document.querySelector('#mini-guide');
|
|
||||||
const isCompactSidebarDisabled
|
|
||||||
= compactSidebar === null
|
|
||||||
|| window.getComputedStyle(compactSidebar).display === 'none';
|
|
||||||
|
|
||||||
if (isCompactSidebarDisabled) {
|
|
||||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic-renderer';
|
|
||||||
|
|
||||||
const config = new PluginConfig('crossfade', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('crossfade', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
@ -15,8 +15,6 @@
|
|||||||
* v0.2.0, 07/2016
|
* v0.2.0, 07/2016
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Internal utility: check if value is a valid volume level and throw if not
|
// Internal utility: check if value is a valid volume level and throw if not
|
||||||
const validateVolumeLevel = (value: number) => {
|
const validateVolumeLevel = (value: number) => {
|
||||||
// Number between 0 and 1?
|
// Number between 0 and 1?
|
||||||
|
|||||||
298
src/plugins/crossfade/index.ts
Normal file
298
src/plugins/crossfade/index.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import { Innertube } from 'youtubei.js';
|
||||||
|
|
||||||
|
import { BrowserWindow } from 'electron';
|
||||||
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
|
import { Howl } from 'howler';
|
||||||
|
|
||||||
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { VolumeFader } from './fader';
|
||||||
|
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
|
||||||
|
export type CrossfadePluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
fadeInDuration: number;
|
||||||
|
fadeOutDuration: number;
|
||||||
|
secondsBeforeEnd: number;
|
||||||
|
fadeScaling: 'linear' | 'logarithmic' | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
config: CrossfadePluginConfig | null;
|
||||||
|
ipc: RendererContext<CrossfadePluginConfig>['ipc'] | null;
|
||||||
|
},
|
||||||
|
CrossfadePluginConfig
|
||||||
|
>({
|
||||||
|
name: 'Crossfade [beta]',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
/**
|
||||||
|
* The duration of the fade in and fade out in milliseconds.
|
||||||
|
*
|
||||||
|
* @default 1500ms
|
||||||
|
*/
|
||||||
|
fadeInDuration: 1500,
|
||||||
|
/**
|
||||||
|
* The duration of the fade in and fade out in milliseconds.
|
||||||
|
*
|
||||||
|
* @default 5000ms
|
||||||
|
*/
|
||||||
|
fadeOutDuration: 5000,
|
||||||
|
/**
|
||||||
|
* The duration of the fade in and fade out in seconds.
|
||||||
|
*
|
||||||
|
* @default 10s
|
||||||
|
*/
|
||||||
|
secondsBeforeEnd: 10,
|
||||||
|
/**
|
||||||
|
* The scaling algorithm to use for the fade.
|
||||||
|
* (or a positive number in dB)
|
||||||
|
*
|
||||||
|
* @default 'linear'
|
||||||
|
*/
|
||||||
|
fadeScaling: 'linear',
|
||||||
|
},
|
||||||
|
menu({ window, getConfig, setConfig }) {
|
||||||
|
const promptCrossfadeValues = async (win: BrowserWindow, options: CrossfadePluginConfig): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
|
||||||
|
const res = await prompt(
|
||||||
|
{
|
||||||
|
title: 'Crossfade Options',
|
||||||
|
type: 'multiInput',
|
||||||
|
multiInputOptions: [
|
||||||
|
{
|
||||||
|
label: 'Fade in duration (ms)',
|
||||||
|
value: options.fadeInDuration,
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
min: '0',
|
||||||
|
step: '100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fade out duration (ms)',
|
||||||
|
value: options.fadeOutDuration,
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
min: '0',
|
||||||
|
step: '100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Crossfade x seconds before end',
|
||||||
|
value:
|
||||||
|
options.secondsBeforeEnd,
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
min: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fade scaling',
|
||||||
|
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
|
||||||
|
value: options.fadeScaling,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resizable: true,
|
||||||
|
height: 360,
|
||||||
|
...promptOptions(),
|
||||||
|
},
|
||||||
|
win,
|
||||||
|
).catch(console.error);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fadeScaling: 'linear' | 'logarithmic' | number;
|
||||||
|
if (res[3] === 'linear' || res[3] === 'logarithmic') {
|
||||||
|
fadeScaling = res[3];
|
||||||
|
} else if (isFinite(Number(res[3]))) {
|
||||||
|
fadeScaling = Number(res[3]);
|
||||||
|
} else {
|
||||||
|
fadeScaling = options.fadeScaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fadeInDuration: Number(res[0]),
|
||||||
|
fadeOutDuration: Number(res[1]),
|
||||||
|
secondsBeforeEnd: Number(res[2]),
|
||||||
|
fadeScaling,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Advanced',
|
||||||
|
async click() {
|
||||||
|
const newOptions = await promptCrossfadeValues(window, await getConfig());
|
||||||
|
if (newOptions) {
|
||||||
|
setConfig(newOptions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
async backend({ ipc }) {
|
||||||
|
const yt = await Innertube.create({
|
||||||
|
fetch: getNetFetchAsFetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
ipc.handle('audio-url', async (videoID: string) => {
|
||||||
|
const info = await yt.getBasicInfo(videoID);
|
||||||
|
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
config: null,
|
||||||
|
ipc: null,
|
||||||
|
|
||||||
|
start({ ipc }) {
|
||||||
|
this.ipc = ipc;
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
},
|
||||||
|
onPlayerApiReady() {
|
||||||
|
let transitionAudio: Howl; // Howler audio used to fade out the current music
|
||||||
|
let firstVideo = true;
|
||||||
|
let waitForTransition: Promise<unknown>;
|
||||||
|
|
||||||
|
const getStreamURL = async (videoID: string): Promise<string> => this.ipc?.invoke('audio-url', videoID);
|
||||||
|
|
||||||
|
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
|
||||||
|
|
||||||
|
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
|
||||||
|
|
||||||
|
const watchVideoIDChanges = (cb: (id: string) => void) => {
|
||||||
|
window.navigation.addEventListener('navigate', (event) => {
|
||||||
|
const currentVideoID = getVideoIDFromURL(
|
||||||
|
(event.currentTarget as Navigation).currentEntry?.url ?? '',
|
||||||
|
);
|
||||||
|
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextVideoID
|
||||||
|
&& currentVideoID
|
||||||
|
&& (firstVideo || nextVideoID !== currentVideoID)
|
||||||
|
) {
|
||||||
|
if (isReadyToCrossfade()) {
|
||||||
|
crossfade(() => {
|
||||||
|
cb(nextVideoID);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cb(nextVideoID);
|
||||||
|
firstVideo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAudioForCrossfade = (url: string) => {
|
||||||
|
if (transitionAudio) {
|
||||||
|
transitionAudio.unload();
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionAudio = new Howl({
|
||||||
|
src: url,
|
||||||
|
html5: true,
|
||||||
|
volume: 0,
|
||||||
|
});
|
||||||
|
syncVideoWithTransitionAudio();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncVideoWithTransitionAudio = () => {
|
||||||
|
const video = document.querySelector('video')!;
|
||||||
|
|
||||||
|
const videoFader = new VolumeFader(video, {
|
||||||
|
fadeScaling: this.config?.fadeScaling,
|
||||||
|
fadeDuration: this.config?.fadeInDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
transitionAudio.play();
|
||||||
|
transitionAudio.seek(video.currentTime);
|
||||||
|
|
||||||
|
video.addEventListener('seeking', () => {
|
||||||
|
transitionAudio.seek(video.currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('pause', () => {
|
||||||
|
transitionAudio.pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('play', () => {
|
||||||
|
transitionAudio.play();
|
||||||
|
transitionAudio.seek(video.currentTime);
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
const videoVolume = video.volume;
|
||||||
|
video.volume = 0;
|
||||||
|
videoFader.fadeTo(videoVolume);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exit just before the end for the transition
|
||||||
|
const transitionBeforeEnd = () => {
|
||||||
|
if (
|
||||||
|
video.currentTime >= video.duration - this.config!.secondsBeforeEnd
|
||||||
|
&& isReadyToCrossfade()
|
||||||
|
) {
|
||||||
|
video.removeEventListener('timeupdate', transitionBeforeEnd);
|
||||||
|
|
||||||
|
// Go to next video - XXX: does not support "repeat 1" mode
|
||||||
|
document.querySelector<HTMLButtonElement>('.next-button')?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('timeupdate', transitionBeforeEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const crossfade = (cb: () => void) => {
|
||||||
|
if (!isReadyToCrossfade()) {
|
||||||
|
cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolveTransition: () => void;
|
||||||
|
waitForTransition = new Promise<void>((resolve) => {
|
||||||
|
resolveTransition = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = document.querySelector('video')!;
|
||||||
|
|
||||||
|
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
|
||||||
|
initialVolume: video.volume,
|
||||||
|
fadeScaling: this.config?.fadeScaling,
|
||||||
|
fadeDuration: this.config?.fadeOutDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade out the music
|
||||||
|
video.volume = 0;
|
||||||
|
fader.fadeOut(() => {
|
||||||
|
resolveTransition();
|
||||||
|
cb();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watchVideoIDChanges(async (videoID) => {
|
||||||
|
await waitForTransition;
|
||||||
|
const url = await getStreamURL(videoID);
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createAudioForCrossfade(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { ipcMain } from 'electron';
|
|
||||||
import { Innertube } from 'youtubei.js';
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
const yt = await Innertube.create();
|
|
||||||
|
|
||||||
ipcMain.handle('audio-url', async (_, videoID: string) => {
|
|
||||||
const info = await yt.getBasicInfo(videoID);
|
|
||||||
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import prompt from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
import configOptions from '../../config/defaults';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const defaultOptions = configOptions.plugins.crossfade;
|
|
||||||
|
|
||||||
export default (win: BrowserWindow): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Advanced',
|
|
||||||
async click() {
|
|
||||||
const newOptions = await promptCrossfadeValues(win, config.getAll());
|
|
||||||
if (newOptions) {
|
|
||||||
config.setAll(newOptions);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'crossfade'>): Promise<Partial<ConfigType<'crossfade'>> | undefined> {
|
|
||||||
const res = await prompt(
|
|
||||||
{
|
|
||||||
title: 'Crossfade Options',
|
|
||||||
type: 'multiInput',
|
|
||||||
multiInputOptions: [
|
|
||||||
{
|
|
||||||
label: 'Fade in duration (ms)',
|
|
||||||
value: options.fadeInDuration || defaultOptions.fadeInDuration,
|
|
||||||
inputAttrs: {
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
min: '0',
|
|
||||||
step: '100',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Fade out duration (ms)',
|
|
||||||
value: options.fadeOutDuration || defaultOptions.fadeOutDuration,
|
|
||||||
inputAttrs: {
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
min: '0',
|
|
||||||
step: '100',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Crossfade x seconds before end',
|
|
||||||
value:
|
|
||||||
options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd,
|
|
||||||
inputAttrs: {
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
min: '0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Fade scaling',
|
|
||||||
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
|
|
||||||
value: options.fadeScaling || defaultOptions.fadeScaling,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
resizable: true,
|
|
||||||
height: 360,
|
|
||||||
...promptOptions(),
|
|
||||||
},
|
|
||||||
win,
|
|
||||||
).catch(console.error);
|
|
||||||
if (!res) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fadeInDuration: Number(res[0]),
|
|
||||||
fadeOutDuration: Number(res[1]),
|
|
||||||
secondsBeforeEnd: Number(res[2]),
|
|
||||||
fadeScaling: res[3],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
import { Howl } from 'howler';
|
|
||||||
|
|
||||||
// Extracted from https://github.com/bitfasching/VolumeFader
|
|
||||||
import { VolumeFader } from './fader';
|
|
||||||
|
|
||||||
import configProvider from './config-renderer';
|
|
||||||
|
|
||||||
import defaultConfigs from '../../config/defaults';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
let transitionAudio: Howl; // Howler audio used to fade out the current music
|
|
||||||
let firstVideo = true;
|
|
||||||
let waitForTransition: Promise<unknown>;
|
|
||||||
|
|
||||||
const defaultConfig = defaultConfigs.plugins.crossfade;
|
|
||||||
|
|
||||||
let crossfadeConfig: ConfigType<'crossfade'>;
|
|
||||||
|
|
||||||
const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(crossfadeConfig[key]) || (defaultConfig[key] as number);
|
|
||||||
|
|
||||||
const getStreamURL = async (videoID: string) => window.ipcRenderer.invoke('audio-url', videoID) as Promise<string>;
|
|
||||||
|
|
||||||
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
|
|
||||||
|
|
||||||
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
|
|
||||||
|
|
||||||
const watchVideoIDChanges = (cb: (id: string) => void) => {
|
|
||||||
window.navigation.addEventListener('navigate', (event) => {
|
|
||||||
const currentVideoID = getVideoIDFromURL(
|
|
||||||
(event.currentTarget as Navigation).currentEntry?.url ?? '',
|
|
||||||
);
|
|
||||||
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextVideoID
|
|
||||||
&& currentVideoID
|
|
||||||
&& (firstVideo || nextVideoID !== currentVideoID)
|
|
||||||
) {
|
|
||||||
if (isReadyToCrossfade()) {
|
|
||||||
crossfade(() => {
|
|
||||||
cb(nextVideoID);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
cb(nextVideoID);
|
|
||||||
firstVideo = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAudioForCrossfade = (url: string) => {
|
|
||||||
if (transitionAudio) {
|
|
||||||
transitionAudio.unload();
|
|
||||||
}
|
|
||||||
|
|
||||||
transitionAudio = new Howl({
|
|
||||||
src: url,
|
|
||||||
html5: true,
|
|
||||||
volume: 0,
|
|
||||||
});
|
|
||||||
syncVideoWithTransitionAudio();
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncVideoWithTransitionAudio = () => {
|
|
||||||
const video = document.querySelector('video')!;
|
|
||||||
|
|
||||||
const videoFader = new VolumeFader(video, {
|
|
||||||
fadeScaling: configGetNumber('fadeScaling'),
|
|
||||||
fadeDuration: configGetNumber('fadeInDuration'),
|
|
||||||
});
|
|
||||||
|
|
||||||
transitionAudio.play();
|
|
||||||
transitionAudio.seek(video.currentTime);
|
|
||||||
|
|
||||||
video.addEventListener('seeking', () => {
|
|
||||||
transitionAudio.seek(video.currentTime);
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('pause', () => {
|
|
||||||
transitionAudio.pause();
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('play', () => {
|
|
||||||
transitionAudio.play();
|
|
||||||
transitionAudio.seek(video.currentTime);
|
|
||||||
|
|
||||||
// Fade in
|
|
||||||
const videoVolume = video.volume;
|
|
||||||
video.volume = 0;
|
|
||||||
videoFader.fadeTo(videoVolume);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Exit just before the end for the transition
|
|
||||||
const transitionBeforeEnd = () => {
|
|
||||||
if (
|
|
||||||
video.currentTime >= video.duration - configGetNumber('secondsBeforeEnd')
|
|
||||||
&& isReadyToCrossfade()
|
|
||||||
) {
|
|
||||||
video.removeEventListener('timeupdate', transitionBeforeEnd);
|
|
||||||
|
|
||||||
// Go to next video - XXX: does not support "repeat 1" mode
|
|
||||||
document.querySelector<HTMLButtonElement>('.next-button')?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener('timeupdate', transitionBeforeEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onApiLoaded = () => {
|
|
||||||
watchVideoIDChanges(async (videoID) => {
|
|
||||||
await waitForTransition;
|
|
||||||
const url = await getStreamURL(videoID);
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
createAudioForCrossfade(url);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const crossfade = (cb: () => void) => {
|
|
||||||
if (!isReadyToCrossfade()) {
|
|
||||||
cb();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolveTransition: () => void;
|
|
||||||
waitForTransition = new Promise<void>((resolve) => {
|
|
||||||
resolveTransition = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
const video = document.querySelector('video')!;
|
|
||||||
|
|
||||||
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
|
|
||||||
initialVolume: video.volume,
|
|
||||||
fadeScaling: configGetNumber('fadeScaling'),
|
|
||||||
fadeDuration: configGetNumber('fadeOutDuration'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fade out the music
|
|
||||||
video.volume = 0;
|
|
||||||
fader.fadeOut(() => {
|
|
||||||
resolveTransition();
|
|
||||||
cb();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
crossfadeConfig = configProvider.getAll();
|
|
||||||
|
|
||||||
configProvider.subscribeAll((newConfig) => {
|
|
||||||
crossfadeConfig = newConfig;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('apiLoaded', onApiLoaded, {
|
|
||||||
once: true,
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
79
src/plugins/disable-autoplay/index.ts
Normal file
79
src/plugins/disable-autoplay/index.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import type { VideoDataChanged } from '@/types/video-data-changed';
|
||||||
|
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||||
|
|
||||||
|
export type DisableAutoPlayPluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
applyOnce: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
config: DisableAutoPlayPluginConfig | null;
|
||||||
|
api: YoutubePlayer | null;
|
||||||
|
eventListener: (event: CustomEvent<VideoDataChanged>) => void;
|
||||||
|
timeUpdateListener: (e: Event) => void;
|
||||||
|
},
|
||||||
|
DisableAutoPlayPluginConfig
|
||||||
|
>({
|
||||||
|
name: 'Disable Autoplay',
|
||||||
|
restartNeeded: false,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
applyOnce: false,
|
||||||
|
},
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Applies only on startup',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.applyOnce,
|
||||||
|
async click() {
|
||||||
|
const nowConfig = await getConfig();
|
||||||
|
setConfig({
|
||||||
|
applyOnce: !nowConfig.applyOnce,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
config: null,
|
||||||
|
api: null,
|
||||||
|
eventListener(event: CustomEvent<VideoDataChanged>) {
|
||||||
|
if (this.config?.applyOnce) {
|
||||||
|
document.removeEventListener('videodatachange', this.eventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.detail.name === 'dataloaded') {
|
||||||
|
this.api?.pauseVideo();
|
||||||
|
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', this.timeUpdateListener, { once: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeUpdateListener(e: Event) {
|
||||||
|
if (e.target instanceof HTMLVideoElement) {
|
||||||
|
e.target.pause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async start({ getConfig }) {
|
||||||
|
this.config = await getConfig();
|
||||||
|
},
|
||||||
|
onPlayerApiReady(api) {
|
||||||
|
this.api = api;
|
||||||
|
|
||||||
|
document.addEventListener('videodatachange', this.eventListener);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
document.removeEventListener('videodatachange', this.eventListener);
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (_: BrowserWindow, options: ConfigType<'disable-autoplay'>): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Applies only on startup',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.applyOnce,
|
|
||||||
click() {
|
|
||||||
setMenuOptions('disable-autoplay', {
|
|
||||||
applyOnce: !options.applyOnce,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (options: ConfigType<'disable-autoplay'>) => {
|
|
||||||
const timeUpdateListener = (e: Event) => {
|
|
||||||
if (e.target instanceof HTMLVideoElement) {
|
|
||||||
e.target.pause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('apiLoaded', (apiEvent) => {
|
|
||||||
const eventListener = (name: string) => {
|
|
||||||
if (options.applyOnce) {
|
|
||||||
apiEvent.detail.removeEventListener('videodatachange', eventListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === 'dataloaded') {
|
|
||||||
apiEvent.detail.pauseVideo();
|
|
||||||
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
apiEvent.detail.addEventListener('videodatachange', eventListener);
|
|
||||||
}, { once: true, passive: true });
|
|
||||||
};
|
|
||||||
52
src/plugins/discord/index.ts
Normal file
52
src/plugins/discord/index.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { backend } from './main';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
|
||||||
|
export type DiscordPluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
autoReconnect: boolean;
|
||||||
|
/**
|
||||||
|
* If enabled, the discord rich presence gets cleared when music paused after the time specified below
|
||||||
|
*/
|
||||||
|
activityTimeoutEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* The time in milliseconds after which the discord rich presence gets cleared when music paused
|
||||||
|
*
|
||||||
|
* @default 10 * 60 * 1000 (10 minutes)
|
||||||
|
*/
|
||||||
|
activityTimeoutTime: number;
|
||||||
|
/**
|
||||||
|
* Add a "Play on YouTube Music" button to rich presence
|
||||||
|
*/
|
||||||
|
playOnYouTubeMusic: boolean;
|
||||||
|
/**
|
||||||
|
* Hide the "View App On GitHub" button in the rich presence
|
||||||
|
*/
|
||||||
|
hideGitHubButton: boolean;
|
||||||
|
/**
|
||||||
|
* Hide the "duration left" in the rich presence
|
||||||
|
*/
|
||||||
|
hideDurationLeft: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Discord Rich Presence',
|
||||||
|
restartNeeded: false,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
autoReconnect: true,
|
||||||
|
activityTimeoutEnabled: true,
|
||||||
|
activityTimeoutTime: 10 * 60 * 1000,
|
||||||
|
playOnYouTubeMusic: true,
|
||||||
|
hideGitHubButton: false,
|
||||||
|
hideDurationLeft: false,
|
||||||
|
} as DiscordPluginConfig,
|
||||||
|
menu: onMenu,
|
||||||
|
backend,
|
||||||
|
});
|
||||||
|
|
||||||
@ -4,9 +4,12 @@ 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, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
|
import registerCallback, { type SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
|
import { createBackend } from '@/utils';
|
||||||
|
|
||||||
|
import type { DiscordPluginConfig } from './index';
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
// Application ID registered by @th-ch/youtube-music dev team
|
// Application ID registered by @th-ch/youtube-music dev team
|
||||||
const clientId = '1177081335727267940';
|
const clientId = '1177081335727267940';
|
||||||
@ -51,7 +54,6 @@ const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => {
|
|||||||
|
|
||||||
info.rpc.login().then(resolve).catch(reject);
|
info.rpc.login().then(resolve).catch(reject);
|
||||||
}, 5000));
|
}, 5000));
|
||||||
|
|
||||||
const connectRecursive = () => {
|
const connectRecursive = () => {
|
||||||
if (!info.autoReconnect || info.rpc.isConnected) {
|
if (!info.autoReconnect || info.rpc.isConnected) {
|
||||||
return;
|
return;
|
||||||
@ -92,53 +94,35 @@ export const connect = (showError = false) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let clearActivity: NodeJS.Timeout | undefined;
|
let clearActivity: NodeJS.Timeout | undefined;
|
||||||
let updateActivity: SongInfoCallback;
|
|
||||||
|
|
||||||
type DiscordOptions = ConfigType<'discord'>;
|
export const clear = () => {
|
||||||
|
if (info.rpc) {
|
||||||
|
info.rpc.user?.clearActivity();
|
||||||
|
}
|
||||||
|
|
||||||
export default (
|
clearTimeout(clearActivity);
|
||||||
win: Electron.BrowserWindow,
|
};
|
||||||
options: DiscordOptions,
|
|
||||||
) => {
|
|
||||||
info.rpc.on('connected', () => {
|
|
||||||
if (dev()) {
|
|
||||||
console.log('discord connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const cb of refreshCallbacks) {
|
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
|
||||||
cb();
|
export const isConnected = () => info.rpc !== null;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
info.rpc.on('ready', () => {
|
export const backend = createBackend<{
|
||||||
info.ready = true;
|
config?: DiscordPluginConfig;
|
||||||
if (info.lastSongInfo) {
|
updateActivity: (songInfo: SongInfo, config: DiscordPluginConfig) => void;
|
||||||
updateActivity(info.lastSongInfo);
|
}, DiscordPluginConfig>({
|
||||||
}
|
/**
|
||||||
});
|
* We get multiple events
|
||||||
|
* Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
|
||||||
info.rpc.on('disconnected', () => {
|
* Skip time: PAUSE(N), PLAY(N)
|
||||||
resetInfo();
|
*/
|
||||||
|
updateActivity: (songInfo, config) => {
|
||||||
if (info.autoReconnect) {
|
|
||||||
connectTimeout();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
info.autoReconnect = options.autoReconnect;
|
|
||||||
|
|
||||||
window = win;
|
|
||||||
// We get multiple events
|
|
||||||
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
|
|
||||||
// Skip time: PAUSE(N), PLAY(N)
|
|
||||||
updateActivity = (songInfo) => {
|
|
||||||
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
|
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
info.lastSongInfo = songInfo;
|
info.lastSongInfo = songInfo;
|
||||||
|
|
||||||
// Stop the clear activity timout
|
// Stop the clear activity timeout
|
||||||
clearTimeout(clearActivity);
|
clearTimeout(clearActivity);
|
||||||
|
|
||||||
// Stop early if discord connection is not ready
|
// Stop early if discord connection is not ready
|
||||||
@ -148,7 +132,7 @@ export default (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear directly if timeout is 0
|
// Clear directly if timeout is 0
|
||||||
if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) {
|
if (songInfo.isPaused && config.activityTimeoutEnabled && config.activityTimeoutTime === 0) {
|
||||||
info.rpc.user?.clearActivity().catch(console.error);
|
info.rpc.user?.clearActivity().catch(console.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -170,8 +154,11 @@ export default (
|
|||||||
largeImageKey: songInfo.imageSrc ?? '',
|
largeImageKey: songInfo.imageSrc ?? '',
|
||||||
largeImageText: songInfo.album ?? '',
|
largeImageText: songInfo.album ?? '',
|
||||||
buttons: [
|
buttons: [
|
||||||
...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []),
|
...(config.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []),
|
||||||
...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]),
|
...(config.hideGitHubButton ? [] : [{
|
||||||
|
label: 'View App On GitHub',
|
||||||
|
url: 'https://github.com/th-ch/youtube-music'
|
||||||
|
}]),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -180,10 +167,10 @@ export default (
|
|||||||
activityInfo.smallImageKey = 'paused';
|
activityInfo.smallImageKey = 'paused';
|
||||||
activityInfo.smallImageText = 'Paused';
|
activityInfo.smallImageText = 'Paused';
|
||||||
// Set start the timer so the activity gets cleared after a while if enabled
|
// Set start the timer so the activity gets cleared after a while if enabled
|
||||||
if (options.activityTimoutEnabled) {
|
if (config.activityTimeoutEnabled) {
|
||||||
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000);
|
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), config.activityTimeoutTime ?? 10_000);
|
||||||
}
|
}
|
||||||
} else if (!options.hideDurationLeft) {
|
} else if (!config.hideDurationLeft) {
|
||||||
// Add the start and end time of the song
|
// Add the start and end time of the song
|
||||||
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
|
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
|
||||||
activityInfo.startTimestamp = songStartTime;
|
activityInfo.startTimestamp = songStartTime;
|
||||||
@ -192,39 +179,70 @@ export default (
|
|||||||
}
|
}
|
||||||
|
|
||||||
info.rpc.user?.setActivity(activityInfo).catch(console.error);
|
info.rpc.user?.setActivity(activityInfo).catch(console.error);
|
||||||
};
|
},
|
||||||
|
async start({ window: win, getConfig }) {
|
||||||
|
this.config = await getConfig();
|
||||||
|
|
||||||
// If the page is ready, register the callback
|
info.rpc.on('connected', () => {
|
||||||
win.once('ready-to-show', () => {
|
if (dev()) {
|
||||||
let lastSongInfo: SongInfo;
|
console.log('discord connected');
|
||||||
registerCallback((songInfo) => {
|
}
|
||||||
lastSongInfo = songInfo;
|
|
||||||
updateActivity(songInfo);
|
for (const cb of refreshCallbacks) {
|
||||||
});
|
cb();
|
||||||
connect();
|
|
||||||
let lastSent = Date.now();
|
|
||||||
ipcMain.on('timeChanged', (_, t: number) => {
|
|
||||||
const currentTime = Date.now();
|
|
||||||
// if lastSent is more than 5 seconds ago, send the new time
|
|
||||||
if (currentTime - lastSent > 5000) {
|
|
||||||
lastSent = currentTime;
|
|
||||||
if (lastSongInfo) {
|
|
||||||
lastSongInfo.elapsedSeconds = t;
|
|
||||||
updateActivity(lastSongInfo);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
app.on('window-all-closed', clear);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clear = () => {
|
info.rpc.on('ready', () => {
|
||||||
if (info.rpc) {
|
info.ready = true;
|
||||||
info.rpc.user?.clearActivity();
|
if (info.lastSongInfo && this.config) {
|
||||||
}
|
this.updateActivity(info.lastSongInfo, this.config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
clearTimeout(clearActivity);
|
info.rpc.on('disconnected', () => {
|
||||||
};
|
resetInfo();
|
||||||
|
|
||||||
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
|
if (info.autoReconnect) {
|
||||||
export const isConnected = () => info.rpc !== null;
|
connectTimeout();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info.autoReconnect = this.config.autoReconnect;
|
||||||
|
|
||||||
|
window = win;
|
||||||
|
|
||||||
|
// If the page is ready, register the callback
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
let lastSongInfo: SongInfo;
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
lastSongInfo = songInfo;
|
||||||
|
if (this.config) this.updateActivity(songInfo, this.config);
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
let lastSent = Date.now();
|
||||||
|
ipcMain.on('timeChanged', (_, t: number) => {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
// if lastSent is more than 5 seconds ago, send the new time
|
||||||
|
if (currentTime - lastSent > 5000) {
|
||||||
|
lastSent = currentTime;
|
||||||
|
if (lastSongInfo) {
|
||||||
|
lastSongInfo.elapsedSeconds = t;
|
||||||
|
if (this.config) this.updateActivity(lastSongInfo, this.config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.on('window-all-closed', clear);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
resetInfo();
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
info.autoReconnect = newConfig.autoReconnect;
|
||||||
|
if (info.lastSongInfo) {
|
||||||
|
this.updateActivity(info.lastSongInfo, newConfig);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -2,21 +2,22 @@ import prompt from 'custom-electron-prompt';
|
|||||||
|
|
||||||
import { clear, connect, isConnected, registerRefresh } from './main';
|
import { clear, connect, isConnected, registerRefresh } from './main';
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
import { singleton } from '@/providers/decorators';
|
||||||
import promptOptions from '../../providers/prompt-options';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
import { singleton } from '../../providers/decorators';
|
import { setMenuOptions } from '@/config/plugins';
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { DiscordPluginConfig } from './index';
|
||||||
|
|
||||||
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
||||||
registerRefresh(refreshMenu);
|
registerRefresh(refreshMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
type DiscordOptions = ConfigType<'discord'>;
|
export const onMenu = async ({ window, getConfig, setConfig, refresh }: MenuContext<DiscordPluginConfig>): Promise<MenuTemplate> => {
|
||||||
|
const config = await getConfig();
|
||||||
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => {
|
registerRefreshOnce(refresh);
|
||||||
registerRefreshOnce(refreshMenu);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -27,10 +28,11 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen
|
|||||||
{
|
{
|
||||||
label: 'Auto reconnect',
|
label: 'Auto reconnect',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.autoReconnect,
|
checked: config.autoReconnect,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.autoReconnect = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
autoReconnect: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -40,51 +42,55 @@ export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMen
|
|||||||
{
|
{
|
||||||
label: 'Clear activity after timeout',
|
label: 'Clear activity after timeout',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.activityTimoutEnabled,
|
checked: config.activityTimeoutEnabled,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.activityTimoutEnabled = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
activityTimeoutEnabled: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Play on YouTube Music',
|
label: 'Play on YouTube Music',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.playOnYouTubeMusic,
|
checked: config.playOnYouTubeMusic,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.playOnYouTubeMusic = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
playOnYouTubeMusic: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hide GitHub link Button',
|
label: 'Hide GitHub link Button',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.hideGitHubButton,
|
checked: config.hideGitHubButton,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.hideGitHubButton = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
hideGitHubButton: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hide duration left',
|
label: 'Hide duration left',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.hideDurationLeft,
|
checked: config.hideDurationLeft,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.hideDurationLeft = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
hideGitHubButton: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Set inactivity timeout',
|
label: 'Set inactivity timeout',
|
||||||
click: () => setInactivityTimeout(win, options),
|
click: () => setInactivityTimeout(window, config),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
|
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordPluginConfig) {
|
||||||
const output = await prompt({
|
const output = await prompt({
|
||||||
title: 'Set Inactivity Timeout',
|
title: 'Set Inactivity Timeout',
|
||||||
label: 'Enter inactivity timeout in seconds:',
|
label: 'Enter inactivity timeout in seconds:',
|
||||||
value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)),
|
value: String(Math.round((options.activityTimeoutTime ?? 0) / 1e3)),
|
||||||
type: 'counter',
|
type: 'counter',
|
||||||
counterOptions: { minimum: 0, multiFire: true },
|
counterOptions: { minimum: 0, multiFire: true },
|
||||||
width: 450,
|
width: 450,
|
||||||
@ -92,7 +98,7 @@ async function setInactivityTimeout(win: Electron.BrowserWindow, options: Discor
|
|||||||
}, win);
|
}, win);
|
||||||
|
|
||||||
if (output) {
|
if (output) {
|
||||||
options.activityTimoutTime = Math.round(~~output * 1e3);
|
options.activityTimeoutTime = Math.round(~~output * 1e3);
|
||||||
setMenuOptions('discord', options);
|
setMenuOptions('discord', options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('downloader');
|
|
||||||
export default config;
|
|
||||||
41
src/plugins/downloader/index.ts
Normal file
41
src/plugins/downloader/index.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { DefaultPresetList, Preset } from './types';
|
||||||
|
|
||||||
|
import style from './style.css?inline';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
|
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||||
|
|
||||||
|
export type DownloaderPluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
downloadFolder?: string;
|
||||||
|
selectedPreset: string;
|
||||||
|
customPresetSetting: Preset;
|
||||||
|
skipExisting: boolean;
|
||||||
|
playlistMaxItems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultConfig: DownloaderPluginConfig = {
|
||||||
|
enabled: false,
|
||||||
|
downloadFolder: undefined,
|
||||||
|
selectedPreset: 'mp3 (256kbps)', // Selected preset
|
||||||
|
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
|
||||||
|
skipExisting: false,
|
||||||
|
playlistMaxItems: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Downloader',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: defaultConfig,
|
||||||
|
stylesheets: [style],
|
||||||
|
backend: {
|
||||||
|
start: onMainLoad,
|
||||||
|
onConfigChange,
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
start: onRendererLoad,
|
||||||
|
onPlayerApiReady,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ import {
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
|
import { app, BrowserWindow, dialog } from 'electron';
|
||||||
import {
|
import {
|
||||||
ClientType,
|
ClientType,
|
||||||
Innertube,
|
Innertube,
|
||||||
@ -27,16 +27,18 @@ import {
|
|||||||
sendFeedback as sendFeedback_,
|
sendFeedback as sendFeedback_,
|
||||||
setBadge,
|
setBadge,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import config from './config';
|
|
||||||
import { YoutubeFormatList, type Preset, DefaultPresetList } from './types';
|
|
||||||
|
|
||||||
import style from './style.css';
|
import { fetchFromGenius } from '@/plugins/lyrics-genius/main';
|
||||||
|
import { isEnabled } from '@/config/plugins';
|
||||||
|
import { cleanupName, getImage, SongInfo } from '@/providers/song-info';
|
||||||
|
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
||||||
|
import { cache } from '@/providers/decorators';
|
||||||
|
|
||||||
import { fetchFromGenius } from '../lyrics-genius/main';
|
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
|
||||||
import { isEnabled } from '../../config/plugins';
|
|
||||||
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
|
import type { DownloaderPluginConfig } from '../index';
|
||||||
import { injectCSS } from '../utils/main';
|
|
||||||
import { cache } from '../../providers/decorators';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||||
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
||||||
@ -44,7 +46,7 @@ import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic';
|
|||||||
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
||||||
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
||||||
|
|
||||||
import type { GetPlayerResponse } from '../../types/get-player-response';
|
import type { GetPlayerResponse } from '@/types/get-player-response';
|
||||||
|
|
||||||
type CustomSongInfo = SongInfo & { trackId?: string };
|
type CustomSongInfo = SongInfo & { trackId?: string };
|
||||||
|
|
||||||
@ -89,41 +91,27 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
|
|||||||
.join(';');
|
.join(';');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async (win_: BrowserWindow) => {
|
let config: DownloaderPluginConfig;
|
||||||
win = win_;
|
|
||||||
injectCSS(win.webContents, style);
|
export const onMainLoad = async ({ window: _win, getConfig, ipc }: BackendContext<DownloaderPluginConfig>) => {
|
||||||
|
win = _win;
|
||||||
|
config = await getConfig();
|
||||||
|
|
||||||
yt = await Innertube.create({
|
yt = await Innertube.create({
|
||||||
cache: new UniversalCache(false),
|
cache: new UniversalCache(false),
|
||||||
cookie: await getCookieFromWindow(win),
|
cookie: await getCookieFromWindow(win),
|
||||||
generate_session_locally: true,
|
generate_session_locally: true,
|
||||||
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
|
fetch: getNetFetchAsFetch(),
|
||||||
const url =
|
|
||||||
typeof input === 'string'
|
|
||||||
? new URL(input)
|
|
||||||
: input instanceof URL
|
|
||||||
? input
|
|
||||||
: new URL(input.url);
|
|
||||||
|
|
||||||
if (init?.body && !init.method) {
|
|
||||||
init.method = 'POST';
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = new Request(
|
|
||||||
url,
|
|
||||||
input instanceof Request ? input : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
return net.fetch(request, init);
|
|
||||||
}) as typeof fetch,
|
|
||||||
});
|
});
|
||||||
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
|
ipc.handle('download-song', (url: string) => downloadSong(url));
|
||||||
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
|
ipc.on('video-src-changed', (data: GetPlayerResponse) => {
|
||||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||||
});
|
});
|
||||||
ipcMain.on('download-playlist-request', async (_event, url: string) =>
|
ipc.handle('download-playlist-request', async (url: string) => downloadPlaylist(url));
|
||||||
downloadPlaylist(url),
|
};
|
||||||
);
|
|
||||||
|
export const onConfigChange = (newConfig: DownloaderPluginConfig) => {
|
||||||
|
config = newConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function downloadSong(
|
export async function downloadSong(
|
||||||
@ -209,7 +197,7 @@ async function downloadSongUnsafe(
|
|||||||
metadata.trackId = trackId;
|
metadata.trackId = trackId;
|
||||||
|
|
||||||
const dir =
|
const dir =
|
||||||
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
|
playlistFolder || config.downloadFolder || app.getPath('downloads');
|
||||||
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
||||||
metadata.title
|
metadata.title
|
||||||
}`;
|
}`;
|
||||||
@ -239,11 +227,11 @@ async function downloadSongUnsafe(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)';
|
const selectedPreset = config.selectedPreset ?? 'mp3 (256kbps)';
|
||||||
let presetSetting: Preset;
|
let presetSetting: Preset;
|
||||||
if (selectedPreset === 'Custom') {
|
if (selectedPreset === 'Custom') {
|
||||||
presetSetting =
|
presetSetting =
|
||||||
config.get('customPresetSetting') ?? DefaultPresetList['Custom'];
|
config.customPresetSetting ?? DefaultPresetList['Custom'];
|
||||||
} else if (selectedPreset === 'Source') {
|
} else if (selectedPreset === 'Source') {
|
||||||
presetSetting = DefaultPresetList['Source'];
|
presetSetting = DefaultPresetList['Source'];
|
||||||
} else {
|
} else {
|
||||||
@ -276,7 +264,7 @@ async function downloadSongUnsafe(
|
|||||||
}
|
}
|
||||||
const filePath = join(dir, filename);
|
const filePath = join(dir, filename);
|
||||||
|
|
||||||
if (config.get('skipExisting') && existsSync(filePath)) {
|
if (config.skipExisting && existsSync(filePath)) {
|
||||||
sendFeedback(null, -1);
|
sendFeedback(null, -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -330,7 +318,7 @@ async function iterableStreamToTargetFile(
|
|||||||
contentLength: number,
|
contentLength: number,
|
||||||
sendFeedback: (str: string, value?: number) => void,
|
sendFeedback: (str: string, value?: number) => void,
|
||||||
increasePlaylistProgress: (value: number) => void = () => {},
|
increasePlaylistProgress: (value: number) => void = () => {},
|
||||||
) {
|
): Promise<Uint8Array | null> {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let downloaded = 0;
|
let downloaded = 0;
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
@ -390,6 +378,7 @@ async function iterableStreamToTargetFile(
|
|||||||
} finally {
|
} finally {
|
||||||
releaseFFmpegMutex();
|
releaseFFmpegMutex();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCoverBuffer = cache(async (url: string) => {
|
const getCoverBuffer = cache(async (url: string) => {
|
||||||
@ -517,10 +506,10 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
|
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
|
||||||
}
|
}
|
||||||
|
|
||||||
const folder = getFolder(config.get('downloadFolder') ?? '');
|
const folder = getFolder(config.downloadFolder ?? '');
|
||||||
const playlistFolder = join(folder, safePlaylistTitle);
|
const playlistFolder = join(folder, safePlaylistTitle);
|
||||||
if (existsSync(playlistFolder)) {
|
if (existsSync(playlistFolder)) {
|
||||||
if (!config.get('skipExisting')) {
|
if (!config.skipExisting) {
|
||||||
sendError(new Error(`The folder ${playlistFolder} already exists`));
|
sendError(new Error(`The folder ${playlistFolder} already exists`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -637,6 +626,7 @@ const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
|
|||||||
client_type: ClientType.TV_EMBEDDED,
|
client_type: ClientType.TV_EMBEDDED,
|
||||||
generate_session_locally: true,
|
generate_session_locally: true,
|
||||||
retrieve_player: true,
|
retrieve_player: true,
|
||||||
|
fetch: getNetFetchAsFetch(),
|
||||||
});
|
});
|
||||||
// GetInfo 404s with the bypass, so we use getBasicInfo instead
|
// GetInfo 404s with the bypass, so we use getBasicInfo instead
|
||||||
// that's fine as we only need the streaming data
|
// that's fine as we only need the streaming data
|
||||||
@ -1,46 +1,52 @@
|
|||||||
import { dialog } from 'electron';
|
import { dialog } from 'electron';
|
||||||
|
|
||||||
import { downloadPlaylist } from './main';
|
import { downloadPlaylist } from './main';
|
||||||
import { defaultMenuDownloadLabel, getFolder } from './utils';
|
import { defaultMenuDownloadLabel, getFolder } from './main/utils';
|
||||||
import { DefaultPresetList } from './types';
|
import { DefaultPresetList } from './types';
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
export default (): MenuTemplate => [
|
import type { DownloaderPluginConfig } from './index';
|
||||||
{
|
|
||||||
label: defaultMenuDownloadLabel,
|
export const onMenu = async ({ getConfig, setConfig }: MenuContext<DownloaderPluginConfig>): Promise<MenuTemplate> => {
|
||||||
click: () => downloadPlaylist(),
|
const config = await getConfig();
|
||||||
},
|
|
||||||
{
|
return [
|
||||||
label: 'Choose download folder',
|
{
|
||||||
click() {
|
label: defaultMenuDownloadLabel,
|
||||||
const result = dialog.showOpenDialogSync({
|
click: () => downloadPlaylist(),
|
||||||
properties: ['openDirectory', 'createDirectory'],
|
|
||||||
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
config.set('downloadFolder', result[0]);
|
|
||||||
} // Else = user pressed cancel
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
label: 'Choose download folder',
|
||||||
label: 'Presets',
|
|
||||||
submenu: Object.keys(DefaultPresetList).map((preset) => ({
|
|
||||||
label: preset,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('selectedPreset') === preset,
|
|
||||||
click() {
|
click() {
|
||||||
config.set('selectedPreset', preset);
|
const result = dialog.showOpenDialogSync({
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
defaultPath: getFolder(config.downloadFolder ?? ''),
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
setConfig({ downloadFolder: result[0] });
|
||||||
|
} // Else = user pressed cancel
|
||||||
},
|
},
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Skip existing files',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('skipExisting'),
|
|
||||||
click(item) {
|
|
||||||
config.set('skipExisting', item.checked);
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
];
|
label: 'Presets',
|
||||||
|
submenu: Object.keys(DefaultPresetList).map((preset) => ({
|
||||||
|
label: preset,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.selectedPreset === preset,
|
||||||
|
click() {
|
||||||
|
setConfig({ selectedPreset: preset });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Skip existing files',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.skipExisting,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ skipExisting: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import downloadHTML from './templates/download.html?raw';
|
import downloadHTML from './templates/download.html?raw';
|
||||||
|
|
||||||
import defaultConfig from '../../config/defaults';
|
import defaultConfig from '@/config/defaults';
|
||||||
import { getSongMenu } from '../../providers/dom-elements';
|
import { getSongMenu } from '@/providers/dom-elements';
|
||||||
|
import { getSongInfo } from '@/providers/song-info-front';
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils/renderer';
|
import { ElementFromHtml } from '../utils/renderer';
|
||||||
import { getSongInfo } from '../../providers/song-info-front';
|
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
|
||||||
|
import type { DownloaderPluginConfig } from './index';
|
||||||
|
|
||||||
let menu: Element | null = null;
|
let menu: Element | null = null;
|
||||||
let progress: Element | null = null;
|
let progress: Element | null = null;
|
||||||
@ -11,34 +16,34 @@ const downloadButton = ElementFromHtml(downloadHTML);
|
|||||||
|
|
||||||
let doneFirstLoad = false;
|
let doneFirstLoad = false;
|
||||||
|
|
||||||
export default () => {
|
const menuObserver = new MutationObserver(() => {
|
||||||
const menuObserver = new MutationObserver(() => {
|
if (!menu) {
|
||||||
|
menu = getSongMenu();
|
||||||
if (!menu) {
|
if (!menu) {
|
||||||
menu = getSongMenu();
|
|
||||||
if (!menu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (menu.contains(downloadButton)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
|
if (menu.contains(downloadButton)) {
|
||||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
menu.prepend(downloadButton);
|
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint')?.href;
|
||||||
progress = document.querySelector('#ytmcustom-download');
|
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (doneFirstLoad) {
|
menu.prepend(downloadButton);
|
||||||
return;
|
progress = document.querySelector('#ytmcustom-download');
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
if (doneFirstLoad) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const onRendererLoad = ({ ipc }: RendererContext<DownloaderPluginConfig>) => {
|
||||||
window.download = () => {
|
window.download = () => {
|
||||||
let videoUrl = getSongMenu()
|
let videoUrl = getSongMenu()
|
||||||
// Selector of first button which is always "Start Radio"
|
// Selector of first button which is always "Start Radio"
|
||||||
@ -50,24 +55,17 @@ export default () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (videoUrl.includes('?playlist=')) {
|
if (videoUrl.includes('?playlist=')) {
|
||||||
window.ipcRenderer.send('download-playlist-request', videoUrl);
|
ipc.invoke('download-playlist-request', videoUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
videoUrl = getSongInfo().url || window.location.href;
|
videoUrl = getSongInfo().url || window.location.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ipcRenderer.send('download-song', videoUrl);
|
ipc.invoke('download-song', videoUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('apiLoaded', () => {
|
ipc.on('downloader-feedback', (feedback: string) => {
|
||||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}, { once: true, passive: true });
|
|
||||||
|
|
||||||
window.ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
|
|
||||||
if (progress) {
|
if (progress) {
|
||||||
progress.innerHTML = feedback || 'Download';
|
progress.innerHTML = feedback || 'Download';
|
||||||
} else {
|
} else {
|
||||||
@ -75,3 +73,10 @@ export default () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const onPlayerApiReady = () => {
|
||||||
|
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
50
src/plugins/exponential-volume/index.ts
Normal file
50
src/plugins/exponential-volume/index.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Exponential Volume',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
onPlayerApiReady() {
|
||||||
|
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
|
||||||
|
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
|
||||||
|
|
||||||
|
// Manipulation exponent, higher value = lower volume
|
||||||
|
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
|
||||||
|
// 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
|
||||||
|
const EXPONENT = 3;
|
||||||
|
|
||||||
|
const storedOriginalVolumes = new WeakMap<HTMLMediaElement, number>();
|
||||||
|
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLMediaElement.prototype,
|
||||||
|
'volume',
|
||||||
|
);
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
|
||||||
|
get(this: HTMLMediaElement) {
|
||||||
|
const lowVolume = propertyDescriptor?.get?.call(this) as number ?? 0;
|
||||||
|
const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
|
||||||
|
|
||||||
|
// The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
|
||||||
|
// To avoid this, I'll store the unmodified volume to return it when read here.
|
||||||
|
// This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
|
||||||
|
// To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
|
||||||
|
const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0;
|
||||||
|
const storedDeviation = Math.abs(
|
||||||
|
storedOriginalVolume - calculatedOriginalVolume,
|
||||||
|
);
|
||||||
|
|
||||||
|
return storedDeviation < 0.01
|
||||||
|
? storedOriginalVolume
|
||||||
|
: calculatedOriginalVolume;
|
||||||
|
},
|
||||||
|
set(this: HTMLMediaElement, originalVolume: number) {
|
||||||
|
const lowVolume = originalVolume ** EXPONENT;
|
||||||
|
storedOriginalVolumes.set(this, originalVolume);
|
||||||
|
propertyDescriptor?.set?.call(this, lowVolume);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,45 +0,0 @@
|
|||||||
// "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
|
|
||||||
// https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
|
|
||||||
|
|
||||||
const exponentialVolume = () => {
|
|
||||||
// Manipulation exponent, higher value = lower volume
|
|
||||||
// 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
|
|
||||||
// 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
|
|
||||||
const EXPONENT = 3;
|
|
||||||
|
|
||||||
const storedOriginalVolumes = new WeakMap<HTMLMediaElement, number>();
|
|
||||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(
|
|
||||||
HTMLMediaElement.prototype,
|
|
||||||
'volume',
|
|
||||||
);
|
|
||||||
Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
|
|
||||||
get(this: HTMLMediaElement) {
|
|
||||||
const lowVolume = propertyDescriptor?.get?.call(this) as number ?? 0;
|
|
||||||
const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
|
|
||||||
|
|
||||||
// The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
|
|
||||||
// To avoid this, I'll store the unmodified volume to return it when read here.
|
|
||||||
// This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
|
|
||||||
// To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
|
|
||||||
const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0;
|
|
||||||
const storedDeviation = Math.abs(
|
|
||||||
storedOriginalVolume - calculatedOriginalVolume,
|
|
||||||
);
|
|
||||||
|
|
||||||
return storedDeviation < 0.01
|
|
||||||
? storedOriginalVolume
|
|
||||||
: calculatedOriginalVolume;
|
|
||||||
},
|
|
||||||
set(this: HTMLMediaElement, originalVolume: number) {
|
|
||||||
const lowVolume = originalVolume ** EXPONENT;
|
|
||||||
storedOriginalVolumes.set(this, originalVolume);
|
|
||||||
propertyDescriptor?.set?.call(this, lowVolume);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default () =>
|
|
||||||
document.addEventListener('apiLoaded', exponentialVolume, {
|
|
||||||
once: true,
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
34
src/plugins/in-app-menu/index.ts
Normal file
34
src/plugins/in-app-menu/index.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import titlebarStyle from './titlebar.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { onMainLoad } from './main';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||||
|
|
||||||
|
export interface InAppMenuConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
hideDOMWindowControls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'In-App Menu',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
!window.navigator?.userAgent?.includes('mac')
|
||||||
|
) || (
|
||||||
|
typeof global !== 'undefined' &&
|
||||||
|
global.process?.platform !== 'darwin'
|
||||||
|
),
|
||||||
|
hideDOMWindowControls: false,
|
||||||
|
} as InAppMenuConfig,
|
||||||
|
stylesheets: [titlebarStyle],
|
||||||
|
menu: onMenu,
|
||||||
|
|
||||||
|
backend: onMainLoad,
|
||||||
|
renderer: {
|
||||||
|
start: onRendererLoad,
|
||||||
|
onPlayerApiReady,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@ -2,25 +2,21 @@ import { register } from 'electron-localshortcut';
|
|||||||
|
|
||||||
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron';
|
import { BrowserWindow, Menu, MenuItem, ipcMain, nativeImage } from 'electron';
|
||||||
|
|
||||||
import titlebarStyle from './titlebar.css';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
import type { InAppMenuConfig } from './index';
|
||||||
import { injectCSS } from '../utils/main';
|
|
||||||
|
|
||||||
// Tracks menu visibility
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, titlebarStyle);
|
|
||||||
|
|
||||||
|
export const onMainLoad = ({ window: win, ipc: { handle, send } }: BackendContext<InAppMenuConfig>) => {
|
||||||
win.on('close', () => {
|
win.on('close', () => {
|
||||||
win.webContents.send('close-all-in-app-menu-panel');
|
send('close-all-in-app-menu-panel');
|
||||||
});
|
});
|
||||||
|
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
register(win, '`', () => {
|
register(win, '`', () => {
|
||||||
win.webContents.send('toggle-in-app-menu');
|
send('toggle-in-app-menu');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(
|
handle(
|
||||||
'get-menu',
|
'get-menu',
|
||||||
() => JSON.parse(JSON.stringify(
|
() => JSON.parse(JSON.stringify(
|
||||||
Menu.getApplicationMenu(),
|
Menu.getApplicationMenu(),
|
||||||
@ -51,7 +47,7 @@ export default (win: BrowserWindow) => {
|
|||||||
if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
|
if (target) target.click(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-menu-by-id', (_, commandId: number) => {
|
handle('get-menu-by-id', (commandId: number) => {
|
||||||
const result = getMenuItemById(commandId);
|
const result = getMenuItemById(commandId);
|
||||||
|
|
||||||
return JSON.parse(JSON.stringify(
|
return JSON.parse(JSON.stringify(
|
||||||
@ -60,16 +56,16 @@ export default (win: BrowserWindow) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('window-is-maximized', () => win.isMaximized());
|
handle('window-is-maximized', () => win.isMaximized());
|
||||||
|
|
||||||
ipcMain.handle('window-close', () => win.close());
|
handle('window-close', () => win.close());
|
||||||
ipcMain.handle('window-minimize', () => win.minimize());
|
handle('window-minimize', () => win.minimize());
|
||||||
ipcMain.handle('window-maximize', () => win.maximize());
|
handle('window-maximize', () => win.maximize());
|
||||||
win.on('maximize', () => win.webContents.send('window-maximize'));
|
win.on('maximize', () => send('window-maximize'));
|
||||||
ipcMain.handle('window-unmaximize', () => win.unmaximize());
|
handle('window-unmaximize', () => win.unmaximize());
|
||||||
win.on('unmaximize', () => win.webContents.send('window-unmaximize'));
|
win.on('unmaximize', () => send('window-unmaximize'));
|
||||||
|
|
||||||
ipcMain.handle('image-path-to-data-url', (_, imagePath: string) => {
|
handle('image-path-to-data-url', (imagePath: string) => {
|
||||||
const nativeImageIcon = nativeImage.createFromPath(imagePath);
|
const nativeImageIcon = nativeImage.createFromPath(imagePath);
|
||||||
return nativeImageIcon?.toDataURL();
|
return nativeImageIcon?.toDataURL();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,22 +1,25 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
import type { InAppMenuConfig } from './index';
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
import type { MenuTemplate } from '../../menu';
|
export const onMenu = async ({ getConfig, setConfig }: MenuContext<InAppMenuConfig>): Promise<MenuTemplate> => {
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
const config = await getConfig();
|
||||||
|
|
||||||
export default (_: BrowserWindow, config: ConfigType<'in-app-menu'>): MenuTemplate => [
|
if (is.linux()) {
|
||||||
...(is.linux() ? [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Hide DOM Window Controls',
|
label: 'Hide DOM Window Controls',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.hideDOMWindowControls,
|
checked: config.hideDOMWindowControls,
|
||||||
click(item) {
|
click(item) {
|
||||||
config.hideDOMWindowControls = item.checked;
|
config.hideDOMWindowControls = item.checked;
|
||||||
setMenuOptions('in-app-menu', config);
|
setConfig(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
];
|
||||||
] : []) satisfies Electron.MenuItemConstructorOptions[],
|
}
|
||||||
];
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|||||||
@ -8,15 +8,17 @@ import unmaximizeRaw from './assets/unmaximize.svg?inline';
|
|||||||
|
|
||||||
import type { Menu } from 'electron';
|
import type { Menu } from 'electron';
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string) {
|
import type { RendererContext } from '@/types/contexts';
|
||||||
return document.querySelector<E>(selector);
|
import type { InAppMenuConfig } from '@/plugins/in-app-menu/index';
|
||||||
}
|
|
||||||
|
|
||||||
const isMacOS = navigator.userAgent.includes('Macintosh');
|
const isMacOS = navigator.userAgent.includes('Macintosh');
|
||||||
const isNotWindowsOrMacOS = !navigator.userAgent.includes('Windows') && !isMacOS;
|
const isNotWindowsOrMacOS = !navigator.userAgent.includes('Windows') && !isMacOS;
|
||||||
|
|
||||||
export default async () => {
|
export const onRendererLoad = async ({ getConfig, ipc: { invoke, on } }: RendererContext<InAppMenuConfig>) => {
|
||||||
const hideDOMWindowControls = window.mainConfig.get('plugins.in-app-menu.hideDOMWindowControls');
|
const config = await getConfig();
|
||||||
|
|
||||||
|
const hideDOMWindowControls = config.hideDOMWindowControls;
|
||||||
|
|
||||||
let hideMenu = window.mainConfig.get('options.hideMenu');
|
let hideMenu = window.mainConfig.get('options.hideMenu');
|
||||||
const titleBar = document.createElement('title-bar');
|
const titleBar = document.createElement('title-bar');
|
||||||
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
|
const navBar = document.querySelector<HTMLDivElement>('#nav-bar-background');
|
||||||
@ -60,7 +62,7 @@ export default async () => {
|
|||||||
};
|
};
|
||||||
logo.onclick = logoClick;
|
logo.onclick = logoClick;
|
||||||
|
|
||||||
window.ipcRenderer.on('toggle-in-app-menu', logoClick);
|
on('toggle-in-app-menu', logoClick);
|
||||||
|
|
||||||
if (!isMacOS) titleBar.appendChild(logo);
|
if (!isMacOS) titleBar.appendChild(logo);
|
||||||
document.body.appendChild(titleBar);
|
document.body.appendChild(titleBar);
|
||||||
@ -73,10 +75,10 @@ export default async () => {
|
|||||||
const minimizeButton = document.createElement('button');
|
const minimizeButton = document.createElement('button');
|
||||||
minimizeButton.classList.add('window-control');
|
minimizeButton.classList.add('window-control');
|
||||||
minimizeButton.appendChild(minimize);
|
minimizeButton.appendChild(minimize);
|
||||||
minimizeButton.onclick = () => window.ipcRenderer.invoke('window-minimize');
|
minimizeButton.onclick = () => invoke('window-minimize');
|
||||||
|
|
||||||
maximizeButton = document.createElement('button');
|
maximizeButton = document.createElement('button');
|
||||||
if (await window.ipcRenderer.invoke('window-is-maximized')) {
|
if (await invoke('window-is-maximized')) {
|
||||||
maximizeButton.classList.add('window-control');
|
maximizeButton.classList.add('window-control');
|
||||||
maximizeButton.appendChild(unmaximize);
|
maximizeButton.appendChild(unmaximize);
|
||||||
} else {
|
} else {
|
||||||
@ -84,27 +86,27 @@ export default async () => {
|
|||||||
maximizeButton.appendChild(maximize);
|
maximizeButton.appendChild(maximize);
|
||||||
}
|
}
|
||||||
maximizeButton.onclick = async () => {
|
maximizeButton.onclick = async () => {
|
||||||
if (await window.ipcRenderer.invoke('window-is-maximized')) {
|
if (await invoke('window-is-maximized')) {
|
||||||
// change icon to maximize
|
// change icon to maximize
|
||||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
maximizeButton.removeChild(maximizeButton.firstChild!);
|
||||||
maximizeButton.appendChild(maximize);
|
maximizeButton.appendChild(maximize);
|
||||||
|
|
||||||
// call unmaximize
|
// call unmaximize
|
||||||
await window.ipcRenderer.invoke('window-unmaximize');
|
await invoke('window-unmaximize');
|
||||||
} else {
|
} else {
|
||||||
// change icon to unmaximize
|
// change icon to unmaximize
|
||||||
maximizeButton.removeChild(maximizeButton.firstChild!);
|
maximizeButton.removeChild(maximizeButton.firstChild!);
|
||||||
maximizeButton.appendChild(unmaximize);
|
maximizeButton.appendChild(unmaximize);
|
||||||
|
|
||||||
// call maximize
|
// call maximize
|
||||||
await window.ipcRenderer.invoke('window-maximize');
|
await invoke('window-maximize');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeButton = document.createElement('button');
|
const closeButton = document.createElement('button');
|
||||||
closeButton.classList.add('window-control');
|
closeButton.classList.add('window-control');
|
||||||
closeButton.appendChild(close);
|
closeButton.appendChild(close);
|
||||||
closeButton.onclick = () => window.ipcRenderer.invoke('window-close');
|
closeButton.onclick = () => invoke('window-close');
|
||||||
|
|
||||||
// Create a container div for the window control buttons
|
// Create a container div for the window control buttons
|
||||||
const windowControlsContainer = document.createElement('div');
|
const windowControlsContainer = document.createElement('div');
|
||||||
@ -137,7 +139,7 @@ export default async () => {
|
|||||||
});
|
});
|
||||||
panelClosers = [];
|
panelClosers = [];
|
||||||
|
|
||||||
const menu = await window.ipcRenderer.invoke('get-menu') as Menu | null;
|
const menu = await invoke('get-menu') as Menu | null;
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
|
||||||
menu.items.forEach((menuItem) => {
|
menu.items.forEach((menuItem) => {
|
||||||
@ -157,17 +159,17 @@ export default async () => {
|
|||||||
|
|
||||||
document.title = 'Youtube Music';
|
document.title = 'Youtube Music';
|
||||||
|
|
||||||
window.ipcRenderer.on('close-all-in-app-menu-panel', () => {
|
on('close-all-in-app-menu-panel', () => {
|
||||||
panelClosers.forEach((closer) => closer());
|
panelClosers.forEach((closer) => closer());
|
||||||
});
|
});
|
||||||
window.ipcRenderer.on('refresh-in-app-menu', () => updateMenu());
|
on('refresh-in-app-menu', () => updateMenu());
|
||||||
window.ipcRenderer.on('window-maximize', () => {
|
on('window-maximize', () => {
|
||||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
|
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
|
||||||
maximizeButton.removeChild(maximizeButton.firstChild);
|
maximizeButton.removeChild(maximizeButton.firstChild);
|
||||||
maximizeButton.appendChild(unmaximize);
|
maximizeButton.appendChild(unmaximize);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.ipcRenderer.on('window-unmaximize', () => {
|
on('window-unmaximize', () => {
|
||||||
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
|
if (isNotWindowsOrMacOS && !hideDOMWindowControls && maximizeButton.firstChild) {
|
||||||
maximizeButton.removeChild(maximizeButton.firstChild);
|
maximizeButton.removeChild(maximizeButton.firstChild);
|
||||||
maximizeButton.appendChild(unmaximize);
|
maximizeButton.appendChild(unmaximize);
|
||||||
@ -175,17 +177,16 @@ export default async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (window.mainConfig.plugins.isEnabled('picture-in-picture')) {
|
if (window.mainConfig.plugins.isEnabled('picture-in-picture')) {
|
||||||
window.ipcRenderer.on('pip-toggle', () => {
|
on('pip-toggle', () => {
|
||||||
updateMenu();
|
updateMenu();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
// Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it)
|
|
||||||
document.addEventListener('apiLoaded', () => {
|
export const onPlayerApiReady = () => {
|
||||||
const htmlHeadStyle = $('head > div > style');
|
const htmlHeadStyle = document.querySelector('head > div > style');
|
||||||
if (htmlHeadStyle) {
|
if (htmlHeadStyle) {
|
||||||
// HACK: This is a hack to remove the scrollbar width
|
// HACK: This is a hack to remove the scrollbar width
|
||||||
htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {');
|
htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace('html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);', 'html::-webkit-scrollbar {');
|
||||||
}
|
}
|
||||||
}, { once: true, passive: true });
|
|
||||||
};
|
};
|
||||||
|
|||||||
74
src/plugins/last-fm/index.ts
Normal file
74
src/plugins/last-fm/index.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import registerCallback from '@/providers/song-info';
|
||||||
|
import { addScrobble, getAndSetSessionKey, setNowPlaying } from './main';
|
||||||
|
|
||||||
|
export interface LastFmPluginConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* Token used for authentication
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
/**
|
||||||
|
* Session key used for scrabbling
|
||||||
|
*/
|
||||||
|
session_key?: string;
|
||||||
|
/**
|
||||||
|
* Root of the Last.fm API
|
||||||
|
*
|
||||||
|
* @default 'http://ws.audioscrobbler.com/2.0/'
|
||||||
|
*/
|
||||||
|
api_root: string;
|
||||||
|
/**
|
||||||
|
* Last.fm api key registered by @semvis123
|
||||||
|
*
|
||||||
|
* @default '04d76faaac8726e60988e14c105d421a'
|
||||||
|
*/
|
||||||
|
api_key: string;
|
||||||
|
/**
|
||||||
|
* Last.fm api secret registered by @semvis123
|
||||||
|
*
|
||||||
|
* @default 'a5d2a36fdf64819290f6982481eaffa2'
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Last.fm',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
api_root: 'http://ws.audioscrobbler.com/2.0/',
|
||||||
|
api_key: '04d76faaac8726e60988e14c105d421a',
|
||||||
|
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
||||||
|
} as LastFmPluginConfig,
|
||||||
|
async backend({ getConfig, setConfig }) {
|
||||||
|
let config = await getConfig();
|
||||||
|
// This will store the timeout that will trigger addScrobble
|
||||||
|
let scrobbleTimer: number | undefined;
|
||||||
|
|
||||||
|
if (!config.api_root) {
|
||||||
|
config.enabled = true;
|
||||||
|
setConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.session_key) {
|
||||||
|
// Not authenticated
|
||||||
|
config = await getAndSetSessionKey(config, setConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
// Set remove the old scrobble timer
|
||||||
|
clearTimeout(scrobbleTimer);
|
||||||
|
if (!songInfo.isPaused) {
|
||||||
|
setNowPlaying(songInfo, config, setConfig);
|
||||||
|
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
||||||
|
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
||||||
|
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
||||||
|
// Scrobble still needs to happen
|
||||||
|
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
||||||
|
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,14 +1,9 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import { BrowserWindow, net, shell } from 'electron';
|
import { net, shell } from 'electron';
|
||||||
|
|
||||||
import { setOptions } from '../../config/plugins';
|
import type { LastFmPluginConfig } from './index';
|
||||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
import type { SongInfo } from '@/providers/song-info';
|
||||||
import defaultConfig from '../../config/defaults';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
type LastFMOptions = ConfigType<'last-fm'>;
|
|
||||||
|
|
||||||
interface LastFmData {
|
interface LastFmData {
|
||||||
method: string,
|
method: string,
|
||||||
@ -56,7 +51,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
|||||||
keys.sort();
|
keys.sort();
|
||||||
let sig = '';
|
let sig = '';
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (String(key) === 'format') {
|
if (key === 'format') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,7 +63,7 @@ const createApiSig = (parameters: LastFmSongData, secret: string) => {
|
|||||||
return sig;
|
return sig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFMOptions) => {
|
const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFmPluginConfig) => {
|
||||||
// Creates and stores the auth token
|
// Creates and stores the auth token
|
||||||
const data = {
|
const data = {
|
||||||
method: 'auth.gettoken',
|
method: 'auth.gettoken',
|
||||||
@ -81,12 +76,14 @@ const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastF
|
|||||||
return json?.token;
|
return json?.token;
|
||||||
};
|
};
|
||||||
|
|
||||||
const authenticate = async (config: LastFMOptions) => {
|
const authenticate = async (config: LastFmPluginConfig) => {
|
||||||
// Asks the user for authentication
|
// Asks the user for authentication
|
||||||
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAndSetSessionKey = async (config: LastFMOptions) => {
|
type SetConfType = (conf: Partial<Omit<LastFmPluginConfig, 'enabled'>>) => (void | Promise<void>);
|
||||||
|
|
||||||
|
export const getAndSetSessionKey = async (config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||||
// Get and store the session key
|
// Get and store the session key
|
||||||
const data = {
|
const data = {
|
||||||
api_key: config.api_key,
|
api_key: config.api_key,
|
||||||
@ -105,19 +102,19 @@ const getAndSetSessionKey = async (config: LastFMOptions) => {
|
|||||||
if (json.error) {
|
if (json.error) {
|
||||||
config.token = await createToken(config);
|
config.token = await createToken(config);
|
||||||
await authenticate(config);
|
await authenticate(config);
|
||||||
setOptions('last-fm', config);
|
setConfig(config);
|
||||||
}
|
}
|
||||||
if (json.session) {
|
if (json.session) {
|
||||||
config.session_key = json.session.key;
|
config.session_key = json.session.key;
|
||||||
}
|
}
|
||||||
setOptions('last-fm', config);
|
setConfig(config);
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data: LastFmData) => {
|
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFmPluginConfig, data: LastFmData, setConfig: SetConfType) => {
|
||||||
// This sends a post request to the api, and adds the common data
|
// This sends a post request to the api, and adds the common data
|
||||||
if (!config.session_key) {
|
if (!config.session_key) {
|
||||||
await getAndSetSessionKey(config);
|
await getAndSetSessionKey(config, setConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
const postData: LastFmSongData = {
|
const postData: LastFmSongData = {
|
||||||
@ -146,58 +143,24 @@ const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data
|
|||||||
config.session_key = undefined;
|
config.session_key = undefined;
|
||||||
config.token = await createToken(config);
|
config.token = await createToken(config);
|
||||||
await authenticate(config);
|
await authenticate(config);
|
||||||
setOptions('last-fm', config);
|
setConfig(config);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => {
|
export const addScrobble = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||||
// This adds one scrobbled song to last.fm
|
// This adds one scrobbled song to last.fm
|
||||||
const data = {
|
const data = {
|
||||||
method: 'track.scrobble',
|
method: 'track.scrobble',
|
||||||
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
|
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
|
||||||
};
|
};
|
||||||
postSongDataToAPI(songInfo, config, data);
|
postSongDataToAPI(songInfo, config, data, setConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => {
|
export const setNowPlaying = (songInfo: SongInfo, config: LastFmPluginConfig, setConfig: SetConfType) => {
|
||||||
// This sets the now playing status in last.fm
|
// This sets the now playing status in last.fm
|
||||||
const data = {
|
const data = {
|
||||||
method: 'track.updateNowPlaying',
|
method: 'track.updateNowPlaying',
|
||||||
};
|
};
|
||||||
postSongDataToAPI(songInfo, config, data);
|
postSongDataToAPI(songInfo, config, data, setConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
// This will store the timeout that will trigger addScrobble
|
|
||||||
let scrobbleTimer: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => {
|
|
||||||
if (!config.api_root) {
|
|
||||||
// Settings are not present, creating them with the default values
|
|
||||||
config = defaultConfig.plugins['last-fm'];
|
|
||||||
config.enabled = true;
|
|
||||||
setOptions('last-fm', config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.session_key) {
|
|
||||||
// Not authenticated
|
|
||||||
config = await getAndSetSessionKey(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
// Set remove the old scrobble timer
|
|
||||||
clearTimeout(scrobbleTimer);
|
|
||||||
if (!songInfo.isPaused) {
|
|
||||||
setNowPlaying(songInfo, config);
|
|
||||||
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
|
||||||
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
|
||||||
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
|
||||||
// Scrobble still needs to happen
|
|
||||||
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
|
||||||
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default lastfm;
|
|
||||||
|
|||||||
88
src/plugins/lumiastream/index.ts
Normal file
88
src/plugins/lumiastream/index.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { net } from 'electron';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import registerCallback from '@/providers/song-info';
|
||||||
|
|
||||||
|
type LumiaData = {
|
||||||
|
origin: string;
|
||||||
|
eventType: string;
|
||||||
|
url?: string;
|
||||||
|
videoId?: string;
|
||||||
|
playlistId?: string;
|
||||||
|
cover?: string|null;
|
||||||
|
cover_url?: string|null;
|
||||||
|
title?: string;
|
||||||
|
artists?: string[];
|
||||||
|
status?: string;
|
||||||
|
progress?: number;
|
||||||
|
duration?: number;
|
||||||
|
album_url?: string|null;
|
||||||
|
album?: string|null;
|
||||||
|
views?: number;
|
||||||
|
isPaused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Lumia Stream [beta]',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
backend() {
|
||||||
|
const secToMilisec = (t?: number) => t ? Math.round(Number(t) * 1e3) : undefined;
|
||||||
|
const previousStatePaused = null;
|
||||||
|
|
||||||
|
const data: LumiaData = {
|
||||||
|
origin: 'youtubemusic',
|
||||||
|
eventType: 'switchSong',
|
||||||
|
};
|
||||||
|
|
||||||
|
const post = (data: LumiaData) => {
|
||||||
|
const port = 39231;
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Access-Control-Allow-Headers': '*',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
} as const;
|
||||||
|
const url = `http://127.0.0.1:${port}/api/media`;
|
||||||
|
|
||||||
|
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
|
||||||
|
.catch((error: { code: number, errno: number }) => {
|
||||||
|
console.log(
|
||||||
|
`Error: '${
|
||||||
|
error.code || error.errno
|
||||||
|
}' - when trying to access lumiastream webserver at port ${port}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
if (!songInfo.title && !songInfo.artist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousStatePaused === null) {
|
||||||
|
data.eventType = 'switchSong';
|
||||||
|
} else if (previousStatePaused !== songInfo.isPaused) {
|
||||||
|
data.eventType = 'playPause';
|
||||||
|
}
|
||||||
|
|
||||||
|
data.duration = secToMilisec(songInfo.songDuration);
|
||||||
|
data.progress = secToMilisec(songInfo.elapsedSeconds);
|
||||||
|
data.url = songInfo.url;
|
||||||
|
data.videoId = songInfo.videoId;
|
||||||
|
data.playlistId = songInfo.playlistId;
|
||||||
|
data.cover = songInfo.imageSrc;
|
||||||
|
data.cover_url = songInfo.imageSrc;
|
||||||
|
data.album_url = songInfo.imageSrc;
|
||||||
|
data.title = songInfo.title;
|
||||||
|
data.artists = [songInfo.artist];
|
||||||
|
data.status = songInfo.isPaused ? 'stopped' : 'playing';
|
||||||
|
data.isPaused = songInfo.isPaused;
|
||||||
|
data.album = songInfo.album;
|
||||||
|
data.views = songInfo.views;
|
||||||
|
post(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import { BrowserWindow , net } from 'electron';
|
|
||||||
|
|
||||||
import registerCallback from '../../providers/song-info';
|
|
||||||
|
|
||||||
const secToMilisec = (t?: number) => t ? Math.round(Number(t) * 1e3) : undefined;
|
|
||||||
const previousStatePaused = null;
|
|
||||||
|
|
||||||
type LumiaData = {
|
|
||||||
origin: string;
|
|
||||||
eventType: string;
|
|
||||||
url?: string;
|
|
||||||
videoId?: string;
|
|
||||||
playlistId?: string;
|
|
||||||
cover?: string|null;
|
|
||||||
cover_url?: string|null;
|
|
||||||
title?: string;
|
|
||||||
artists?: string[];
|
|
||||||
status?: string;
|
|
||||||
progress?: number;
|
|
||||||
duration?: number;
|
|
||||||
album_url?: string|null;
|
|
||||||
album?: string|null;
|
|
||||||
views?: number;
|
|
||||||
isPaused?: boolean;
|
|
||||||
}
|
|
||||||
const data: LumiaData = {
|
|
||||||
origin: 'youtubemusic',
|
|
||||||
eventType: 'switchSong',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const post = (data: LumiaData) => {
|
|
||||||
const port = 39231;
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Access-Control-Allow-Headers': '*',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
};
|
|
||||||
const url = `http://localhost:${port}/api/media`;
|
|
||||||
|
|
||||||
net.fetch(url, { method: 'POST', body: JSON.stringify({ token: 'lsmedia_ytmsI7812', data }), headers })
|
|
||||||
.catch((error: { code: number, errno: number }) => {
|
|
||||||
console.log(
|
|
||||||
`Error: '${
|
|
||||||
error.code || error.errno
|
|
||||||
}' - when trying to access lumiastream webserver at port ${port}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default (_: BrowserWindow) => {
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
if (!songInfo.title && !songInfo.artist) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousStatePaused === null) {
|
|
||||||
data.eventType = 'switchSong';
|
|
||||||
} else if (previousStatePaused !== songInfo.isPaused) {
|
|
||||||
data.eventType = 'playPause';
|
|
||||||
}
|
|
||||||
|
|
||||||
data.duration = secToMilisec(songInfo.songDuration);
|
|
||||||
data.progress = secToMilisec(songInfo.elapsedSeconds);
|
|
||||||
data.url = songInfo.url;
|
|
||||||
data.videoId = songInfo.videoId;
|
|
||||||
data.playlistId = songInfo.playlistId;
|
|
||||||
data.cover = songInfo.imageSrc;
|
|
||||||
data.cover_url = songInfo.imageSrc;
|
|
||||||
data.album_url = songInfo.imageSrc;
|
|
||||||
data.title = songInfo.title;
|
|
||||||
data.artists = [songInfo.artist];
|
|
||||||
data.status = songInfo.isPaused ? 'stopped' : 'playing';
|
|
||||||
data.isPaused = songInfo.isPaused;
|
|
||||||
data.album = songInfo.album;
|
|
||||||
data.views = songInfo.views;
|
|
||||||
post(data);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
41
src/plugins/lyrics-genius/index.ts
Normal file
41
src/plugins/lyrics-genius/index.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
|
import { onRendererLoad } from './renderer';
|
||||||
|
|
||||||
|
export type LyricsGeniusPluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
romanizedLyrics: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Lyrics Genius',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
romanizedLyrics: false,
|
||||||
|
} as LyricsGeniusPluginConfig,
|
||||||
|
stylesheets: [style],
|
||||||
|
async menu({ getConfig, setConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Romanized Lyrics',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.romanizedLyrics,
|
||||||
|
click(item) {
|
||||||
|
setConfig({
|
||||||
|
romanizedLyrics: item.checked,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
backend: {
|
||||||
|
start: onMainLoad,
|
||||||
|
onConfigChange,
|
||||||
|
},
|
||||||
|
renderer: onRendererLoad,
|
||||||
|
});
|
||||||
@ -1,36 +1,32 @@
|
|||||||
import { BrowserWindow, ipcMain, net } from 'electron';
|
import { net } from 'electron';
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
import { convert } from 'html-to-text';
|
import { convert } from 'html-to-text';
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
import { GetGeniusLyric } from './types';
|
import { GetGeniusLyric } from './types';
|
||||||
|
import { cleanupName, type SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
import { cleanupName, SongInfo } from '../../providers/song-info';
|
import type { LyricsGeniusPluginConfig } from './index';
|
||||||
|
|
||||||
import { injectCSS } from '../utils/main';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
export type LyricGeniusType = ConfigType<'lyrics-genius'>;
|
export const onMainLoad = async ({ ipc, getConfig }: BackendContext<LyricsGeniusPluginConfig>) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: LyricGeniusType) => {
|
if (config.romanizedLyrics) {
|
||||||
if (options.romanizedLyrics) {
|
|
||||||
revRomanized = true;
|
revRomanized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
injectCSS(win.webContents, style);
|
ipc.handle('search-genius-lyrics', async (extractedSongInfo: SongInfo) => {
|
||||||
|
|
||||||
ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: SongInfo) => {
|
|
||||||
const metadata = extractedSongInfo;
|
const metadata = extractedSongInfo;
|
||||||
return await fetchFromGenius(metadata);
|
return await fetchFromGenius(metadata);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleRomanized = () => {
|
export const onConfigChange = (newConfig: LyricsGeniusPluginConfig) => {
|
||||||
revRomanized = !revRomanized;
|
revRomanized = newConfig.romanizedLyrics;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchFromGenius = async (metadata: SongInfo) => {
|
export const fetchFromGenius = async (metadata: SongInfo) => {
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
import { BrowserWindow, MenuItem } from 'electron';
|
|
||||||
|
|
||||||
import { LyricGeniusType, toggleRomanized } from './main';
|
|
||||||
|
|
||||||
import { setOptions } from '../../config/plugins';
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
export default (_: BrowserWindow, options: LyricGeniusType): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Romanized Lyrics',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.romanizedLyrics,
|
|
||||||
click(item: MenuItem) {
|
|
||||||
options.romanizedLyrics = item.checked;
|
|
||||||
setOptions('lyrics-genius', options);
|
|
||||||
toggleRomanized();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import type { SongInfo } from '../../providers/song-info';
|
import type { SongInfo } from '@/providers/song-info';
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
import type { LyricsGeniusPluginConfig } from '@/plugins/lyrics-genius/index';
|
||||||
|
|
||||||
export default () => {
|
export const onRendererLoad = ({ ipc: { invoke, on } }: RendererContext<LyricsGeniusPluginConfig>) => {
|
||||||
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
|
const setLyrics = (lyricsContainer: Element, lyrics: string | null) => {
|
||||||
lyricsContainer.innerHTML = `
|
lyricsContainer.innerHTML = `
|
||||||
<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
|
<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
|
||||||
@ -21,7 +23,7 @@ export default () => {
|
|||||||
|
|
||||||
let unregister: (() => void) | null = null;
|
let unregister: (() => void) | null = null;
|
||||||
|
|
||||||
window.ipcRenderer.on('update-song-info', (_, extractedSongInfo: SongInfo) => {
|
on('update-song-info', (extractedSongInfo: SongInfo) => {
|
||||||
unregister?.();
|
unregister?.();
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
@ -35,7 +37,7 @@ export default () => {
|
|||||||
// Check if disabled
|
// Check if disabled
|
||||||
if (!tabs.lyrics?.hasAttribute('disabled')) return;
|
if (!tabs.lyrics?.hasAttribute('disabled')) return;
|
||||||
|
|
||||||
const lyrics = await window.ipcRenderer.invoke(
|
const lyrics = await invoke(
|
||||||
'search-genius-lyrics',
|
'search-genius-lyrics',
|
||||||
extractedSongInfo,
|
extractedSongInfo,
|
||||||
) as string | null;
|
) as string | null;
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||||
|
|
||||||
import forwardHTML from './templates/forward.html?raw';
|
import forwardHTML from './templates/forward.html?raw';
|
||||||
import backHTML from './templates/back.html?raw';
|
import backHTML from './templates/back.html?raw';
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils/renderer';
|
export default createPlugin({
|
||||||
|
name: 'Navigation',
|
||||||
export function run() {
|
restartNeeded: true,
|
||||||
window.ipcRenderer.on('navigation-css-ready', () => {
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
stylesheets: [style],
|
||||||
|
renderer() {
|
||||||
const forwardButton = ElementFromHtml(forwardHTML);
|
const forwardButton = ElementFromHtml(forwardHTML);
|
||||||
const backButton = ElementFromHtml(backHTML);
|
const backButton = ElementFromHtml(backHTML);
|
||||||
const menu = document.querySelector('#right-content');
|
const menu = document.querySelector('#right-content');
|
||||||
@ -12,7 +20,5 @@ export function run() {
|
|||||||
if (menu) {
|
if (menu) {
|
||||||
menu.prepend(backButton, forwardButton);
|
menu.prepend(backButton, forwardButton);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
export default run;
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils/main';
|
|
||||||
|
|
||||||
export function handle(win: BrowserWindow) {
|
|
||||||
injectCSS(win.webContents, style, () => {
|
|
||||||
win.webContents.send('navigation-css-ready');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default handle;
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
function removeLoginElements() {
|
|
||||||
const elementsToRemove = [
|
|
||||||
'.sign-in-link.ytmusic-nav-bar',
|
|
||||||
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const selector of elementsToRemove) {
|
|
||||||
const node = document.querySelector(selector);
|
|
||||||
if (node) {
|
|
||||||
node.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the library button
|
|
||||||
const libraryIconPath
|
|
||||||
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
const menuEntries = document.querySelectorAll(
|
|
||||||
'#items ytmusic-guide-entry-renderer',
|
|
||||||
);
|
|
||||||
menuEntries.forEach((item) => {
|
|
||||||
const icon = item.querySelector('path');
|
|
||||||
if (icon) {
|
|
||||||
observer.disconnect();
|
|
||||||
if (icon.getAttribute('d') === libraryIconPath) {
|
|
||||||
item.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default removeLoginElements;
|
|
||||||
46
src/plugins/no-google-login/index.ts
Normal file
46
src/plugins/no-google-login/index.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Remove Google Login',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
stylesheets: [style],
|
||||||
|
renderer() {
|
||||||
|
const elementsToRemove = [
|
||||||
|
'.sign-in-link.ytmusic-nav-bar',
|
||||||
|
'.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of elementsToRemove) {
|
||||||
|
const node = document.querySelector(selector);
|
||||||
|
if (node) {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the library button
|
||||||
|
const libraryIconPath
|
||||||
|
= 'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const menuEntries = document.querySelectorAll(
|
||||||
|
'#items ytmusic-guide-entry-renderer',
|
||||||
|
);
|
||||||
|
menuEntries.forEach((item) => {
|
||||||
|
const icon = item.querySelector('path');
|
||||||
|
if (icon) {
|
||||||
|
observer.disconnect();
|
||||||
|
if (icon.getAttribute('d') === libraryIconPath) {
|
||||||
|
item.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils/main';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
};
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('notifications');
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
46
src/plugins/notifications/index.ts
Normal file
46
src/plugins/notifications/index.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
|
||||||
|
export interface NotificationsPluginConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
unpauseNotification: boolean;
|
||||||
|
/**
|
||||||
|
* Has effect only on Linux
|
||||||
|
*/
|
||||||
|
urgency: 'low' | 'normal' | 'critical';
|
||||||
|
/**
|
||||||
|
* the following has effect only on Windows
|
||||||
|
*/
|
||||||
|
interactive: boolean;
|
||||||
|
/**
|
||||||
|
* See plugins/notifications/utils for more info
|
||||||
|
*/
|
||||||
|
toastStyle: number;
|
||||||
|
refreshOnPlayPause: boolean;
|
||||||
|
trayControls: boolean;
|
||||||
|
hideButtonText: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultConfig: NotificationsPluginConfig = {
|
||||||
|
enabled: false,
|
||||||
|
unpauseNotification: false,
|
||||||
|
urgency: 'normal',
|
||||||
|
interactive: true,
|
||||||
|
toastStyle: 1,
|
||||||
|
refreshOnPlayPause: false,
|
||||||
|
trayControls: true,
|
||||||
|
hideButtonText: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Notifications',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: defaultConfig,
|
||||||
|
menu: onMenu,
|
||||||
|
backend: {
|
||||||
|
start: onMainLoad,
|
||||||
|
onConfigChange,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,29 +1,220 @@
|
|||||||
import { app, BrowserWindow, ipcMain, Notification } from 'electron';
|
import { app, BrowserWindow, Notification } from 'electron';
|
||||||
|
|
||||||
|
import playIcon from '@assets/media-icons-black/play.png?asset&asarUnpack';
|
||||||
|
import pauseIcon from '@assets/media-icons-black/pause.png?asset&asarUnpack';
|
||||||
|
import nextIcon from '@assets/media-icons-black/next.png?asset&asarUnpack';
|
||||||
|
import previousIcon from '@assets/media-icons-black/previous.png?asset&asarUnpack';
|
||||||
|
|
||||||
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
|
import { notificationImage, secondsToMinutes, ToastStyles } from './utils';
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import getSongControls from '../../providers/song-controls';
|
import getSongControls from '@/providers/song-controls';
|
||||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
import registerCallback, { SongInfo } from '@/providers/song-info';
|
||||||
import { changeProtocolHandler } from '../../providers/protocol-handler';
|
import { changeProtocolHandler } from '@/providers/protocol-handler';
|
||||||
import { setTrayOnClick, setTrayOnDoubleClick } from '../../tray';
|
import { setTrayOnClick, setTrayOnDoubleClick } from '@/tray';
|
||||||
import { mediaIcons } from '../../types/media-icons';
|
import { mediaIcons } from '@/types/media-icons';
|
||||||
|
|
||||||
import playIcon from '../../../assets/media-icons-black/play.png?asset&asarUnpack';
|
import type { NotificationsPluginConfig } from './index';
|
||||||
import pauseIcon from '../../../assets/media-icons-black/pause.png?asset&asarUnpack';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
import nextIcon from '../../../assets/media-icons-black/next.png?asset&asarUnpack';
|
|
||||||
import previousIcon from '../../../assets/media-icons-black/previous.png?asset&asarUnpack';
|
|
||||||
|
|
||||||
let songControls: ReturnType<typeof getSongControls>;
|
let songControls: ReturnType<typeof getSongControls>;
|
||||||
let savedNotification: Notification | undefined;
|
let savedNotification: Notification | undefined;
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
type Accessor<T> = () => T;
|
||||||
|
|
||||||
|
export default (
|
||||||
|
win: BrowserWindow,
|
||||||
|
config: Accessor<NotificationsPluginConfig>,
|
||||||
|
{ ipc: { on, send } }: BackendContext<NotificationsPluginConfig>,
|
||||||
|
) => {
|
||||||
|
const sendNotification = (songInfo: SongInfo) => {
|
||||||
|
const iconSrc = notificationImage(songInfo, config());
|
||||||
|
|
||||||
|
savedNotification?.close();
|
||||||
|
|
||||||
|
let icon: string;
|
||||||
|
if (typeof iconSrc === 'object') {
|
||||||
|
icon = iconSrc.toDataURL();
|
||||||
|
} else {
|
||||||
|
icon = iconSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
savedNotification = new Notification({
|
||||||
|
title: songInfo.title || 'Playing',
|
||||||
|
body: songInfo.artist,
|
||||||
|
icon: iconSrc,
|
||||||
|
silent: true,
|
||||||
|
// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root
|
||||||
|
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema
|
||||||
|
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
|
||||||
|
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
|
||||||
|
toastXml: getXml(songInfo, icon),
|
||||||
|
});
|
||||||
|
|
||||||
|
savedNotification.on('close', () => {
|
||||||
|
savedNotification = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
savedNotification.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getXml = (songInfo: SongInfo, iconSrc: string) => {
|
||||||
|
switch (config().toastStyle) {
|
||||||
|
default:
|
||||||
|
case ToastStyles.logo:
|
||||||
|
case ToastStyles.legacy: {
|
||||||
|
return xmlLogo(songInfo, iconSrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
case ToastStyles.banner_top_custom: {
|
||||||
|
return xmlBannerTopCustom(songInfo, iconSrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
case ToastStyles.hero: {
|
||||||
|
return xmlHero(songInfo, iconSrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
case ToastStyles.banner_bottom: {
|
||||||
|
return xmlBannerBottom(songInfo, iconSrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
case ToastStyles.banner_centered_bottom: {
|
||||||
|
return xmlBannerCenteredBottom(songInfo, iconSrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
case ToastStyles.banner_centered_top: {
|
||||||
|
return xmlBannerCenteredTop(songInfo, iconSrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectIcon = (kind: keyof typeof mediaIcons): string => {
|
||||||
|
switch (kind) {
|
||||||
|
case 'play':
|
||||||
|
return playIcon;
|
||||||
|
case 'pause':
|
||||||
|
return pauseIcon;
|
||||||
|
case 'next':
|
||||||
|
return nextIcon;
|
||||||
|
case 'previous':
|
||||||
|
return previousIcon;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const display = (kind: keyof typeof mediaIcons) => {
|
||||||
|
if (config().toastStyle === ToastStyles.legacy) {
|
||||||
|
return `content="${mediaIcons[kind]}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `\
|
||||||
|
content="${config().toastStyle ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
|
||||||
|
imageUri="file:///${selectIcon(kind)}"
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButton = (kind: keyof typeof mediaIcons) =>
|
||||||
|
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
|
||||||
|
|
||||||
|
const getButtons = (isPaused: boolean) => `\
|
||||||
|
<actions>
|
||||||
|
${getButton('previous')}
|
||||||
|
${isPaused ? getButton('play') : getButton('pause')}
|
||||||
|
${getButton('next')}
|
||||||
|
</actions>\
|
||||||
|
`;
|
||||||
|
|
||||||
|
const toast = (content: string, isPaused: boolean) => `\
|
||||||
|
<toast>
|
||||||
|
<audio silent="true" />
|
||||||
|
<visual>
|
||||||
|
<binding template="ToastGeneric">
|
||||||
|
${content}
|
||||||
|
</binding>
|
||||||
|
</visual>
|
||||||
|
|
||||||
|
${getButtons(isPaused)}
|
||||||
|
</toast>`;
|
||||||
|
|
||||||
|
const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\
|
||||||
|
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
|
||||||
|
<text id="1">${title}</text>
|
||||||
|
<text id="2">${artist}</text>\
|
||||||
|
`, isPaused ?? false);
|
||||||
|
|
||||||
|
const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"');
|
||||||
|
|
||||||
|
const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"');
|
||||||
|
|
||||||
|
const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, '');
|
||||||
|
|
||||||
|
const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\
|
||||||
|
<image id="1" src="${imgSrc}" name="Image" />
|
||||||
|
<text>ㅤ</text>
|
||||||
|
<group>
|
||||||
|
<subgroup>
|
||||||
|
<text hint-style="body">${songInfo.title}</text>
|
||||||
|
<text hint-style="captionSubtle">${songInfo.artist}</text>
|
||||||
|
</subgroup>
|
||||||
|
${xmlMoreData(songInfo)}
|
||||||
|
</group>\
|
||||||
|
`, songInfo.isPaused ?? false);
|
||||||
|
|
||||||
|
const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\
|
||||||
|
<subgroup hint-textStacking="bottom">
|
||||||
|
${album
|
||||||
|
? `<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
|
||||||
|
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)}</text>
|
||||||
|
</subgroup>\
|
||||||
|
`;
|
||||||
|
|
||||||
|
const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
||||||
|
<text>ㅤ</text>
|
||||||
|
<group>
|
||||||
|
<subgroup hint-weight="1" hint-textStacking="center">
|
||||||
|
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
||||||
|
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
||||||
|
</subgroup>
|
||||||
|
</group>
|
||||||
|
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
|
||||||
|
`, isPaused ?? false);
|
||||||
|
|
||||||
|
const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
||||||
|
<image id="1" src="${imgSrc}" name="Image" />
|
||||||
|
<text>ㅤ</text>
|
||||||
|
<group>
|
||||||
|
<subgroup hint-weight="1" hint-textStacking="center">
|
||||||
|
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
||||||
|
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
||||||
|
</subgroup>
|
||||||
|
</group>\
|
||||||
|
`, isPaused ?? false);
|
||||||
|
|
||||||
|
const titleFontPicker = (title: string) => {
|
||||||
|
if (title.length <= 13) {
|
||||||
|
return 'Header';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.length <= 22) {
|
||||||
|
return 'Subheader';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.length <= 26) {
|
||||||
|
return 'Title';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Subtitle';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
songControls = getSongControls(win);
|
songControls = getSongControls(win);
|
||||||
|
|
||||||
let currentSeconds = 0;
|
let currentSeconds = 0;
|
||||||
ipcMain.on('apiLoaded', () => win.webContents.send('setupTimeChangedListener'));
|
on('ytmd:player-api-loaded', () => send('setupTimeChangedListener'));
|
||||||
|
|
||||||
ipcMain.on('timeChanged', (_, t: number) => currentSeconds = t);
|
on('timeChanged', (t: number) => {
|
||||||
|
currentSeconds = t;
|
||||||
|
});
|
||||||
|
|
||||||
let savedSongInfo: SongInfo;
|
let savedSongInfo: SongInfo;
|
||||||
let lastUrl: string | undefined;
|
let lastUrl: string | undefined;
|
||||||
@ -36,14 +227,14 @@ export default (win: BrowserWindow) => {
|
|||||||
|
|
||||||
savedSongInfo = { ...songInfo };
|
savedSongInfo = { ...songInfo };
|
||||||
if (!songInfo.isPaused
|
if (!songInfo.isPaused
|
||||||
&& (songInfo.url !== lastUrl || config.get('unpauseNotification'))
|
&& (songInfo.url !== lastUrl || config().unpauseNotification)
|
||||||
) {
|
) {
|
||||||
lastUrl = songInfo.url;
|
lastUrl = songInfo.url;
|
||||||
sendNotification(songInfo);
|
sendNotification(songInfo);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.get('trayControls')) {
|
if (config().trayControls) {
|
||||||
setTrayOnClick(() => {
|
setTrayOnClick(() => {
|
||||||
if (savedNotification) {
|
if (savedNotification) {
|
||||||
savedNotification.close();
|
savedNotification.close();
|
||||||
@ -73,9 +264,9 @@ export default (win: BrowserWindow) => {
|
|||||||
(cmd) => {
|
(cmd) => {
|
||||||
if (Object.keys(songControls).includes(cmd)) {
|
if (Object.keys(songControls).includes(cmd)) {
|
||||||
songControls[cmd as keyof typeof songControls]();
|
songControls[cmd as keyof typeof songControls]();
|
||||||
if (config.get('refreshOnPlayPause') && (
|
if (config().refreshOnPlayPause && (
|
||||||
cmd === 'pause'
|
cmd === 'pause'
|
||||||
|| (cmd === 'play' && !config.get('unpauseNotification'))
|
|| (cmd === 'play' && !config().unpauseNotification)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
setImmediate(() =>
|
setImmediate(() =>
|
||||||
@ -90,183 +281,3 @@ export default (win: BrowserWindow) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function sendNotification(songInfo: SongInfo) {
|
|
||||||
const iconSrc = notificationImage(songInfo);
|
|
||||||
|
|
||||||
savedNotification?.close();
|
|
||||||
|
|
||||||
let icon: string;
|
|
||||||
if (typeof iconSrc === 'object') {
|
|
||||||
icon = iconSrc.toDataURL();
|
|
||||||
} else {
|
|
||||||
icon = iconSrc;
|
|
||||||
}
|
|
||||||
|
|
||||||
savedNotification = new Notification({
|
|
||||||
title: songInfo.title || 'Playing',
|
|
||||||
body: songInfo.artist,
|
|
||||||
icon: iconSrc,
|
|
||||||
silent: true,
|
|
||||||
// https://learn.microsoft.com/en-us/uwp/schemas/tiles/toastschema/schema-root
|
|
||||||
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/toast-schema
|
|
||||||
// https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml
|
|
||||||
// https://learn.microsoft.com/en-us/uwp/api/windows.ui.notifications.toasttemplatetype
|
|
||||||
toastXml: getXml(songInfo, icon),
|
|
||||||
});
|
|
||||||
|
|
||||||
savedNotification.on('close', () => {
|
|
||||||
savedNotification = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
savedNotification.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
const getXml = (songInfo: SongInfo, iconSrc: string) => {
|
|
||||||
switch (config.get('toastStyle')) {
|
|
||||||
default:
|
|
||||||
case ToastStyles.logo:
|
|
||||||
case ToastStyles.legacy: {
|
|
||||||
return xmlLogo(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.banner_top_custom: {
|
|
||||||
return xmlBannerTopCustom(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.hero: {
|
|
||||||
return xmlHero(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.banner_bottom: {
|
|
||||||
return xmlBannerBottom(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.banner_centered_bottom: {
|
|
||||||
return xmlBannerCenteredBottom(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
case ToastStyles.banner_centered_top: {
|
|
||||||
return xmlBannerCenteredTop(songInfo, iconSrc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectIcon = (kind: keyof typeof mediaIcons): string => {
|
|
||||||
switch (kind) {
|
|
||||||
case 'play':
|
|
||||||
return playIcon;
|
|
||||||
case 'pause':
|
|
||||||
return pauseIcon;
|
|
||||||
case 'next':
|
|
||||||
return nextIcon;
|
|
||||||
case 'previous':
|
|
||||||
return previousIcon;
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const display = (kind: keyof typeof mediaIcons) => {
|
|
||||||
if (config.get('toastStyle') === ToastStyles.legacy) {
|
|
||||||
return `content="${mediaIcons[kind]}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `\
|
|
||||||
content="${config.get('hideButtonText') ? '' : kind.charAt(0).toUpperCase() + kind.slice(1)}"\
|
|
||||||
imageUri="file:///${selectIcon(kind)}"
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButton = (kind: keyof typeof mediaIcons) =>
|
|
||||||
`<action ${display(kind)} activationType="protocol" arguments="youtubemusic://${kind}"/>`;
|
|
||||||
|
|
||||||
const getButtons = (isPaused: boolean) => `\
|
|
||||||
<actions>
|
|
||||||
${getButton('previous')}
|
|
||||||
${isPaused ? getButton('play') : getButton('pause')}
|
|
||||||
${getButton('next')}
|
|
||||||
</actions>\
|
|
||||||
`;
|
|
||||||
|
|
||||||
const toast = (content: string, isPaused: boolean) => `\
|
|
||||||
<toast>
|
|
||||||
<audio silent="true" />
|
|
||||||
<visual>
|
|
||||||
<binding template="ToastGeneric">
|
|
||||||
${content}
|
|
||||||
</binding>
|
|
||||||
</visual>
|
|
||||||
|
|
||||||
${getButtons(isPaused)}
|
|
||||||
</toast>`;
|
|
||||||
|
|
||||||
const xmlImage = ({ title, artist, isPaused }: SongInfo, imgSrc: string, placement: string) => toast(`\
|
|
||||||
<image id="1" src="${imgSrc}" name="Image" ${placement}/>
|
|
||||||
<text id="1">${title}</text>
|
|
||||||
<text id="2">${artist}</text>\
|
|
||||||
`, isPaused ?? false);
|
|
||||||
|
|
||||||
const xmlLogo = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="appLogoOverride"');
|
|
||||||
|
|
||||||
const xmlHero = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, 'placement="hero"');
|
|
||||||
|
|
||||||
const xmlBannerBottom = (songInfo: SongInfo, imgSrc: string) => xmlImage(songInfo, imgSrc, '');
|
|
||||||
|
|
||||||
const xmlBannerTopCustom = (songInfo: SongInfo, imgSrc: string) => toast(`\
|
|
||||||
<image id="1" src="${imgSrc}" name="Image" />
|
|
||||||
<text>ㅤ</text>
|
|
||||||
<group>
|
|
||||||
<subgroup>
|
|
||||||
<text hint-style="body">${songInfo.title}</text>
|
|
||||||
<text hint-style="captionSubtle">${songInfo.artist}</text>
|
|
||||||
</subgroup>
|
|
||||||
${xmlMoreData(songInfo)}
|
|
||||||
</group>\
|
|
||||||
`, songInfo.isPaused ?? false);
|
|
||||||
|
|
||||||
const xmlMoreData = ({ album, elapsedSeconds, songDuration }: SongInfo) => `\
|
|
||||||
<subgroup hint-textStacking="bottom">
|
|
||||||
${album
|
|
||||||
? `<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${album}</text>` : ''}
|
|
||||||
<text hint-style="captionSubtle" hint-wrap="true" hint-align="right">${secondsToMinutes(elapsedSeconds ?? 0)} / ${secondsToMinutes(songDuration)}</text>
|
|
||||||
</subgroup>\
|
|
||||||
`;
|
|
||||||
|
|
||||||
const xmlBannerCenteredBottom = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
|
||||||
<text>ㅤ</text>
|
|
||||||
<group>
|
|
||||||
<subgroup hint-weight="1" hint-textStacking="center">
|
|
||||||
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
|
||||||
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
|
||||||
</subgroup>
|
|
||||||
</group>
|
|
||||||
<image id="1" src="${imgSrc}" name="Image" hint-removeMargin="true" />\
|
|
||||||
`, isPaused ?? false);
|
|
||||||
|
|
||||||
const xmlBannerCenteredTop = ({ title, artist, isPaused }: SongInfo, imgSrc: string) => toast(`\
|
|
||||||
<image id="1" src="${imgSrc}" name="Image" />
|
|
||||||
<text>ㅤ</text>
|
|
||||||
<group>
|
|
||||||
<subgroup hint-weight="1" hint-textStacking="center">
|
|
||||||
<text hint-align="center" hint-style="${titleFontPicker(title)}">${title}</text>
|
|
||||||
<text hint-align="center" hint-style="SubtitleSubtle">${artist}</text>
|
|
||||||
</subgroup>
|
|
||||||
</group>\
|
|
||||||
`, isPaused ?? false);
|
|
||||||
|
|
||||||
const titleFontPicker = (title: string) => {
|
|
||||||
if (title.length <= 13) {
|
|
||||||
return 'Header';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title.length <= 22) {
|
|
||||||
return 'Subheader';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title.length <= 26) {
|
|
||||||
return 'Title';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Subtitle';
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,25 +1,25 @@
|
|||||||
import { BrowserWindow, Notification } from 'electron';
|
import { Notification } from 'electron';
|
||||||
|
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
|
|
||||||
import { notificationImage } from './utils';
|
import { notificationImage } from './utils';
|
||||||
import config from './config';
|
|
||||||
import interactive from './interactive';
|
import interactive from './interactive';
|
||||||
|
|
||||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
import registerCallback, { type SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
import type { NotificationsPluginConfig } from './index';
|
||||||
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
type NotificationOptions = ConfigType<'notifications'>;
|
let config: NotificationsPluginConfig;
|
||||||
|
|
||||||
const notify = (info: SongInfo) => {
|
const notify = (info: SongInfo) => {
|
||||||
// Send the notification
|
// Send the notification
|
||||||
const currentNotification = new Notification({
|
const currentNotification = new Notification({
|
||||||
title: info.title || 'Playing',
|
title: info.title || 'Playing',
|
||||||
body: info.artist,
|
body: info.artist,
|
||||||
icon: notificationImage(info),
|
icon: notificationImage(info, config),
|
||||||
silent: true,
|
silent: true,
|
||||||
urgency: config.get('urgency') as 'normal' | 'critical' | 'low',
|
urgency: config.urgency,
|
||||||
});
|
});
|
||||||
currentNotification.show();
|
currentNotification.show();
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ const setup = () => {
|
|||||||
let currentUrl: string | undefined;
|
let currentUrl: string | undefined;
|
||||||
|
|
||||||
registerCallback((songInfo: SongInfo) => {
|
registerCallback((songInfo: SongInfo) => {
|
||||||
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.get('unpauseNotification'))) {
|
if (!songInfo.isPaused && (songInfo.url !== currentUrl || config.unpauseNotification)) {
|
||||||
// Close the old notification
|
// Close the old notification
|
||||||
oldNotification?.close();
|
oldNotification?.close();
|
||||||
currentUrl = songInfo.url;
|
currentUrl = songInfo.url;
|
||||||
@ -43,9 +43,14 @@ const setup = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: NotificationOptions) => {
|
export const onMainLoad = async (context: BackendContext<NotificationsPluginConfig>) => {
|
||||||
|
config = await context.getConfig();
|
||||||
|
|
||||||
// Register the callback for new song information
|
// Register the callback for new song information
|
||||||
is.windows() && options.interactive
|
if (is.windows() && config.interactive) interactive(context.window, () => config, context);
|
||||||
? interactive(win)
|
else setup();
|
||||||
: setup();
|
};
|
||||||
|
|
||||||
|
export const onConfigChange = (newConfig: NotificationsPluginConfig) => {
|
||||||
|
config = newConfig;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,93 +1,95 @@
|
|||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
|
import { MenuItem } from 'electron';
|
||||||
import { BrowserWindow, MenuItem } from 'electron';
|
|
||||||
|
|
||||||
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
|
import { snakeToCamel, ToastStyles, urgencyLevels } from './utils';
|
||||||
|
|
||||||
import config from './config';
|
import type { NotificationsPluginConfig } from './index';
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
export const onMenu = async ({ getConfig, setConfig }: MenuContext<NotificationsPluginConfig>): Promise<MenuTemplate> => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
const getMenu = (options: ConfigType<'notifications'>): MenuTemplate => {
|
const getToastStyleMenuItems = (options: NotificationsPluginConfig) => {
|
||||||
if (is.linux()) {
|
const array = Array.from({ length: Object.keys(ToastStyles).length });
|
||||||
return [
|
|
||||||
{
|
// ToastStyles index starts from 1
|
||||||
label: 'Notification Priority',
|
for (const [name, index] of Object.entries(ToastStyles)) {
|
||||||
submenu: urgencyLevels.map((level) => ({
|
array[index - 1] = {
|
||||||
label: level.name,
|
label: snakeToCamel(name),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: options.urgency === level.value,
|
checked: options.toastStyle === index,
|
||||||
click: () => config.set('urgency', level.value),
|
click: () => setConfig({ toastStyle: index }),
|
||||||
})),
|
} satisfies Electron.MenuItemConstructorOptions;
|
||||||
}
|
}
|
||||||
];
|
|
||||||
} else if (is.windows()) {
|
return array as Electron.MenuItemConstructorOptions[];
|
||||||
return [
|
};
|
||||||
{
|
|
||||||
label: 'Interactive Notifications',
|
const getMenu = (): MenuTemplate => {
|
||||||
type: 'checkbox',
|
if (is.linux()) {
|
||||||
checked: options.interactive,
|
return [
|
||||||
// Doesn't update until restart
|
{
|
||||||
click: (item: MenuItem) => config.setAndMaybeRestart('interactive', item.checked),
|
label: 'Notification Priority',
|
||||||
},
|
submenu: urgencyLevels.map((level) => ({
|
||||||
{
|
label: level.name,
|
||||||
// Submenu with settings for interactive notifications (name shouldn't be too long)
|
type: 'radio',
|
||||||
label: 'Interactive Settings',
|
checked: config.urgency === level.value,
|
||||||
submenu: [
|
click: () => setConfig({ urgency: level.value }),
|
||||||
{
|
})),
|
||||||
label: 'Open/Close on tray click',
|
}
|
||||||
type: 'checkbox',
|
];
|
||||||
checked: options.trayControls,
|
} else if (is.windows()) {
|
||||||
click: (item: MenuItem) => config.set('trayControls', item.checked),
|
return [
|
||||||
},
|
{
|
||||||
{
|
label: 'Interactive Notifications',
|
||||||
label: 'Hide Button Text',
|
type: 'checkbox',
|
||||||
type: 'checkbox',
|
checked: config.interactive,
|
||||||
checked: options.hideButtonText,
|
// Doesn't update until restart
|
||||||
click: (item: MenuItem) => config.set('hideButtonText', item.checked),
|
click: (item: MenuItem) => setConfig({ interactive: item.checked }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Refresh on Play/Pause',
|
// Submenu with settings for interactive notifications (name shouldn't be too long)
|
||||||
type: 'checkbox',
|
label: 'Interactive Settings',
|
||||||
checked: options.refreshOnPlayPause,
|
submenu: [
|
||||||
click: (item: MenuItem) => config.set('refreshOnPlayPause', item.checked),
|
{
|
||||||
},
|
label: 'Open/Close on tray click',
|
||||||
],
|
type: 'checkbox',
|
||||||
},
|
checked: config.trayControls,
|
||||||
{
|
click: (item: MenuItem) => setConfig({ trayControls: item.checked }),
|
||||||
label: 'Style',
|
},
|
||||||
submenu: getToastStyleMenuItems(options),
|
{
|
||||||
},
|
label: 'Hide Button Text',
|
||||||
];
|
type: 'checkbox',
|
||||||
} else {
|
checked: config.hideButtonText,
|
||||||
return [];
|
click: (item: MenuItem) => setConfig({ hideButtonText: item.checked }),
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
label: 'Refresh on Play/Pause',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.refreshOnPlayPause,
|
||||||
|
click: (item: MenuItem) => setConfig({ refreshOnPlayPause: item.checked }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Style',
|
||||||
|
submenu: getToastStyleMenuItems(config),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
...getMenu(),
|
||||||
|
{
|
||||||
|
label: 'Show notification on unpause',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.unpauseNotification,
|
||||||
|
click: (item) => setConfig({ unpauseNotification: item.checked }),
|
||||||
|
},
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (_win: BrowserWindow, options: ConfigType<'notifications'>): MenuTemplate => [
|
|
||||||
...getMenu(options),
|
|
||||||
{
|
|
||||||
label: 'Show notification on unpause',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.unpauseNotification,
|
|
||||||
click: (item: MenuItem) => config.set('unpauseNotification', item.checked),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getToastStyleMenuItems(options: ConfigType<'notifications'>) {
|
|
||||||
const array = Array.from({ length: Object.keys(ToastStyles).length });
|
|
||||||
|
|
||||||
// ToastStyles index starts from 1
|
|
||||||
for (const [name, index] of Object.entries(ToastStyles)) {
|
|
||||||
array[index - 1] = {
|
|
||||||
label: snakeToCamel(name),
|
|
||||||
type: 'radio',
|
|
||||||
checked: options.toastStyle === index,
|
|
||||||
click: () => config.set('toastStyle', index),
|
|
||||||
} satisfies Electron.MenuItemConstructorOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array as Electron.MenuItemConstructorOptions[];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,13 +3,12 @@ import fs from 'node:fs';
|
|||||||
|
|
||||||
import { app, NativeImage } from 'electron';
|
import { app, NativeImage } from 'electron';
|
||||||
|
|
||||||
import config from './config';
|
import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack';
|
||||||
|
|
||||||
import { cache } from '../../providers/decorators';
|
import { cache } from '@/providers/decorators';
|
||||||
import { SongInfo } from '../../providers/song-info';
|
import { SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
import youtubeMusicIcon from '../../../assets/youtube-music.png?asset&asarUnpack';
|
|
||||||
|
|
||||||
|
import type { NotificationsPluginConfig } from './index';
|
||||||
|
|
||||||
const userData = app.getPath('userData');
|
const userData = app.getPath('userData');
|
||||||
const temporaryIcon = path.join(userData, 'tempIcon.png');
|
const temporaryIcon = path.join(userData, 'tempIcon.png');
|
||||||
@ -27,9 +26,9 @@ export const ToastStyles = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const urgencyLevels = [
|
export const urgencyLevels = [
|
||||||
{ name: 'Low', value: 'low' },
|
{ name: 'Low', value: 'low' } as const,
|
||||||
{ name: 'Normal', value: 'normal' },
|
{ name: 'Normal', value: 'normal' } as const,
|
||||||
{ name: 'High', value: 'critical' },
|
{ name: 'High', value: 'critical' } as const,
|
||||||
];
|
];
|
||||||
|
|
||||||
const nativeImageToLogo = cache((nativeImage: NativeImage) => {
|
const nativeImageToLogo = cache((nativeImage: NativeImage) => {
|
||||||
@ -44,16 +43,16 @@ const nativeImageToLogo = cache((nativeImage: NativeImage) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notificationImage = (songInfo: SongInfo) => {
|
export const notificationImage = (songInfo: SongInfo, config: NotificationsPluginConfig) => {
|
||||||
if (!songInfo.image) {
|
if (!songInfo.image) {
|
||||||
return youtubeMusicIcon;
|
return youtubeMusicIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.get('interactive')) {
|
if (!config.interactive) {
|
||||||
return nativeImageToLogo(songInfo.image);
|
return nativeImageToLogo(songInfo.image);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (config.get('toastStyle')) {
|
switch (config.toastStyle) {
|
||||||
case ToastStyles.logo:
|
case ToastStyles.logo:
|
||||||
case ToastStyles.legacy: {
|
case ToastStyles.legacy: {
|
||||||
return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon);
|
return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon);
|
||||||
|
|||||||
45
src/plugins/picture-in-picture/index.ts
Normal file
45
src/plugins/picture-in-picture/index.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||||
|
|
||||||
|
export type PictureInPicturePluginConfig = {
|
||||||
|
'enabled': boolean;
|
||||||
|
'alwaysOnTop': boolean;
|
||||||
|
'savePosition': boolean;
|
||||||
|
'saveSize': boolean;
|
||||||
|
'hotkey': 'P',
|
||||||
|
'pip-position': [number, number];
|
||||||
|
'pip-size': [number, number];
|
||||||
|
'isInPiP': boolean;
|
||||||
|
'useNativePiP': boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Picture In Picture',
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
'enabled': false,
|
||||||
|
'alwaysOnTop': true,
|
||||||
|
'savePosition': true,
|
||||||
|
'saveSize': false,
|
||||||
|
'hotkey': 'P',
|
||||||
|
'pip-position': [10, 10],
|
||||||
|
'pip-size': [450, 275],
|
||||||
|
'isInPiP': false,
|
||||||
|
'useNativePiP': true,
|
||||||
|
} as PictureInPicturePluginConfig,
|
||||||
|
stylesheets: [style],
|
||||||
|
menu: onMenu,
|
||||||
|
|
||||||
|
backend: {
|
||||||
|
start: onMainLoad,
|
||||||
|
onConfigChange,
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
start: onRendererLoad,
|
||||||
|
onPlayerApiReady,
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,111 +1,111 @@
|
|||||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
import { app } from 'electron';
|
||||||
|
|
||||||
import style from './style.css';
|
import type { PictureInPicturePluginConfig } from './index';
|
||||||
|
|
||||||
import { injectCSS } from '../utils/main';
|
import type { BackendContext } from '@/types/contexts';
|
||||||
import { setOptions as setPluginOptions } from '../../config/plugins';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
let config: PictureInPicturePluginConfig;
|
||||||
|
|
||||||
let isInPiP = false;
|
export const onMainLoad = async ({ window, getConfig, setConfig, ipc: { send, handle, on } }: BackendContext<PictureInPicturePluginConfig>) => {
|
||||||
let originalPosition: number[];
|
let isInPiP = false;
|
||||||
let originalSize: number[];
|
let originalPosition: number[];
|
||||||
let originalFullScreen: boolean;
|
let originalSize: number[];
|
||||||
let originalMaximized: boolean;
|
let originalFullScreen: boolean;
|
||||||
|
let originalMaximized: boolean;
|
||||||
|
|
||||||
let win: BrowserWindow;
|
const pipPosition = () => (config.savePosition && config['pip-position']) || [10, 10];
|
||||||
|
const pipSize = () => (config.saveSize && config['pip-size']) || [450, 275];
|
||||||
|
|
||||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
const togglePiP = () => {
|
||||||
|
isInPiP = !isInPiP;
|
||||||
|
setConfig({ isInPiP });
|
||||||
|
|
||||||
let options: Partial<PiPOptions>;
|
if (isInPiP) {
|
||||||
|
originalFullScreen = window.isFullScreen();
|
||||||
|
if (originalFullScreen) {
|
||||||
|
window.setFullScreen(false);
|
||||||
|
}
|
||||||
|
|
||||||
const pipPosition = () => (options.savePosition && options['pip-position']) || [10, 10];
|
originalMaximized = window.isMaximized();
|
||||||
const pipSize = () => (options.saveSize && options['pip-size']) || [450, 275];
|
if (originalMaximized) {
|
||||||
|
window.unmaximize();
|
||||||
|
}
|
||||||
|
|
||||||
const setLocalOptions = (_options: Partial<PiPOptions>) => {
|
originalPosition = window.getPosition();
|
||||||
options = { ...options, ..._options };
|
originalSize = window.getSize();
|
||||||
setPluginOptions('picture-in-picture', _options);
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePiP = () => {
|
handle('before-input-event', blockShortcutsInPiP);
|
||||||
isInPiP = !isInPiP;
|
|
||||||
setLocalOptions({ isInPiP });
|
|
||||||
|
|
||||||
if (isInPiP) {
|
window.setMaximizable(false);
|
||||||
originalFullScreen = win.isFullScreen();
|
window.setFullScreenable(false);
|
||||||
if (originalFullScreen) {
|
|
||||||
win.setFullScreen(false);
|
send('pip-toggle', true);
|
||||||
|
|
||||||
|
app.dock?.hide();
|
||||||
|
window.setVisibleOnAllWorkspaces(true, {
|
||||||
|
visibleOnFullScreen: true,
|
||||||
|
});
|
||||||
|
app.dock?.show();
|
||||||
|
if (config.alwaysOnTop) {
|
||||||
|
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
||||||
|
window.setMaximizable(true);
|
||||||
|
window.setFullScreenable(true);
|
||||||
|
|
||||||
|
send('pip-toggle', false);
|
||||||
|
|
||||||
|
window.setVisibleOnAllWorkspaces(false);
|
||||||
|
window.setAlwaysOnTop(false);
|
||||||
|
|
||||||
|
if (originalFullScreen) {
|
||||||
|
window.setFullScreen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalMaximized) {
|
||||||
|
window.maximize();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
originalMaximized = win.isMaximized();
|
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
||||||
if (originalMaximized) {
|
const [w, h] = isInPiP ? pipSize() : originalSize;
|
||||||
win.unmaximize();
|
window.setPosition(x, y);
|
||||||
|
window.setSize(w, h);
|
||||||
|
|
||||||
|
window.setWindowButtonVisibility?.(!isInPiP);
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
||||||
|
const key = input.key.toLowerCase();
|
||||||
|
|
||||||
|
if (key === 'f') {
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (key === 'escape') {
|
||||||
|
togglePiP();
|
||||||
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
originalPosition = win.getPosition();
|
config ??= await getConfig();
|
||||||
originalSize = win.getSize();
|
setConfig({ isInPiP });
|
||||||
|
on('picture-in-picture', () => {
|
||||||
win.webContents.on('before-input-event', blockShortcutsInPiP);
|
|
||||||
|
|
||||||
win.setMaximizable(false);
|
|
||||||
win.setFullScreenable(false);
|
|
||||||
|
|
||||||
win.webContents.send('pip-toggle', true);
|
|
||||||
|
|
||||||
app.dock?.hide();
|
|
||||||
win.setVisibleOnAllWorkspaces(true, {
|
|
||||||
visibleOnFullScreen: true,
|
|
||||||
});
|
|
||||||
app.dock?.show();
|
|
||||||
if (options.alwaysOnTop) {
|
|
||||||
win.setAlwaysOnTop(true, 'screen-saver', 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
win.webContents.removeListener('before-input-event', blockShortcutsInPiP);
|
|
||||||
win.setMaximizable(true);
|
|
||||||
win.setFullScreenable(true);
|
|
||||||
|
|
||||||
win.webContents.send('pip-toggle', false);
|
|
||||||
|
|
||||||
win.setVisibleOnAllWorkspaces(false);
|
|
||||||
win.setAlwaysOnTop(false);
|
|
||||||
|
|
||||||
if (originalFullScreen) {
|
|
||||||
win.setFullScreen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (originalMaximized) {
|
|
||||||
win.maximize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [x, y] = isInPiP ? pipPosition() : originalPosition;
|
|
||||||
const [w, h] = isInPiP ? pipSize() : originalSize;
|
|
||||||
win.setPosition(x, y);
|
|
||||||
win.setSize(w, h);
|
|
||||||
|
|
||||||
win.setWindowButtonVisibility?.(!isInPiP);
|
|
||||||
};
|
|
||||||
|
|
||||||
const blockShortcutsInPiP = (event: Electron.Event, input: Electron.Input) => {
|
|
||||||
const key = input.key.toLowerCase();
|
|
||||||
|
|
||||||
if (key === 'f') {
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (key === 'escape') {
|
|
||||||
togglePiP();
|
togglePiP();
|
||||||
event.preventDefault();
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default (_win: BrowserWindow, _options: PiPOptions) => {
|
window.on('move', () => {
|
||||||
options ??= _options;
|
if (config.isInPiP && !config.useNativePiP) {
|
||||||
win ??= _win;
|
setConfig({ 'pip-position': window.getPosition() as [number, number] });
|
||||||
setLocalOptions({ isInPiP });
|
}
|
||||||
injectCSS(win.webContents, style);
|
});
|
||||||
ipcMain.on('picture-in-picture', () => {
|
|
||||||
togglePiP();
|
window.on('resize', () => {
|
||||||
|
if (config.isInPiP && !config.useNativePiP) {
|
||||||
|
setConfig({ 'pip-size': window.getSize() as [number, number] });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setOptions = setLocalOptions;
|
export const onConfigChange = (newConfig: PictureInPicturePluginConfig) => {
|
||||||
|
config = newConfig;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,75 +1,77 @@
|
|||||||
import prompt from 'custom-electron-prompt';
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
|
||||||
import { setOptions } from './main';
|
import type { PictureInPicturePluginConfig } from './index';
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
export const onMenu = async ({ window, getConfig, setConfig }: MenuContext<PictureInPicturePluginConfig>): Promise<MenuTemplate> => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
export default (win: BrowserWindow, options: ConfigType<'picture-in-picture'>): MenuTemplate => [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Always on top',
|
label: 'Always on top',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.alwaysOnTop,
|
checked: config.alwaysOnTop,
|
||||||
click(item) {
|
click(item) {
|
||||||
setOptions({ alwaysOnTop: item.checked });
|
setConfig({ alwaysOnTop: item.checked });
|
||||||
win.setAlwaysOnTop(item.checked);
|
window.setAlwaysOnTop(item.checked);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
label: 'Save window position',
|
||||||
label: 'Save window position',
|
type: 'checkbox',
|
||||||
type: 'checkbox',
|
checked: config.savePosition,
|
||||||
checked: options.savePosition,
|
click(item) {
|
||||||
click(item) {
|
setConfig({ savePosition: item.checked });
|
||||||
setOptions({ savePosition: item.checked });
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
label: 'Save window size',
|
||||||
label: 'Save window size',
|
type: 'checkbox',
|
||||||
type: 'checkbox',
|
checked: config.saveSize,
|
||||||
checked: options.saveSize,
|
click(item) {
|
||||||
click(item) {
|
setConfig({ saveSize: item.checked });
|
||||||
setOptions({ saveSize: item.checked });
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
label: 'Hotkey',
|
||||||
label: 'Hotkey',
|
type: 'checkbox',
|
||||||
type: 'checkbox',
|
checked: !!config.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',
|
label: 'Choose a hotkey for toggling Picture in Picture',
|
||||||
label: 'Choose a hotkey for toggling Picture in Picture',
|
type: 'keybind',
|
||||||
type: 'keybind',
|
keybindOptions: [{
|
||||||
keybindOptions: [{
|
value: 'hotkey',
|
||||||
value: 'hotkey',
|
label: 'Hotkey',
|
||||||
label: 'Hotkey',
|
default: config.hotkey,
|
||||||
default: options.hotkey,
|
}],
|
||||||
}],
|
...promptOptions(),
|
||||||
...promptOptions(),
|
}, window);
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (output) {
|
if (output) {
|
||||||
const { value, accelerator } = output[0];
|
const { value, accelerator } = output[0];
|
||||||
setOptions({ [value]: accelerator });
|
setConfig({ [value]: accelerator });
|
||||||
|
|
||||||
item.checked = !!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;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
label: 'Use native PiP',
|
||||||
label: 'Use native PiP',
|
type: 'checkbox',
|
||||||
type: 'checkbox',
|
checked: config.useNativePiP,
|
||||||
checked: options.useNativePiP,
|
click(item) {
|
||||||
click(item) {
|
setConfig({ useNativePiP: item.checked });
|
||||||
setOptions({ useNativePiP: item.checked });
|
},
|
||||||
},
|
},
|
||||||
},
|
];
|
||||||
];
|
};
|
||||||
|
|||||||
@ -3,13 +3,12 @@ import keyEventAreEqual from 'keyboardevents-areequal';
|
|||||||
|
|
||||||
import pipHTML from './templates/picture-in-picture.html?raw';
|
import pipHTML from './templates/picture-in-picture.html?raw';
|
||||||
|
|
||||||
import { getSongMenu } from '../../providers/dom-elements';
|
import { getSongMenu } from '@/providers/dom-elements';
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils/renderer';
|
import { ElementFromHtml } from '../utils/renderer';
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
import type { PictureInPicturePluginConfig } from './index';
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
type PiPOptions = ConfigType<'picture-in-picture'>;
|
|
||||||
|
|
||||||
function $<E extends Element = Element>(selector: string) {
|
function $<E extends Element = Element>(selector: string) {
|
||||||
return document.querySelector<E>(selector);
|
return document.querySelector<E>(selector);
|
||||||
@ -135,36 +134,13 @@ const listenForToggle = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function observeMenu(options: PiPOptions) {
|
export const onRendererLoad = async ({ getConfig }: RendererContext<PictureInPicturePluginConfig>) => {
|
||||||
useNativePiP = options.useNativePiP;
|
const config = await getConfig();
|
||||||
document.addEventListener(
|
|
||||||
'apiLoaded',
|
|
||||||
() => {
|
|
||||||
listenForToggle();
|
|
||||||
|
|
||||||
cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
|
useNativePiP = config.useNativePiP;
|
||||||
await togglePictureInPicture();
|
|
||||||
setTimeout(() => $<HTMLButtonElement>('#player')?.click());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allows easily closing the menu by programmatically clicking outside of it
|
if (config.hotkey) {
|
||||||
$('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click');
|
const hotkeyEvent = toKeyEvent(config.hotkey);
|
||||||
// TODO: think about wether an additional button in songMenu is needed
|
|
||||||
const popupContainer = $('ytmusic-popup-container');
|
|
||||||
if (popupContainer) observer.observe(popupContainer, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ once: true, passive: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (options: PiPOptions) => {
|
|
||||||
observeMenu(options);
|
|
||||||
|
|
||||||
if (options.hotkey) {
|
|
||||||
const hotkeyEvent = toKeyEvent(options.hotkey);
|
|
||||||
window.addEventListener('keydown', (event) => {
|
window.addEventListener('keydown', (event) => {
|
||||||
if (
|
if (
|
||||||
keyEventAreEqual(event, hotkeyEvent)
|
keyEventAreEqual(event, hotkeyEvent)
|
||||||
@ -175,3 +151,21 @@ export default (options: PiPOptions) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const onPlayerApiReady = () => {
|
||||||
|
listenForToggle();
|
||||||
|
|
||||||
|
cloneButton('.player-minimize-button')?.addEventListener('click', async () => {
|
||||||
|
await togglePictureInPicture();
|
||||||
|
setTimeout(() => $<HTMLButtonElement>('#player')?.click());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allows easily closing the menu by programmatically clicking outside of it
|
||||||
|
$('#expanding-menu')?.removeAttribute('no-cancel-on-outside-click');
|
||||||
|
// TODO: think about wether an additional button in songMenu is needed
|
||||||
|
const popupContainer = $('ytmusic-popup-container');
|
||||||
|
if (popupContainer) observer.observe(popupContainer, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user